Replacing tmux send-keys for agent coordination? stdin FIFO is the right answer, not an alternative

tmux send-keys became the backbone of multi-agent relay systems for one reason: it could inject input into a running AI agent process at any moment, simulating a human interrupt. It worked. But it was always a hack — it targets a terminal pane, not a process, and carries all of tmux's fragility on Windows Git Bash with it.

The cleaner architecture separates the two things tmux was doing simultaneously:

1. Process lifecycle: supervisor loop

Instead of an external process injecting /next-run <path> into a live agent's stdin, the agent writes its own handoff file before exiting:

# supervisor wrapper
handoff="/tmp/next-run-${session_id}.md"
printf 'poll\n' > "$handoff"
while [[ -f "$handoff" ]]; do
    claude-code "/next-run $handoff"
done

The agent finishes a cycle, writes the next handoff, and exits cleanly. The supervisor reads the file and restarts. No injection, no timing race, no tmux session required. The agent writes its last will before dying — context stays clean across restarts because each cycle starts fresh.

2. Async wake / interrupt: stdin FIFO + registration file

For the "prod is on fire, wake up the polling agent" case, give each agent a named pipe as its stdin:

fifo="/tmp/mux-agent-${slug}-${backend}-${mode}.fifo"
mkfifo "$fifo"
claude-code "/next-run $handoff" < "$fifo" &
printf '%s\t%s\n' "$$" "$fifo" > "/tmp/mux-agent-${slug}-${backend}-${mode}.reg"

Any process that wants to wake the agent writes to the FIFO — same semantics as send-keys, but at the OS level, no tmux required.

3. Discovery: the registration file is the registry

mux agents no longer needs to walk tmux list-panes -a. It scans /tmp/mux-agent-*.reg, checks kill -0 $pid for each entry, and lists the live ones. The FIFO path in the registration file is both the agent's address and its communication channel — one mechanism solves discovery and messaging.

What this unlocks

With display decoupled from communication, agents can run entirely in the background. Output goes to a log file; mux log <agent> tails it when you want to watch. Closing a terminal window no longer kills the agent. On locked-down Windows hosts without WSL2, the whole stack runs in Git Bash with no tmux dependency at all.

The tmux send-keys approach worked because it exploited the one interface AI coding agents expose: their terminal stdin. The FIFO approach exploits the same interface at the OS level — it's not a workaround, it's the same idea done properly.

The one thing tmux still does better is real-time pane-peeking across agents in team mode. For multi-agent workflows where one agent needs to interrupt another mid-execution, stdin FIFO is the right replacement. For single-agent lifecycle management, the supervisor loop makes the interrupt unnecessary in the first place.

Need a per-repo toggle for your own tool? Stash it in `git config`

When you build tooling around git repos, you eventually hit "where do I store a per-repo setting?" Committing a .toolrc pollutes the repo and needs a PR; a sidecar file in ~/.config can't easily be per-repo. The clean answer: put it straight into git config under your own namespace.

# per-repo (lands in <repo>/.git/config — local, never committed)
git config --local mux.adhocWorktree false

# per-machine, all repos (lands in ~/.gitconfig)
git config --global mux.adhocWorktree false

# read it back, normalized to true/false
git config --bool --get mux.adhocWorktree   # -> false ; exit 1 if unset

git lets you invent any <section>.<key> it doesn't recognize (mux.* here) and just stores it. That hands you three things for free that you'd otherwise have to build:

  • A per-repo store that never leaks.git/config is local to the checkout and never committed, so it won't pollute someone else's repo.
  • Scope layering with native precedence--local (repo) transparently overrides --global (machine). One git config --get resolves the hierarchy; you don't write any "check repo, then fall back to machine" logic.
  • Type parsing--bool normalizes false/0/no/offfalse, and a non-zero exit cleanly signals "not configured" so you can fall through to your built-in default.

So a full precedence chain for a feature flag becomes: CLI flag → env var → git config (local→global) → built-in default — and the middle two scopes are entirely git's job.

One gotcha with worktrees: git config --local writes to the shared common .git/config, not the linked worktree, because worktrees share config. If your setting is meant to key off the main checkout (it usually is), that's exactly right — but don't expect two worktrees of the same repo to hold different --local values.

The decision that surfaced this: an agent launcher was hardwiring a git worktree per session (hundreds of MB on big repos, painful on a VM). The whole "make it opt-out, but where does the per-repo marker live?" problem evaporated the moment we realized git config already is a per-repo + per-machine settings store with precedence baked in.

PI_CODING_AGENT_DIR points at the agent dir, not the `.pi` home

If you copy pi's default config to a custom dir and it silently "doesn't work at all" (no auth, no models), you probably pointed PI_CODING_AGENT_DIR one level too high.

The trap is that pi's default and its env override are asymmetric:

  • Default (no env var): the agent dir is ~/.pi/agent. Your auth.json/models.json live in ~/.pi/agent/.
  • Override: PI_CODING_AGENT_DIR is used verbatim as the agent dir — no /agent is appended.

So this looks right but isn't:

# WRONG: pi reads ~/.piz/auth.json (empty) and ~/.piz/models.json (missing)
PI_CODING_AGENT_DIR=~/.piz pi
# even though your real config is in ~/.piz/agent/

The fix is to point the variable at the agent leaf, mirroring the default layout:

PI_CODING_AGENT_DIR=~/.piz/agent pi

The relevant resolution (packages/coding-agent/src/config.ts):

export function getAgentDir(): string {
  const envDir = process.env[ENV_AGENT_DIR];
  if (envDir) return expandTildePath(envDir);        // used as-is
  return join(homedir(), CONFIG_DIR_NAME, "agent");  // default appends "/agent"
}

This feels weird next to .claude/.codex/.copilot, which keep their config flat in one dir and have no hidden subdir. But the name is the giveaway: it's PI_CODING_AGENT_DIR, not PI_HOME. It names the agent directory itself — the leaf, equivalent to ~/.pi/agent. The flat behavior is exactly what the name promises.

Once you read it that way the design makes sense: ~/.pi is just a home/container, and you can park several independent agent dirs under it (~/.pi/agent, ~/.pi/work, ~/.pi/personal) and switch between them with the env var.

For a rotating Claude gateway, key off the provider, not the token prefix

If you put Pi behind a local Anthropic gateway that rotates multiple Claude accounts, the interesting part is not the token format. The gateway may hand Pi a placeholder token like claude, while the real upstream auth happens inside the proxy. That means request shaping should be attached to the anthropic provider itself, not gated on whether the token looks like sk-ant-oat....

In pi-anthropic-auth, the fix was to make the transport wrapper unconditional for Anthropic requests. Pi already routes the built-in anthropic provider through the same registry transport, so once the request is in that lane, it can be shaped every time:

pi.registerProvider("anthropic", {
  oauth: anthropicOAuthOverride,
  api: "anthropic-messages",
  streamSimple: createAnthropicOAuthStreamSimple(builtinTransport.streamSimple),
});

That lets the proxy stay dumb and flexible. I ended up using ~/.pi/agent/models.json to point anthropic.baseUrl at the local gateway, and a placeholder OAuth record in ~/.pi/agent/auth.json:

{
  "providers": {
    "anthropic": {
      "baseUrl": "http://gateway-host:9949"
    }
  }
}
{
  "anthropic": {
    "type": "oauth",
    "access": "claude",
    "refresh": "",
    "expires": 1813218019502
  }
}

The gateway can then rotate across multiple Claude accounts behind the scenes, so one account hitting the 5-hour wall does not interrupt the session. Pi still sees a normal Anthropic provider, and the shaping layer still injects the Claude Code billing header and prompt normalization before the request leaves the machine.

Got personal files to ignore? Use .git/info/exclude, not the shared .gitignore

When you want Git to ignore something that's yours — a local scratch dir, editor cruft, a personal plans/ folder, machine-local config — don't add it to the tracked .gitignore. That file is shared: a rule like plans/ or wiki-drafts/ forces your personal directory conventions onto everyone, and they don't have those dirs.

Put it in .git/info/exclude instead. Same syntax as .gitignore, but it's per-clone and never committed — so it's invisible to teammates and travels with nothing:

# .git/info/exclude
.claude/settings.local.json
.claude/plans/
.claude/wiki-drafts/

The rule of thumb: **.gitignore is for what the project should ignore (build output, node_modules/, *.user); .git/info/exclude is for what you happen to ignore on this machine.** If a teammate cloning fresh wouldn't also produce the file, it doesn't belong in the shared list.

One gotcha with worktrees: info/exclude lives in the common git dir, so it's shared across all linked worktrees of the same clone — but still not across separate clones. Find it with git rev-parse --git-common-dir if you're not sure where it is.

And if you ever need to start tracking something a blanket exclude was hiding (e.g. you had .claude excluded but now want .claude/skills/ committed), narrow the exclude line to the specific paths you still want ignored — don't delete it and shove replacement rules into the shared .gitignore.