跳到主要內容
Lab Grimoire
TW EN
請喝咖啡
讓 Agent 互相指派任務:機制怎麼設計、實際怎麼用
動手實作

讓 Agent 互相指派任務:機制怎麼設計、實際怎麼用

本頁目錄

同時開六個 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 短指令,作用是包裝日常操作,讓你不必打長命令。

這一層不決定任務內容,也不挑模型,只做四件事:實例命名、容量配置、訊息傳輸、追溯與健康檢查。

先把通訊與容量層蓋穩,再談誰派什麼工。

agents session 拓撲:入口負責發現、容量與投遞

地基:一個專用 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-2codex-3);副作用是兩個同名 claude 視窗不再是錯誤,而會被解析成 claude-mainclaude-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。

先有可追溯的檔案,再有投進終端的摘要。

交接生命週期:筆記、匯流排、投遞、DONE/FAIL 回報

註冊表會過期:最容易忽略的坑

訊息匯流排要能送給「某個 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;那是投遞細節,不能取代「外部文字先經人工審閱」這條路徑分流。

覺得這篇有幫助?

追蹤以收到新的 AI × 生醫研究筆記:

或請我喝杯咖啡,讓新內容持續產出。

☕ 請我喝杯咖啡