跳转到主要内容

cmux 一键复刻配置

cmux(Ghostty 内核的 macOS AI 终端)的配色、侧栏、远程 SSH 全套配置,附一次实配过程中踩到的 12 个坑与实测数据。

· 约 3 分钟阅读

在一台新 macOS 机器上复刻 cmux 环境:纯白内容卡 + 暖杏侧栏、agent 休眠、原生 SSH 工作区(标题跟随远端目录)。

2026-08-05 变更:这份配置不再用侧栏分组。 折叠加嵌套之后侧栏反而更难扫,改成一行一个会话的平铺列表。§1 的复刻块已经去掉 workspaceGroups 和建组步骤,两个建组快捷键(⌃⌘G / ⌘⇧G)都解绑了。分组的机制说明和实测坑留在 §2§3.4 备查 —— 那些结论对 cmux 本身仍然成立,只是这套配置不用了。

用法:把 §1 的整块复制粘给 Claude Code。它自包含——所有配置全文和约束都在块内,不依赖本地文件。§2 之后是给人看的参考,复刻时不必读,但 §3 的坑清单值得先扫一遍,那是这套配置里唯一无法从文档推导出来的部分。

适用环境:macOS (Apple Silicon),cmux 装在 /Applications/cmux.app,14” 屏(部分选择是为窄屏做的,见 §2)。 配置快照:2026-08-01,cmux 0.64.20 (build 100)。 同一台机器的 shell 侧配置见 Zsh 一键复刻配置,两份合起来才是完整环境。


1. 一键复刻 Prompt(唯一入口,整块复制)

你是我的环境配置助手。请在这台 macOS (Apple Silicon) 机器上复刻我的 cmux 配置。
所需信息全在本提示词内,不要去读任何本地文件。

【总约束】
1. 动手前备份:~/.config/cmux/cmux.json 和 ~/.config/ghostty/config 若已存在,
   各复制一份为 *.bak.<今天日期时分秒>,绝不覆盖未备份的文件。
2. 配置写 ~/.config/cmux/cmux.json(primary)。~/.config/cmux/settings.json 是 legacy,
   不要往里写,两个文件都有同名键会打架。用 `cmux config paths` 可确认地位。
3. 改完执行 `cmux reload-config` 生效(同时重载 cmux + ghostty 配置,不需要重启 app)。
   校验用 `cmux config doctor`。
4. cmux 自带 ghostty CLI,路径 /Applications/cmux.app/Contents/Resources/bin/ghostty,
   可用 `+list-themes` 预览主题。cmux 命令在 /Applications/cmux.app/Contents/Resources/bin/cmux。
5. cmux.json 是 JSONC(允许注释和尾逗号)。但 `cmux config doctor` 对尾逗号宽容,
   容易漏;写完请额外用严格 JSON 解析器验一遍(把 // 注释行剥掉再 json.loads)。

【第一步:~/.config/ghostty/config】
终端配色/字体/内边距/起始目录由 ghostty 配置控制,cmux 直接读它。写入以下内容:

    # —— 日夜自动切换 ——
    theme = light:GitHub Light Default,dark:GitHub Dark Dimmed

    # —— 新工作区/窗口的起始目录 ——
    # 生效前提:cmux.json 里 app.workspaceInheritWorkingDirectory = false
    # 可填绝对路径 / ~/xxx / home / inherit
    working-directory = ~/Documents/_work/99_code

    # —— 内边距留白,配合悬浮卡片观感 ——
    window-padding-x = 12
    window-padding-y = 10
    window-padding-balance = true

    # background-opacity 保持默认 1(不透明)。别试透明毛玻璃,原因见下方说明。

【第二步:~/.config/cmux/cmux.json】
写入以下内容(注释保留,它们记录了为什么这么选):

    {
      "$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json",
      "schemaVersion": 1,

      "app": {
        "appearance": "system",
        "minimalMode": true,
        "workspaceInheritWorkingDirectory": true,
        "forkConversationDefaultDestination": "newTab",
        "globalFontMagnification": 90,
        "hideTabCloseButton": true,
        "reorderOnNotification": true,
        "warnBeforeQuit": true
      },

      "activePaneBorderColor": "#D97706",

      "sidebarAppearance": {
        "matchTerminalBackground": false,
        "lightModeTintColor": "#E8955A",
        "darkModeTintColor": "#E8B48A",
        "tintOpacity": 0.11
      },

      "workspaceColors": {
        "indicatorStyle": "lift",
        "selectionColor": "#FDF8F3"
      },

      "canvas": { "paneGap": 20 },

      "sidebar": {
        "showProgress": true,
        "showLog": true,
        "showPorts": true,
        "showPullRequests": true,
        "showSSH": true,
        "showBranchDirectory": false,
        "showNotificationMessage": true,
        "notificationMessageLineLimit": 3,
        "beta": {
          "workspaceTodos": { "checklistStyle": "inline" }
        }
      },

      "markdown": {
        "fontSize": 16,
        "maxWidth": 820,
        "fontFamily": ""
      },

      "fileEditor": { "wordWrap": true },
      "fileExplorer": { "doubleClickAction": "preview" },

      // 不用侧栏分组,所以没有 workspaceGroups 块(它只管分组头的图标/颜色/落位)。
      // 两个建组快捷键都解绑,免得侧栏又冒出组来。cmux 没有"禁用分组"的开关,
      // 解绑是能做到的最接近的事;右键菜单和 CLI 不受影响。
      "shortcuts": {
        "bindings": {
          "newWorkspaceGroup": null,
          "groupSelectedWorkspaces": null
        }
      },

      "terminal": {
        "agentHibernation": {
          "enabled": true,
          "idleSeconds": 30,
          "maxLiveTerminals": 6
        },
        "uploadCommands": [
          {
            "command": "scp -q -o ControlMaster=auto -o ControlPath=/tmp/cmux-ssh-%r@%h:%p -o ControlPersist=300 ${CMUX_UPLOAD_PORT:+-P $CMUX_UPLOAD_PORT} ${CMUX_UPLOAD_IDENTITY_FILE:+-i $CMUX_UPLOAD_IDENTITY_FILE} $CMUX_UPLOAD_SSH_OPTIONS \"$CMUX_UPLOAD_LOCAL_PATH\" \"$CMUX_UPLOAD_DESTINATION:$CMUX_UPLOAD_REMOTE_PATH\" >&2 && printf %s \"$CMUX_UPLOAD_REMOTE_PATH\"",
            "enabled": true
          }
        ]
      },

      "automation": {
        "workspaceAutoNaming": true,
        "autoNamingAgent": "auto"
      },

      "notifications": {
        "dockBadge": true,
        "showInMenuBar": true,
        "unreadPaneRing": true,
        "paneFlash": true,
        "agentPermissionPrompt": true,
        "agentTurnComplete": "whenIdle",
        "agentIdleReminder": true,
        "suppressOnlyFocusedSurface": true,
        "sound": "Tink"
      },

      "diffViewer": { "defaultLayout": "unified" },

      "commands": [
        {
          "name": "新工作区 · 99_code",
          "description": "从代码根目录开一个空工作区(显式 --cwd 才可靠,见 §5)",
          "keywords": ["new", "workspace", "99code", "root"],
          "command": "cmux workspace create --cwd ~/Documents/_work/99_code"
        },
        {
          "name": "SSH · dev-env",
          "description": "标题跟随远端目录",
          "keywords": ["ssh", "devenv"],
          "command": "cmux ssh <user>@<dev-host> --identity ~/.ssh/id_ed25519"
        },
        {
          "name": "SSH · agent-env",
          "description": "标题跟随远端目录",
          "keywords": ["ssh", "agentenv"],
          "command": "cmux ssh <user>@<agent-host> --identity ~/.ssh/id_ed25519"
        },
        {
          "name": "SSH · 重连断掉的远程会话",
          "description": "只重连不健康的远程工作区(合盖醒来用;见 §3.12)",
          "keywords": ["ssh", "reconnect", "wake"],
          "command": "zsh -ic sshreconnect"
        },
        {
          "name": "SSH · 强制重连全部远程会话",
          "description": "无条件重连所有远程工作区",
          "keywords": ["ssh", "reconnect", "force"],
          "command": "zsh -ic 'sshreconnect -f'"
        }
      ]
    }

  把 commands 里的 <user>/<dev-host>/<agent-host> 和 --cwd 路径换成本机实际值。
  重连预设调用的 sshreconnect 函数在第三步的 ~/.zshrc 里,逻辑只维护这一份。

【第三步:~/.zshrc 追加】

    # ---- cmux diff 快捷方式 ----
    alias cdiff='cmux diff --unstaged'    # 未暂存改动
    alias cdlast='cmux diff --last-turn'  # agent 这一轮改了什么
    alias cdbr='cmux diff --branch'       # 当前分支 vs merge base

    # ---- cmux 原生 SSH 工作区 ----
    # 故意不传 --name:显式命名会永久盖住远端发来的 OSC 标题(见 §3.6)。
    # 侧栏里认哪台机器,看工作区下面的 SSH 明细行(sidebar.showSSH)。
    devenv()   { cmux ssh <user>@<dev-host>   --identity "$HOME/.ssh/id_ed25519" "$@"; }
    agentenv() { cmux ssh <user>@<agent-host> --identity "$HOME/.ssh/id_ed25519" "$@"; }

    # ---- 一键重连远程工作区(合盖过夜后救活,见 §3.12)----
    # 默认只重连不健康的(跳过正常连接,不打断正在跑的 agent);-f 强制全部。
    # 实测重连不丢会话(远端 session id 前后一致)。判活用 state+daemon+proxy,
    # 不看 remote.heartbeat(那字段会冻住不刷新,见 §3.12)。
    sshreconnect() {
      local cmux; cmux=$(command -v cmux) || { echo "找不到 cmux CLI"; return 1; }
      local force=""; [ "$1" = "-f" ] && force="1"
      "$cmux" list-windows --json 2>/dev/null | FORCE="$force" python3 -c '
import sys, json, os, subprocess
force = os.environ.get("FORCE") == "1"
cmux = "cmux"
try:    wins = json.load(sys.stdin).get("windows", [])
except Exception: wins = [{"ref": None}]
refs = [w.get("ref") for w in wins] or [None]
targets, skipped = [], 0
for wref in refs:
    cmd = [cmux, "workspace", "list", "--json"] + (["--window", wref] if wref else [])
    try:    d = json.loads(subprocess.run(cmd, capture_output=True, text=True).stdout)
    except Exception: continue
    for w in d.get("workspaces", []):
        r = w.get("remote", {})
        if not r.get("enabled"): continue
        healthy = (r.get("state")"connected" and r.get("daemon",{}).get("state")"ready"
                   and r.get("proxy",{}).get("state")=="ready")
        if healthy and not force: skipped += 1; continue
        targets.append((wref, w["ref"], r.get("destination") or "?", r.get("state")))
if not targets:
    print(f"  没有需要重连的(跳过 {skipped} 个健康连接)" if skipped else "  没有远程工作区"); sys.exit(0)
for wref, ws, dest, state in targets:
    cmd = [cmux, "workspace", "reconnect", "--workspace", ws] + (["--window", wref] if wref else [])
    ok = subprocess.run(cmd, capture_output=True, text=True)
    print(f"  {\"OK\" if ok.returncode==0 else \"失败\"} {ws} {dest} (原 state={state})")
if skipped: print(f"  (另跳过 {skipped} 个健康连接,-f 可强制全部)")
'
    }

【第四步:远端 daemon(国内网络必做,否则 cmux ssh 一定超时)】
cmux ssh 要在远端跑 cmuxd-remote daemon,这个二进制不在 app 包里,要从 GitHub Releases 下。
国内直连 release-assets.githubusercontent.com 会超时,报
"Remote daemon bootstrap failed: 请求超时"。做法:

  a) 查目标平台的资源名、下载 URL、期望 sha256、缓存路径:
       cmux remote-daemon-status --os linux --arch amd64
  b) 用能翻的网络(浏览器/代理)下载那个 cmuxd-remote-linux-amd64。
  c) 放到 ~/.local/state/cmux/remote-daemons/<版本号>/linux-amd64/cmuxd-remote 并 chmod +x。
  d) 再跑一次 a),应显示 cache exists: yes / cache verified: yes。

  【重要】下载后必须核对 sha256,不能只看 HTTP 200。实测同一条内网链路上
  366B 的 checksums.txt 完好,而 5.9MB 的二进制被截断成 2.7MB 却仍返回 200。
  macOS `file` 报 "too large section header offset" 就是截断征兆。
  缓存路径带版本号,cmux 升级后失效,同样的超时会再犯一次。

【第五步:远端 shell 的标题联动(可选但推荐)】
让 cmux 侧栏标题跟着远端当前目录实时变。先确认远端交互 shell 是 bash 还是 zsh
(注意:登录 shell 可能是 bash,但 ~/.bashrc 里有 exec zsh 把它换掉,
 这种情况要改 ~/.zshrc,往 .bashrc 末尾加东西永远执行不到)。

zsh 版本,追加到远端 ~/.zshrc 末尾(必须在 oh-my-zsh / powerlevel10k 之后,
否则会被它们的标题设置盖掉):

    # >>> BEGIN cmux-title  (整段删除即可还原)
    : ${CMUX_HOST_LABEL:=dev-env}
    __cmux_set_title() {
      printf '\033]0;%s:%s\007' "$CMUX_HOST_LABEL" "${PWD/#$HOME/~}"
    }
    autoload -Uz add-zsh-hook
    add-zsh-hook precmd __cmux_set_title
    # <<< END cmux-title

bash 版本用 PROMPT_COMMAND 前插,并加 [ -n "$PS1" ] 守卫避免污染非交互会话。
改远端配置前先备份,且用标记注释包裹以便整段删除。

【第六步:验证】
  cmux config doctor          # 两个配置文件都应 OK,keys 里不该有 workspaceGroups
  cmux reload-config          # 应返回 OK Reloaded config
  cmux themes list            # 确认 light/dark 主题名生效
  cmux workspace-group list   # 应返回 No groups —— 这套配置不用分组
  source ~/.zshrc && devenv   # 开远程工作区,侧栏应显示主机 + "已连接"

  日常一条要记住:笔记本过夜合盖后,SSH 工作区可能卡在"connected 但传输已断",
  不会自愈(详见 §3.12)。救活是一条命令,远端会话零损失:
    cmux workspace reconnect --workspace <ws>

【完成后告诉我】
- 侧栏是暖杏底 + 纯白内容卡 + 选中项浮起吗
- devenv 连上后,在远端 cd 时侧栏标题跟着变吗

2. 配置分区说明

配置分两个文件,边界很清楚:

归属文件管什么
终端渲染~/.config/ghostty/config主题、字体、内边距、透明度、起始目录
cmux 自身~/.config/cmux/cmux.json侧栏、图标、通知、agent 行为、SSH 预设

配色:纯白内容卡 + 暖杏侧栏

内容区用 GitHub Light Default(纯白),侧栏叠一层暖杏 tint(#E8955A @ 11%,实际渲染约 #F1E8E6)。选中项用 indicatorStyle: lift + selectionColor: #FDF8F3(暖象牙白)——比侧栏亮所以”浮起”效果成立,比纯白柔和所以跟暖杏同色系更搭。

强调色 #D97706(琥珀)只用在活动面板边框,且只在分屏时可见。

为窄屏做的六个选择

14” 屏上这几项和大屏的最优解相反:

  • app.minimalMode: true —— 隐藏工作区标题栏,省一行垂直空间
  • app.globalFontMagnification: 90 —— UI 整体缩到 90%(终端、标签、侧栏、浮层;不影响浏览器里渲染的网页)
  • app.forkConversationDefaultDestination: newTab —— Fork Conversation 默认去新 tab 而不是右侧分屏。右键 tab 的子菜单仍可选任意方向
  • diffViewer.defaultLayout: unified —— 左右分栏在窄屏每侧太窄,代码全折行反而难读
  • sidebar.notificationMessageLineLimit: 3 —— 默认 12 行,一个工作区能吃掉侧栏 1/5 高度
  • app.hideTabCloseButton: true —— 单 surface 时那条横向标签栏没法隐藏,只能去掉 ✕ 减少噪音

窄屏上与其分屏,不如用 ⌘B 隐藏侧栏、⌘T 开新 tab(每个都是全宽)、⌘⇧回车 临时全屏当前面板。

用法:一行一个会话,只走纵向

cmux 的四层模型是 Window / Workspace / Pane / Surface。关键点:侧栏的一行就是一个 workspace,没有比它更轻的”会话”对象——cmux 内部就把侧栏这些行叫 tabcmux sidebar-state 返回的字段名是 tab=<uuid>),所谓 “vertical tabs” 指的正是它们。

所以”一个工作区放一个会话、在侧栏纵向往下加”是正统用法,不是绕路:

  • ⌘NnewTab)→ 新建侧栏一行
  • ⌘TnewSurface)→ 在当前工作区内加一个横向 tab —— 不想要横向就别用它
  • ⌘W 关 tab。app.keepWorkspaceOpenWhenClosingLastSurface 保持默认 false,关掉最后一个 surface 会连整行一起收掉,不留空壳

单 surface 时那条横向 surface 标签栏没有配置项能隐藏(搜过整个 schema)。只有两个相关开关:app.hideTabCloseButton(去掉关闭按钮,减少噪音,已开)和 ui.surfaceTabBar.buttons(能定制按钮,但是 nightly 特性)。minimalMode 隐藏的是工作区标题栏,不是这条。

侧栏分组(已不用,机制留档)

这套配置 2026-08-05 起不用分组了。 用过四天的结论:分组确实能把侧栏收短,但代价是多一层折叠——想找某个会话,先要记住它在哪个组、那个组是不是折着的。平铺列表虽然长,扫一眼就到底,反而更快。

下面的机制说明和 CLI 速查都还准(对 cmux 0.64.20 成立),留着备查。注意 cmux 没有”禁用分组”的开关workspaceGroups 只管分组头的图标/颜色/落位,分组本身是侧栏内置行为。所以”关掉”只能是三件事:解散已有的组(ungroup,保留成员)、删掉 workspaceGroups 配置、解绑两个建组快捷键。

核心模型一句话:每个分组由一个 anchor 工作区拥有,anchor 那一行就是分组头,没有额外的头行。 点标题聚焦 anchor 的面板,点箭头折叠。

三个快捷键(前两个在这份配置里都已用 shortcuts.bindings 解绑):

快捷键动作触发条件
⌃⌘G新建分组无条件,误触就多一个叫「分组 N」的空壳
⌘⇧G把选中的工作区成组必须选中 ≥2 个,否则不响应
⌃⌘.折叠/展开聚焦的分组没有组时是空操作,所以没解绑

⌘⇧G 和 React Grab 撞键,cmux 只在有多选时才抢这个键,所以平时不受影响;解绑之后彻底让给 React Grab 了。真正容易误触的是 ⌃⌘G —— 无条件触发,误触过两次。

新工作区进不进组,UI 和 CLI 不一样(这条实测出来的,文档没写):

路径结果
UI 里活动工作区是 anchor 或成员时按 ⌘N新工作区进组
分组头 hover 出来的 + 按钮进组,cwd = anchor 的 cwd
CLI cmux workspace create 不带 --group永不进组

CLI 那条试了两种可能的暗示方式——CMUX_WORKSPACE_ID 指到组内成员、以及当前选中项就在组内——都不进组。要进组必须显式 --group <g>(还有 --group-placement / --group-reference)。

cmux ssh 更彻底:它根本没有 --group 参数(只有 workspace create 有)。所以 devenv/agentenv 这类基于 cmux ssh 的函数开出来的远程工作区天生游离,不会进任何组,也没有”以后连 .130 的都归 dev-env”这种规则——分组只是一次性把当时的工作区收进壳。想让它自动进组,只能在函数里绕:cmux ssh 的 stdout 是 OK workspace=workspace:N target=... state=...,解析出这个 ref,再按组名(不是运行时会变的 workspace_group:N)动态查到 group ref,workspace-group add 进去;add 幂等所以重连也安全。§1 第三步的 devenv/agentenv 曾经这么干过,撤掉分组时一起删了 —— 现在就是直接 cmux ssh

组内新建工作区落在哪由 workspaceGroups.newWorkspacePlacement 决定:afterCurrent(默认,插在活动成员后面)/ top(紧跟 anchor)/ end(追加到最后)。「一行一个会话往下加」的用法配 end 顺序最可预期。还能在某条 byCwd 规则里单独覆盖。

分组的 pin 独立于工作区的 pin,置顶的分组排在所有未置顶的顶层行之上。组名、anchor、pin、折叠状态、颜色、图标都跨重启保留,成员关系存在每个工作区上。

CLI 速查(<group> 收 UUID 或 list 打印的 workspace_group:N):

cmux workspace-group list --json                       # icon/color 为 null 才是走 byCwd
cmux workspace-group create --name x --from a,b         # 永远显式 --from(见 §3.4)
cmux workspace-group create --name x --from ""          # 真空组,不碰现有工作区
cmux workspace-group add --group <g> --workspace <ws>   # 从别的组移过来也是这条
cmux workspace-group remove --workspace <ws>
cmux workspace-group set-anchor --group <g> --workspace <ws>
cmux workspace-group ungroup <g>                        # 解散,保留成员
cmux workspace-group delete  <g>                        # 连带关闭所有成员,destructive
cmux workspace-group set-icon  <g> --symbol server.rack # 传 "" 清除,退回 byCwd
cmux workspace-group set-color <g> --hex "#1A5276"
cmux workspace-group collapse|expand|pin|unpin|focus <g>
cmux workspace-group move <g> --to-index 0              # 也支持 --before / --after
cmux workspace-group new-workspace <g> [--placement end]

误关了工作区可以用 reopenClosedWorkspace 动作救回来。

byCwd 每条规则其实有四个字段,常用的只有前两个:iconcolornewWorkspacePlacement(单目录覆盖落位)、contextMenu(分组 + 按钮的右键菜单,schema 与 ui.newWorkspace.contextMenu 相同——后者标注为 nightly,这份配置没用)。

侧栏直接显示 agent todo

sidebar.beta.workspaceTodos.checklistStyle 有两个值,功能本身一直是开的、没有开关,只能选展示方式:

  • inline(现用)—— 清单就地在侧栏那行下面展开,扫一眼就知道 agent 在干到第几步
  • popover —— 从摘要行弹锚定浮层,不占侧栏纵向空间

窄屏上 inline 会往下顶其它工作区,如果侧栏工作区多到挤不下,换回 popover

markdown 查看器与文件树

内置 markdown 查看器带 live reload——.md 打开后,文件被改(包括 agent 改)会自动刷新,写 wiki 类内容很顺。

  • markdown.fontSize: 16 —— 默认 15,但开了 90% 缩放后要提一档正文才舒服。查看器内 ⌘+ / ⌘- / ⌘0 可临时缩放
  • markdown.maxWidth: 820 —— 默认 980 在 14” 上一行太长;中文一行 45 字左右最好读
  • fileEditor.wordWrap: true —— 默认 false。中文长句没有空格,不换行就得横向滚动
  • fileExplorer.doubleClickAction: preview —— 保持默认。cmux 内置预览支持 markdown live reload、PDF、图片,比丢给外部应用顺。想双击进外部编辑器要两处一起设:改成 preferredEditor 并设 app.preferredEditor 命令,只改前者会回落到 macOS 默认应用

agent 相关

  • agentHibernation(默认关):活跃 agent 终端超过 maxLiveTerminals: 6 时,后台空闲的自动休眠释放内存,回访时用保存的 session 恢复
  • workspaceAutoNamingauto 模式下每个会话用它自己的 agent 起名(Claude 的用 Claude,Kiro 的用 Kiro)
  • notifications.agentTurnComplete: whenIdle:后台任务真跑完才提示一次,不会中途误报
  • notifications.suppressOnlyFocusedSurface: true:并行多 agent 时,非聚焦面板的横幅会留着等你看

SSH 上传走连接复用

terminal.uploadCommands 里那条规则替代内置 scp,加 ControlMaster=auto + ControlPersist=300。收益是拖 N 个文件只握手 1 次。不加 -C 压缩——传的是 .json.gz,已经压过。

命令里 scp 输出全转 stderr,stdout 只回远端路径,cmux 会把它插到光标处。想临时停用改 enabled: false


3. 踩过的坑

这节是这份文档的主要价值。以下每条都是实测结论,不是文档推导。

3.1 配置文件:settings.json 是 legacy

~/.config/cmux/settings.json 是旧位置,primary 是 cmux.json。两个文件都放同名键会打架。用 cmux config paths 确认,cmux config doctor 校验。

顺带:cmux reload-config 一条命令重载 cmux + ghostty 两份配置,不需要重启 app

3.2 「毛玻璃侧栏 + 不透明白卡」做不到

想复刻 otty 的 Floating Card 那种双层效果(外框半透明透出壁纸、中间白卡实心),在 cmux 0.64.20 上是死路,三个原因叠加:

  1. sidebarAppearance 只有实色 tint,cmux 没有独立的窗口 vibrancy/material 配置项
  2. ghostty 的 background-opacity全局的,一降透明度中间正文会跟侧栏一起被壁纸染色
  3. 想用 background-image 单独把正文盖白也不行——background-image-opacity相对 background-opacity 的、封顶

otty 能做是因为它的主题格式把 [sidebar] / [container] / [window] material 分开定义。所以只能在”白卡”和”通透”之间选一个,这份配置选了白卡(background-opacity = 1)。

3.3 selectionColor: null 不是”自适应中性”,是系统蓝

想让选中卡走中性色而把这个字段设 null,结果会回退到 cmux 默认的系统蓝。要白色悬浮卡必须显式写颜色。该字段不分明暗,只有一个值——所以纯白卡在深色模式下会偏亮扎眼。

3.4 分组这套东西,四个坑连在一起

workspaceGroups.byCwd 配的是侧栏分组头的图标和颜色。围绕它有四个独立的坑,实测逐个确认过。

这份配置现在不用分组了(见 §2),下面四条对 cmux 本身仍然成立,留档。第 (4) 条尤其值得记住 —— 它能在你只想建个组的时候悄悄关掉一个正在跑的 agent 会话。

(1) 没有分组时规则完全不显示。 分组必须显式创建,不会按 cwd 自动形成。看起来像配置没生效,其实是缺前提。分组是运行时状态,不进 cmux.json,换机器要重建。

(2) 匹配的是 anchor 的 cwd,不是成员的。 anchor 的 cwd 来源有三条:成组时继承 --from 里第一个成员 / CLI 不传 --cwd 时继承活动工作区 / create --cwd <path> 显式指定(实测有效,而且允许不存在的路径,会原样记下来)。

所以 cmux ssh 开的远程工作区是个死角——它们在本机视角 cwd 是空字符串,byCwd 永远匹配不上:

workspace:1  (本地 anchor)  cwd='/Users/.../awp-aggregator'   → 规则命中
workspace:11 (SSH 工作区)    cwd=''                            → 任何规则都不命中

两条出路:set-icon/set-color 显式设(命令式,换机器丢),或者给远程组建一个 cwd 落在空标记目录里的本地 anchor(声明式,写在 cmux.json 里):

mkdir -p ~/Documents/_work/99_code/_remote/dev-env
cmux workspace-group create --name dev-env --from "" \
     --cwd ~/Documents/_work/99_code/_remote/dev-env    # --from "" = 真空组,不碰现有工作区
cmux workspace-group add --group workspace_group:N --workspace <ssh 工作区 ref>

这份配置原来走的是第二条(_remote/ 下两个空目录当 anchor),撤掉分组时连 anchor 工作区一起关了。

(3) 侧栏分组头显示的是 anchor 工作区的标题,不是组名。 这条最容易误判。workspace-group create --name X 会把组名和 anchor 标题一起设成 X,看起来一致;但之后 workspace-group rename 只改组名,不动 anchor 标题,侧栏毫无变化。实测:

$ cmux workspace-group rename workspace_group:5 --name zz-renamed
OK
$ cmux workspace list          # anchor 标题没变
workspace:18   zz-test         ← 侧栏显示的是这个

所以 ⌃⌘G 建出来的 分组 2 这类标题,改组名是白费的,得改工作区:

cmux workspace rename --workspace <anchor> "blogv2"

(4) create 不传 --from 会抓走你正在看的工作区。 CLI help 写的是 “Defaults —from to the active sidebar selection / caller workspace”——选中项优先于调用方。实测在一个 SSH agent 会话被选中时跑:

cmux workspace-group create --name zz-test --cwd ~/somewhere    # 没传 --from
# → 新组成员是 [新 anchor, workspace:5],workspace:5 是正在跑 agent 的 SSH 会话,
#   而且它被从原来的 dev-env 组里拽出来了(一个工作区只能属于一个组)

危险在下一步:workspace-group delete连带关闭组内所有工作区。顺手 delete 就等于关掉那个活会话。永远显式传 --from;要真空组就 --from "";解散用 ungroup(保留成员),delete 才是 destructive 的。

3.5 工作区标题里的 session-id 后缀无法关掉

Claude Code 的工作区标题会带 · <session-id 前 16 字符> 后缀(如 · 78f819f5-9bcb-4f)。把整个 schema 搜遍了,没有任何开关能去掉它。来源是 cmux 读 Claude Code 的 session JSONL 文件名。

唯一可靠解是手动改名(⌘⇧Rcmux workspace-action --action rename),文档明确写了手动命名永久优先。

3.6 --name 会永久压死 OSC 标题

这条和 3.5 是同一个机制的两面。给 cmux ssh--name 相当于手动命名,会把远端通过 OSC 转义序列发来的标题彻底盖住。所以要标题跟随远端目录,就不能传 --name,二者只能选一个。

3.7 远端命令没法自动执行

想让 cmux ssh 连上后自动跑一条远端命令(比如 zellij attach),两条路都不通:

写法结果
--command "…"压根不执行(用远端标记文件验证过:工作区能连上,命令不跑)
-- <远端命令>命令执行,但绕过 cmux 远端 shell 集成 → 永远卡在 [ssh:connecting]、持久 PTY 建立不起来、转义序列漏成乱码(终端里出现 ^[[?997;2n

结论:连上后手敲。而且 cmux 自己的持久 PTY 已经做了 zellij 的保活那份活,新活儿不必再套一层。

3.8 远端 shell 可能不是 passwd 里那个

getent passwd$SHELL 都显示 /bin/bash,但 ~/.bashrc 第 33 行有 exec zsh -l——交互环境实际是 zsh。往 .bashrc 末尾追加的东西在 exec 之后,永远执行不到。

判断方法:看报错格式。文件:行号: command not found: xxx 是 zsh 的格式,bash 的长得不一样。

3.9 内网下载大文件必须核对 sha256

见 §1 第四步。5.9MB 二进制被截断成 2.7MB 但仍返回 HTTP 200,同链路 366B 小文件完好。不能信 HTTP 200

3.10 SSH 会话大多不用管,但远端中继端口会被陈旧连接卡住

正常情况不用清理:一轮下来开关 6 个 SSH 工作区,之后 ssh-session-list 只剩 1 个(对应唯一存活的工作区),零孤儿,生命周期跟着工作区走。

但有一个例外会真的出问题。 报错长这样:

Remote daemon error: Remote SSH relay...
Error: remote port forwarding failed for listen port 57279 (retry in 2s)

成因链条:

  1. cmux 用 SSH 远端端口转发(-R)建中继,端口号记在 ~/.cmux/relay/<port>.slot 一类文件里
  2. 陈旧的 SSH 连接(实测有存活 10~12 小时的)仍占着那个端口
  3. cmux 自带一段 cmux_stale_relay_listener_cleanup 脚本处理这种情况——用 lsof 找出占端口的 sshd,核对 relay 元数据确认无主后 kill 掉
  4. -R 转发的监听套接字是由root 身份的 sshd 监控进程创建的。没有 root 权限时,lsofss -tlnp看不到属主
  5. 脚本找不到 PID → [ -n "$cmux_listener_pids" ] || exit 0 → 静默空转 → 无限重试

影响范围有限:终端本身是好的,坏掉的只是中继功能(内置浏览器从远端出网)。不影响敲命令。

为什么每次重启 Mac 都会撞:远端 sshd 若没配 ClientAliveInterval(默认 0 = 从不探测),你的机器一关机,远端那条连接不会被回收,实测能挂 10~12 小时不动。有 root 的话让运维配上 ClientAliveInterval 是根治办法。

免密 sudo 帮不上 cmux:那段自愈脚本直接调 lsof,源码里没有任何 sudo。配了 sudo 它也不会用,只方便你手动查(sudo ss -tlnp | grep <port>)。为这个开 root 免密不值。

不用 root 的回收办法:陈旧的 sshd: <user>@notty 进程归你自己所有,可以直接 kill。判据是有没有 cmuxd-remote 子进程——有=在用,无=陈旧。放进 ~/.zshrc

# sshgc <ssh-host>      只列出
# sshgc <ssh-host> -f   真的杀
sshgc() {
  local host="${1:?用法: sshgc <ssh-host> [-f]}" force="${2:-}"
  ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" "FORCE='$force' bash -s" <<'REMOTE'
self_chain=""; p=$$
while [ "$p" -gt 1 ] 2>/dev/null; do
  self_chain="$self_chain $p"; p=$(ps -p "$p" -o ppid= 2>/dev/null | tr -d ' '); [ -z "$p" ] && break
done
found=0
for pid in $(ps -eo pid,user,args --no-headers | awk -v u="$USER" '$2==u && /sshd:.*@notty/ {print $1}'); do
  case " $self_chain " in *" $pid "*) continue ;; esac   # 别把自己这条连接杀了
  kids=$(ps -eo ppid,args --no-headers | awk -v p="$pid" '$1==p && /cmuxd-remote/ {c++} END{print c+0}')
  [ "$kids" != "0" ] && continue
  et=$(ps -p "$pid" -o etime= 2>/dev/null | tr -d ' '); found=$((found+1))
  if [ "$FORCE" = "-f" ]; then kill "$pid" 2>/dev/null && echo "  已杀 pid=$pid (运行 $et)"
  else echo "  陈旧 pid=$pid (运行 $et) —— 加 -f 才真的杀"; fi
done
[ "$found" = "0" ] && echo "  没有陈旧连接"
REMOTE
}

排除当前连接祖先链那段是必须的,否则脚本会把自己所在的 ssh 连接一起杀掉。

还有一种撞法是自冲突,sshgc 治不了:同一条活着的连接,PTY 桥断开重连后 cmux 会在同一个端口重建转发,撞上自己先前那个还没释放的转发。实测表现是杀光所有陈旧连接后端口仍被占,而剩下的唯一持有者就是当前在用的那条。这种只能等——关掉该工作区或重启 cmux 即可,期间终端功能不受影响。

别做的事:不要凭 --slot 和 session id 的前缀是否匹配去判断哪个 cmuxd-remote 是孤儿然后杀掉——见 3.11。

3.11 --slot 不等于 session id,别靠前缀匹配判断孤儿

cmuxd-remote 进程的 --slot ssh-<uuid>SSH 连接槽位,而 ssh-session-list 输出的 session id 是 ssh-<uuid>-<uuid> 形式的另一套标识一个 slot 可以承载 ID 完全不相关的会话。

踩过的坑:看到某个 --persistent-server 进程的 slot(ssh-412642b9…)跟当前唯一会话 ID(ssh-5D4FF9A3…)前缀不匹配,就判定它是孤儿并 kill 掉——结果它正在服务那个活着的工作区,终端被重启,1 MB 回滚内容和正在跑的 agent 会话全丢

--persistent-serverppid=1 也不是孤儿的证据:持久化设计本来就会让它脱离父进程,这样断连才不掉线。

要判断某个远端 daemon 能不能动,可靠依据只有 ssh-session-list --workspace <ws> 逐个工作区对照,而不是进程参数。

3.12 过夜合盖后不会自动重连,且 heartbeat.age 是假指标

这条推翻了两个先前的判断,实测过程记在这。

现象:笔记本合盖过夜(约 9 小时),早上打开,三个 SSH 工作区侧栏都挂着红字:

Remote proxy to <user>@<host> unavailable:
Remote daemon transport failed: daemon transport keepalive timed out (retry in 3s)

注意这不是 3.10 那个中继端口坑——文案是 daemon transport keepalive timed out(传输层 keepalive 超时),不是 listen port NNNNN(端口冲突)。成因是合盖期间本机到远端的网络中断,cmux 的自动重连”暂停”了,醒来没有自己恢复。

别信 remote.heartbeat.ageworkspace list --json 里三个会话全写 state=connected / daemon=ready / proxy=ready,但 heartbeat.age_seconds 冻在断连时刻、8~9 小时不动。一度拿这个 age 判定”连接已死”——错的。真实流量能跑通时这个字段照样不刷新,它只统计某种独立心跳、不随数据流更新。判活只能靠读屏或发探针命令看回显,不能看这个字段,也不能看 state=connected(那是缓存态)。

远端会话其实都活着ssh-session-list --workspace <ws> 显示三个远端持久会话 attachments=1scrollback_bytes=1048576(1 MB 满额)全在。丢的只是本机↔远端这段传输,远端 PTY 和昨晚跑的 agent 没受影响。

正确处理是 reconnect,不是 sshgc

cmux workspace reconnect --workspace <ws>   # 逐个救;救活后昨晚的东西原样都在
cmux mark-notification-read --all && cmux dismiss-notification --all-read   # 清红字横幅

工作区多了逐个 reconnect 很烦,包成一个函数(§1 第三步已含):sshreconnect 只重连不健康的、跳过正常连接(不打断在跑的 agent),sshreconnect -f 强制全部。命令面板也有对应两条预设。实测重连不丢会话——远端 session id 前后一致。判活务必用 state+daemon+proxy 三个字段,别用 heartbeat

sshgc 在这里没用——它是给”远端陈旧连接占端口”用的(3.10),而这里远端 daemon 好好的,是本机侧传输要重建。clear-notifications 也清不掉这两条红字:它们是 none 作用域的未读通知,得先 mark-notification-read --alldismiss-notification --all-read

能不能根治:不能一劳永逸,但能减轻。合盖断网这段传输必然中断(物理决定,任何 SSH 客户端都躲不掉)。三个改善入口:① 记住 reconnect 命令(零成本,推荐);② cmux ssh--ssh-option ServerAliveInterval=30 --ssh-option ServerAliveCountMax=3,只对短暂抖动有效、对整夜断网无效(网络不通探测包也发不出),为整夜合盖场景改它不值;③ 远端 sshd 配 ClientAliveInterval(要 root,且治的是 3.10 的陈旧连接不是这条)。实测本机 ~/.ssh/config 无 keepalive、远端 ClientAliveInterval=0(从不探测),所以”死连接挂整晚”是必然而非偶发。


4. 实测数据

项目实测值
远端 daemon 进程约 2 个/连接,合计 16.1 MB RSS
占远端内存比例0.001%(该机 1487 GB 内存)
远端磁盘 ~/.cmux6.2 MB,其中 6.0 MB 是 daemon 二进制本身(别删,删了要重推)
断连重连workspace disconnectreconnect 后 session ID 不变,scrollback 从 93 KB 增长到 99 KB 完整保留
过夜合盖后三个会话侧栏显示 connected 但传输实际已断(见 §3.12),ssh-session-list 显示远端持久会话 attachments=1、scrollback 1 MB 满额全部存活;workspace reconnect 后恢复,昨晚跑的 agent 全在
被截断的下载期望 5.9 MB,实收 2.7 MB,HTTP 200

远端进程不会因本机断网而死:远端跑的是远端进程,PTY 和回滚由远端 cmuxd-remote 持有。短暂断网(合盖几分钟、切网络)cmux 能自动重连接回同一会话,scrollback 不丢。

但”过夜合盖后自动重连”不可靠——实测过一次整夜合盖,醒来三个会话都卡在”connected 但传输已断”,不会自愈,要手动 cmux workspace reconnect --workspace <ws>(详见 §3.12)。远端会话本身全部存活,救回零损失。命令行里有 --persistent-lease-port 参数暗示有租约机制,TTL 未知,但这次实测过夜(约 9 小时)远端会话没被回收。


5. 已确认的实现与文档不一致

workspaceInheritWorkingDirectory: false 不会用 ghostty 的 working-directory

schema 文档原文是”When false, new workspaces use Ghostty’s working-directory setting instead”,但实测(含重启 app 后复测)新工作区一律落在家目录 ~,无论 ghostty 那边设成什么。排查过程中排除了两个假设:

  • 不是”启动时缓存”——重启后行为不变
  • 不是派生配置被剥离——cmux 在 ~/Library/Application Support/com.cmuxterm.app/config.ghostty 生成的派生文件是空的,说明它直接读源配置渲染

ghostty +show-config 能正确回显 working-directory,所以问题在 cmux 侧。

结论与处理workspaceInheritWorkingDirectory 保持默认 true(继承当前工作区目录)。设 false 会掉到家目录,比继承更难用。

要”固定从某个根目录开新工作区”,用 commands 预设显式传 --cwd,这条路是可靠的:

{
  "name": "新工作区 · 99_code",
  "keywords": ["new", "workspace", "99code"],
  "command": "cmux workspace create --cwd ~/Documents/_work/99_code"
}

于是两种行为都有:⌘N 继承当前目录,⌘⇧P 搜预设从固定根目录开。

未验证的细节:上述测试都走 CLI cmux workspace create。UI 的 ⌘N 路径是否也忽略该设置没单独确认过。


6. 没配的东西

  • actions / ui.newWorkspace(+ 按钮菜单、自定义工作区布局):官方文档标注为 nightly 特性,稳定版可能不认。而且 ui.newWorkspace.contextMenu 一旦定义会替换默认菜单,如果解析失败会把 + 按钮菜单搞坏。风险不值当,等切 nightly 再说。
  • 整个 workspaceGroups:2026-08-05 起不用侧栏分组(原因见 §2),所以图标/颜色/落位规则和那条 byCwd.*.contextMenu(分组 + 按钮的右键菜单)都不配了。后者本来也有风险 —— schema 与上面那个 nightly 的 ui.newWorkspace.contextMenu 相同,一样会替换默认菜单。
  • 自定义侧栏~/.config/cmux/sidebars/*.swift,运行时解释的 SwiftUI(beta)。cmux docs sidebars 有说明。
  • cmux vm 云端开发机:需要 cmux auth login,是 cmux 自家的云环境,跟自建 SSH 机器是两套东西。
  • 全局 SSH 连接复用~/.ssh/config 里给 Host *ControlMaster auto 能让所有 ssh/scp/git 受益,不只 cmux 拖拽。影响面大所以没动。