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.settings.json.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:
/tmp/team/<slug>.jsonl) and tee a human-readable mirror to <slug>.log.tail -F those files — split the pane so structured transcript is on the left, raw log on the right.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.
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.
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"
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.
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
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)"
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.
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.
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"
# 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"
| Command | Effect |
|---|---|
./team-launch.sh | Start the session, attach if you're not already inside tmux. |
./team-launch.sh --replace | Kill any existing session first, then start fresh. |
tmux attach -t my-team | Reattach later from any terminal. |
| Ctrl-b d | Detach. Session keeps running. |
| Ctrl-b w | Window picker (preview list). |
| Ctrl-b 0 | Jump 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.sh | Stop everything, archive live logs. |
Things team-launch.sh does that the minimal version skips, and why each one matters:
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; }
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.
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
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.
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:
| Path | What it contributes |
|---|---|
.claude/agents/cto.md | Frontmatter (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}.md | The 6 sub-agents the CTO dispatches via the Agent tool. |
.claude/TEAM.md | Team-wide shared rules referenced from cto.md. |
.claude/hooks/session-start-cto.sh | SessionStart 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.md | Per-session "current rollout state" the hook injects. Refreshed manually as work progresses. |
.claude/settings.json | Wires 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:
.claude/ as markdown agent prompts + a SessionStart hook. The kit ships with generic placeholder personalities; you add your project's intro to each role.Two formats, same contents:
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
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
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.
| Env var | Default | What it does |
|---|---|---|
PW_TEAM_SESSION | pw-team | tmux session name |
PW_TEAM_DIR | parent of scripts/ | project root |
PW_TEAM_CORE | same as PW_TEAM_DIR | git repo to provision dev worktrees from. Set empty to skip. |
PW_TEAM_LOG_DIR | /tmp/pw-team | per-task monitor log directory |
PW_TEAM_ARCHIVE_DIR | $PW_TEAM_DIR/.claude/monitors/archive | where closed-task logs go |
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:
allowedTools: frontmatter with whatever MCP servers, skills, or shell commands your team relies on.additionalContext at every CTO session start. Fill it with your own context (current epic, recent merges, in-flight incidents) before kicking off your team.Three files, ~30 lines each, drop them into scripts/ and adjust the agent invocation on the send-keys line:
team-launch.sh — sections 3, "preflight" and "orphan sweep" from §7monitor-open.sh — full block in §4monitor-close.sh — full block in §5Flow:
./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