Skip to main content
Lab Grimoire
TW EN
Coffee
Letting Agents Assign Tasks to Each Other: A Practical Build from tmux to Handoff
Hands-On

Letting Agents Assign Tasks to Each Other: A Practical Build from tmux to Handoff

On this page

When running six CLI agents side by side, handoffs should no longer depend on manual copy-pasting.

Running Claude Code, Codex, and Grok simultaneously, my biggest annoyance is not weak model outputs. It is the handoffs: copying output from Agent A to Agent B when A finishes, pasting context back from B to A when B gets stuck.

I do not need an orchestrator that makes model-level decisions. I need a communication layer for instance naming, capacity allocation, message transport, auditability, and health checks. The ten sections below break down that layer, built from four components: a tmux session on a dedicated socket, a Python entry point, a message bus for named agent messaging, and a set of shell wrapper functions.

What I Am Building

Six interactive CLI agent runtimes (claude, pi, codex, grok, agy, hermes) all run inside a single tmux session named agents, on a dedicated socket at /tmp/tmux-agents-cyuh.sock, isolated from my daily tmux sessions.

A Python entry point handles discovery, capacity allocation, delivery, auditability, and health checks. A message bus registers each agent id to a tmux target location and delivers messages to specific ids (using mempal's cowork command suite in this setup). Shell wrappers package routine operations so I do not have to type lengthy commands.

None of this layer decides task logic or selects models. It only does four things: instance naming, capacity allocation, message transport, and auditability with health checks.

Solidify the communication and capacity layer first, before deciding who delegates what.

agents session topology: entry point handles discovery, capacity, and delivery

The Foundation: A Dedicated Socket tmux Session

My daily tmux sessions hold everything from server logs to scratch scripts. An agent fleet must be isolated, or the discovery layer will mistake unrelated windows for agent candidates.

I fix the session name as agents and enforce a dedicated socket. Every subsequent tmux call carries this socket path, so list, capture, and send operations all hit the same set of panes.

Both the Python entry point and shell wrappers assume this session exists or can be created. A wrapper command like ta handles attaching or creating the session; all other commands operate strictly on this dedicated socket.

Without an isolated socket, every layer downstream risks polluting your daily terminals.

The Runtime Catalog: Runtimes as Data

Adding a new agent runtime should not mean adding another conditional block. It belongs in a frozen data specification: each runtime defines an agent id, tool name, window name, and launch command.

@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 packs the name, window, and launch command into a single row. AGENTS is the full directory; DEFAULT_FLEET controls which runtimes auto-provision, leaving the rest on demand. Adding a runtime means adding one data row.

Catalogs are data, not branching logic.

Dynamic Discovery: A Single tmux Format String as the Discovery Layer

Panes drift as windows close, reopen, or shift positions. Rather than hardcoding positions, I query tmux on every execution.

_run([
    "tmux", "-S", socket_path, "list-panes", "-a", "-F",
    "#{window_name}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_path}",
])

Three output columns: window name, pane location, and pane working directory. The entire discovery layer rests on this line. Window names resolve runtimes, locations route messages, and cwd guards against cross-project injection.

But a valid window name alone is not enough. The pane's working directory must resolve to the designated workspace, or execution is rejected:

if Path(pane_cwd).resolve() != WORKSPACE.resolve():
    raise MeshError(f"{agent_id} 的 pane cwd 不在工作區:{pane_cwd};拒絕跨專案注入。")

If a window named claude happens to be open in a different repository and delivery goes through, commands and context hit the wrong project. This guard separates what looks like a valid agent from what actually belongs to the current workspace.

Recompute locations on every run, and reject non-matching working directories.

Naming Contracts: Leading Ordinals Accommodate Legacy Windows

Deploying a new naming contract cannot rename existing windows, because that would interrupt active workloads. Compatibility has to live entirely inside the parser.

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

Three naming styles parse correctly: new-style <ordinal>-<window_name> (like 3-Codex) reads the numeric prefix; old-style <window_name>-<ordinal> reads the numeric suffix; bare names like claude fall back to the tmux window_index. Migration cost is zero.

Agent ID assignment sorts panes by (window_index, pane_index). Each runtime's first pane gets the base ID (claude-main), while subsequent panes get <tool>-<ordinal> (claude-2, codex-3). Two windows both named claude are no longer an error; they parse into claude-main and claude-2. Rejection happens only when two panes resolve to the exact same ID.

An ordinal is not a tmux window index. In practice, window 3-Codex can sit at tmux index 4 if index 3 is occupied by an unrelated python3.11 window. Unrecognized windows are quietly ignored.

The parser ingests three naming formats, keeping ongoing work uninterrupted.

Busy Detection: Reading the Screen and Falling Back Safely

Interactive TUIs have no unified API for busy queries. The only option is capturing the pane's bottom output and matching against known prompts.

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",
)

Idle indicators vary across tools; busy markers are more uniform. The detection skeleton:

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

Four design constraints, all required: read screen output directly; strip ANSI escape sequences before string matching; inspect only the last few non-empty lines to avoid matching stale scrollback; prioritize busy markers over idle markers, returning False on unhandled states.

I default to busy on purpose. Misjudging a busy agent as idle injects tasks into an active session, while misjudging an idle agent as busy just provisions a new window.

Treat unknown states as busy, preferring extra windows over colliding tasks.

Capacity Allocation: Lowest Free Ordinal with a File Lock

mesh-open codex does not unconditionally create a window. It acquires a safe, available instance: reuse a verified idle instance of the same runtime first, create a new one only when none exists.

When provisioning a new instance, ordinal selection fills the lowest free slot rather than incrementing the maximum:

def _next_free_ordinal(occupied: set[int]) -> int:
    ordinal = 0
    while ordinal in occupied:
        ordinal += 1
    return ordinal

occupied tracks global ordinals shared across runtimes. Closing instance 2 means the next allocation reclaims ordinal 2 rather than jumping to 7.

Concurrency is the real trap: two simultaneous callers both see a maximum of 3 and both try to provision ordinal 4. Discovery, assessment, ordinal selection, and creation must sit inside a single critical section:

with lock_path.open("a+", encoding="utf-8") as lock_file:
    fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
    ...

Under the lock, the system inspects state, computes free ordinals, and creates windows. Non-allocating operations stay outside the critical section.

Capacity allocation is a critical section, not optimistic guessing.

capacity allocation flow: discovery, idle reuse, lowest free ordinal, file lock

Handoff Transport: File First, Deliver Second, and Breaking Response Loops with DONE and FAIL

Pasting large text blocks into a TUI fragments context and destroys auditability. So handoff follows a fixed three-step workflow: write the full content to a Markdown note under memory/handoff/, import it into the message bus, then deliver only a summary and note path to the target terminal.

Standard messages automatically append instructions requiring a reply starting with DONE: or FAIL: upon completion. But if a completion response also triggers a reply request, agents enter an infinite loop. A guard prevents this:

if summary.lstrip().upper().startswith(("DONE:", "FAIL:")):
    return base_message

Summaries starting with DONE: or FAIL: skip the reply instructions, terminating the loop cleanly.

Unreviewed external text (web content, emails, API responses) routes through manual mode. It pastes text into the input buffer without pressing Enter, requiring human review before submission. Default mode submits directly, reserved for operator or agent-authored messages.

One practical delivery detail: certain TUIs interpret an appended Enter key as an input line break. For those runtimes, I send an extra Enter keystroke after a 1.5-second delay.

Establish an auditable file first, then deliver the summary to the terminal.

handoff lifecycle: note creation, message bus, delivery, DONE/FAIL reporting

Registry Stale State: The Most Overlooked Pitfall

For the bus to route messages to an agent id, it first registers the mapping between that id and its tmux target location. Registration runs after every discovery pass:

mempal cowork-register \
  --agent-id codex-cloud --tool codex \
  --cwd "$WORKSPACE" \
  --transport tmux --tmux-target agents:4.0

--transport tmux binds to a specific --tmux-target. The problem: tmux targets shift as windows close, reopen, or renumber, and the registry still points to the old location.

In production, I found a stale grok-main record pointing to a pane running python3.11. Sending a message to grok-main at that point would have injected commands into that Python process.

Protection requires two safeguards. First, the entry point checks delivery targets against live discovery results, rejecting unlisted targets. Second, a prune command removes stale entries lacking active panes. Pruning defaults to dry-run mode, creating backups before writing, and never alters append-only event logs.

The registry is a cache; discovery results are the source of truth.

Daily Workflow and Health Checks

Daily usage wraps every operation in shell functions to avoid manual long commands:

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 "$*"
}

Sending from within an agent pane automatically infers the sender ID. Outside a pane, I specify the sender explicitly. With wrappers in place, the daily workflow looks like this:

ta                                   # enter or reattach the agents fleet
mesh-open codex                      # acquire a codex workspace instance
mesh-status                          # view currently active agents
mesh-send codex-cloud "take over stage two and report back when done"
mesh-smoke                           # provision, reregister, and run health check without messaging
mesh-prune                           # dry run: list stale registrations lacking active panes

Fleet health relies on two commands: discover lists each agent id alongside its real-time location; smoke provisions default fleet runtimes, reregisters panes, and probes target locations without sending test messages. Passing criteria require configured to equal ok with zero pending deliveries.

Shell wrappers are the surface; health checks verify the connectivity of the underlying layer.

Still copy-pasting between agent windows by hand? Drop a line.

Found this useful?

Follow for new AI × biomedical research notes:

Or buy me a coffee to keep new content coming.

☕ Buy Me a Coffee