Launching an agent team in tmux

A distilled walkthrough of scripts/team-launch.sh, monitor-open.sh and monitor-close.sh — how to spawn one CLI process per agent into a tmux session, then attach tail -F windows that follow each agent's transcript live.
Contents
1. The concept · 2. Session layout · 3. Minimal launcher · 4. Adding monitor windows · 5. Closing & archiving · 6. Operating it · 7. Niceties from the real script · 8. What lives outside scripts/ · 9. Download the kit
⬇ download pw-team-kit.sh — single self-extracting installer (22 KB). Bundles the four scripts plus generic agent personalities, the SessionStart hook, a placeholder CTO reminder, and settings.json.
Also available as a plain tarball: pw-team-kit.tar.gz (14 KB). See §9 for what's inside and how to install it.

1. The concept

You want to run several long-lived agent processes side by side, each writing a structured log somewhere, and keep an eye on them from one terminal. The pattern is:

The CTO/agent itself never touches the terminal you'd be typing in; you only ever interact with the orchestrator window. Monitor windows are read-only views.

2. Session layout

tmux session: pw-team
├── window 0: cto              ← interactive — you type here
├── window 1: feature-X        ← split pane: transcript | raw log
├── window 2: bugfix-Y         ← split pane: transcript | raw log
└── window 3: qa-regression    ← split pane: transcript | raw log

Switch between them with Ctrl-b w (window picker) or Ctrl-b n / p.

3. Minimal launcher

Strip the real script down to the load-bearing five lines and you get this:

#!/bin/bash
set -euo pipefail
SESSION="my-team"
WORKDIR="$HOME/projects/foo"

# Bail if it's already up
tmux has-session -t "$SESSION" 2>/dev/null && {
  echo "Session $SESSION already running."; exit 0;
}

# Detached session, one window 'cto', cwd = $WORKDIR
tmux new-session -d -s "$SESSION" -n cto -c "$WORKDIR"

# Send a command into that window — note the trailing 'Enter' literal
tmux send-keys -t "$SESSION:cto" 'claude --agent cto' Enter

# If we're not already inside tmux, attach
[ -z "${TMUX:-}" ] && tmux attach -t "$SESSION"
tip tmux send-keys with the literal word Enter (no quotes around it) sends the Return key. Quoting it as 'Enter' would type the string. This is the single most common mistake.

Replacing an existing session

The real launcher accepts --replace:

REPLACE=false
[ "${1:-}" = "--replace" ] && REPLACE=true

if tmux has-session -t "$SESSION" 2>/dev/null; then
    if $REPLACE; then
        tmux kill-session -t "$SESSION"; sleep 1
    else
        echo "Already running — use --replace"; exit 0
    fi
fi

4. Adding monitor windows

When the orchestrator dispatches a task and a transcript file appears at /tmp/team/<slug>.jsonl, open a window that follows it:

#!/bin/bash
# monitor-open.sh — open a tail -F window for an agent task
set -euo pipefail

SESSION="${TEAM_SESSION:-my-team}"
LOG_DIR="${TEAM_LOG_DIR:-/tmp/team}"

SLUG="$1"      # kebab-case task id, e.g. iter-50-qa
JSONL="$2"     # path to the agent's transcript JSONL

# Reject bad slugs early — they become tmux window names
printf '%s' "$SLUG" | grep -qE '^[a-z0-9][a-z0-9-]{0,39}$' \
  || { echo "bad slug"; exit 2; }

# Refuse to clobber an existing window of the same name
tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null \
  | grep -qx "$SLUG" && { echo "window $SLUG exists"; exit 3; }

mkdir -p "$LOG_DIR"
MONITOR_LOG="$LOG_DIR/$SLUG.log"
: > "$MONITOR_LOG"   # touch so tail -F has something to open

# Shell-quote paths before splicing into tmux command strings.
# tmux passes the cmd to /bin/sh -c, so spaces or quotes in paths break it.
JSONL_Q=$(printf '%q' "$JSONL")
LOG_Q=$(printf '%q' "$MONITOR_LOG")

# Left pane: tail the structured JSONL, optionally pretty-print with jq
tmux new-window -t "$SESSION" -n "$SLUG" \
  "tail -c +0 -F $JSONL_Q"

# Right pane: tail the raw mirror log
# -h splits horizontally → side-by-side panes (tmux's flag naming is famously
# the opposite of what most people expect)
tmux split-window -h -t "$SESSION:$SLUG" \
  "tail -c +0 -F $LOG_Q"

# Drop a marker so monitor-close.sh can audit orphans later
touch "$LOG_DIR/.opened-$SLUG"

# Snap the operator back to the cto window — they didn't ask to context-switch
tmux select-window -t "$SESSION:cto" 2>/dev/null || true

echo "OK: opened $SLUG (jsonl=$JSONL log=$MONITOR_LOG)"

The pretty-printing trick

Raw JSONL is unreadable. The real script pipes each line through jq so events become one-liners:

tmux new-window -t "$SESSION" -n "$SLUG" \
  "tail -c +0 -F $JSONL_Q 2>/dev/null \
     | while IFS= read -r line; do \
         printf '%s\n' \"\$line\" | jq -r -f $JQ_PROGRAM_Q 2>/dev/null \
           || echo '⚠️  malformed line skipped'; \
       done"

The jq filter (you write it once) renders tool_use, tool_result, and assistant text as 🔧 Bash(...), ✅ <tool> → ..., 💬 .... Malformed lines are skipped per-line so a single bad event never aborts the tail.

gotcha Always shell-quote interpolated paths with printf %q before embedding them in tmux new-window command strings. tmux hands the command to sh -c, so unquoted spaces or quotes break the shell parse mid-launch.

5. Closing & archiving

When a task finishes, archive the live files and kill the window. Idempotent:

#!/bin/bash
# monitor-close.sh — archive logs & close window
set -euo pipefail
SESSION="${TEAM_SESSION:-my-team}"
LOG_DIR="${TEAM_LOG_DIR:-/tmp/team}"
ARCHIVE_DIR="${TEAM_ARCHIVE_DIR:-$HOME/.team/archive}"

SLUG="$1"
DATE="$(date +%Y-%m-%d)"
mkdir -p "$ARCHIVE_DIR"

# Optional audit: did monitor-open.sh ever see this slug?
[ -f "$LOG_DIR/.opened-$SLUG" ] || \
  echo "$(date -Iseconds) close without open: $SLUG" \
    >> "$ARCHIVE_DIR/.audit.log"

for ext in log jsonl; do
    live="$LOG_DIR/$SLUG.$ext"
    [ -f "$live" ] || continue
    dest="$ARCHIVE_DIR/$DATE-$SLUG.$ext"
    # Avoid clobber on same-day double-close
    [ -e "$dest" ] && dest="$ARCHIVE_DIR/$DATE-$SLUG-$(date +%H%M%S).$ext"
    mv "$live" "$dest"
done

rm -f "$LOG_DIR/.opened-$SLUG"
tmux kill-window -t "$SESSION:$SLUG" 2>/dev/null || true
echo "OK: closed $SLUG"

Stop everything

# team-stop.sh — sweep live logs into archive, kill the session
shopt -s nullglob
ARCHIVE_FILES=("$LOG_DIR"/*.log "$LOG_DIR"/*.jsonl)
shopt -u nullglob
DATE="$(date +%Y-%m-%d)"
mkdir -p "$ARCHIVE_DIR"
for f in "${ARCHIVE_FILES[@]}"; do
    mv "$f" "$ARCHIVE_DIR/stop-$DATE-$(basename "$f")"
done
rm -f "$LOG_DIR"/.opened-*

tmux has-session -t "$SESSION" 2>/dev/null \
  && tmux kill-session -t "$SESSION"

6. Operating it

CommandEffect
./team-launch.shStart the session, attach if you're not already inside tmux.
./team-launch.sh --replaceKill any existing session first, then start fresh.
tmux attach -t my-teamReattach later from any terminal.
Ctrl-b dDetach. Session keeps running.
Ctrl-b wWindow picker (preview list).
Ctrl-b 0Jump to window 0 (the cto window).
Ctrl-b [Enter copy/scrollback mode in current pane. q to exit.
bash monitor-open.sh <slug> <jsonl>Open a follow-window for a task.
bash monitor-close.sh <slug>Archive & close one task's window.
./team-stop.shStop everything, archive live logs.

7. Niceties from the real script

Things team-launch.sh does that the minimal version skips, and why each one matters:

Preflight checks

command -v claude >/dev/null 2>&1 || { echo "claude not found"; exit 1; }
command -v tmux   >/dev/null 2>&1 || { echo "tmux not found";   exit 1; }
command -v jq     >/dev/null 2>&1 || { echo "jq not found";     exit 1; }

# Smoke-test the jq filter at launch — fail fast, not mid-dispatch
echo '{}' | jq -rf "$JQ_PROGRAM" >/dev/null 2>&1 \
  || { echo "jq program has a syntax error"; exit 1; }

Orphan sweep on launch

If a previous run crashed, leftover logs in $LOG_DIR get archived into archive/orphaned-YYYY-MM-DD/ before the new session starts — operator never has to guess what is current vs stale.

shopt -s nullglob dotglob
ORPHANS=("$LOG_DIR"/*)
shopt -u nullglob dotglob
if [ ${#ORPHANS[@]} -gt 0 ]; then
    ORPHAN_DIR="$ARCHIVE_DIR/orphaned-$(date +%Y-%m-%d)"
    [ -d "$ORPHAN_DIR" ] && ORPHAN_DIR="$ORPHAN_DIR-$(date +%H%M%S)"
    mkdir -p "$ORPHAN_DIR"
    mv "${ORPHANS[@]}" "$ORPHAN_DIR"/
fi

nullglob stops * from expanding to the literal string "$LOG_DIR/*" when the dir is empty. dotglob picks up the .opened-<slug> markers too.

Worktree provisioning (project-specific)

If the team uses git worktrees, create them lazily so each agent gets an isolated checkout. Skip if your team isn't doing that.

git fetch origin develop
for DEV in dev-1 dev-2; do
    WT="$WORKTREE_ROOT/$DEV"
    [ -d "$WT" ] && continue
    git worktree add "$WT" -b "team/$DEV" origin/develop
done

Why a marker file?

Touching $LOG_DIR/.opened-<slug> at open time, deleting it at close time, and writing an audit line if close runs without seeing the marker — that gives you a free crash detector. If the close hook fires for a slug that was never opened (e.g. agent died before its monitor window), the audit log records it without aborting the close.

8. What lives outside scripts/

The launcher itself is shell-script-only — no config files, no env file. But the line tmux send-keys -t "$SESSION:cto" 'claude --agent cto' Enter hands off to claude with an agent named cto, and that agent is defined entirely in the project's .claude/ directory:

PathWhat it contributes
.claude/agents/cto.mdFrontmatter (model, allowedTools) + system prompt for the orchestrator. The allowedTools list includes Bash(bash scripts/monitor-open.sh:*) so the CTO can open monitor windows without a permission prompt every dispatch.
.claude/agents/{dev-1,dev-2,pm,qa,devops,docs}.mdThe 6 sub-agents the CTO dispatches via the Agent tool.
.claude/TEAM.mdTeam-wide shared rules referenced from cto.md.
.claude/hooks/session-start-cto.shSessionStart hook — re-injects the CTO reminder file as additionalContext on every session start (and exits silently for sub-agents, by checking for agent_id in the stdin payload).
.claude/reminders/cto.mdPer-session "current rollout state" the hook injects. Refreshed manually as work progresses.
.claude/settings.jsonWires the SessionStart hook to session-start-cto.sh via $CLAUDE_PROJECT_DIR.
.claude/monitors/archive/Just a directory the scripts mkdir and write into — no config.
.claude/worktrees/dev-{1,2}/Optional — one git worktree per dev so they don't fight over a single working tree. Skipped automatically if PW_TEAM_CORE= is set empty.

So the rough split is:

9. Download the kit

Two formats, same contents:

One-liner install

curl -fsSL https://agent-team.protrener.com/pw-team-kit.sh | bash -s -- /path/to/your/project

Or step-by-step:

curl -O https://agent-team.protrener.com/pw-team-kit.sh
chmod +x pw-team-kit.sh

./pw-team-kit.sh --help                       # show usage
./pw-team-kit.sh --list                       # list bundled files (no extraction)
./pw-team-kit.sh --extract /tmp/inspect       # just extract, don't install
./pw-team-kit.sh                              # install into $PWD
./pw-team-kit.sh /path/to/your/project        # install into another dir

Install — tarball form

curl -O https://agent-team.protrener.com/pw-team-kit.tar.gz
tar xzf pw-team-kit.tar.gz
cd pw-team-kit
./install.sh /path/to/your/project

Layout

pw-team-kit/
├── install.sh                       — copy everything into a target project
├── README.md                        — same content as this page, in markdown
├── scripts/
│   ├── team-launch.sh               — start the tmux session
│   ├── team-stop.sh                 — stop & archive
│   ├── monitor-open.sh              — open one task's monitor window
│   ├── monitor-close.sh             — archive & close one task's window
│   └── format-transcript.jq         — pretty-printer for the JSONL transcript
└── .claude/
    ├── agents/{cto,dev-1,dev-2,pm,qa,devops,docs}.md
    ├── TEAM.md                      — team-wide shared rules
    ├── hooks/session-start-cto.sh   — re-injects the CTO reminder
    ├── reminders/cto.md             — running rollout state (replace this!)
    └── settings.json                — wires the SessionStart hook

If your target already has a .claude/settings.json, the installer drops the kit's version as settings.json.kit beside it for manual merge of the SessionStart hook block.

Configuration env vars

Env varDefaultWhat it does
PW_TEAM_SESSIONpw-teamtmux session name
PW_TEAM_DIRparent of scripts/project root
PW_TEAM_COREsame as PW_TEAM_DIRgit repo to provision dev worktrees from. Set empty to skip.
PW_TEAM_LOG_DIR/tmp/pw-teamper-task monitor log directory
PW_TEAM_ARCHIVE_DIR$PW_TEAM_DIR/.claude/monitors/archivewhere closed-task logs go

What you'll want to customise

The kit is a generic skeleton. Bundled agent files have a short role intro and a key-rules list — add your project-specific paragraph at the top of each one:


Skeleton you can copy

Three files, ~30 lines each, drop them into scripts/ and adjust the agent invocation on the send-keys line:

Flow:

./scripts/team-launch.sh                       # start the session
# ...orchestrator dispatches a task, knows the JSONL path...
bash scripts/monitor-open.sh feature-x /tmp/team/feature-x.jsonl
# ...task finishes...
bash scripts/monitor-close.sh feature-x
./scripts/team-stop.sh                         # at end of day