diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 000000000000..4db500823e0b --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,61 @@ +name: Sync upstream + +on: + schedule: + # Daily at 06:00 UTC + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: write + issues: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/ssh-fish-shell-compat + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch upstream + run: | + git remote add upstream https://github.com/manaflow-ai/cmux.git || true + git fetch upstream main + + - name: Rebase onto upstream/main + id: rebase + run: | + if git rebase upstream/main; then + echo "status=success" >> "$GITHUB_OUTPUT" + else + git rebase --abort + echo "status=conflict" >> "$GITHUB_OUTPUT" + fi + + - name: Push rebased branch + if: steps.rebase.outputs.status == 'success' + run: git push --force-with-lease + + - name: Update main from upstream + if: steps.rebase.outputs.status == 'success' + run: | + git checkout main + git merge upstream/main --ff-only + git push + + - name: Open issue on conflict + if: steps.rebase.outputs.status == 'conflict' + run: | + gh issue create \ + --title "Upstream sync conflict on fix/ssh-fish-shell-compat" \ + --body "Auto-rebase of \`fix/ssh-fish-shell-compat\` onto \`upstream/main\` failed with conflicts. Manual resolution needed." \ + --label "sync-conflict" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7b47b783c17a..fa52aa84d99e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4060,6 +4060,8 @@ struct CMUXCLI { let identityFile: String? let workspaceName: String? let noFocus: Bool + let forceNewWorkspace: Bool + let attachExistingWorkspace: Bool let sshOptions: [String] let extraArguments: [String] let localSocketPath: String @@ -4125,6 +4127,29 @@ struct CMUXCLI { } logSSHTiming("parsed") + if let reusableWorkspace = try findReusableSSHWorkspace(options: sshOptions, client: client) { + let payload = reusableSSHWorkspacePayload(workspace: reusableWorkspace, destination: sshOptions.destination) + if !sshOptions.noFocus, + let reusableWorkspaceId = payload["workspace_id"] as? String, + !reusableWorkspaceId.isEmpty { + var selectParams: [String: Any] = ["workspace_id": reusableWorkspaceId] + if let reusableWindowId = payload["window_id"] as? String, !reusableWindowId.isEmpty { + selectParams["window_id"] = reusableWindowId + } + _ = try client.sendV2(method: "workspace.select", params: selectParams) + } + logSSHTiming("reused", extra: "workspace=\(String(((payload["workspace_id"] as? String) ?? "").prefix(8)))") + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? ((payload["workspace_id"] as? String) ?? "?") + let remote = payload["remote"] as? [String: Any] + let state = (remote?["state"] as? String) ?? "unknown" + print("OK workspace=\(workspaceHandle) target=\(sshOptions.destination) state=\(state) reused=true") + } + return + } + let terminfoSource = localXtermGhosttyTerminfoSource() cliDebugLog( "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + @@ -4300,6 +4325,7 @@ struct CMUXCLI { "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, ] payload["remote_relay_port"] = remoteRelayPort + payload["reused"] = false logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) @@ -4311,12 +4337,120 @@ struct CMUXCLI { } } + + private func findReusableSSHWorkspace(options: SSHCommandOptions, client: SocketClient) throws -> [String: Any]? { + guard options.attachExistingWorkspace else { return nil } + guard !options.forceNewWorkspace else { return nil } + // Remote command arguments imply a new foreground SSH command should be run. + guard options.extraArguments.isEmpty else { return nil } + // Current public workspace metadata does not expose comparable identity or custom SSH option values. + // Avoid reusing when this invocation asks for values that we cannot prove match safely. + guard options.identityFile == nil else { return nil } + guard options.sshOptions.isEmpty else { return nil } + + // Scope reuse to the window containing the invoking workspace. + // If CMUX_WORKSPACE_ID is set we are running inside a cmux tab; + // resolve its window and search only there. Otherwise fall back + // to the focused window (workspace.list with no window_id). + var targetWindowHandle: String? + if let envWorkspace = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !envWorkspace.isEmpty { + let windowsPayload = try client.sendV2(method: "window.list") + let windows = windowsPayload["windows"] as? [[String: Any]] ?? [] + for window in windows { + let windowHandle = (window["id"] as? String) ?? (window["ref"] as? String) + guard let windowHandle, !windowHandle.isEmpty else { continue } + let listed = try client.sendV2(method: "workspace.list", params: ["window_id": windowHandle]) + let workspaces = listed["workspaces"] as? [[String: Any]] ?? [] + if workspaces.contains(where: { ($0["id"] as? String) == envWorkspace || ($0["ref"] as? String) == envWorkspace }) { + targetWindowHandle = windowHandle + break + } + } + } + + var listParams: [String: Any] = [:] + if let targetWindowHandle { + listParams["window_id"] = targetWindowHandle + } + let listed = try client.sendV2(method: "workspace.list", params: listParams) + if var workspace = reusableSSHWorkspace(in: listed, options: options) { + if workspace["window_id"] == nil { + workspace["window_id"] = listed["window_id"] + } + if workspace["window_ref"] == nil { + workspace["window_ref"] = listed["window_ref"] + } + return workspace + } + + return nil + } + + private func reusableSSHWorkspace(in listed: [String: Any], options: SSHCommandOptions) -> [String: Any]? { + let workspaces = listed["workspaces"] as? [[String: Any]] ?? [] + for workspace in workspaces where sshWorkspaceMatches(workspace, options: options) { + var enriched = workspace + if enriched["window_id"] == nil { + enriched["window_id"] = listed["window_id"] + } + if enriched["window_ref"] == nil { + enriched["window_ref"] = listed["window_ref"] + } + return enriched + } + return nil + } + + private func sshWorkspaceMatches(_ workspace: [String: Any], options: SSHCommandOptions) -> Bool { + guard let remote = workspace["remote"] as? [String: Any] else { return false } + guard (remote["enabled"] as? Bool) == true else { return false } + guard (remote["destination"] as? String) == options.destination else { return false } + + // Only reuse workspaces that are actively connected. + let state = (remote["state"] as? String) ?? "" + guard state == "connected" else { return false } + + let remotePort = intFromAny(remote["port"]) + if let requestedPort = options.port { + guard remotePort == requestedPort else { return false } + } else if remotePort != nil { + return false + } + + // Do not silently attach a default invocation to a workspace that was created with a private key. + if (remote["has_identity_file"] as? Bool) == true { + return false + } + + return true + } + + private func reusableSSHWorkspacePayload(workspace: [String: Any], destination: String) -> [String: Any] { + var payload: [String: Any] = [:] + if let workspaceId = workspace["id"] as? String { + payload["workspace_id"] = workspaceId + } + if let workspaceRef = workspace["ref"] as? String { + payload["workspace_ref"] = workspaceRef + } + payload["window_id"] = workspace["window_id"] ?? NSNull() + payload["window_ref"] = workspace["window_ref"] ?? NSNull() + payload["workspace"] = workspace + payload["remote"] = workspace["remote"] ?? NSNull() + payload["target"] = destination + payload["reused"] = true + return payload + } + private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { var destination: String? var port: Int? var identityFile: String? var workspaceName: String? var noFocus = false + var forceNewWorkspace = false + var attachExistingWorkspace = false var sshOptions: [String] = [] var extraArguments: [String] = [] @@ -4358,6 +4492,12 @@ struct CMUXCLI { case "--no-focus": noFocus = true index += 1 + case "--new": + forceNewWorkspace = true + index += 1 + case "--attach": + attachExistingWorkspace = true + index += 1 case "--ssh-option": guard index + 1 < commandArgs.count else { throw CLIError(message: "ssh: --ssh-option requires a value") @@ -4394,6 +4534,8 @@ struct CMUXCLI { identityFile: identityFile, workspaceName: workspaceName, noFocus: noFocus, + forceNewWorkspace: forceNewWorkspace, + attachExistingWorkspace: attachExistingWorkspace, sshOptions: sshOptions, extraArguments: extraArguments, localSocketPath: localSocketPath, @@ -4494,7 +4636,10 @@ struct CMUXCLI { ) -> String { var lines = remoteBootstrapTTYCaptureLines(remoteRelayPort: remoteRelayPort, includeRelayRPC: true) lines.append("/bin/sh \"$HOME/.cmux/relay/\(remoteRelayPort).bootstrap.sh\"") - return lines.joined(separator: "\n") + let script = lines.joined(separator: "\n") + // Wrap in /bin/sh -c so RemoteCommand works when the remote user's + // login shell is non-POSIX (e.g. Fish). + return "/bin/sh -c " + shellQuote(script) } private func remoteBootstrapInstallShell(remoteRelayPort: Int) -> String { @@ -4522,7 +4667,10 @@ struct CMUXCLI { "rm -f \"$cmux_tmp\"", "exit $cmux_status", ] - return lines.joined(separator: "\n") + let script = lines.joined(separator: "\n") + // Wrap in /bin/sh -c so RemoteCommand works when the remote user's + // login shell is non-POSIX (e.g. Fish). + return "/bin/sh -c " + shellQuote(script) } private func remoteBootstrapTTYCaptureLines( @@ -4570,6 +4718,38 @@ struct CMUXCLI { return merged } + /// Reads CMUX_RESTORE_SCROLLBACK_FILE (set by Workspace.swift's + /// SessionScrollbackReplayStore.replayEnvironment for restored panes) and + /// returns shell snippets that ship the bytes inline to the remote and + /// re-export the env var pointing at a remote path. Existing + /// cmux-{bash,zsh,fish}-integration scripts already cat+rm + /// $CMUX_RESTORE_SCROLLBACK_FILE on shell startup, so no client change is needed. + private func remoteScrollbackInjection( + remoteRelayPort: Int + ) -> (writeFile: [String], exportLine: String?) { + guard remoteRelayPort > 0, + let localPath = ProcessInfo.processInfo.environment["CMUX_RESTORE_SCROLLBACK_FILE"], + !localPath.isEmpty, + let data = try? Data(contentsOf: URL(fileURLWithPath: localPath)), + !data.isEmpty + else { + return ([], nil) + } + let b64 = data.base64EncodedString() + let remotePath = "$HOME/.cmux/relay/\(remoteRelayPort).scrollback" + let b64Tmp = "$HOME/.cmux/relay/\(remoteRelayPort).scrollback.b64" + let writeFile: [String] = [ + "cat > \"\(b64Tmp)\" <<'CMUXSCROLLBACKB64'", + b64, + "CMUXSCROLLBACKB64", + "(base64 -d 2>/dev/null < \"\(b64Tmp)\" || base64 -D 2>/dev/null < \"\(b64Tmp)\") > \"\(remotePath)\" 2>/dev/null || true", + "rm -f \"\(b64Tmp)\" >/dev/null 2>&1 || true", + "chmod 600 \"\(remotePath)\" >/dev/null 2>&1 || true", + ] + let exportLine = "export CMUX_RESTORE_SCROLLBACK_FILE=\"\(remotePath)\"" + return (writeFile, exportLine) + } + func buildInteractiveRemoteShellScript( remoteRelayPort: Int, shellFeatures: String, @@ -4594,6 +4774,10 @@ struct CMUXCLI { commonShellExportLines.append("export CMUX_SOCKET=\(relaySocket)") } commonShellExportLines.append(contentsOf: remoteCallerExportLines) + let scrollbackInjection = remoteScrollbackInjection(remoteRelayPort: remoteRelayPort) + if let scrollbackExport = scrollbackInjection.exportLine { + commonShellExportLines.append(scrollbackExport) + } commonShellExportLines.append(contentsOf: [ "hash -r >/dev/null 2>&1 || true", "rehash >/dev/null 2>&1 || true", @@ -4638,6 +4822,10 @@ struct CMUXCLI { "CMUXCMUXBASH", ] } + // Write scrollback bytes (if any) to a remote file before any shell + // launches, so the per-shell integration's cat-and-rm on + // $CMUX_RESTORE_SCROLLBACK_FILE has something to replay. + outerLines.append(contentsOf: scrollbackInjection.writeFile) outerLines.append(contentsOf: commonShellExportLines) outerLines += [ "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", @@ -4950,7 +5138,10 @@ struct CMUXCLI { "rm -f \"$cmux_tmp\"", "exit $cmux_status", ] - return lines.joined(separator: "\n") + let script = lines.joined(separator: "\n") + // Wrap in /bin/sh -c so RemoteCommand works when the remote user's + // login shell is non-POSIX (e.g. Fish). + return "/bin/sh -c " + shellQuote(script) } func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String { @@ -5232,7 +5423,7 @@ struct CMUXCLI { let escapedForegroundAuthToken = foregroundAuthToken .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") - return [ + let script = [ preferredCLIPath.map { "cmux_reconnect_cli=\(shellQuote($0));" } ?? "cmux_reconnect_cli=\"\";", "cmux_reconnect_socket=\"${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}\";", "if [ -z \"$cmux_reconnect_cli\" ] && [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]; then cmux_reconnect_cli=\"$CMUX_BUNDLED_CLI_PATH\"; fi;", @@ -5248,6 +5439,9 @@ struct CMUXCLI { "fi;", "unset cmux_reconnect_socket cmux_reconnect_cli;", ].joined(separator: " ") + // Wrap in /bin/sh -c so LocalCommand works regardless of the user's + // login shell (e.g. Fish, which does not support POSIX var=value syntax). + return "/bin/sh -c " + shellQuote(script) } private func shouldDeferRemoteReconnect(in options: [String]) -> Bool { @@ -7231,7 +7425,7 @@ struct CMUXCLI { return """ Usage: cmux ssh [flags] [-- ] - Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. + Create a new remote SSH workspace, then start an SSH session for that destination. cmux will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. Flags: @@ -7239,7 +7433,9 @@ struct CMUXCLI { --port SSH port --identity SSH identity file path --ssh-option Extra SSH -o option (repeatable) - --no-focus Create workspace without switching to it + --attach Reuse and focus a connected matching workspace instead of creating + --new Always create a new workspace (overrides --attach) + --no-focus Do not switch to the created or attached workspace Example: cmux ssh dev@my-host @@ -14373,7 +14569,7 @@ struct CMUXCLI { workspace-action --action [--workspace ] [--title ] [--color ] [--description ] list-workspaces new-workspace [--name ] [--description <text>] [--cwd <path>] [--command <text>] - ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>] + ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--attach] [--new] [--no-focus] [-- <remote-command-args>] remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] diff --git a/Resources/shell-integration/fish/vendor_conf.d/cmux-fish-integration.fish b/Resources/shell-integration/fish/vendor_conf.d/cmux-fish-integration.fish new file mode 100644 index 000000000000..f9dc86b7c206 --- /dev/null +++ b/Resources/shell-integration/fish/vendor_conf.d/cmux-fish-integration.fish @@ -0,0 +1,728 @@ +# cmux shell integration for fish +# Injected automatically — do not source manually + +# Guard: only activate when cmux has injected the required environment variables. +if not set -q CMUX_SHELL_INTEGRATION; or not set -q CMUX_SOCKET_PATH + # Return so we don't exit the shell (this file is sourced, not executed). + return 0 +end + +# --------------------------------------------------------------------------- +# _cmux_send — write a single-line payload to the cmux socket. +# Uses ncat > socat > nc fallback chain. +# --------------------------------------------------------------------------- +function _cmux_send --description "Send a payload to the cmux socket" + set -l payload $argv[1] + if command -v ncat >/dev/null 2>&1 + printf '%s\n' $payload | ncat -w 1 -U $CMUX_SOCKET_PATH --send-only + else if command -v socat >/dev/null 2>&1 + printf '%s\n' $payload | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" + else if command -v nc >/dev/null 2>&1 + # Some nc builds don't support unix sockets, but keep as a last-ditch fallback. + # + # Important: macOS/BSD nc will often wait for the peer to close the socket + # after it has finished writing. cmux keeps the connection open, so + # a plain `nc -U` can hang indefinitely and leak background processes. + # + # Prefer flags that guarantee we exit after sending, and fall back to a + # short timeout so we never block sidebar updates. + if printf '%s\n' $payload | nc -N -U $CMUX_SOCKET_PATH >/dev/null 2>&1 + true + else + printf '%s\n' $payload | nc -w 1 -U $CMUX_SOCKET_PATH >/dev/null 2>&1; or true + end + end +end + +# --------------------------------------------------------------------------- +# Remote relay support (PR #2559): when CMUX_SOCKET_PATH is host:port form, +# _cmux_send (unix socket) doesn't work. Use `cmux rpc` instead. +# --------------------------------------------------------------------------- +function _cmux_relay_cli_path --description "Locate the cmux CLI binary" + if test -n "$CMUX_BUNDLED_CLI_PATH" -a -x "$CMUX_BUNDLED_CLI_PATH" + echo $CMUX_BUNDLED_CLI_PATH + return 0 + end + command -v cmux 2>/dev/null +end + +function _cmux_socket_is_unix --description "Detect unix socket mode" + test -n "$CMUX_SOCKET_PATH" -a -S "$CMUX_SOCKET_PATH" +end + +function _cmux_socket_uses_remote_relay --description "Detect host:port relay mode" + test -n "$CMUX_SOCKET_PATH"; or return 1 + if string match -q '/*' -- "$CMUX_SOCKET_PATH" + return 1 + end + string match -q '*:*' -- "$CMUX_SOCKET_PATH"; or return 1 + set -l cli (_cmux_relay_cli_path) + test -n "$cli" +end + +function _cmux_has_send_transport --description "Either send transport works" + _cmux_socket_is_unix; and return 0 + _cmux_socket_uses_remote_relay +end + +function _cmux_relay_rpc_bg --description "Fire-and-forget JSON-RPC via cmux CLI" + set -l method $argv[1] + set -l params $argv[2] + _cmux_socket_uses_remote_relay; or return 1 + set -l cli (_cmux_relay_cli_path); or return 1 + "$cli" rpc $method $params >/dev/null 2>&1 & + disown $last_pid 2>/dev/null +end + +function _cmux_json_escape --description "JSON-escape a string" + set -l s "$argv[1]" + set s (string replace -a '\\' '\\\\' -- $s) + set s (string replace -a '"' '\\"' -- $s) + set s (string replace -a \n '\\n' -- $s) + set s (string replace -a \r '\\r' -- $s) + set s (string replace -a \t '\\t' -- $s) + echo $s +end + +# Pick best transport per call. sidebar.* methods landed via PR #2559. +function _cmux_smart_report_git_branch --description "Report git branch via best transport" + set -l branch "$argv[1]" + set -l dirty "$argv[2]" + if _cmux_socket_is_unix + set -l dirty_opt '' + test "$dirty" = "true"; and set dirty_opt '--status=dirty' + _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else if _cmux_socket_uses_remote_relay + set -l esc (_cmux_json_escape "$branch") + set -l params "{\"branch\":\"$esc\",\"dirty\":$dirty" + test -n "$CMUX_PANEL_ID"; and set params "$params,\"surface_id\":\"$CMUX_PANEL_ID\"" + set params "$params}" + _cmux_relay_rpc_bg "sidebar.report_git_branch" "$params" + end +end + +function _cmux_smart_clear_git_branch --description "Clear git branch badge via best transport" + if _cmux_socket_is_unix + _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else if _cmux_socket_uses_remote_relay + set -l params '{}' + test -n "$CMUX_PANEL_ID"; and set params "{\"surface_id\":\"$CMUX_PANEL_ID\"}" + _cmux_relay_rpc_bg "sidebar.clear_git_branch" "$params" + end +end + +# --------------------------------------------------------------------------- +# Scrollback restore — run once at source time. +# If $CMUX_RESTORE_SCROLLBACK_FILE is set and readable, cat and remove it. +# --------------------------------------------------------------------------- +if set -q CMUX_RESTORE_SCROLLBACK_FILE + set -l _cmux_restore_path $CMUX_RESTORE_SCROLLBACK_FILE + set -e CMUX_RESTORE_SCROLLBACK_FILE + if test -r $_cmux_restore_path + /bin/cat -- $_cmux_restore_path 2>/dev/null; or true + /bin/rm -f -- $_cmux_restore_path >/dev/null 2>&1; or true + end +end + +# --------------------------------------------------------------------------- +# Global state variables +# --------------------------------------------------------------------------- +set -g _CMUX_PWD_LAST_PWD "" +set -g _CMUX_GIT_LAST_PWD "" +set -g _CMUX_GIT_LAST_RUN 0 +set -g _CMUX_GIT_JOB_PID "" +set -g _CMUX_GIT_JOB_STARTED_AT 0 +set -g _CMUX_GIT_HEAD_LAST_PWD "" +set -g _CMUX_GIT_HEAD_PATH "" +set -g _CMUX_GIT_HEAD_SIGNATURE "" +set -g _CMUX_GIT_FORCE 0 +set -g _CMUX_PR_POLL_PID "" +set -g _CMUX_PR_POLL_PWD "" +set -g _CMUX_PR_POLL_INTERVAL 45 +set -g _CMUX_PR_FORCE 0 +set -g _CMUX_ASYNC_JOB_TIMEOUT 20 +set -g _CMUX_PORTS_LAST_RUN 0 +set -g _CMUX_SHELL_ACTIVITY_LAST "" +set -g _CMUX_TTY_NAME "" +set -g _CMUX_TTY_REPORTED 0 + +# --------------------------------------------------------------------------- +# _cmux_git_resolve_head_path — find .git/HEAD without invoking git. +# Handles worktrees via `gitdir:` in a .git file. +# Prints the resolved path and returns 0 on success, 1 if not in a git repo. +# --------------------------------------------------------------------------- +function _cmux_git_resolve_head_path --description "Find .git/HEAD path including worktrees" + set -l dir $PWD + while true + if test -d $dir/.git + printf '%s\n' $dir/.git/HEAD + return 0 + end + if test -f $dir/.git + set -l line "" + read -l line < $dir/.git + if string match -q 'gitdir:*' -- $line + set -l gitdir (string replace -r '^gitdir:\s*' '' -- $line) + set gitdir (string trim -- $gitdir) + if test -z "$gitdir" + return 1 + end + if not string match -q '/*' -- $gitdir + set gitdir $dir/$gitdir + end + printf '%s\n' $gitdir/HEAD + return 0 + end + end + if test $dir = / -o -z "$dir" + break + end + set dir (dirname $dir) + end + return 1 +end + +# --------------------------------------------------------------------------- +# _cmux_git_head_signature — read the first line of a HEAD file as a signature. +# --------------------------------------------------------------------------- +function _cmux_git_head_signature --description "Read first line of HEAD file as signature" + set -l head_path $argv[1] + if test -z "$head_path"; or not test -r $head_path + return 1 + end + set -l line "" + read -l line < $head_path + if test $status -ne 0 + return 1 + end + printf '%s\n' $line +end + +# --------------------------------------------------------------------------- +# _cmux_report_tty_once — send the TTY name to the app once per session. +# --------------------------------------------------------------------------- +function _cmux_report_tty_once --description "Report TTY name to app once per session" + test $_CMUX_TTY_REPORTED -eq 1; and return 0 + test -S $CMUX_SOCKET_PATH; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + test -n "$_CMUX_TTY_NAME"; or return 0 + set -g _CMUX_TTY_REPORTED 1 + _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" >/dev/null 2>&1 & + disown $last_pid 2>/dev/null +end + +# --------------------------------------------------------------------------- +# _cmux_report_shell_activity_state — send prompt/running state, de-duped. +# --------------------------------------------------------------------------- +function _cmux_report_shell_activity_state --description "Send prompt/running state to app" + set -l state $argv[1] + test -n "$state"; or return 0 + test -S $CMUX_SOCKET_PATH; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + test "$_CMUX_SHELL_ACTIVITY_LAST" = "$state"; and return 0 + set -g _CMUX_SHELL_ACTIVITY_LAST $state + _cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" >/dev/null 2>&1 & + disown $last_pid 2>/dev/null +end + +# --------------------------------------------------------------------------- +# _cmux_ports_kick — tell the app to run a batched scan for this panel. +# The app coalesces kicks across all panels and runs a single ps+lsof. +# --------------------------------------------------------------------------- +function _cmux_ports_kick --description "Trigger batched port scan in app" + test -S $CMUX_SOCKET_PATH; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + set -g _CMUX_PORTS_LAST_RUN (date +%s) + _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" >/dev/null 2>&1 & + disown $last_pid 2>/dev/null +end + +# --------------------------------------------------------------------------- +# _cmux_clear_pr_for_panel — clear the PR badge for this panel. +# --------------------------------------------------------------------------- +function _cmux_clear_pr_for_panel --description "Clear PR badge for panel" + test -S $CMUX_SOCKET_PATH; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +end + +# --------------------------------------------------------------------------- +# _cmux_pr_output_indicates_no_pull_request — check if gh error means no PR. +# --------------------------------------------------------------------------- +function _cmux_pr_output_indicates_no_pull_request --description "Check if gh error means no PR" + set -l output (string lower -- $argv[1]) + string match -q '*no pull requests found*' -- $output; and return 0 + string match -q '*no pull request found*' -- $output; and return 0 + string match -q '*no pull requests associated*' -- $output; and return 0 + string match -q '*no pull request associated*' -- $output; and return 0 + return 1 +end + +# --------------------------------------------------------------------------- +# _cmux_report_pr_for_path — run gh pr view and send the result to cmux. +# --------------------------------------------------------------------------- +function _cmux_report_pr_for_path --description "Run gh pr view and send result to app" + set -l repo_path $argv[1] + if test -z "$repo_path" + _cmux_clear_pr_for_panel + return 0 + end + if not test -d $repo_path + _cmux_clear_pr_for_panel + return 0 + end + test -S $CMUX_SOCKET_PATH; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + + set -l branch (git -C $repo_path branch --show-current 2>/dev/null) + if test -z "$branch"; or not command -v gh >/dev/null 2>&1 + _cmux_clear_pr_for_panel + return 0 + end + + set -l tmpdir $TMPDIR + if test -z "$tmpdir" + set tmpdir /tmp + end + set -l err_file (/usr/bin/mktemp "$tmpdir/cmux-gh-pr-view.XXXXXX" 2>/dev/null) + if test -z "$err_file" + return 1 + end + + set -l gh_output "" + cd $repo_path 2>/dev/null + if test $status -ne 0 + _cmux_clear_pr_for_panel + /bin/rm -f -- $err_file >/dev/null 2>&1; or true + return 1 + end + set gh_output (gh pr view \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>$err_file) + set -l gh_status $status + + set -l gh_error "" + if test -f $err_file + set gh_error (cat -- $err_file 2>/dev/null; or true) + /bin/rm -f -- $err_file >/dev/null 2>&1; or true + end + + if test $gh_status -ne 0 + if _cmux_pr_output_indicates_no_pull_request "$gh_error" + # Retry with the explicit branch name before clearing — worktree or + # implicit-resolution failures may succeed when branch is named directly. + set -l retry_err_file (/usr/bin/mktemp "$tmpdir/cmux-gh-pr-view.XXXXXX" 2>/dev/null) + if test -n "$retry_err_file" + set gh_output (gh pr view "$branch" \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>$retry_err_file) + set gh_status $status + /bin/rm -f -- $retry_err_file >/dev/null 2>&1; or true + end + if test $gh_status -ne 0 + _cmux_clear_pr_for_panel + return 0 + end + else + # Preserve the last-known PR badge when gh fails transiently, then retry + # on the next background poll instead of clearing visible state. + return 1 + end + end + + if test -z "$gh_output" + _cmux_clear_pr_for_panel + return 0 + end + + set -l parts (string split \t -- $gh_output) + set -l number $parts[1] + set -l state $parts[2] + set -l url $parts[3] + + if test -z "$number" -o -z "$url" + return 1 + end + + set -l status_opt "" + switch $state + case MERGED + set status_opt "--state=merged" + case OPEN + set status_opt "--state=open" + case CLOSED + set status_opt "--state=closed" + case '*' + return 1 + end + + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +end + +# --------------------------------------------------------------------------- +# _cmux_child_pids — list direct child PIDs of a given process. +# --------------------------------------------------------------------------- +function _cmux_child_pids --description "List direct child PIDs of process" + set -l parent_pid $argv[1] + test -n "$parent_pid"; or return 0 + /bin/ps -ax -o pid= -o ppid= 2>/dev/null | /usr/bin/awk -v parent=$parent_pid '$2 == parent { print $1 }' +end + +# --------------------------------------------------------------------------- +# _cmux_kill_process_tree — recursively kill a process and all its children. +# --------------------------------------------------------------------------- +function _cmux_kill_process_tree --description "Recursively kill process and children" + set -l pid $argv[1] + set -l signal TERM + if test (count $argv) -ge 2 + set signal $argv[2] + end + test -n "$pid"; or return 0 + + for child_pid in (_cmux_child_pids $pid) + test -n "$child_pid"; or continue + test "$child_pid" = "$pid"; and continue + _cmux_kill_process_tree $child_pid $signal + end + + kill -$signal $pid >/dev/null 2>&1; or true +end + +# --------------------------------------------------------------------------- +# _cmux_stop_pr_poll_loop — kill the background PR poll loop if running. +# --------------------------------------------------------------------------- +function _cmux_stop_pr_poll_loop --description "Kill background PR poll loop" + if test -n "$_CMUX_PR_POLL_PID" + # Use SIGKILL directly to avoid blocking sleep in preexec. + # The poll loop is lightweight and safe to kill abruptly. + _cmux_kill_process_tree $_CMUX_PR_POLL_PID KILL + set -g _CMUX_PR_POLL_PID "" + end +end + +# --------------------------------------------------------------------------- +# _cmux_start_pr_poll_loop — start (or restart) the background PR poll loop. +# +# The poll loop runs as a background fish -c process. It sources the +# integration file using the path stored in $_CMUX_INTEGRATION_FILE so that +# _cmux_report_pr_for_path and _cmux_send are available. The integration file +# path is set once below after all functions are defined. +# --------------------------------------------------------------------------- +function _cmux_start_pr_poll_loop --description "Start or restart background PR poll loop" + test -S $CMUX_SOCKET_PATH; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + + set -l watch_pwd $PWD + if test (count $argv) -ge 1 + set watch_pwd $argv[1] + end + set -l force_restart 0 + if test (count $argv) -ge 2 + set force_restart $argv[2] + end + set -l watch_shell_pid $fish_pid + set -l interval $_CMUX_PR_POLL_INTERVAL + set -l async_timeout $_CMUX_ASYNC_JOB_TIMEOUT + set -l socket_path $CMUX_SOCKET_PATH + set -l tab_id $CMUX_TAB_ID + set -l panel_id $CMUX_PANEL_ID + set -l integration_file $_CMUX_INTEGRATION_FILE + + if test $force_restart -ne 1 + and test "$watch_pwd" = "$_CMUX_PR_POLL_PWD" + and test -n "$_CMUX_PR_POLL_PID" + and kill -0 $_CMUX_PR_POLL_PID 2>/dev/null + return 0 + end + + _cmux_stop_pr_poll_loop + set -g _CMUX_PR_POLL_PWD $watch_pwd + + # Escape single quotes in paths interpolated into fish -c strings. + set -l escaped_pwd (string replace -a "'" "\\'" -- $watch_pwd) + set -l escaped_socket (string replace -a "'" "\\'" -- $socket_path) + set -l escaped_integration (string replace -a "'" "\\'" -- $integration_file) + + # Build a self-contained poll loop script that sources the integration + # file to get access to _cmux_send and _cmux_report_pr_for_path. + fish -c " +set -x CMUX_SOCKET_PATH '$escaped_socket' +set -x CMUX_TAB_ID '$tab_id' +set -x CMUX_PANEL_ID '$panel_id' +set -x CMUX_SHELL_INTEGRATION 1 +source '$escaped_integration' +set -l watch_pwd '$escaped_pwd' +set -l interval $interval +set -l watch_shell_pid $watch_shell_pid +set -l async_timeout $async_timeout +while true + kill -0 \$watch_shell_pid >/dev/null 2>&1; or break + # Run probe in a subprocess so we can apply a timeout. + fish -c \" +set -x CMUX_SOCKET_PATH '$escaped_socket' +set -x CMUX_TAB_ID '$tab_id' +set -x CMUX_PANEL_ID '$panel_id' +set -x CMUX_SHELL_INTEGRATION 1 +source '$escaped_integration' +_cmux_report_pr_for_path '$escaped_pwd' +\" >/dev/null 2>&1 & + set -l probe_pid \$last_pid + set -l started_at (date +%s) + set -l timed_out 0 + while kill -0 \$probe_pid >/dev/null 2>&1 + sleep 1 + set -l now (date +%s) + if test \$async_timeout -gt 0; and test (math \$now - \$started_at) -ge \$async_timeout + _cmux_kill_process_tree \$probe_pid KILL + set timed_out 1 + break + end + end + sleep \$interval +end +" >/dev/null 2>&1 & + set -g _CMUX_PR_POLL_PID $last_pid + disown $_CMUX_PR_POLL_PID 2>/dev/null +end + +# --------------------------------------------------------------------------- +# Preexec hook — called before each command runs. +# --------------------------------------------------------------------------- +function _cmux_fish_preexec --description "Preexec hook before command runs" --on-event fish_preexec + test -S $CMUX_SOCKET_PATH; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + + if test -z "$_CMUX_TTY_NAME" + set -l tty_raw (tty 2>/dev/null; or true) + set tty_raw (string replace -r '^.*/' '' -- $tty_raw) + if test -n "$tty_raw" -a "$tty_raw" != "not a tty" + set -g _CMUX_TTY_NAME $tty_raw + end + end + + _cmux_report_shell_activity_state running + + # Heuristic: commands that may change git branch/dirty state without changing $PWD. + set -l command_string (string trim -- "$argv[1]") + switch $command_string + case 'git *' git 'gh *' lazygit 'lazygit *' tig 'tig *' gitui 'gitui *' 'stg *' 'jj *' + set -g _CMUX_GIT_FORCE 1 + set -g _CMUX_PR_FORCE 1 + end + + _cmux_report_tty_once + _cmux_ports_kick + _cmux_stop_pr_poll_loop +end + +# --------------------------------------------------------------------------- +# Prompt (precmd) hook — called before each prompt is drawn. +# --------------------------------------------------------------------------- +function _cmux_fish_prompt --description "Prompt hook before prompt is drawn" --on-event fish_prompt + _cmux_has_send_transport; or return 0 + test -n "$CMUX_TAB_ID"; or return 0 + test -n "$CMUX_PANEL_ID"; or return 0 + + _cmux_report_shell_activity_state prompt + + set -l now (date +%s) + set -l pwd $PWD + + # Post-wake socket writes can occasionally leave a probe process wedged. + # If one probe is stale, clear the guard so fresh async probes can resume. + if test -n "$_CMUX_GIT_JOB_PID" + if not kill -0 $_CMUX_GIT_JOB_PID 2>/dev/null + set -g _CMUX_GIT_JOB_PID "" + set -g _CMUX_GIT_JOB_STARTED_AT 0 + else if test $_CMUX_GIT_JOB_STARTED_AT -gt 0 + and test (math $now - $_CMUX_GIT_JOB_STARTED_AT) -ge $_CMUX_ASYNC_JOB_TIMEOUT + _cmux_kill_process_tree $_CMUX_GIT_JOB_PID KILL + set -g _CMUX_GIT_JOB_PID "" + set -g _CMUX_GIT_JOB_STARTED_AT 0 + end + end + + # Resolve TTY name once. + if test -z "$_CMUX_TTY_NAME" + set -l tty_raw (tty 2>/dev/null; or true) + set tty_raw (string replace -r '^.*/' '' -- $tty_raw) + if test "$tty_raw" != "not a tty" + set -g _CMUX_TTY_NAME $tty_raw + end + end + + _cmux_report_tty_once + + # CWD: keep the app in sync with the actual shell directory. + if test "$pwd" != "$_CMUX_PWD_LAST_PWD" + set -g _CMUX_PWD_LAST_PWD $pwd + set -l quoted_pwd (string replace -a '"' '\\"' -- $pwd) + _cmux_send "report_pwd \"$quoted_pwd\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" >/dev/null 2>&1 & + disown $last_pid 2>/dev/null + end + + # HEAD signature tracking: detect branch/commit changes without running git. + # Uses the precmd-based approach (like bash) — no background watcher. + set -l git_head_changed 0 + if test "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" + set -g _CMUX_GIT_HEAD_LAST_PWD $pwd + set -g _CMUX_GIT_HEAD_PATH (_cmux_git_resolve_head_path 2>/dev/null; or printf '') + set -g _CMUX_GIT_HEAD_SIGNATURE "" + end + if test -n "$_CMUX_GIT_HEAD_PATH" + set -l head_signature (_cmux_git_head_signature $_CMUX_GIT_HEAD_PATH 2>/dev/null; or printf '') + if test -n "$head_signature" -a "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" + if test -n "$_CMUX_GIT_HEAD_SIGNATURE" + # Signature changed from a known baseline — force both probes to refresh. + set git_head_changed 1 + set -g _CMUX_GIT_FORCE 1 + set -g _CMUX_PR_FORCE 1 + end + # Always update the stored signature (baseline on first read, track changes after). + set -g _CMUX_GIT_HEAD_SIGNATURE $head_signature + end + end + + # Git branch/dirty: decide whether to launch a new async probe this prompt. + # Throttle to avoid redundant git calls, but always refresh on directory + # change, HEAD change, or after a git-related command (_CMUX_GIT_FORCE). + set -l should_git 0 + if test "$pwd" != "$_CMUX_GIT_LAST_PWD" + set should_git 1 + else if test $_CMUX_GIT_FORCE -eq 1 + set should_git 1 + else if test (math $now - $_CMUX_GIT_LAST_RUN) -ge 3 + set should_git 1 + end + # HEAD change always forces a refresh (set above in the HEAD tracking block). + test $git_head_changed -eq 1; and set should_git 1 + + if test $should_git -eq 1 + set -l can_launch_git 1 + if test -n "$_CMUX_GIT_JOB_PID" + and kill -0 $_CMUX_GIT_JOB_PID 2>/dev/null + # If a stale probe is still running but the cwd changed or we just ran + # a git command, restart immediately so branch state isn't delayed. + if test "$pwd" != "$_CMUX_GIT_LAST_PWD"; or test $_CMUX_GIT_FORCE -eq 1 + _cmux_kill_process_tree $_CMUX_GIT_JOB_PID KILL + set -g _CMUX_GIT_JOB_PID "" + set -g _CMUX_GIT_JOB_STARTED_AT 0 + else + set can_launch_git 0 + end + end + + if test $can_launch_git -eq 1 + set -g _CMUX_GIT_FORCE 0 + set -g _CMUX_GIT_LAST_PWD $pwd + set -g _CMUX_GIT_LAST_RUN $now + # Source the integration file so _cmux_send is available in the subprocess. + set -l escaped_socket (string replace -a "'" "\\'" -- $CMUX_SOCKET_PATH) + set -l escaped_integration (string replace -a "'" "\\'" -- $_CMUX_INTEGRATION_FILE) + set -l escaped_cli (string replace -a "'" "\\'" -- "$CMUX_BUNDLED_CLI_PATH") + set -l tab_id $CMUX_TAB_ID + set -l panel_id $CMUX_PANEL_ID + fish -c " +set -x CMUX_SOCKET_PATH '$escaped_socket' +set -x CMUX_TAB_ID '$tab_id' +set -x CMUX_PANEL_ID '$panel_id' +set -x CMUX_SHELL_INTEGRATION 1 +set -x CMUX_BUNDLED_CLI_PATH '$escaped_cli' +source '$escaped_integration' +set -l branch (git branch --show-current 2>/dev/null) +if test -n \"\$branch\" + set -l first (git status --porcelain -uno 2>/dev/null | head -1) + set -l dirty_bool false + if test -n \"\$first\"; set dirty_bool true; end + _cmux_smart_report_git_branch \"\$branch\" \"\$dirty_bool\" +else + _cmux_smart_clear_git_branch +end +" >/dev/null 2>&1 & + set -g _CMUX_GIT_JOB_PID $last_pid + disown $_CMUX_GIT_JOB_PID 2>/dev/null + set -g _CMUX_GIT_JOB_STARTED_AT $now + end + end + + # Pull request: restart the poll loop when directory or HEAD changes. + set -l should_restart_pr_poll 0 + set -l pr_context_changed 0 + if test -n "$_CMUX_PR_POLL_PWD" -a "$pwd" != "$_CMUX_PR_POLL_PWD" + set pr_context_changed 1 + else if test $git_head_changed -eq 1 + set pr_context_changed 1 + end + if test "$pwd" != "$_CMUX_PR_POLL_PWD" -o $git_head_changed -eq 1 + set should_restart_pr_poll 1 + else if test $_CMUX_PR_FORCE -eq 1 + set should_restart_pr_poll 1 + else if test -z "$_CMUX_PR_POLL_PID" + or not kill -0 $_CMUX_PR_POLL_PID 2>/dev/null + set should_restart_pr_poll 1 + end + + if test $should_restart_pr_poll -eq 1 + set -g _CMUX_PR_FORCE 0 + if test $pr_context_changed -eq 1 + _cmux_clear_pr_for_panel + end + _cmux_start_pr_poll_loop $pwd 1 + end + + # Ports: lightweight kick to the app's batched scanner every ~10s. + if test (math $now - $_CMUX_PORTS_LAST_RUN) -ge 10 + _cmux_ports_kick + end +end + +# --------------------------------------------------------------------------- +# PATH fix — run once on first prompt, then self-remove. +# Prepend Resources/bin, remove MacOS dir from PATH. +# Shell init files may prepend other dirs after launch; fix on first prompt +# after all init files have run. +# --------------------------------------------------------------------------- +function _cmux_fix_path --description "Fix PATH on first prompt then remove" --on-event fish_prompt + if set -q GHOSTTY_BIN_DIR + set -l gui_dir (string replace -r '/$' '' -- $GHOSTTY_BIN_DIR) + set -l bin_dir (string replace -r '/MacOS$' '' -- $gui_dir)/Resources/bin + if test -d $bin_dir + # Remove existing entries for bin_dir and gui_dir, then prepend bin_dir. + # In fish, $PATH is already a list — no need to split on ':'. + set -l new_parts + for part in $PATH + if test "$part" != "$bin_dir" -a "$part" != "$gui_dir" + set -a new_parts $part + end + end + set -gx PATH $bin_dir $new_parts + end + end + # Self-remove after first run. + functions -e _cmux_fix_path +end + +# --------------------------------------------------------------------------- +# Store the integration file path so the PR poll loop can source it. +# This must come after all function definitions. +# --------------------------------------------------------------------------- +set -g _CMUX_INTEGRATION_FILE (status filename) + +# --------------------------------------------------------------------------- +# Cleanup on shell exit. +# --------------------------------------------------------------------------- +function _cmux_fish_exit --description "Cleanup on shell exit" --on-event fish_exit + if test -n "$_CMUX_GIT_JOB_PID" + _cmux_kill_process_tree $_CMUX_GIT_JOB_PID KILL + end + _cmux_stop_pr_poll_loop +end diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 035e36b1dce9..6aa804b60525 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2195,6 +2195,42 @@ class TerminalController { case "notification.clear": return v2Result(id: id, self.v2NotificationClear()) + // Sidebar + case "sidebar.set_status": + return v2Result(id: id, self.v2SidebarSetStatus(params: params)) + case "sidebar.clear_status": + return v2Result(id: id, self.v2SidebarClearStatus(params: params)) + case "sidebar.list_status": + return v2Result(id: id, self.v2SidebarListStatus(params: params)) + case "sidebar.set_progress": + return v2Result(id: id, self.v2SidebarSetProgress(params: params)) + case "sidebar.clear_progress": + return v2Result(id: id, self.v2SidebarClearProgress(params: params)) + case "sidebar.log": + return v2Result(id: id, self.v2SidebarLog(params: params)) + case "sidebar.clear_log": + return v2Result(id: id, self.v2SidebarClearLog(params: params)) + case "sidebar.list_log": + return v2Result(id: id, self.v2SidebarListLog(params: params)) + case "sidebar.set_meta": + return v2Result(id: id, self.v2SidebarSetMeta(params: params)) + case "sidebar.clear_meta": + return v2Result(id: id, self.v2SidebarClearMeta(params: params)) + case "sidebar.list_meta": + return v2Result(id: id, self.v2SidebarListMeta(params: params)) + case "sidebar.set_agent_pid": + return v2Result(id: id, self.v2SidebarSetAgentPid(params: params)) + case "sidebar.clear_agent_pid": + return v2Result(id: id, self.v2SidebarClearAgentPid(params: params)) + case "sidebar.report_git_branch": + return v2Result(id: id, self.v2SidebarReportGitBranch(params: params)) + case "sidebar.clear_git_branch": + return v2Result(id: id, self.v2SidebarClearGitBranch(params: params)) + case "sidebar.state": + return v2Result(id: id, self.v2SidebarState(params: params)) + case "sidebar.reset": + return v2Result(id: id, self.v2SidebarReset(params: params)) + // App focus case "app.focus_override.set": return v2Result(id: id, self.v2AppFocusOverride(params: params)) @@ -2520,6 +2556,23 @@ class TerminalController { "notification.create_for_target", "notification.list", "notification.clear", + "sidebar.set_status", + "sidebar.clear_status", + "sidebar.list_status", + "sidebar.set_progress", + "sidebar.clear_progress", + "sidebar.log", + "sidebar.clear_log", + "sidebar.list_log", + "sidebar.set_meta", + "sidebar.clear_meta", + "sidebar.list_meta", + "sidebar.set_agent_pid", + "sidebar.clear_agent_pid", + "sidebar.report_git_branch", + "sidebar.clear_git_branch", + "sidebar.state", + "sidebar.reset", "app.focus_override.set", "app.simulate_active", "markdown.open", @@ -6910,6 +6963,565 @@ class TerminalController { return .ok([:]) } + // MARK: - V2 Sidebar Methods + + // NOTE: These methods use v2MainSync to mutate workspace state on the main thread, + // matching the existing V1 handler behavior. For high-frequency paths (set_status, + // set_progress, log), this serializes socket I/O with UI updates. This is acceptable + // for current usage patterns but could be optimized with async dispatch if telemetry + // shows main-thread contention from sidebar updates. + + private func v2SidebarSetStatus(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing or empty 'key'", data: nil) + } + guard let value = v2String(params, "value") else { + return .err(code: "invalid_params", message: "Missing or empty 'value'", data: nil) + } + let icon = v2String(params, "icon") + let color = v2String(params, "color") + let parsedURL: URL? + if let rawURL = v2String(params, "url") { + guard let candidate = URL(string: rawURL), + let scheme = candidate.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return .err(code: "invalid_params", message: "Invalid URL '\(rawURL)' — expected http(s) URL", data: nil) + } + parsedURL = candidate + } else { + parsedURL = nil + } + let priority = max(-9999, min(9999, v2Int(params, "priority") ?? 0)) + let formatRaw = v2String(params, "format") ?? SidebarMetadataFormat.plain.rawValue + let normalizedFormat: String = { + let lower = formatRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lower == "md" ? SidebarMetadataFormat.markdown.rawValue : lower + }() + guard let format = SidebarMetadataFormat(rawValue: normalizedFormat) else { + return .err(code: "invalid_params", message: "Invalid format '\(formatRaw)' — use: plain, markdown", data: nil) + } + let pidValue: pid_t? = { + if let p = v2Int(params, "pid"), p > 0, p <= Int(Int32.max) { return pid_t(p) } + return nil + }() + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to set status", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + guard Self.shouldReplaceStatusEntry( + current: ws.statusEntries[key], + key: key, + value: value, + icon: icon, + color: color, + url: parsedURL, + priority: priority, + format: format + ) else { + if let pidValue { + ws.agentPIDs[key] = pidValue + } + result = .ok(["workspace_id": ws.id.uuidString]) + return + } + ws.statusEntries[key] = SidebarStatusEntry( + key: key, + value: value, + icon: icon, + color: color, + url: parsedURL, + priority: priority, + format: format, + timestamp: Date() + ) + if let pidValue { + ws.agentPIDs[key] = pidValue + } + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarClearStatus(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing or empty 'key'", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear status", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.statusEntries.removeValue(forKey: key) + ws.agentPIDs.removeValue(forKey: key) + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarListStatus(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let isoFormatter = ISO8601DateFormatter() + var result: V2CallResult = .err(code: "internal_error", message: "Failed to list status", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let entries: [[String: Any]] = ws.sidebarStatusEntriesInDisplayOrder().map { entry in + [ + "key": entry.key, + "value": entry.value, + "icon": v2OrNull(entry.icon), + "color": v2OrNull(entry.color), + "url": v2OrNull(entry.url?.absoluteString), + "priority": entry.priority, + "format": entry.format.rawValue, + "timestamp": isoFormatter.string(from: entry.timestamp) + ] + } + result = .ok(["entries": entries]) + } + return result + } + + private func v2SidebarSetProgress(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let rawValue: Double? = { + if let d = params["value"] as? Double { return d } + if let n = params["value"] as? NSNumber { return n.doubleValue } + if let s = params["value"] as? String { return Double(s) } + return nil + }() + guard let rawValue else { + return .err(code: "invalid_params", message: "Missing or invalid 'value' — must be a number 0.0 to 1.0", data: nil) + } + guard rawValue.isFinite else { + return .err(code: "invalid_params", message: "Invalid 'value' — must be a finite number 0.0 to 1.0", data: nil) + } + let clamped = min(1.0, max(0.0, rawValue)) + let label = v2String(params, "label") + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to set progress", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + guard Self.shouldReplaceProgress(current: ws.progress, value: clamped, label: label) else { + result = .ok(["workspace_id": ws.id.uuidString]) + return + } + ws.progress = SidebarProgressState(value: clamped, label: label) + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarClearProgress(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear progress", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.progress = nil + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarLog(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let message = v2String(params, "message") else { + return .err(code: "invalid_params", message: "Missing or empty 'message'", data: nil) + } + let levelStr = v2String(params, "level") ?? "info" + guard let level = SidebarLogLevel(rawValue: levelStr) else { + return .err(code: "invalid_params", message: "Unknown log level '\(levelStr)' — use: info, progress, success, warning, error", data: nil) + } + let source = v2String(params, "source") + let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 + let limit = max(1, min(500, configuredLimit)) + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to log", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.logEntries.append(SidebarLogEntry(message: message, level: level, source: source, timestamp: Date())) + if ws.logEntries.count > limit { + ws.logEntries.removeFirst(ws.logEntries.count - limit) + } + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarClearLog(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear log", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.logEntries.removeAll() + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarListLog(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let limit = v2Int(params, "limit") + let isoFormatter = ISO8601DateFormatter() + var result: V2CallResult = .err(code: "internal_error", message: "Failed to list log", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let logSlice: [SidebarLogEntry] + if let limit { + logSlice = Array(ws.logEntries.suffix(max(0, limit))) + } else { + logSlice = ws.logEntries + } + let entries: [[String: Any]] = logSlice.map { entry in + [ + "message": entry.message, + "level": entry.level.rawValue, + "source": v2OrNull(entry.source), + "timestamp": isoFormatter.string(from: entry.timestamp) + ] + } + result = .ok(["entries": entries]) + } + return result + } + + private func v2SidebarSetMeta(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing or empty 'key'", data: nil) + } + // Use raw extraction — v2String trims whitespace which breaks markdown formatting. + // Normalize escape sequences (\\n → newline, \\t → tab) and CRLF → LF, matching V1 behavior. + guard let rawMarkdown = params["markdown"] as? String else { + return .err(code: "invalid_params", message: "Missing 'markdown'", data: nil) + } + let markdown = rawMarkdown + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\t", with: "\t") + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + guard !markdown.isEmpty else { + return .err(code: "invalid_params", message: "Empty 'markdown'", data: nil) + } + let priority = max(-9999, min(9999, v2Int(params, "priority") ?? 0)) + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to set metadata block", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + guard Self.shouldReplaceMetadataBlock( + current: ws.metadataBlocks[key], + key: key, + markdown: markdown, + priority: priority + ) else { + result = .ok(["workspace_id": ws.id.uuidString]) + return + } + ws.metadataBlocks[key] = SidebarMetadataBlock( + key: key, + markdown: markdown, + priority: priority, + timestamp: Date() + ) + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarClearMeta(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing or empty 'key'", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear metadata block", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.metadataBlocks.removeValue(forKey: key) + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarListMeta(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let isoFormatter = ISO8601DateFormatter() + var result: V2CallResult = .err(code: "internal_error", message: "Failed to list metadata", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let blocks: [[String: Any]] = ws.sidebarMetadataBlocksInDisplayOrder().map { block in + [ + "key": block.key, + "markdown": block.markdown, + "priority": block.priority, + "timestamp": isoFormatter.string(from: block.timestamp) + ] + } + result = .ok(["blocks": blocks]) + } + return result + } + + private func v2SidebarSetAgentPid(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing or empty 'key'", data: nil) + } + guard let pid = v2Int(params, "pid"), pid > 0, pid <= Int(Int32.max) else { + return .err(code: "invalid_params", message: "Missing or invalid 'pid' — must be > 0", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to set agent PID", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.agentPIDs[key] = pid_t(pid) + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarClearAgentPid(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing or empty 'key'", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear agent PID", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.agentPIDs.removeValue(forKey: key) + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarReportGitBranch(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let branch = v2String(params, "branch") else { + return .err(code: "invalid_params", message: "Missing or empty 'branch'", data: nil) + } + let isDirty = v2Bool(params, "dirty") ?? false + let surfaceId = v2UUID(params, "surface_id") + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to report git branch", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + if let surfaceId { + guard Self.shouldReplaceGitBranch(current: ws.panelGitBranches[surfaceId], branch: branch, isDirty: isDirty) else { + result = .ok(["workspace_id": ws.id.uuidString]) + return + } + ws.panelGitBranches[surfaceId] = SidebarGitBranchState(branch: branch, isDirty: isDirty) + } else { + guard Self.shouldReplaceGitBranch(current: ws.gitBranch, branch: branch, isDirty: isDirty) else { + result = .ok(["workspace_id": ws.id.uuidString]) + return + } + ws.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) + } + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarClearGitBranch(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let surfaceId = v2UUID(params, "surface_id") + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear git branch", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + if let surfaceId { + ws.panelGitBranches.removeValue(forKey: surfaceId) + } else { + ws.gitBranch = nil + } + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + + private func v2SidebarState(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let isoFormatter = ISO8601DateFormatter() + var result: V2CallResult = .err(code: "internal_error", message: "Failed to get sidebar state", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + // Status entries + let statusEntries: [[String: Any]] = ws.sidebarStatusEntriesInDisplayOrder().map { entry in + [ + "key": entry.key, + "value": entry.value, + "icon": v2OrNull(entry.icon), + "color": v2OrNull(entry.color), + "url": v2OrNull(entry.url?.absoluteString), + "priority": entry.priority, + "format": entry.format.rawValue, + "timestamp": isoFormatter.string(from: entry.timestamp) + ] + } + // Progress + let progressDict: Any + if let progress = ws.progress { + progressDict = [ + "value": progress.value, + "label": v2OrNull(progress.label) + ] as [String: Any] + } else { + progressDict = NSNull() + } + // Git branch + let gitBranchDict: Any + if let git = ws.gitBranch { + gitBranchDict = [ + "branch": git.branch, + "dirty": git.isDirty + ] as [String: Any] + } else { + gitBranchDict = NSNull() + } + // Log entries + let logEntries: [[String: Any]] = ws.logEntries.map { entry in + [ + "message": entry.message, + "level": entry.level.rawValue, + "source": v2OrNull(entry.source), + "timestamp": isoFormatter.string(from: entry.timestamp) + ] + } + // Metadata blocks + let metadataBlocks: [[String: Any]] = ws.sidebarMetadataBlocksInDisplayOrder().map { block in + [ + "key": block.key, + "markdown": block.markdown, + "priority": block.priority, + "timestamp": isoFormatter.string(from: block.timestamp) + ] + } + // Panel git branches + var panelGitBranchesJSON: [String: Any] = [:] + for (surfaceId, git) in ws.panelGitBranches { + panelGitBranchesJSON[surfaceId.uuidString] = ["branch": git.branch, "dirty": git.isDirty] + } + // Agent PIDs + var agentPids: [String: Any] = [:] + for (key, pid) in ws.agentPIDs { + agentPids[key] = Int(pid) + } + let state: [String: Any] = [ + "workspace_id": ws.id.uuidString, + "status_entries": statusEntries, + "progress": progressDict, + "git_branch": gitBranchDict, + "panel_git_branches": panelGitBranchesJSON, + "log_entries": logEntries, + "metadata_blocks": metadataBlocks, + "agent_pids": agentPids + ] + result = .ok(state) + } + return result + } + + private func v2SidebarReset(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to reset sidebar", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + ws.resetSidebarContext(reason: "v2_sidebar_reset") + result = .ok(["workspace_id": ws.id.uuidString]) + } + return result + } + private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { let workspaceId = v2UUID(params, "workspace_id") let windowId = v2UUID(params, "window_id") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index dac4b68fca8c..7e7e46332872 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3508,6 +3508,7 @@ final class WorkspaceRemoteSessionController { ) connectionAttemptStartedAt = Date() debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") + cleanStaleControlMasterIfNeeded() reconnectWorkItem = nil bootstrapRemoteTTYRetryWorkItem?.cancel() bootstrapRemoteTTYRetryWorkItem = nil @@ -5105,6 +5106,28 @@ final class WorkspaceRemoteSessionController { .sorted() } + private func cleanStaleControlMasterIfNeeded() { + var checkArgs = sshCommonArguments(batchMode: true) + checkArgs += ["-O", "check", configuration.destination] + do { + let result = try sshExec(arguments: checkArgs, timeout: 3) + if result.status == 0 { + debugLog("remote.controlmaster.check ok \(debugConfigSummary())") + return + } + } catch { + // Timeout or failure means the ControlMaster is stale + } + debugLog("remote.controlmaster.stale cleaning up \(debugConfigSummary())") + var exitArgs = sshCommonArguments(batchMode: true) + exitArgs += ["-O", "exit", configuration.destination] + do { + _ = try sshExec(arguments: exitArgs, timeout: 3) + } catch { + // Best-effort cleanup; if this also fails the socket is already gone + } + } + private static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { guard let output = captureCommandStandardOutput( executablePath: "/bin/ps", diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 819c45bfa758..865d2a1d90f3 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -2187,6 +2187,10 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { firstInvocation.first(where: { $0.hasPrefix("LocalCommand=") }) ) let localCommand = String(localCommandArgument.dropFirst("LocalCommand=".count)) + XCTAssertTrue( + localCommand.hasPrefix("/bin/sh -c "), + "Expected LocalCommand to be wrapped in /bin/sh -c for non-POSIX shell compatibility (e.g. Fish), saw \(localCommand)" + ) XCTAssertTrue( firstInvocation.contains(where: { $0.contains("LocalCommand=") && $0.contains("workspace.remote.foreground_auth_ready") }), "Expected the bootstrap install SSH hop to signal foreground auth readiness via LocalCommand, saw \(firstInvocation)" diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index 2d3d37c07c6c..a6743ee50164 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -45,6 +45,17 @@ type commandSpec struct { paramKeyOverrides map[string]string // defaultParams are applied before flags/env fallbacks. defaultParams map[string]any + // positionalKeys maps positional arguments to param keys by index. + // Each key consumes exactly one positional arg. If greedyLast is true, + // the final key joins all remaining positional args (for free-form text + // like status values or markdown). A separate posIdx cursor tracks which + // positional to consume next, so flags that satisfy earlier keys don't + // misalign the cursor. + positionalKeys []string + // greedyLast makes the last positionalKey consume all remaining positional + // args (joined with spaces). Only set this for commands whose last param + // is free-form text (value, markdown, message). + greedyLast bool } var commands = []commandSpec{ @@ -75,6 +86,25 @@ var commands = []commandSpec{ {name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}}, {name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}}, {name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true}, + + // Sidebar commands + {name: "set-status", proto: protoV2, v2Method: "sidebar.set_status", flagKeys: []string{"key", "value", "icon", "color", "url", "priority", "format", "pid", "workspace"}, positionalKeys: []string{"key", "value"}, greedyLast: true}, + {name: "clear-status", proto: protoV2, v2Method: "sidebar.clear_status", flagKeys: []string{"key", "workspace"}, positionalKeys: []string{"key"}}, + {name: "list-status", proto: protoV2, v2Method: "sidebar.list_status", flagKeys: []string{"workspace"}}, + {name: "set-progress", proto: protoV2, v2Method: "sidebar.set_progress", flagKeys: []string{"value", "label", "workspace"}, positionalKeys: []string{"value"}}, + {name: "clear-progress", proto: protoV2, v2Method: "sidebar.clear_progress", flagKeys: []string{"workspace"}}, + {name: "log", proto: protoV2, v2Method: "sidebar.log", flagKeys: []string{"message", "level", "source", "workspace"}, positionalKeys: []string{"message"}, greedyLast: true}, + {name: "clear-log", proto: protoV2, v2Method: "sidebar.clear_log", flagKeys: []string{"workspace"}}, + {name: "list-log", proto: protoV2, v2Method: "sidebar.list_log", flagKeys: []string{"limit", "workspace"}}, + {name: "set-meta", proto: protoV2, v2Method: "sidebar.set_meta", flagKeys: []string{"key", "markdown", "priority", "workspace"}, positionalKeys: []string{"key", "markdown"}, greedyLast: true}, + {name: "clear-meta", proto: protoV2, v2Method: "sidebar.clear_meta", flagKeys: []string{"key", "workspace"}, positionalKeys: []string{"key"}}, + {name: "list-meta", proto: protoV2, v2Method: "sidebar.list_meta", flagKeys: []string{"workspace"}}, + {name: "set-agent-pid", proto: protoV2, v2Method: "sidebar.set_agent_pid", flagKeys: []string{"key", "pid", "workspace"}, positionalKeys: []string{"key", "pid"}}, + {name: "clear-agent-pid", proto: protoV2, v2Method: "sidebar.clear_agent_pid", flagKeys: []string{"key", "workspace"}, positionalKeys: []string{"key"}}, + {name: "report-git-branch", proto: protoV2, v2Method: "sidebar.report_git_branch", flagKeys: []string{"branch", "dirty", "surface", "workspace"}, positionalKeys: []string{"branch"}}, + {name: "clear-git-branch", proto: protoV2, v2Method: "sidebar.clear_git_branch", flagKeys: []string{"surface", "workspace"}}, + {name: "sidebar-state", proto: protoV2, v2Method: "sidebar.state", flagKeys: []string{"workspace"}}, + {name: "reset-sidebar", proto: protoV2, v2Method: "sidebar.reset", flagKeys: []string{"workspace"}}, } var commandIndex map[string]*commandSpec @@ -236,8 +266,31 @@ func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool } } - // First positional arg is used as initial_command if --command wasn't given - if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { + // Map positional args to named params via spec.positionalKeys. + // Uses a separate posIdx cursor so flags that satisfy earlier keys + // don't misalign the positional consumption. + if len(spec.positionalKeys) > 0 && len(parsed.positional) > 0 { + posIdx := 0 + for i, pkey := range spec.positionalKeys { + paramKey := flagToParamKey(pkey) + if _, exists := params[paramKey]; exists { + continue // explicit flag takes precedence — don't advance posIdx + } + if posIdx >= len(parsed.positional) { + break + } + isLast := i == len(spec.positionalKeys)-1 + if isLast && spec.greedyLast { + // Greedy: join all remaining positional args + params[paramKey] = strings.Join(parsed.positional[posIdx:], " ") + posIdx = len(parsed.positional) + } else { + params[paramKey] = parsed.positional[posIdx] + posIdx++ + } + } + } else if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { + // Fallback: first positional arg is used as initial_command if --command wasn't given params["initial_command"] = parsed.positional[0] } @@ -779,4 +832,23 @@ func cliUsage() { fmt.Fprintln(os.Stderr, " omx [args...] Launch Oh My Codex with cmux integration") fmt.Fprintln(os.Stderr, " omc [args...] Launch Oh My Claude Code with cmux integration") fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Sidebar commands:") + fmt.Fprintln(os.Stderr, " set-status Set a sidebar status pill") + fmt.Fprintln(os.Stderr, " clear-status Clear a sidebar status pill") + fmt.Fprintln(os.Stderr, " list-status List all sidebar status pills") + fmt.Fprintln(os.Stderr, " set-progress Set the sidebar progress bar") + fmt.Fprintln(os.Stderr, " clear-progress Clear the sidebar progress bar") + fmt.Fprintln(os.Stderr, " log Add a sidebar activity log entry") + fmt.Fprintln(os.Stderr, " clear-log Clear the sidebar activity log") + fmt.Fprintln(os.Stderr, " list-log List sidebar activity log entries") + fmt.Fprintln(os.Stderr, " set-meta Set a sidebar metadata block") + fmt.Fprintln(os.Stderr, " clear-meta Clear a sidebar metadata block") + fmt.Fprintln(os.Stderr, " list-meta List sidebar metadata blocks") + fmt.Fprintln(os.Stderr, " set-agent-pid Associate a PID with a status key") + fmt.Fprintln(os.Stderr, " clear-agent-pid Remove PID association from a status key") + fmt.Fprintln(os.Stderr, " report-git-branch Report git branch info (surface optional)") + fmt.Fprintln(os.Stderr, " clear-git-branch Clear git branch info (surface optional)") + fmt.Fprintln(os.Stderr, " sidebar-state Get full sidebar state") + fmt.Fprintln(os.Stderr, " reset-sidebar Reset all sidebar state") } diff --git a/docs/superpowers/plans/2026-04-19-ssh-reuse-workspace.md b/docs/superpowers/plans/2026-04-19-ssh-reuse-workspace.md new file mode 100644 index 000000000000..3c881102589a --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-ssh-reuse-workspace.md @@ -0,0 +1,40 @@ +# SSH Reuse Existing Workspace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `cmux ssh <destination>` reuse an existing matching SSH workspace by default, with `--new` preserving the old create-new behavior. + +**Architecture:** Keep the feature in the CLI layer. Parse `--new`, use existing `workspace.list` metadata to find safe matches before relay/bootstrap setup, select the existing workspace unless `--no-focus` is set, and report `reused` in command output. + +**Tech Stack:** Swift CLI (`CLI/cmux.swift`) and Python v2 integration tests (`tests_v2/test_ssh_remote_cli_metadata.py`). + +--- + +### Task 1: Regression Tests + +**Files:** +- Modify: `tests_v2/test_ssh_remote_cli_metadata.py` + +- [ ] Add a test that configures one remote workspace, invokes `cmux ssh <same destination> --no-focus --json`, and asserts the workspace count does not increase and JSON contains the original workspace id with `reused: true`. +- [ ] Add a test that invokes `cmux ssh <same destination> --new --no-focus --json` and asserts a second workspace is created with `reused: false`. +- [ ] Run the new tests before implementation and confirm they fail because `cmux ssh` currently creates duplicate workspaces and does not parse `--new`. + +### Task 2: CLI Implementation + +**Files:** +- Modify: `CLI/cmux.swift` + +- [ ] Add `forceNewWorkspace: Bool` to `SSHCommandOptions`. +- [ ] Parse `--new` in `parseSSHCommandOptions` and document it in help text. +- [ ] Add helpers in `CLI/cmux.swift` to locate a reusable SSH workspace from `workspace.list` payload. +- [ ] In `runSSH`, after parsing options and before generating relay/bootstrap state, return early when a reusable workspace exists and `--new` is absent. +- [ ] Include `reused: true` for reused workspaces and `reused: false` for newly-created workspaces. + +### Task 3: Verification and Deployment + +**Files:** +- Existing build/deploy scripts in `/Users/minoo/projs/cmux-patch` + +- [ ] Run the targeted regression tests. +- [ ] Run Swift build/reload or the repository's existing verification command if targeted tests require a fresh binary. +- [ ] Run `/Users/minoo/projs/cmux-patch/rebuild.sh` to deploy the patched app. diff --git a/docs/superpowers/specs/2026-04-19-ssh-reuse-workspace-design.md b/docs/superpowers/specs/2026-04-19-ssh-reuse-workspace-design.md new file mode 100644 index 000000000000..397a3a1d3d76 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-ssh-reuse-workspace-design.md @@ -0,0 +1,18 @@ +# SSH Reuse Existing Workspace Design + +## Goal +Make `cmux ssh <destination>` attach to an existing matching remote SSH workspace by default instead of always creating a duplicate workspace. + +## Behavior +- `cmux ssh acer` searches existing workspaces before creating a new one. +- If a workspace with `remote.enabled == true` and matching `remote.destination` exists, the CLI reuses it. +- If `--port` is provided, the existing workspace must also have the same `remote.port`. +- If `--identity` or custom `--ssh-option` is provided, the CLI creates a new workspace because current public remote metadata only exposes booleans, not comparable values. +- `--new` bypasses reuse and preserves the old always-create behavior. +- `--no-focus` keeps its existing meaning: reuse can return the existing workspace without selecting it. + +## Architecture +The change stays in `CLI/cmux.swift`. `parseSSHCommandOptions` gains a `forceNewWorkspace` flag for `--new`. `runSSH` performs a preflight `workspace.list` lookup after parsing options and before generating SSH relay/bootstrap state. When a match is found, it returns the workspace payload with `reused: true`; otherwise it runs the existing create/configure flow and returns `reused: false`. + +## Testing +Add focused Python regression coverage in `tests_v2/test_ssh_remote_cli_metadata.py` because that file already validates SSH CLI metadata and workspace list behavior. diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 3bd6d764457d..6adc042e7d07 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -128,7 +128,10 @@ def main() -> int: cli = _find_cli_binary() help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) _must("cmux ssh" in help_text, "ssh --help output should include command header") - _must("Create a new workspace" in help_text, "ssh --help output should describe workspace creation") + _must("Create a new remote SSH workspace" in help_text, "ssh --help output should describe default workspace creation") + _must("Create or reuse" not in help_text, "ssh --help output should not describe implicit workspace reuse") + _must("--attach" in help_text, "ssh --help output should document explicit attach") + _must("--new" in help_text, "ssh --help output should document --new") workspace_id = "" workspace_id_without_name = "" diff --git a/tests_v2/test_ssh_reuse_existing_workspace.py b/tests_v2/test_ssh_reuse_existing_workspace.py new file mode 100755 index 000000000000..574e87ea6639 --- /dev/null +++ b/tests_v2/test_ssh_reuse_existing_workspace.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Regression: `cmux ssh` creates by default and only attaches explicitly.""" + +from __future__ import annotations + +import glob +import json +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str], *, json_output: bool) -> str: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + cmd = [cli, "--socket", SOCKET_PATH] + if json_output: + cmd.append("--json") + cmd.extend(args) + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + output = _run_cli(cli, args, json_output=True) + try: + return json.loads(output or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})") + + +def _resolve_workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_ref.startswith("workspace:"): + return "" + + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _workspace_count(client: cmux) -> int: + listed = client._call("workspace.list", {}) or {} + return len(listed.get("workspaces") or []) + + +def main() -> int: + cli = _find_cli_binary() + help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) + _must("Create a new remote SSH workspace" in help_text, "ssh --help output should describe default creation") + _must("Create or reuse" not in help_text, "ssh --help output should not advertise implicit reuse") + _must("--attach" in help_text, "ssh --help output should document explicit attach") + _must("--new" in help_text, "ssh --help output should document --new") + + workspaces_to_close: list[str] = [] + with cmux(SOCKET_PATH) as client: + try: + selected_before_setup = client.current_workspace() + + # Create a workspace configured as remote but NOT connected (auto_connect=False). + disconnected_workspace_id = client.new_workspace() + workspaces_to_close.append(disconnected_workspace_id) + client._call( + "workspace.remote.configure", + { + "workspace_id": disconnected_workspace_id, + "destination": "cmux-reuse.test", + "port": 2200, + "auto_connect": False, + }, + ) + client.select_workspace(selected_before_setup) + + # Verify disconnected workspaces are NOT reused — cmux ssh should + # create a new workspace instead of attaching to a stale session. + count_before = _workspace_count(client) + selected_before = client.current_workspace() + new_payload = _run_cli_json( + cli, + ["ssh", "cmux-reuse.test", "--port", "2200", "--no-focus"], + ) + new_workspace_id = _resolve_workspace_id_from_payload(client, new_payload) + if new_workspace_id: + workspaces_to_close.append(new_workspace_id) + + _must( + bool(new_payload.get("reused")) is False, + f"disconnected workspace should not be reused: {new_payload}", + ) + _must( + not new_workspace_id or new_workspace_id != disconnected_workspace_id, + f"cmux ssh should create a new workspace, not reuse disconnected {disconnected_workspace_id}: {new_payload}", + ) + _must( + _workspace_count(client) == count_before + 1, + f"cmux ssh should create a new workspace when existing one is disconnected: before={count_before} payload={new_payload}", + ) + _must( + client.current_workspace() == selected_before, + "cmux ssh --no-focus should not switch workspace", + ) + + # --new should always create a new workspace regardless. + count_before_new = _workspace_count(client) + force_new_payload = _run_cli_json( + cli, + ["ssh", "cmux-reuse.test", "--port", "2200", "--new", "--no-focus"], + ) + force_new_workspace_id = _resolve_workspace_id_from_payload(client, force_new_payload) + if force_new_workspace_id: + workspaces_to_close.append(force_new_workspace_id) + + _must(bool(force_new_payload.get("reused")) is False, f"--new payload should set reused=false: {force_new_payload}") + _must( + bool(force_new_workspace_id) and force_new_workspace_id != disconnected_workspace_id, + f"cmux ssh --new should create a distinct workspace: existing={disconnected_workspace_id} payload={force_new_payload}", + ) + _must( + _workspace_count(client) == count_before_new + 1, + f"cmux ssh --new should create exactly one workspace: before={count_before_new} payload={force_new_payload}", + ) + finally: + for workspace_id in dict.fromkeys(workspaces_to_close): + try: + client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: cmux ssh creates by default and reserves reuse for explicit attach") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())