diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cf243326b..57f2ce87d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -495,12 +495,35 @@ jobs: {"helperBinaryPath":"$HELPER_PATH"} EOF - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -destination "platform=macOS" \ - -only-testing:cmuxUITests/DisplayResolutionRegressionUITests \ - test + run_display_resolution_ui_test() { + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + -only-testing:cmuxUITests/DisplayResolutionRegressionUITests \ + test 2>&1 + } + + set +e + run_display_resolution_ui_test | tee /tmp/display-resolution-ui-test-output.txt + EXIT_CODE=${PIPESTATUS[0]} + OUTPUT=$(cat /tmp/display-resolution-ui-test-output.txt) + set -e + + if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Failed to activate application.*Running Background"; then + echo "Display resolution UI regression hit launch activation flake, retrying once" + pkill -x "cmux DEV" || true + sleep 2 + set +e + run_display_resolution_ui_test | tee /tmp/display-resolution-ui-test-output.txt + EXIT_CODE=${PIPESTATUS[0]} + OUTPUT=$(cat /tmp/display-resolution-ui-test-output.txt) + set -e + fi + + if [ "$EXIT_CODE" -ne 0 ]; then + exit "$EXIT_CODE" + fi - name: Run browser find focus UI regression run: | diff --git a/.gitignore b/.gitignore index 071e93da94..35c1e9ec00 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,92 @@ tests/visual_report.html # Local scratch (screenshots, etc.) tmp/ tmp-*/ + +# System patterns (added by Gitignore Guardian) + +# Changelog Generated +changelog-git.md + +# Coverage Gate +.coverage-gate-baseline +coverage.json + +# Editor Artifacts +*.swp +*.swo +*~ +*.bak + +# Makefile Artifacts +*.o +*.a +*.so +*.dylib + +# Mutation Testing Js +.stryker-tmp/ +reports/ +.stryker-incremental.json + +# Mutation Testing Python +mutants/ +.mutmut-cache/ +html/ + +# Node Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Os +**/.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +.AppleDouble +.LSOverride +Thumbs.db +Desktop.ini +ehthumbs.db + +# Python Bytecode +*.py[cod] +*.pyd +*$py.class + +# Python Caches +.mypy_cache/ +.ruff_cache/ +.tox/ +.nox/ +.coverage +htmlcov/ +.python-version +.benchmarks/ + +# Python Uv Cache +.uv/ + +# Python Venv +.venv/ +venv/ +ENV/ +env/ + +# Ruby Dependencies +.bundle/ +vendor/bundle/ + +# Security +.env.* +*.pem +*.key +*.crt +*.p12 +.secrets/ +secrets/ +credentials.json +service-account*.json diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 17279ef63e..d11bf6a722 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2245,6 +2245,120 @@ struct CMUXCLI { throw error } + case "set-status": + let (icon, r1) = parseOption(commandArgs, name: "--icon") + let (color, r2) = parseOption(r1, name: "--color") + let (pidRaw, r3) = parseOption(r2, name: "--pid") + let pid: Int32? + if let pidRaw { + guard let parsedPid = Int32(pidRaw), parsedPid > 0 else { + throw CLIError(message: "--pid must be a positive integer") + } + pid = parsedPid + } else { + pid = nil + } + let (wsFlag, r4) = parseOption(r3, name: "--workspace") + guard r4.count >= 2 else { + throw CLIError(message: "set-status requires and ") + } + let key = r4[0] + let value = r4.dropFirst().joined(separator: " ") + guard !value.isEmpty else { + throw CLIError(message: "set-status requires a non-empty value") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "set_status \(key) \(socketQuote(value))" + if let icon { socketCmd += " --icon=\(socketQuote(icon))" } + if let color { socketCmd += " --color=\(socketQuote(color))" } + if let pid { socketCmd += " --pid=\(pid)" } + socketCmd += " --tab=\(wsId)" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "clear-status": + let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace") + guard let key = csRemaining.first else { + throw CLIError(message: "clear-status requires a ") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client) + print(response) + + case "list-status": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("list_status --tab=\(wsId)", client: client) + print(response) + + case "set-progress": + let (label, spR1) = parseOption(commandArgs, name: "--label") + let (wsFlag, spR2) = parseOption(spR1, name: "--workspace") + guard let valueStr = spR2.first else { + throw CLIError(message: "set-progress requires a progress value (0.0-1.0)") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "set_progress \(valueStr)" + if let label { socketCmd += " --label=\(socketQuote(label))" } + socketCmd += " --tab=\(wsId)" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "clear-progress": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client) + print(response) + + case "log": + let (level, r1) = parseOption(commandArgs, name: "--level") + let (source, r2) = parseOption(r1, name: "--source") + let (wsFlag, r3) = parseOption(r2, name: "--workspace") + // Strip leading "--" separator if present + let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3 + let message = positional.joined(separator: " ") + guard !message.isEmpty else { + throw CLIError(message: "log requires a message") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "log" + if let level { socketCmd += " --level=\(level)" } + if let source { socketCmd += " --source=\(socketQuote(source))" } + socketCmd += " --tab=\(wsId) -- \(socketQuote(message))" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "clear-log": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("clear_log --tab=\(wsId)", client: client) + print(response) + + case "list-log": + let (limitStr, r1) = parseOption(commandArgs, name: "--limit") + let (wsFlag, _) = parseOption(r1, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "list_log" + if let limitStr { socketCmd += " --limit=\(limitStr)" } + socketCmd += " --tab=\(wsId)" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "sidebar-state": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client) + print(response) + case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try sendV1Command("set_app_focus \(value)", client: client) @@ -6895,6 +7009,7 @@ struct CMUXCLI { Flags: --icon Icon name (e.g. "sparkle", "hammer") --color <#hex> Pill color (e.g. "#ff9500") + --pid Track a process for stale-pill cleanup --workspace Target workspace (default: $CMUX_WORKSPACE_ID) Example: @@ -11409,6 +11524,18 @@ struct CMUXCLI { list-notifications clear-notifications claude-hook [--workspace ] [--surface ] + + # sidebar metadata commands + set-status [--icon ] [--color <#hex>] [--pid ] [--workspace ] + clear-status [--workspace ] + list-status [--workspace ] + set-progress <0.0-1.0> [--label ] [--workspace ] + clear-progress [--workspace ] + log [--level ] [--source ] [--workspace ] [--] + clear-log [--workspace ] + list-log [--limit ] [--workspace ] + sidebar-state [--workspace ] + set-app-focus simulate-app-active diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 01d6d6058f..9633d6c70b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -75,6 +75,8 @@ B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; }; + E1A0D30002A1B2C3D4E5F719 /* opencode in Copy CLI */ = {isa = PBXBuildFile; fileRef = E1A0D30001A1B2C3D4E5F719 /* opencode */; }; + E1A0D30004A1B2C3D4E5F719 /* opencode-cmux-plugin.js in Copy CLI */ = {isa = PBXBuildFile; fileRef = E1A0D30003A1B2C3D4E5F719 /* opencode-cmux-plugin.js */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; @@ -145,6 +147,8 @@ B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, + E1A0D30002A1B2C3D4E5F719 /* opencode in Copy CLI */, + E1A0D30004A1B2C3D4E5F719 /* opencode-cmux-plugin.js in Copy CLI */, ); name = "Copy CLI"; runOnlyForDeploymentPostprocessing = 0; @@ -247,6 +251,8 @@ B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; }; D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; }; + E1A0D30001A1B2C3D4E5F719 /* opencode */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/opencode"; sourceTree = SOURCE_ROOT; }; + E1A0D30003A1B2C3D4E5F719 /* opencode-cmux-plugin.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "Resources/bin/opencode-cmux-plugin.js"; sourceTree = SOURCE_ROOT; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -476,6 +482,8 @@ B2E7294509CC42FE9191870E /* xterm-ghostty */, A5002001 /* THIRD_PARTY_LICENSES.md */, C1ADE00001A1B2C3D4E5F719 /* claude */, + E1A0D30001A1B2C3D4E5F719 /* opencode */, + E1A0D30003A1B2C3D4E5F719 /* opencode-cmux-plugin.js */, DA7A10CA710E000000000001 /* Localizable.xcstrings */, DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, A5001622 /* cmux.sdef */, diff --git a/Resources/bin/opencode b/Resources/bin/opencode new file mode 100755 index 0000000000..0686b8cdf0 --- /dev/null +++ b/Resources/bin/opencode @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# cmux opencode wrapper - injects sidebar status + notifications + +find_real_opencode() { + local self_dir + self_dir="$(cd "$(dirname "$0")" && pwd)" + local IFS=: + local dir + for dir in $PATH; do + [[ "$dir" == "$self_dir" ]] && continue + [[ -x "$dir/opencode" ]] && printf '%s' "$dir/opencode" && return 0 + done + return 1 +} + +CMUX_BIN="" + +cmux_socket_available() { + local socket="${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}" + [[ -n "$socket" && -S "$socket" ]] || return 1 + + local self_dir + self_dir="$(cd "$(dirname "$0")" && pwd)" + CMUX_BIN="$self_dir/cmux" + [[ -x "$CMUX_BIN" ]] || CMUX_BIN="$(command -v cmux || true)" + [[ -n "$CMUX_BIN" ]] || return 1 + + CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.75 \ + "$CMUX_BIN" --socket "$socket" ping >/dev/null 2>&1 +} + +run_cmux() { + [[ -n "$CMUX_BIN" ]] || return 1 + "$CMUX_BIN" "$@" >/dev/null 2>&1 +} + +link_children() { + local source_dir="$1" + local destination_dir="$2" + [[ -d "$source_dir" ]] || return 0 + + mkdir -p "$destination_dir" + local item + local name + shopt -s dotglob nullglob + for item in "$source_dir"/*; do + name="$(basename "$item")" + [[ "$name" == "." || "$name" == ".." ]] && continue + [[ -e "$destination_dir/$name" ]] && continue + ln -s "$item" "$destination_dir/$name" + done + shopt -u dotglob nullglob +} + +IN_CMUX=0 +if [[ -n "${CMUX_SURFACE_ID:-}" ]]; then + IN_CMUX=1 +fi + +if [[ "$IN_CMUX" == "0" || "${CMUX_OPENCODE_INTEGRATION_DISABLED:-}" == "1" ]] || ! cmux_socket_available; then + REAL_OPENCODE="$(find_real_opencode)" || { echo "Error: opencode not found in PATH" >&2; exit 127; } + exec "$REAL_OPENCODE" "$@" +fi + +REAL_OPENCODE="$(find_real_opencode)" || { echo "Error: opencode not found in PATH" >&2; exit 127; } + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_SOURCE="$SELF_DIR/opencode-cmux-plugin.js" +if [[ ! -r "$PLUGIN_SOURCE" ]]; then + exec "$REAL_OPENCODE" "$@" +fi + +CMUX_OPENCODE_CONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-opencode-config.XXXXXX")" || { + exec "$REAL_OPENCODE" "$@" +} +cleanup() { + rm -rf "$CMUX_OPENCODE_CONFIG_DIR" +} +trap cleanup EXIT HUP INT TERM + +EXISTING_CONFIG_DIR="${OPENCODE_CONFIG_DIR:-}" +if [[ -n "$EXISTING_CONFIG_DIR" && -d "$EXISTING_CONFIG_DIR" ]]; then + local_item="" + local_name="" + shopt -s dotglob nullglob + for local_item in "$EXISTING_CONFIG_DIR"/*; do + local_name="$(basename "$local_item")" + [[ "$local_name" == "." || "$local_name" == ".." ]] && continue + [[ "$local_name" == "plugin" || "$local_name" == "plugins" ]] && continue + [[ -e "$CMUX_OPENCODE_CONFIG_DIR/$local_name" ]] && continue + ln -s "$local_item" "$CMUX_OPENCODE_CONFIG_DIR/$local_name" + done + shopt -u dotglob nullglob + + link_children "$EXISTING_CONFIG_DIR/plugin" "$CMUX_OPENCODE_CONFIG_DIR/plugin" + link_children "$EXISTING_CONFIG_DIR/plugins" "$CMUX_OPENCODE_CONFIG_DIR/plugins" +fi + +if ! mkdir -p "$CMUX_OPENCODE_CONFIG_DIR/plugins" || + ! cp "$PLUGIN_SOURCE" "$CMUX_OPENCODE_CONFIG_DIR/plugins/cmux-integration.js"; then + rm -rf "$CMUX_OPENCODE_CONFIG_DIR" + exec "$REAL_OPENCODE" "$@" +fi + +export OPENCODE_CONFIG_DIR="$CMUX_OPENCODE_CONFIG_DIR" + +run_cmux clear-status opencode || true + +"$REAL_OPENCODE" "$@" +STATUS=$? + +run_cmux clear-status opencode || true +run_cmux clear-notifications || true + +exit "$STATUS" diff --git a/Resources/bin/opencode-cmux-plugin.js b/Resources/bin/opencode-cmux-plugin.js new file mode 100644 index 0000000000..43e3590130 --- /dev/null +++ b/Resources/bin/opencode-cmux-plugin.js @@ -0,0 +1,275 @@ +const BLUE = "#4C8DFF" +const GRAY = "#8E8E93" +const ORANGE = "#FF9500" +const RED = "#FF3B30" + +function clip(value, limit = 160) { + const text = `${value ?? ""}`.replace(/\s+/g, " ").trim() + if (!text) return "" + if (text.length <= limit) return text + return `${text.slice(0, limit - 3)}...` +} + +function ensure(sessions, sessionID) { + const current = sessions.get(sessionID) + if (current) return current + const created = { state: "idle", waiting: "", error: "", completed: false } + sessions.set(sessionID, created) + return created +} + +function questionText(event) { + const question = event?.properties?.questions?.[0] + if (!question) return "Question asked" + return clip(question.question || question.header || question.options?.[0]?.label || "Question asked") +} + +function permissionText(event) { + const permission = clip(event?.properties?.permission || "Permission") + const patterns = Array.isArray(event?.properties?.patterns) + ? event.properties.patterns.map((item) => clip(item, 80)).filter(Boolean) + : [] + if (!patterns.length) return `Permission required: ${permission}` + return clip(`Permission required: ${permission} ${patterns.join(", ")}`) +} + +function legacyPermissionText(event) { + const message = clip(event?.properties?.message) + if (message) return message + const permission = clip(event?.properties?.type || "Permission") + const pattern = event?.properties?.pattern + const patterns = Array.isArray(pattern) ? pattern.map((item) => clip(item, 80)).filter(Boolean) : [clip(pattern, 80)].filter(Boolean) + if (!patterns.length) return `Permission required: ${permission}` + return clip(`Permission required: ${permission} ${patterns.join(", ")}`) +} + +function extractMessage(value) { + if (!value) return "" + if (typeof value === "string") return value + if (typeof value.message === "string" && value.message) return value.message + if (value.data) return extractMessage(value.data) + return "" +} + +function errorText(event) { + return clip(extractMessage(event?.properties?.error) || "Session error") +} + +function desiredStatus(sessions) { + const items = [...sessions.values()] + if (!items.length) return null + if (items.some((item) => item.error)) { + return { value: "Error", icon: "exclamationmark.triangle.fill", color: RED, attention: true } + } + if (items.some((item) => item.waiting)) { + return { value: "Needs input", icon: "bell.fill", color: BLUE, attention: true } + } + if (items.some((item) => item.state === "retry")) { + return { value: "Retrying", icon: "arrow.triangle.2.circlepath", color: ORANGE, attention: false } + } + if (items.some((item) => item.state === "busy")) { + return { value: "Running", icon: "bolt.fill", color: BLUE, attention: false } + } + // Completion: show Idle with attention (blue ring) instead of a sixth state + if (items.some((item) => item.completed)) { + return { value: "Idle", icon: "pause.circle.fill", color: BLUE, attention: true } + } + return { value: "Idle", icon: "pause.circle.fill", color: GRAY, attention: false } +} + +export const CmuxIntegrationPlugin = async ({ $ }) => { + const sessions = new Map() + let applied = "" + let attention = false + async function notify(subtitle, body) { + const text = clip(body) + if (!text) return + try { + await $`cmux notify --title OpenCode --subtitle ${subtitle} --body ${text}`.quiet() + attention = true + } catch {} + } + + // NOTE: clear-notifications is workspace-global; cmux does not yet support + // --pid scoping for clears. In practice each surface runs one OpenCode instance. + async function clearNotifications() { + if (!attention) return + try { + await $`cmux clear-notifications`.quiet() + attention = false + } catch {} + } + + async function setStatus(value, icon, color) { + const pid = typeof process.pid === "number" && process.pid > 0 ? process.pid : 0 + const next = `${value}\u0000${icon}\u0000${color}\u0000${pid}` + if (applied === next) return + try { + if (pid > 0) { + await $`cmux set-status opencode ${value} --icon ${icon} --color ${color} --pid ${pid}`.quiet() + } else { + await $`cmux set-status opencode ${value} --icon ${icon} --color ${color}`.quiet() + } + applied = next + } catch {} + } + + // NOTE: clear-status is keyed by "opencode" but not scoped by --pid. + // Same limitation as clearNotifications above. + async function clearStatus() { + if (!applied) return + try { + await $`cmux clear-status opencode`.quiet() + applied = "" + } catch {} + } + + async function sync() { + const next = desiredStatus(sessions) + if (!next) { + await clearNotifications() + await clearStatus() + return + } + if (!next.attention) { + await clearNotifications() + } + await setStatus(next.value, next.icon, next.color) + } + + return { + event: async ({ event }) => { + if (event.type === "session.created" || event.type === "session.updated") { + const sessionID = event.properties?.info?.id + if (sessionID) ensure(sessions, sessionID) + await sync() + return + } + + if (event.type === "session.deleted") { + const sessionID = event.properties?.info?.id + if (sessionID) sessions.delete(sessionID) + await sync() + return + } + + if (event.type === "session.status") { + const sessionID = event.properties?.sessionID + if (!sessionID) return + const state = ensure(sessions, sessionID) + const prevState = state.state + state.state = event.properties?.status?.type || "idle" + state.error = "" + // Reset completed flag and waiting text when leaving idle (starting new work) + if (prevState === "idle" && state.state !== "idle") { + state.completed = false + state.waiting = "" + } + // Detect completion: busy -> idle transition (not initial idle) + if (prevState === "busy" && state.state === "idle") { + state.completed = true + await notify("Done", "Session completed") + } + await sync() + return + } + + if (event.type === "session.idle") { + const sessionID = event.properties?.sessionID + if (!sessionID) return + const state = ensure(sessions, sessionID) + state.state = "idle" + state.error = "" + await sync() + return + } + + if (event.type === "permission.asked") { + const sessionID = event.properties?.sessionID + if (!sessionID) return + const state = ensure(sessions, sessionID) + const text = permissionText(event) + const changed = state.waiting !== text + state.state = "idle" + state.waiting = text + state.error = "" + if (changed) { + await notify("Permission", text) + } + await sync() + return + } + + if (event.type === "permission.updated") { + const sessionID = event.properties?.sessionID + if (!sessionID) return + const state = ensure(sessions, sessionID) + const text = legacyPermissionText(event) + const changed = state.waiting !== text + state.state = "idle" + state.waiting = text + state.error = "" + if (changed) { + await notify("Permission", text) + } + await sync() + return + } + + if (event.type === "permission.replied") { + const sessionID = event.properties?.sessionID + const state = sessionID ? sessions.get(sessionID) : undefined + if (!state) return + state.waiting = "" + await sync() + return + } + + if (event.type === "question.asked") { + const sessionID = event.properties?.sessionID + if (!sessionID) return + const state = ensure(sessions, sessionID) + const text = questionText(event) + const changed = state.waiting !== text + state.state = "idle" + state.waiting = text + state.error = "" + if (changed) { + await notify("Question", text) + } + await sync() + return + } + + if (event.type === "question.replied" || event.type === "question.rejected") { + const sessionID = event.properties?.sessionID + const state = sessionID ? sessions.get(sessionID) : undefined + if (!state) return + state.waiting = "" + await sync() + return + } + + if (event.type === "session.error") { + const text = errorText(event) + const sessionID = event.properties?.sessionID + if (!sessionID) { + await notify("Error", text) + await setStatus("Error", "exclamationmark.triangle.fill", RED) + return + } + const state = ensure(sessions, sessionID) + const changed = state.error !== text + state.state = "idle" + state.waiting = "" + state.error = text + if (changed) { + await notify("Error", text) + } + await sync() + } + }, + } +} + +export default CmuxIntegrationPlugin diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0aa385ef84..53c0353101 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2078,6 +2078,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupMultiWindowNotificationsUITest = false private var didSetupDisplayResolutionUITestDiagnostics = false private var displayResolutionUITestObservers: [NSObjectProtocol] = [] + private var didRequestFallbackUITestWindow = false private struct UITestRenderDiagnosticsSnapshot { let panelId: UUID let drawCount: Int @@ -2384,13 +2385,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self else { return } - if NSApp.windows.isEmpty { - self.openNewMainWindow(nil) - } - self.moveUITestWindowToTargetDisplayIfNeeded() - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) - self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") + self?.stabilizeUITestLaunchWindowAndForeground() } if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in @@ -2416,6 +2411,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG + // Retry launch stabilization until a delayed WindowGroup materialization produces + // a visible key window that XCUITest can bring to the foreground on the shared VM. + private func stabilizeUITestLaunchWindowAndForeground(attempt: Int = 0) { + let env = ProcessInfo.processInfo.environment + guard isRunningUnderXCTest(env) else { return } + + if NSApp.windows.isEmpty, !didRequestFallbackUITestWindow { + didRequestFallbackUITestWindow = true + openNewMainWindow(nil) + } + + moveUITestWindowToTargetDisplayIfNeeded() + activateUITestAppIfNeeded() + + let hasWindow = !NSApp.windows.isEmpty + let hasVisibleWindow = NSApp.windows.contains { $0.isVisible } + let hasKeyWindow = NSApp.keyWindow != nil + let stage = attempt == 0 ? "afterForceWindow" : "afterForceWindow.retry\(attempt)" + writeUITestDiagnosticsIfNeeded(stage: stage) + + guard attempt < 20 else { return } + if !hasWindow || !hasVisibleWindow || !hasKeyWindow || !NSRunningApplication.current.isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.stabilizeUITestLaunchWindowAndForeground(attempt: attempt + 1) + } + } + } + + private func activateUITestAppIfNeeded() { + if let window = NSApp.windows.first { + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + if #available(macOS 14.0, *) { + NSRunningApplication.current.activate(options: [.activateAllWindows]) + } else { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + } + private func writeUITestDiagnosticsIfNeeded(stage: String) { let env = ProcessInfo.processInfo.environment guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 4c4588a8a2..506e192f92 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1130,6 +1130,10 @@ final class WindowTerminalPortal: NSObject { synchronizeHostedView(withId: hostedId) scheduleDeferredFullSynchronizeAll() + // Session/window restore can queue additional ancestor layout shifts (sidebar width, + // split positions) after the initial bind tick. Queue a later external sync so the + // portal catches that settled geometry instead of staying at the seeded frame. + scheduleExternalGeometrySynchronize() pruneDeadEntries() } diff --git a/TODO.md b/TODO.md index 5453b8f596..3f08af1dd3 100644 --- a/TODO.md +++ b/TODO.md @@ -51,7 +51,7 @@ ## Additional Integrations - [ ] Codex integration -- [ ] OpenCode integration +- [x] OpenCode integration ## Browser - [ ] Per-WKWebView proxy observability/inspection once remote proxy path is shipped (URL, method, headers, body, status, timing) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 24ec48a63b..60a2292beb 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2939,6 +2939,83 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { ) } + func testBindQueuesExternalGeometrySyncForQueuedLayoutShift() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + shiftedContainer.addSubview(anchor) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + DispatchQueue.main.async { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.minX, + originalAnchorFrameInWindow.minX + 1, + "The queued layout shift should move the anchor to the right" + ) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "Bind should queue a later external sync so restore-like ancestor shifts do not leave a stale portal in the sidebar region" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "Bind should refresh the portal after queued ancestor layout settles" + ) + } + func testScheduledExternalGeometrySyncKeepsDragDrivenResizeResponsive() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), diff --git a/docs/notifications.md b/docs/notifications.md index 42bb590394..1a09f82368 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -106,28 +106,27 @@ Then use: notify = ["bash", "~/.local/bin/codex-notify.sh"] ``` -### OpenCode Plugin +### OpenCode -Create `.opencode/plugins/cmux-notify.js`: +When you launch `opencode` inside a cmux terminal, cmux's bundled `opencode` +wrapper automatically injects a local OpenCode plugin. The sidebar status pill +switches between `Running`, `Retrying`, `Needs input`, `Idle`, and `Error` +based on OpenCode session events, and permission/question/error events create +cmux notifications without any manual setup. + +If you prefer a manual setup (or you're running an OpenCode binary outside the +bundled cmux wrapper), create `.opencode/plugins/cmux-notify.js`. For the full +integration (status pills, all event types), see `Resources/bin/opencode-cmux-plugin.js`. +A minimal notification-only example: ```javascript -export const CmuxNotificationPlugin = async ({ $, }) => { - const notify = async (title, body) => { - try { - await $`command -v cmux && cmux notify --title ${title} --body ${body}`; - } catch { - await $`osascript -e ${"display notification \"" + body + "\" with title \"" + title + "\""}`; +export const CmuxNotificationPlugin = async ({ $ }) => ({ + event: async ({ event }) => { + if (event.type === "session.idle") { + await $`command -v cmux &>/dev/null && cmux notify --title OpenCode --body 'Session idle' || osascript -e 'display notification "Session idle" with title "OpenCode"'` } - }; - - return { - event: async ({ event }) => { - if (event.type === "session.idle") { - await notify("OpenCode", "Session idle"); - } - }, - }; -}; + }, +}) ``` ## Environment Variables @@ -137,8 +136,10 @@ cmux sets these in child shells: | Variable | Description | |----------|-------------| | `CMUX_SOCKET_PATH` | Path to control socket | -| `CMUX_TAB_ID` | UUID of the current tab | -| `CMUX_PANEL_ID` | UUID of the current panel | +| `CMUX_WORKSPACE_ID` | UUID of the current workspace | +| `CMUX_SURFACE_ID` | UUID of the current surface | +| `CMUX_TAB_ID` | Backward-compatible workspace alias | +| `CMUX_PANEL_ID` | Backward-compatible surface alias | ## CLI Commands diff --git a/tests/test_opencode_plugin_runtime.js b/tests/test_opencode_plugin_runtime.js new file mode 100644 index 0000000000..58fd41fdb1 --- /dev/null +++ b/tests/test_opencode_plugin_runtime.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node + +const fs = require("fs") +const os = require("os") +const path = require("path") +const { pathToFileURL } = require("url") + +function fail(message) { + console.error(`FAIL: ${message}`) + process.exit(1) +} + +function expect(condition, message) { + if (!condition) { + fail(message) + } +} + +function render(strings, values) { + let output = "" + for (let index = 0; index < strings.length; index += 1) { + output += strings[index] + if (index < values.length) { + output += String(values[index]) + } + } + return output.replace(/\s+/g, " ").trim() +} + +function includes(commands, expected) { + return commands.some((command) => command === expected) +} + +function starts(commands, expected) { + return commands.some((command) => command.startsWith(expected)) +} + +async function main() { + const source = path.join(__dirname, "..", "Resources", "bin", "opencode-cmux-plugin.js") + const copy = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cmux-opencode-plugin-")), "plugin.mjs") + fs.copyFileSync(source, copy) + const mod = await import(pathToFileURL(copy).href) + const plugin = mod.CmuxIntegrationPlugin || mod.default + expect(typeof plugin === "function", "expected an importable OpenCode cmux plugin function") + + const commands = [] + const $ = (strings, ...values) => { + commands.push(render(strings, values)) + const promise = Promise.resolve() + promise.quiet = () => promise + promise.nothrow = () => promise + promise.text = () => Promise.resolve("") + return promise + } + + const hooks = await plugin({ $ }) + expect(hooks && typeof hooks.event === "function", "expected an event hook from the OpenCode cmux plugin") + + async function emit(event) { + await hooks.event({ event }) + const snapshot = [...commands] + commands.length = 0 + return snapshot + } + + let output = await emit({ + type: "session.created", + properties: { info: { id: "s1" } }, + }) + expect( + starts(output, "cmux set-status opencode Idle --icon pause.circle.fill --color #8E8E93 --pid "), + `expected Idle status after session.created, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }) + expect( + starts(output, "cmux set-status opencode Running --icon bolt.fill --color #4C8DFF --pid "), + `expected Running status after busy event, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "permission.asked", + properties: { + id: "p1", + sessionID: "s1", + permission: "bash", + patterns: ["git status"], + metadata: {}, + always: [], + }, + }) + expect( + includes(output, "cmux notify --title OpenCode --subtitle Permission --body Permission required: bash git status"), + `expected permission notification, got ${JSON.stringify(output)}`, + ) + expect( + starts(output, "cmux set-status opencode Needs input --icon bell.fill --color #4C8DFF --pid "), + `expected Needs input status for permission prompt, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }) + expect( + includes(output, "cmux clear-notifications"), + `expected clear-notifications when work resumes, got ${JSON.stringify(output)}`, + ) + expect( + starts(output, "cmux set-status opencode Running --icon bolt.fill --color #4C8DFF --pid "), + `expected Running status after resume, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "session.status", + properties: { + sessionID: "s1", + status: { type: "retry", attempt: 2, message: "Rate limited", next: Date.now() + 1000 }, + }, + }) + expect( + starts(output, "cmux set-status opencode Retrying --icon arrow.triangle.2.circlepath --color #FF9500 --pid "), + `expected Retrying status, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "session.idle", + properties: { sessionID: "s1" }, + }) + expect( + starts(output, "cmux set-status opencode Idle --icon pause.circle.fill --color #8E8E93 --pid "), + `expected Idle status after session.idle, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "question.asked", + properties: { + id: "q1", + sessionID: "s1", + questions: [ + { + question: "Continue with deploy?", + header: "Deploy", + options: [ + { label: "Yes", description: "Continue" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }, + }) + expect( + includes(output, "cmux notify --title OpenCode --subtitle Question --body Continue with deploy?"), + `expected question notification, got ${JSON.stringify(output)}`, + ) + expect( + starts(output, "cmux set-status opencode Needs input --icon bell.fill --color #4C8DFF --pid "), + `expected Needs input status for question, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "permission.updated", + properties: { + id: "legacy-permission", + type: "edit", + pattern: ["src/app.ts"], + sessionID: "s1", + messageID: "m1", + message: "Allow editing src/app.ts", + metadata: {}, + time: { created: Date.now() }, + }, + }) + expect( + includes(output, "cmux notify --title OpenCode --subtitle Permission --body Allow editing src/app.ts"), + `expected legacy permission notification, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "session.error", + properties: { + sessionID: "s1", + error: { message: "Boom" }, + }, + }) + expect( + includes(output, "cmux notify --title OpenCode --subtitle Error --body Boom"), + `expected error notification, got ${JSON.stringify(output)}`, + ) + expect( + starts(output, "cmux set-status opencode Error --icon exclamationmark.triangle.fill --color #FF3B30 --pid "), + `expected Error status, got ${JSON.stringify(output)}`, + ) + + output = await emit({ + type: "session.deleted", + properties: { info: { id: "s1" } }, + }) + expect( + includes(output, "cmux clear-notifications"), + `expected notifications to clear when session disappears, got ${JSON.stringify(output)}`, + ) + expect( + includes(output, "cmux clear-status opencode"), + `expected status to clear when session disappears, got ${JSON.stringify(output)}`, + ) + + console.log("PASS: OpenCode cmux plugin maps session events to sidebar status + notifications") +} + +main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)) +}) diff --git a/tests/test_opencode_wrapper_hooks.py b/tests/test_opencode_wrapper_hooks.py new file mode 100644 index 0000000000..afac57ac64 --- /dev/null +++ b/tests/test_opencode_wrapper_hooks.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Regression tests for Resources/bin/opencode wrapper plugin injection. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "opencode" +SOURCE_PLUGIN = ROOT / "Resources" / "bin" / "opencode-cmux-plugin.js" + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_lines(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.rstrip("\n") for line in path.read_text(encoding="utf-8").splitlines()] + + +def run_wrapper(*, socket_state: str, argv: list[str], existing_config: bool) -> tuple[int, list[str], list[str], str, str, list[str], str]: + with tempfile.TemporaryDirectory(prefix="cmux-opencode-wrapper-test-") as td: + tmp = Path(td) + wrapper_dir = tmp / "wrapper-bin" + real_dir = tmp / "real-bin" + wrapper_dir.mkdir(parents=True, exist_ok=True) + real_dir.mkdir(parents=True, exist_ok=True) + + wrapper = wrapper_dir / "opencode" + plugin = wrapper_dir / "opencode-cmux-plugin.js" + shutil.copy2(SOURCE_WRAPPER, wrapper) + shutil.copy2(SOURCE_PLUGIN, plugin) + wrapper.chmod(0o755) + + real_args_log = tmp / "real-args.log" + cmux_log = tmp / "cmux.log" + config_dir_log = tmp / "config-dir.log" + overlay_log = tmp / "overlay.log" + socket_path = str(tmp / "cmux.sock") + existing_dir = tmp / "existing-config" + + if existing_config: + (existing_dir / "plugins").mkdir(parents=True, exist_ok=True) + (existing_dir / "opencode.json").write_text('{"model":"test/provider"}\n', encoding="utf-8") + (existing_dir / "plugins" / "existing.js").write_text("module.exports = async () => ({})\n", encoding="utf-8") + + make_executable( + real_dir / "opencode", + """#!/usr/bin/env bash +set -euo pipefail +: > "$FAKE_REAL_ARGS_LOG" +printf '%s\n' "$@" > "$FAKE_REAL_ARGS_LOG" +printf '%s\n' "${OPENCODE_CONFIG_DIR-__UNSET__}" > "$FAKE_CONFIG_DIR_LOG" +: > "$FAKE_OVERLAY_LOG" +if [[ -n "${OPENCODE_CONFIG_DIR-}" && -d "$OPENCODE_CONFIG_DIR" ]]; then + [[ -f "$OPENCODE_CONFIG_DIR/opencode.json" ]] && printf '%s\n' "config-json" >> "$FAKE_OVERLAY_LOG" + [[ -f "$OPENCODE_CONFIG_DIR/plugins/existing.js" ]] && printf '%s\n' "existing-plugin" >> "$FAKE_OVERLAY_LOG" + [[ -f "$OPENCODE_CONFIG_DIR/plugins/cmux-integration.js" ]] && printf '%s\n' "cmux-plugin" >> "$FAKE_OVERLAY_LOG" +fi +true +""", + ) + + make_executable( + wrapper_dir / "cmux", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$FAKE_CMUX_LOG" +if [[ "${1:-}" == "--socket" ]]; then + shift 2 +fi +if [[ "${1:-}" == "ping" ]]; then + if [[ "${FAKE_CMUX_PING_OK:-0}" == "1" ]]; then + exit 0 + fi + exit 1 +fi +exit 0 +""", + ) + + test_socket: socket.socket | None = None + if socket_state in {"live", "stale"}: + test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + test_socket.bind(socket_path) + + env = os.environ.copy() + env["PATH"] = f"{wrapper_dir}:{real_dir}:/usr/bin:/bin" + env["CMUX_SURFACE_ID"] = "surface:test" + env["CMUX_SOCKET_PATH"] = socket_path + env["FAKE_REAL_ARGS_LOG"] = str(real_args_log) + env["FAKE_CMUX_LOG"] = str(cmux_log) + env["FAKE_CONFIG_DIR_LOG"] = str(config_dir_log) + env["FAKE_OVERLAY_LOG"] = str(overlay_log) + env["FAKE_CMUX_PING_OK"] = "1" if socket_state == "live" else "0" + if existing_config: + env["OPENCODE_CONFIG_DIR"] = str(existing_dir) + + try: + proc = subprocess.run( + ["opencode", *argv], + cwd=tmp, + env=env, + capture_output=True, + text=True, + check=False, + ) + finally: + if test_socket is not None: + test_socket.close() + + config_dir_lines = read_lines(config_dir_log) + config_dir_value = config_dir_lines[0] if config_dir_lines else "" + return ( + proc.returncode, + read_lines(real_args_log), + read_lines(cmux_log), + proc.stderr.strip(), + config_dir_value, + read_lines(overlay_log), + str(existing_dir), + ) + + +def expect(condition: bool, message: str, failures: list[str]) -> None: + if not condition: + failures.append(message) + + +def test_live_socket_injects_plugin_and_preserves_existing_config(failures: list[str]) -> None: + code, real_argv, cmux_log, stderr, config_dir_value, overlay, existing_dir = run_wrapper( + socket_state="live", + argv=["--version"], + existing_config=True, + ) + expect(code == 0, f"live socket: wrapper exited {code}: {stderr}", failures) + expect(real_argv == ["--version"], f"live socket: expected passthrough args, got {real_argv}", failures) + expect(config_dir_value not in {"", "__UNSET__"}, "live socket: missing OPENCODE_CONFIG_DIR", failures) + expect(config_dir_value != existing_dir, "live socket: expected overlay config dir, got original path", failures) + expect("config-json" in overlay, f"live socket: missing overlaid opencode.json: {overlay}", failures) + expect("existing-plugin" in overlay, f"live socket: missing existing plugin in overlay: {overlay}", failures) + expect("cmux-plugin" in overlay, f"live socket: missing cmux integration plugin in overlay: {overlay}", failures) + expect(any("ping" in line for line in cmux_log), f"live socket: expected cmux ping, got {cmux_log}", failures) + expect(any(line == "clear-status opencode" for line in cmux_log), f"live socket: expected clear-status cleanup, got {cmux_log}", failures) + expect(any(line == "clear-notifications" for line in cmux_log), f"live socket: expected clear-notifications cleanup, got {cmux_log}", failures) + + +def test_missing_socket_skips_plugin_injection(failures: list[str]) -> None: + code, real_argv, cmux_log, stderr, config_dir_value, overlay, existing_dir = run_wrapper( + socket_state="missing", + argv=["--version"], + existing_config=True, + ) + expect(code == 0, f"missing socket: wrapper exited {code}: {stderr}", failures) + expect(real_argv == ["--version"], f"missing socket: expected passthrough args, got {real_argv}", failures) + expect(cmux_log == [], f"missing socket: expected no cmux calls, got {cmux_log}", failures) + expect(config_dir_value == existing_dir, f"missing socket: expected original config dir, got {config_dir_value!r}", failures) + expect("cmux-plugin" not in overlay, f"missing socket: unexpected injected plugin overlay: {overlay}", failures) + + +def test_stale_socket_skips_plugin_injection(failures: list[str]) -> None: + code, real_argv, cmux_log, stderr, config_dir_value, overlay, existing_dir = run_wrapper( + socket_state="stale", + argv=["--version"], + existing_config=True, + ) + expect(code == 0, f"stale socket: wrapper exited {code}: {stderr}", failures) + expect(real_argv == ["--version"], f"stale socket: expected passthrough args, got {real_argv}", failures) + expect(any("ping" in line for line in cmux_log), f"stale socket: expected cmux ping probe, got {cmux_log}", failures) + expect(config_dir_value == existing_dir, f"stale socket: expected original config dir, got {config_dir_value!r}", failures) + expect("cmux-plugin" not in overlay, f"stale socket: unexpected injected plugin overlay: {overlay}", failures) + + +def main() -> int: + failures: list[str] = [] + test_live_socket_injects_plugin_and_preserves_existing_config(failures) + test_missing_socket_skips_plugin_injection(failures) + test_stale_socket_skips_plugin_injection(failures) + + if failures: + print("FAIL: opencode wrapper regression checks failed") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: opencode wrapper injects cmux plugin only when the cmux socket is live") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())