同時開六個 CLI agent 時,交接不該再靠人手複製貼上。
我同時開著 Claude Code、Codex、Grok 這類 CLI agent 時,最煩的不是模型寫不好,是交接還靠人手:A 做完我複製到 B,B 卡住我再貼回 A。
我想要的不是「會調度模型」的中間人,而是一層只負責實例命名、容量配置、訊息傳輸、追溯與健康檢查的通訊網。下面十節就是這一層的完整拆解,四個零件:一個專用 socket 的 tmux session、一支 Python 入口、一個支援具名 agent 收發的訊息匯流排,以及一組 shell 短指令。
要蓋的東西長什麼樣
成品很具體:六類互動式 CLI agent(claude、pi、codex、grok、agy、hermes)全部跑在同一個 tmux session 裡,session 名稱是 agents,使用專用 socket /tmp/tmux-agents-cyuh.sock,與日常 tmux 隔離。
一支 Python 入口負責發現、配置容量、投遞、追溯、健康檢查;一個訊息匯流排負責「註冊一個 agent id 並綁定 tmux 位置」與「送訊息給某個 agent id」(本文用的是 mempal 的 cowork 系列命令);至於那組 shell 短指令,作用是包裝日常操作,讓你不必打長命令。
這一層不決定任務內容,也不挑模型,只做四件事:實例命名、容量配置、訊息傳輸、追溯與健康檢查。
先把通訊與容量層蓋穩,再談誰派什麼工。

地基:一個專用 socket 的 tmux session
日常 tmux 裡什麼都有,日誌、伺服器、臨時腳本混在一起,所以 agent 艦隊必須隔離,否則發現層會把不相干的視窗當成候選。
做法是固定一個 session 名稱 agents 並強制走專用 socket,因為之後所有 tmux 呼叫都得帶著這個 socket 路徑,才保證 list、capture、send 都打在同一批 pane 上。
Python 入口與 shell 短指令都假設這個 session 已存在或可建立;進入艦隊的短指令(例如 ta)負責 attach 或建立,其餘命令則只在這個 socket 上操作。
沒有隔離的 socket,後面每一層都會誤傷日常終端。
runtime 目錄:把「有哪幾類 agent」寫成資料
新增一類 agent 不該是再寫一段 if,而該是一份凍結的資料表:每個 runtime 有 agent id、工具名、視窗名、啟動命令。
@dataclass(frozen=True)
class AgentSpec:
agent_id: str
tool: str
window_name: str
launch_command: str
AGENTS: tuple[AgentSpec, ...] = (
AgentSpec("claude-main", "claude", "claude", "claude"),
AgentSpec("pi-main", "pi", "Pi", "pi"),
AgentSpec("codex-cloud", "codex", "Codex", "codex"),
AgentSpec("grok-main", "grok", "Grok", "grok"),
AgentSpec("agy-main", "agy", "Agy", "agy"),
AgentSpec("hermes-main", "hermes", "Hermes", "hermes"),
)
DEFAULT_FLEET: tuple[str, ...] = ("claude", "pi")
AgentSpec 把「這類 agent 叫什麼、開什麼視窗、怎麼啟動」收成一列。AGENTS 是完整目錄,而 DEFAULT_FLEET 決定自動補齊哪幾類、其餘按需開,因此新增 runtime 只是多一行資料。
目錄是資料,不是分支邏輯。
動態發現:一行 tmux 格式字串就是整個發現層
位置會漂移:視窗關掉、重開、插進不相關的 pane,編號就跟著變,所以系統不寫死位置,每次執行都重新問 tmux。
_run([
"tmux", "-S", socket_path, "list-panes", "-a", "-F",
"#{window_name}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_path}",
])
三欄分別是視窗名稱、位置、該 pane 的工作目錄,而整個發現層就建立在這一行上:名稱用來反解 runtime,位置用來投遞,cwd 用來擋跨專案注入。
但名稱合法還不夠,pane 的工作目錄必須落在指定工作區,否則一律拒絕:
if Path(pane_cwd).resolve() != WORKSPACE.resolve():
raise MeshError(f"{agent_id} 的 pane cwd 不在工作區:{pane_cwd};拒絕跨專案注入。")
視窗叫 claude 卻開在別的 repo,若仍允許投遞,指令與上下文就會打進錯誤專案。這行守衛要把「看起來像」與「真的屬於這個工作區」切開。
位置每次重算,cwd 不合格就拒絕。
命名契約:編號放前面,才容得下既有視窗
上線新契約時,既有視窗一個都不能改名,否則等於中斷正在跑的工作,因此相容性必須整個放進解析層。
def parse_window_name(window_name, window_index=None):
new_style_prefix, separator, base_window = window_name.partition("-")
if separator and new_style_prefix.isdigit():
spec = AGENT_BY_WINDOW.get(base_window)
if spec is not None:
return spec, int(new_style_prefix)
for candidate in AGENTS:
prefix = f"{candidate.window_name}-"
if window_name.startswith(prefix) and window_name[len(prefix):].isdigit():
ordinal = int(window_name[len(prefix):])
if ordinal >= 2:
return candidate, ordinal
spec = AGENT_BY_WINDOW.get(window_name)
if spec is not None and window_index is not None:
return spec, window_index
return None
三種形式都吃:新式 <編號>-<視窗名>(例 3-Codex)取數字前綴;舊式 <視窗名>-<編號> 取數字後綴;裸名 claude 則取該 pane 的 tmux window_index,遷移成本因此是零。
Agent ID 的指派規則:pane 依 (window_index, pane_index) 排序後,每個 runtime 的第一個 pane 取基礎 ID(如 claude-main),其餘取 <tool>-<ordinal>(如 claude-2、codex-3);副作用是兩個同名 claude 視窗不再是錯誤,而會被解析成 claude-main 與 claude-2,只有兩個 pane 算出同一個 ID 才拒絕。
編號不等於 tmux 位置:實測看過視窗 3-Codex 坐在 tmux index 4,因為 index 3 被一個不屬於這套系統的 python3.11 視窗佔著,而那種不認識的視窗會被安靜略過。
解析層吞三種命名,既有工作零中斷。
忙碌判定:沒有統一 API,只能讀畫面,而且要往安全側倒
互動式 TUI 沒有跨工具統一的忙碌查詢介面,你能做的只有截下 pane 尾端畫面,再比對已知提示字。
IDLE_MARKERS = {
"pi": ("● READY",),
"codex": ("›",),
"claude": ("? for shortcuts",),
"grok": ("Shift+Tab:mode",),
"agy": ("? for shortcuts",),
"hermes": ("⏲ 0s",),
}
BUSY_MARKERS = (
"• Working",
"• Running",
"Running PermissionRequest hook",
"esc to interrupt",
"esc to cancel",
"Would you like to run the following command?",
"Press enter to confirm",
)
各家閒置提示不同,忙碌提示反而比較像,判定骨架因此長這樣:
output = _run(["tmux", "-S", socket_path, "capture-pane", "-p", "-t", target, "-S", "-20"])
clean_lines = [ANSI_ESCAPE.sub("", line).rstrip() for line in output.splitlines()]
nonempty_tail = [line for line in clean_lines if line.strip()][-8:]
tail = "\n".join(nonempty_tail)
if any(marker in tail for marker in BUSY_MARKERS):
return False
...
return False
四個設計點缺一不可:第一,只能讀畫面;第二,必須先剝 ANSI escape,否則顏色碼會拆掉字串比對;第三,只看尾端幾行非空白列,避免歷史畫面誤判;第四,忙碌標記優先於閒置標記,且函式最後一行是 return False,看不懂就當忙碌。
之所以往這一側倒,是因為誤判閒置的代價是把新任務塞進還在工作的 agent,而誤判忙碌的代價只是多開一個視窗。
看不懂就當忙,寧可多開不誤搶。
容量配置:最小空號加一把檔案鎖
mesh-open codex 的語意不是「永遠新建」,而是「取得一個安全可用的容量」:先找可信閒置的同類實例重用,找不到才建新的。
建新時編號怎麼取?不是最大加一,是回填最小空號:
def _next_free_ordinal(occupied: set[int]) -> int:
ordinal = 0
while ordinal in occupied:
ordinal += 1
return ordinal
occupied 是跨 runtime 共用的全域編號集合,不是每類各自編號;關掉 2 號再開,就該優先拿回 2,而不是一路衝到 7。
並行才是真正的坑:兩個呼叫同時進來,都判定「目前最大是 3」,然後都去建 4 號,所以「發現、判斷、編號、建立」必須包在同一個臨界區:
with lock_path.open("a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
...
鎖住之後才讀現場、算空號、開視窗,鎖外只做不影響編號的事。
容量配置是臨界區,不是樂觀猜測。

交接傳輸:先留檔再投遞,DONE 與 FAIL 切斷回報迴圈
交接若只往 TUI 貼一大段,上下文會碎、也不可追溯,因此流程固定三步:完整內容寫成 Markdown 筆記存進 memory/handoff/,再匯入訊息匯流排,最後才投遞到目標終端,而投進 TUI 的只有摘要與筆記位置。
一般訊息會自動附上「完成後請反向送回,摘要以 DONE: 或 FAIL: 開頭」;但若回報本身又附上回報要求,兩個 agent 就會互相回報到天荒地老,所以要有守衛:
if summary.lstrip().upper().startswith(("DONE:", "FAIL:")):
return base_message
DONE: 或 FAIL: 開頭的摘要不再附加回報要求,回報迴圈在這裡切斷。
外部文字另走一條路:網頁、信件、第三方 API 回應這類未經人工審閱的內容一律走 manual 模式,只把內容打進目標輸入框、不按 Enter,由人看過再送;預設模式則會直接提交,只適合操作者或 agent 自己寫的內容。
投遞後有個實務細節:某些 TUI 會把同一批送出的 Enter 吃成輸入框換行,對那類 runtime 就要在投遞後延遲 1.5 秒補送一次 Enter。
先有可追溯的檔案,再有投進終端的摘要。

註冊表會過期:最容易忽略的坑
訊息匯流排要能送給「某個 agent id」,就得先註冊「這個 id 對應哪個 tmux 位置」。註冊呼叫長這樣,每次發現之後都重跑一遍:
mempal cowork-register \
--agent-id codex-cloud --tool codex \
--cwd "$WORKSPACE" \
--transport tmux --tmux-target agents:4.0
關鍵是 --transport tmux 加上一個 --tmux-target。問題是位置會漂移,而註冊表記的正是位置。視窗關掉、重開、序號變動之後,註冊表就開始說謊。
實測看過一筆叫 grok-main 的紀錄指向一個 python3.11 的視窗,這時若直接呼叫匯流排送訊息給 grok-main,內容就會打進那個 Python 進程。
防護因此要做兩層:第一,入口在投遞前一定拿當下的發現結果比對,目標不在現行清單就拒絕;第二,提供一個裁剪命令,把沒有現行 pane 的紀錄清掉,而裁剪預設是預演模式只回報不寫檔,實際執行前會先備份,且不動 append-only 的事件日誌。
註冊表是快取,發現結果才是真相。
日常流程與健康檢查
日常不該記長命令,每個操作都包成一則 shell 短指令,長這樣:
mesh-send() {
if (( $# < 2 )); then
printf '用法:mesh-send <agent-id> <訊息>\n' >&2
return 2
fi
local target="$1"
shift
/opt/homebrew/bin/python3 "${_TVW_AGENT_MESH}" send --to "$target" --summary "$*"
}
在 agent 自己的 pane 裡發送時可省略發送者,入口會由目前所在的 pane 反推,不在 pane 裡則必須顯式指定;其餘短指令同理包一層,日常流程於是變成:
ta # 進入或接回 agents 艦隊
mesh-open codex # 取得一個 codex 工作容量
mesh-status # 看目前有哪些 agent
mesh-send codex-cloud "請接手第二階段並完成後回報"
mesh-smoke # 補齊、重登錄、健康檢查,不傳訊
mesh-prune # 預演:列出沒有現行 pane 的過期註冊
怎麼知道它活著?discover 列出目前每個 agent id 與它的即時位置,而 smoke 會補齊預設艦隊、重新登錄,再逐一探測所有位置,全部可達才算通過,且不送測試訊息;判準是 configured 等於 ok,待處理投遞為 0。
短指令只是皮,健康檢查才確認整層還連得起來。
你還在哪個環節人肉搬運?回覆一句。
常見問題
為什麼不寫死 pane 位置?
位置會漂移。視窗關掉、重開、插進不相關的 pane,編號就跟著變,所以系統每次執行都重新問 tmux,不把位置寫死。發現層用一行 list-panes 格式字串取出視窗名稱、位置與工作目錄:名稱反解 runtime,位置用來投遞,cwd 用來擋跨專案注入。實測看過視窗 `3-Codex` 坐在 tmux index 4,因為 index 3 被一個不屬於這套系統的 `python3.11` 視窗佔著。編號不等於 tmux 位置,所以只能每次重算。
為什麼「看不懂就當忙碌」而不是預設閒置?
互動式 TUI 沒有跨工具統一的忙碌查詢介面,只能截下 pane 尾端畫面,再比對已知提示字。忙碌標記優先於閒置標記,且判定函式最後一行是回傳忙碌。之所以往這一側倒,是因為誤判閒置的代價是把新任務塞進還在工作的 agent,而誤判忙碌的代價只是多開一個視窗。原則是:看不懂就當忙,寧可多開不誤搶。
為什麼要先寫檔再投遞,而不是直接把內容貼進終端?
交接若只往 TUI 貼一大段,上下文會碎、也不可追溯。流程因此固定三步:完整內容寫成 Markdown 筆記存進 `memory/handoff/`,再匯入訊息匯流排,最後才投遞到目標終端;投進 TUI 的只有摘要與筆記位置。一般訊息會附上完成後反向送回的要求,且摘要須以 `DONE:` 或 `FAIL:` 開頭;這兩種開頭的回報不再附加回報要求,避免兩個 agent 互相回報到天荒地老。先有可追溯的檔案,再有投進終端的摘要。
為什麼註冊表會過期、要怎麼防?
訊息匯流排要送給某個 agent id,就得先註冊「這個 id 對應哪個 tmux 位置」。問題是位置會漂移,而註冊表記的正是位置;視窗關掉、重開、序號變動之後,註冊表就開始說謊。實測看過一筆 `grok-main` 的紀錄指向一個 `python3.11` 視窗,直接送訊就會打進那個 Python 進程。防護做兩層:投遞前一定拿當下的發現結果比對,目標不在現行清單就拒絕;另提供裁剪命令,把沒有現行 pane 的紀錄清掉。註冊表是快取,發現結果才是真相。
外部文字為什麼一定要走 manual 模式?
網頁、信件、第三方 API 回應這類未經人工審閱的內容,一律走 manual 模式:只把內容打進目標輸入框、不按 Enter,由人看過再送。預設模式會直接提交,只適合操作者或 agent 自己寫的內容。另外,某些 TUI 會把同一批送出的 Enter 吃成輸入框換行,對那類 runtime 還要在投遞後延遲補送一次 Enter;那是投遞細節,不能取代「外部文字先經人工審閱」這條路徑分流。