| name | tmux-agent-manager |
| description | Manage multiple Claude Code agent sessions running in tmux. Report a status summary of all sessions, deliver instructions to a specific agent via tmux send-keys, and automatically monitor the agent after each send to detect permission prompts, clarifying questions, or early-idle states. Use when the user wants to oversee concurrent Claude agents, check what sessions are doing, send a message to an agent, or monitor for sessions that need input or are stuck. Works on native Linux, macOS, WSL (inside), and Windows with WSL (Git Bash or PowerShell host). |
| argument-hint | [--wsl] [status | send [session] <message> | monitor [interval_seconds] | new <issue_number_or_prompt>] |
| allowed-tools | Bash |
| license | Apache-2.0 |
Tmux Agent Manager
Oversees multiple Claude Code sessions running in tmux.
Environment Detection (run first, before any other step)
Run this once at the start of every skill invocation to set the variables
used throughout. Run it as a plain bash block (no subshell wrapper) so the
variables are available in your reasoning context for all subsequent steps:
ARGS="${ARGUMENTS:-}"
USE_WSL_TOOLS=false
if printf '%s\n' "$ARGS" | grep -Eq '(^|[[:space:]])--wsl([[:space:]]|$)'; then
USE_WSL_TOOLS=true
fi
if command -v tmux &>/dev/null; then
TMUX_EXEC="tmux"
SH="bash -c"
elif wsl tmux -V >/dev/null 2>&1; then
TMUX_EXEC="wsl tmux"
SH="wsl bash -c"
else
echo "tmux is not available (native or via wsl tmux). Install tmux and retry."
exit 1
fi
choose_tool() {
tool="$1"
if grep -qi microsoft /proc/version 2>/dev/null && [ "$USE_WSL_TOOLS" = false ]; then
if command -v "${tool}.exe" >/dev/null 2>&1; then
printf '%s.exe\n' "$tool"
return 0
fi
printf 'Missing Windows-hosted tool: %s.exe\n' "$tool" >&2
printf 'Install it on Windows or rerun with --wsl to use native WSL %s.\n' "$tool" >&2
return 1
fi
if [ "$TMUX_EXEC" = "wsl tmux" ]; then
if command -v "${tool}.exe" >/dev/null 2>&1; then
printf '%s.exe\n' "$tool"
return 0
fi
printf 'Missing Windows-hosted tool: %s.exe\n' "$tool" >&2
return 1
fi
if command -v "$tool" >/dev/null 2>&1; then
printf '%s\n' "$tool"
return 0
fi
printf 'Missing native tool: %s\n' "$tool" >&2
return 1
}
if grep -qi microsoft /proc/version 2>/dev/null; then
HOST="wsl_inside"
RM="rm -f"
elif [ "$TMUX_EXEC" = "wsl tmux" ]; then
HOST="windows"
RM="wsl rm -f"
else
HOST="unix"
RM="rm -f"
fi
GIT="$(choose_tool git)" || exit 1
GH="$(choose_tool gh)" || exit 1
PREV_PANE_DIR="${HOME}/.cache/tmux-agent-manager/prev_pane"
YOLO_MODE_DIR="${HOME}/.cache/tmux-agent-manager/yolo_mode"
AGENT_TYPE_DIR="${HOME}/.cache/tmux-agent-manager/agent_type"
mkdir -p "$PREV_PANE_DIR" "$YOLO_MODE_DIR" "$AGENT_TYPE_DIR"
echo "TMUX_EXEC=$TMUX_EXEC SH=$SH HOST=$HOST GIT=$GIT GH=$GH RM=$RM"
Re-declare TMUX_EXEC, SH, HOST, GIT, GH, RM, PREV_PANE_DIR, YOLO_MODE_DIR, and AGENT_TYPE_DIR at the top of every subsequent
bash block that needs them, using the values set above.
Variable reference used in all steps below:
| Variable | Windows/Git Bash | WSL inside | Native Linux/macOS |
|---|
TMUX_EXEC | wsl tmux | tmux | tmux |
SH | wsl bash -c | bash -c | bash -c |
GIT | git.exe | git.exe by default, git with --wsl | git |
GH | gh.exe | gh.exe by default, gh with --wsl | gh |
HOST | windows | wsl_inside | unix |
Path helpers — use these when building paths for tmux vs. for git:
- On
unix: only one path form; no conversion needed.
- On
wsl_inside: git.exe returns Windows paths (D:/foo); convert with wslpath before
passing to tmux or bash. Shell path = wslpath "D:/foo" → /mnt/d/foo.
With --wsl, native git returns WSL paths and conversion is not needed.
- On
windows: git.exe returns Windows paths; convert with wsl wslpath before passing to
wsl tmux or wsl bash. Windows path → WSL path: wsl wslpath "D:/foo" → /mnt/d/foo.
Commands
/tmux-agent-manager or /tmux-agent-manager status — snapshot of all sessions
/tmux-agent-manager send <session-name> <message> — deliver instruction to an agent
/tmux-agent-manager monitor [interval_seconds] — check all sessions, mark any
needing attention in status output, and schedule the next check via ScheduleWakeup
(default: cache_ttl − 60 s, currently 240 s)
/tmux-agent-manager new <issue_number> — create worktree + tmux session for a GitHub
issue and spawn a Claude agent to fix it
/tmux-agent-manager new <free-form prompt> — same, driven by a task description
Parse $ARGUMENTS to determine which command to run. If empty or "status", run status.
Step 1 — Enumerate candidate agent panes, then filter to confirmed agents
$TMUX_EXEC list-sessions -F "#{session_name}"
This produces one SESSION name per session. After listing, filter to
only sessions whose pane tail shows Claude/Codex agent markers: a welcome banner,
a model-info line, or the › prompt pattern expected by Step 2. Discard sessions
that show none of these — they are unrelated tmux sessions and must not be targeted
for status classification, sends, or implicit target resolution.
Use a two-pass capture: first 500 lines of scrollback (welcome banner) plus last 250 lines (current prompt). This avoids loading unbounded scrollback from long-running sessions:
tail_output=$(
{ $TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S 0 -E 500 2>/dev/null
$TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S -250 2>/dev/null; }
)
echo "$tail_output" \
| grep -qE "(Claude Code|Codex|Model: claude-|›.*claude-|^›[[:space:]]*$)" \
&& echo "AGENT" || echo "NOT_AGENT"
Discard any session that returns NOT_AGENT.
After the active agent set is known, purge orphaned state files so the cache directories don't accumulate stale entries indefinitely:
SANITIZED_ACTIVE=$(printf "%s\n" "$ACTIVE_AGENT_SESSIONS" | sed 's/[^a-zA-Z0-9._-]/_/g')
for state_dir in \
"${HOME}/.cache/tmux-agent-manager/prev_pane" \
"${HOME}/.cache/tmux-agent-manager/yolo_mode" \
"${HOME}/.cache/tmux-agent-manager/agent_type"; do
[ -d "$state_dir" ] || continue
for f in "$state_dir"/*; do
[ -f "$f" ] || continue
session_name=$(basename "$f")
if ! printf "%s\n" "$SANITIZED_ACTIVE" | grep -Fxq -- "$session_name"; then
rm -f "$f"
fi
done
done
ACTIVE_AGENT_SESSIONS is the newline-separated list of confirmed AGENT session names. Build it during the filter loop:
ACTIVE_AGENT_SESSIONS=""
while IFS= read -r SESSION; do
tail_output=$(
{ $TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S 0 -E 500 2>/dev/null
$TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S -250 2>/dev/null; }
)
if printf "%s\n" "$tail_output" | grep -qE "(Claude Code|Codex|Model: claude-|›.*claude-|^›[[:space:]]*$)"; then
ACTIVE_AGENT_SESSIONS="${ACTIVE_AGENT_SESSIONS}${SESSION}
"
fi
done < <($TMUX_EXEC list-sessions -F "#{session_name}" 2>/dev/null | tr -d '\r')
Step 2 — Capture pane state
For each pane target SESSION:W.P, capture 250 lines of scrollback (so the ─ Worked for separator and other signals aren't lost after verbose output):
$TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S -250
State detection rules (apply to the captured tail):
| State | Signal in captured output |
|---|
idle | › prompt at bottom with model info line and NO pending message text after ›; ─ Worked for separator may also be present (post-task) but is not required — a freshly started session waiting for its first prompt classifies as idle too |
working | Lines contain • Ran, • Read, • Writing, • Searching, spinner chars, or active build/test output |
needs_approval | Lines near bottom contain "Do you want to", "Allow", "(y/n)", "Yes/No", or "approve" |
pending_message | › prompt followed by user message text (received but not yet processed) |
stuck | Pane content identical across two consecutive polls AND state is neither idle nor needs_approval; monitor-loop only — store previous pane snapshot in PREV_PANE_DIR/<session>; compare with current snapshot on each iteration; if unchanged and state is not idle/needs_approval, classify as stuck |
unknown | None of the above — treat as working |
Stuck detection — state management (monitor-loop only):
Store per-session snapshots in a temp directory (portable; avoids declare -A which requires Bash 4.0+ and is unavailable on macOS's default Bash 3.2):
PREV_PANE_DIR="${HOME}/.cache/tmux-agent-manager/prev_pane"
mkdir -p "$PREV_PANE_DIR"
SAFE_SESSION=$(printf "%s" "$SESSION" | sed 's/[^a-zA-Z0-9._-]/_/g')
prev_tail=$(cat "$PREV_PANE_DIR/$SAFE_SESSION" 2>/dev/null || echo "")
if [ -n "$prev_tail" ] && [ "$current_tail" = "$prev_tail" ] && [ "$state" != "idle" ] && [ "$state" != "needs_approval" ]; then
state="stuck"
fi
printf "%s\n" "$current_tail" > "$PREV_PANE_DIR/$SAFE_SESSION"
On the first iteration the file does not exist, so prev_tail is empty and no session is classified as stuck initially.
Agent type and YOLO mode detection
For each session, detect the agent type (Claude Code vs Codex) and whether it was
started with its bypass flag. The two agents use different bypass flags:
- Claude Code:
--dangerously-skip-permissions — banner prints ⚠️ Bypassing permission checks; status bar shows ⏵⏵ bypass permissions on
- Codex:
--dangerously-bypass-approvals-and-sandbox
Agent type detection — scan the first 500 lines for agent-specific markers:
SAFE_SESSION=$(printf "%s" "$SESSION" | sed 's/[^a-zA-Z0-9._-]/_/g')
banner=$($TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S 0 -E 500 2>/dev/null)
AGENT_TYPE="unknown"
if printf "%s\n" "$banner" | grep -qiE "Claude Code|claude-|bypass permissions on|dangerously-skip-permissions|Bypassing permission"; then
AGENT_TYPE="claude"
elif printf "%s\n" "$banner" | grep -qiE "Codex|gpt-[0-9]|dangerously-bypass-approvals-and-sandbox"; then
AGENT_TYPE="codex"
fi
AGENT_TYPE_DIR="${HOME}/.cache/tmux-agent-manager/agent_type"
mkdir -p "$AGENT_TYPE_DIR"
echo "$AGENT_TYPE" > "$AGENT_TYPE_DIR/$SAFE_SESSION"
YOLO mode detection — scan the same banner for either agent's bypass indicator.
Claude Code also shows ⏵⏵ bypass permissions on in the tmux status bar, which
appears in the first 500 lines of scrollback:
SAFE_SESSION=$(printf "%s" "$SESSION" | sed 's/[^a-zA-Z0-9._-]/_/g')
banner=$($TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S 0 -E 500 2>/dev/null)
YOLO_STATUS=$(printf "%s\n" "$banner" \
| grep -qiE "dangerously-skip-permissions|dangerously-bypass-approvals-and-sandbox|Bypassing permission|bypass permissions on" \
&& echo "yolo" || echo "normal")
YOLO_MODE_DIR="${HOME}/.cache/tmux-agent-manager/yolo_mode"
mkdir -p "$YOLO_MODE_DIR"
echo "$YOLO_STATUS" > "$YOLO_MODE_DIR/$SAFE_SESSION"
To read:
AGENT=$(cat "$AGENT_TYPE_DIR/$SAFE_SESSION" 2>/dev/null || echo "unknown")
YOLO=$(cat "$YOLO_MODE_DIR/$SAFE_SESSION" 2>/dev/null || echo "normal")
Step 3 — Status report
Present a compact table, one row per session:
SESSION STATE ETA SUMMARY
add-skill-to-resolve-… idle — Pushed commit 326730bd — waiting for next instruction
descheap-for-raytracing working ~20 min Running slang-test on descriptor-heap-acceleration-structure.slang
wgsl-require-bab-load needs_approval ⚠ blocked Waiting for permission prompt: "Allow bash command?"
fix-lambda-capture working ~2 min Editing source/slang/slang-check-expr.cpp
Sessions created by Step 7 are capped at 40 chars. Sessions started outside this
skill may have longer names — truncate display names to 40 chars with … as needed
for table alignment. SUMMARY = last meaningful agent output line.
YOLO mode warning — after the table, list every session whose YOLO_MODE is normal
as a dedicated warning block. Include the agent-type-specific bypass flag for each session:
⚠ The following sessions are NOT running with their bypass flag:
• <session-name> (claude → restart with: claude --dangerously-skip-permissions)
• <session-name> (codex → restart with: codex --dangerously-bypass-approvals-and-sandbox)
• <session-name> (unknown → restart with: claude --dangerously-skip-permissions OR codex --dangerously-bypass-approvals-and-sandbox)
These agents will pause and request approval for every tool call.
To restart: $TMUX_EXEC kill-session -t <name>, then /tmux-agent-manager new <issue>.
Read AGENT_TYPE from $AGENT_TYPE_DIR/$SAFE_SESSION to fill in the per-session agent label and flag.
If all sessions are in YOLO mode, omit the warning block entirely — print nothing about YOLO mode. Do not add any confirmation like "all sessions are in YOLO mode" or "no approval warnings needed". Silence is the correct output when everything is as expected.
ETA estimation rules — read the pane tail to classify the current activity, then apply:
| Activity detected in pane | ETA |
|---|
State is idle | — (already stable) |
State is needs_approval or stuck | ⚠ blocked (won't progress without input) |
State is pending_message | ~0 min (about to start processing) |
Running cmake --build or cmake --workflow | ~5–20 min |
Running slang-test (full suite) | ~15–30 min |
| Running a single test file | ~1–3 min |
| Editing/writing files, running short shell commands | ~1–5 min |
| Waiting for CI (GitHub Actions, mentions "workflow run") | ~10–30 min |
| Submodule init or large git operation | ~1–2 min |
| Activity clearly just started (spinner, first tool call) | ~5–15 min |
| Cannot determine from pane content | ? |
When the pane output contains timestamps or progress indicators (e.g. [12/240] in a cmake build), use them to refine the estimate: remaining fraction × typical total time.
Step 4 — Send instruction
Execution order: 4a (pre-send checks) → send → 4b (confirm delivery) → 4c (monitor progress).
- Parse
$ARGUMENTS with two explicit forms:
send <session-name> <message> — session name provided explicitly (Case A in Step 4a)
send <message> — no session token; target resolved implicitly (Case B in Step 4a)
- Run Step 4a — Correlation check before sending anything.
- Send to the agent pane using a temp file to safely handle newlines and special characters.
On Windows (HOST=windows), Git Bash auto-converts paths starting with / to Windows
paths before they reach wsl tmux, breaking load-buffer. Run the file write and all
buffer ops inside wsl bash. Use a quoted outer heredoc delimiter ('WSLBLOCK')
to prevent Git Bash from expanding $ or backticks in MESSAGE — pass $SESSION as a
positional argument via -s and capture it with SESSION="$1" at the top of the
script. The inner delimiter is single-quoted ('EOF_TMUX_AGENT_MSG') to prevent WSL
bash from expanding MESSAGE content. After the Windows heredoc, set TMP_PAYLOAD to
the WSL path so Step 4b's retry logic can reference it uniformly across platforms.
PRE_SEND_TAIL is captured inside each branch immediately before the paste operation
so both paths record the same pre-send buffer snapshot.
if [ "$HOST" = "windows" ]; then
PRE_SEND_TAIL=$($TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S -20)
wsl bash -s "$SESSION" << 'WSLBLOCK'
SESSION="$1"
cat > "/tmp/agent_send_$SESSION.txt" << 'EOF_TMUX_AGENT_MSG'
MESSAGE
EOF_TMUX_AGENT_MSG
tmux load-buffer -b "agent_msg_$SESSION" "/tmp/agent_send_$SESSION.txt"
tmux paste-buffer -b "agent_msg_$SESSION" -t "$SESSION:0.0"
tmux delete-buffer -b "agent_msg_$SESSION" 2>/dev/null || true
sleep 1
tmux send-keys -t "$SESSION:0.0" Enter
WSLBLOCK
TMP_PAYLOAD="/tmp/agent_send_$SESSION.txt"
else
TMP_PAYLOAD=$(mktemp /tmp/agent_send_msg.XXXXXX)
PRE_SEND_TAIL=$($TMUX_EXEC capture-pane -t "$SESSION:0.0" -p -S -20)
cat > "$TMP_PAYLOAD" << 'EOF_TMUX_AGENT_MSG'
MESSAGE
EOF_TMUX_AGENT_MSG
$TMUX_EXEC load-buffer -b "agent_msg_$SESSION" "$TMP_PAYLOAD"
$TMUX_EXEC paste-buffer -b "agent_msg_" -t
delete-buffer -b 2>/dev/null ||
1
send-keys -t Enter
- Run Step 4b — Queue verification to confirm the message was actually submitted.
- Run Step 4c — Post-send monitoring before returning to the user.
Step 4a — Correlation check
Goal: prevent sending a message intended for one agent to an agent working on a
different issue, and catch ambiguous targets before any message is delivered.
Run this check immediately after parsing $ARGUMENTS, before any tmux send-keys.
4a-i — Enumerate active sessions
$TMUX_EXEC list-sessions -F "#{session_name}"
Capture the session name and last 10 lines of pane 0.0 for each session.
4a-ii — Resolve the target session
Case A — Session name was provided explicitly (user wrote send <name> <message>):
- Check that
<name> exactly matches an active agent session (one that passed Step 1
marker filtering). If not, list the active agent sessions and stop — do not send.
If <name> exists in tmux but is NOT_AGENT, explicitly report that it is not a
Claude/Codex agent pane and stop.
- Continue to the mismatch check in 4a-iii.
Case B — Implicit-target form (send <message> with no session token):
-
If there is exactly one active agent session → treat it as the target; skip to 4a-iii.
-
If there are multiple active agent sessions → ask the user:
"There are N active agent sessions: [list names with one-line summaries].
Which session should receive this message?"
Do not guess. Wait for the user's answer, then re-enter at Step 4.
4a-iii — Mismatch check (for Case A with an explicit session name)
Compare the user's stated intent (issue number, keywords, or description in the message)
against the target session's apparent task:
- Session name — the slug encodes the original issue or task (e.g.
fix-lambda-capture,
descheap-for-raytracing). Treat each --separated word as a keyword.
- Pane content — the last 10 lines may show a file name, test name, or error message
that reveals the task more precisely than the slug alone.
Mismatch signals — flag a mismatch when any of the following are true:
| Signal | Example |
|---|
| User references a GitHub issue number and the session name contains a different issue number | User says "#1234", session slug contains "1567" |
| User explicitly names a different issue/feature than the session slug describes | User says "lambda fix", session is descheap-for-raytracing |
| User says "the agent working on X" and the target session name contains none of X's keywords | User says "shader compiler crash", session is fix-lambda-capture |
When a mismatch is detected, stop and ask the user to confirm before sending:
"⚠ The session <name> appears to be working on , but your message
references <user's described task>. Active sessions:
[list all sessions with one-line summaries]
Did you mean to send to a different session, or should I proceed with <name>?"
Wait for explicit confirmation or a corrected session name before proceeding.
When no mismatch is detected, proceed directly to sending (Step 4, item 3).
4a-iv — Ambiguity heuristic
When the user's message contains strong issue-specific signals (an issue number, a unique
identifier, a distinctive file or function name) and there is more than one active session
whose slug partially matches those signals, treat this as ambiguous and ask:
"Multiple sessions could match your description: [list candidates with summaries].
Which one should receive this message?"
Do not guess. Always prefer asking over sending to the wrong agent.
Step 4b — Queue verification
Goal: confirm that the message was received and submitted to the agent. Recover
automatically from two common failure modes: Enter not processed (text visible but stuck
in the input buffer) and paste failed silently (pane looks unchanged).
Run this immediately after the send block in Step 4, before Step 4c.
Parameters
| Parameter | Default |
|---|
VERIFY_WAIT | 2 s — wait before first check |
MAX_RETRIES | 2 — total attempts before giving up |
Algorithm
attempt = 1
while attempt <= MAX_RETRIES:
sleep VERIFY_WAIT
tail = capture last 20 lines of $SESSION:0.0
classify state (idle / working / needs_approval / pending_message / unknown)
if state == working or state == needs_approval or state == unknown:
# Agent received the message and is acting on it (or needs approval).
report "✓ Message queued — agent is processing."
$RM "$TMP_PAYLOAD" 2>/dev/null
return # proceed to Step 4c
if state == pending_message:
# Text is visible after › but Enter was not processed.
# This is the "waiting for ENTER" failure mode.
$TMUX_EXEC send-keys -t "$SESSION:0.0" Enter
attempt += 1
continue
if state == idle:
if attempt == 1 and tail == PRE_SEND_TAIL:
# Pane matches pre-send snapshot — paste failed silently.
# Retry the full send sequence before giving up.
if HOST == "windows":
# $TMP_PAYLOAD is a WSL path; $TMUX_EXEC (=wsl tmux) would mangle it.
# Re-run the buffer ops inside wsl bash; pass SESSION and TMP_PAYLOAD
# as positional args via -s. MSYS_NO_PATHCONV=1 prevents Git Bash from
# converting the /tmp/... path in $TMP_PAYLOAD before wsl sees it.
MSYS_NO_PATHCONV=1 wsl bash -s "$SESSION" "$TMP_PAYLOAD" << 'WSLBLOCK'
SESSION="$1"
TMP_PAYLOAD="$2"
tmux load-buffer -b "agent_msg_$SESSION" "$TMP_PAYLOAD"
tmux paste-buffer -b "agent_msg_$SESSION" -t "$SESSION:0.0"
tmux delete-buffer -b "agent_msg_$SESSION" 2>/dev/null || true
sleep 1
tmux send-keys -t "$SESSION:0.0" Enter
WSLBLOCK
else:
$TMUX_EXEC load-buffer -b "agent_msg_$SESSION" "$TMP_PAYLOAD"
$TMUX_EXEC paste-buffer -b "agent_msg_$SESSION" -t "$SESSION:0.0"
$TMUX_EXEC delete-buffer -b "agent_msg_$SESSION" 2>/dev/null || true
sleep 1
$TMUX_EXEC send-keys -t "$SESSION:0.0" Enter
attempt += 1
continue
elif attempt == 1 and tail != PRE_SEND_TAIL:
# Pane changed from pre-send — command ran and completed quickly.
report "✓ Message queued — agent completed the task quickly."
$RM "$TMP_PAYLOAD" 2>/dev/null
return
else:
# Second idle after retry — give up and report.
ALERT: "⚠ Message delivery to SESSION failed after $MAX_RETRIES attempts.
The agent pane appears unchanged. Last 20 pane lines:"
show tail
$RM "$TMP_PAYLOAD" 2>/dev/null
return # do NOT proceed to Step 4c
$RM "$TMP_PAYLOAD" 2>/dev/null
ALERT: "⚠ Message delivery to SESSION failed after $MAX_RETRIES attempts.
The message still appears pending (Enter may not be processing). Last 20 pane lines:"
show tail
return # do NOT proceed to Step 4c
pending_message state detection: the pane tail contains text after the last ›
prompt line that is not a model-info or separator line — i.e., the agent has typed input
waiting to be submitted with Enter.
Step 4c — Post-send monitoring
Goal: catch permission prompts, clarifying questions, and early-idle states before
the user moves on.
Run this after Step 4b confirms the message was queued. Applies to both regular send
and the initial prompt sent in Step 7h for new sessions.
Parameters
| Parameter | Default | Meaning |
|---|
CHECK_INTERVAL | 10 s | Seconds between pane polls |
MAX_WAIT | 120 s | Stop monitoring after this many seconds |
WORKING_GRACE | 20 s | Seconds after send before an idle return triggers an alert |
Parameter overrides: declare the variable before running the algorithm. For example, Step 7h uses a longer grace period for freshly-started sessions:
WORKING_GRACE=30
Algorithm
elapsed = 0
saw_working = false
SAFE_SESSION=$(printf "%s" "$SESSION" | sed 's/[^a-zA-Z0-9._-]/_/g')
loop every CHECK_INTERVAL until elapsed >= MAX_WAIT:
capture 250 lines of scrollback from $SESSION:0.0
classify state (idle / working / needs_approval / unknown)
if state == needs_approval:
ALERT: "⚠ SESSION needs approval — agent is waiting for a permission prompt."
if get_yolo_mode(SESSION) == "normal": # cat "$YOLO_MODE_DIR/$SAFE_SESSION"
agent_type = cat "$AGENT_TYPE_DIR/$SAFE_SESSION" or "unknown"
bypass_flag = "--dangerously-skip-permissions" if agent_type == "claude"
else "--dangerously-bypass-approvals-and-sandbox" if agent_type == "codex"
else "--dangerously-skip-permissions (Claude) or --dangerously-bypass-approvals-and-sandbox (Codex)"
ALERT (append): "⚠ This agent was NOT started with <bypass_flag>.
It will block on every tool call requiring approval.
Consider restarting it with bypass mode enabled."
show the relevant pane lines
return (stop monitoring)
if state == working or state == unknown:
saw_working = true
if elapsed >= MAX_WAIT - CHECK_INTERVAL:
report "✓ SESSION is working — monitoring complete."
return
if state == idle:
if NOT saw_working AND elapsed < WORKING_GRACE:
# Too soon to judge — the agent may still be thinking.
sleep CHECK_INTERVAL
elapsed += CHECK_INTERVAL
continue
if NOT saw_working:
ALERT: "⚠ SESSION returned to idle without any visible tool activity.
The agent may be asking a clarifying question or encountered an error."
show last 35 pane lines
return
else:
# Agent finished quickly — that is fine.
report "✓ SESSION completed the task and is now idle."
return
sleep CHECK_INTERVAL
elapsed += CHECK_INTERVAL
# Timed out while still working — that is OK.
report "✓ SESSION is still working after MAX_WAIT s — no attention needed."
Alert format
When emitting an alert, always include:
- Session name
- Detected state
- The last 250 lines of scrollback so the user can see the exact prompt or error
After an alert
Do not automatically send any reply or click "Yes". Present the pane content to the
user and let them decide how to respond (e.g., use send to answer a question or approve
a permission prompt).
Step 5 — Notifications
When a session is in needs_approval or stuck state, skip the notification for now and
simply include a prominent ⚠ NEEDS ATTENTION marker in the status report printed to the
user.
Step 6 — Monitor loop
- Run status workflow (Steps 1–3).
- Mark every
needs_approval or stuck session with ⚠ NEEDS ATTENTION in the status table (notifications are not yet implemented — see Step 5).
- Report status table to user.
- Schedule next wakeup via ScheduleWakeup:
delaySeconds: interval from $ARGUMENTS; if not provided, use 240 (the cache TTL
of 300 s minus a 60 s safety margin). Never use a value at or above 300 — doing so
forces a cold context re-read on every wakeup. If the cache TTL changes, recalculate
as <new_ttl_seconds> - 60.
prompt: /tmux-agent-manager monitor <interval>
reason: "periodic tmux agent health check"
Step 7 — Spawn a new agent session
When command is new <args>:
- If
<args> is a bare integer → GitHub issue number
- Otherwise → free-form task prompt
7a — Discover paths dynamically
Run this inside a single shell call (adapt prefix for HOST):
MAIN_NATIVE=$("$GIT" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | head -n 1 | tr -d '\r')
if [ -z "$MAIN_NATIVE" ]; then
echo "Error: not in a git repository or could not determine main worktree."
exit 1
fi
if [ "$HOST" = "wsl_inside" ] && [ "${GIT%.exe}" != "$GIT" ]; then
MAIN_SHELL=$(wslpath "$MAIN_NATIVE")
elif [ "$HOST" = "windows" ]; then
MAIN_SHELL="$MAIN_NATIVE"
else
MAIN_SHELL="$MAIN_NATIVE"
fi
MAIN_NATIVE=$(printf "%s" "$MAIN_NATIVE" | tr '\\' '/')
PARENT_SHELL=$( )
PARENT_NATIVE=$( )
REPO=$( && repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null | -d )
[ -z ];
1
New worktree paths:
- Shell path (for tmux
-c and cd): $PARENT_SHELL/<slug>
- Native path (for
git worktree add): $PARENT_NATIVE/<slug>
7b — Determine slug and task prompt
Issue number path ($REPO is now set from Step 7a):
"$GH" issue view <number> --repo "$REPO" --json number,title,body,labels
slug ← from title
branch prefix ← labels: "bug"/"crash" → fix/; "feature"/"enhancement" → feature/; else fix/
- Claude prompt: issue title + body (truncated to 3000 chars) + instruction to fix, test, commit, and follow WSL tool handling (
git.exe, cmake.exe, slangc.exe, and slang-test.exe for Windows-hosted WSL work)
Free-form path:
slug ← from the prompt text
branch prefix ← prompt contains "feature"/"add" → feature/; else fix/
- Claude prompt: the user's prompt verbatim + instruction to test, commit, and follow WSL tool handling (
git.exe, cmake.exe, slangc.exe, and slang-test.exe for Windows-hosted WSL work)
Slug rule: lowercase → replace runs of non-alphanumeric chars with - → collapse
consecutive - → strip leading/trailing - → truncate to 40 chars → strip any
trailing - left by the truncation.
SLUG=$(printf "%s" "$INPUT" | tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g' \
| cut -c 1-40 \
| sed -E 's/-+$//')
Keep the most meaningful words (usually the first few): a 40-char slug must still be
recognisable at a glance without needing further truncation in the status table.
Full branch: <prefix><slug> (e.g. fix/getTypeNameHint-cr)
Session/worktree name: <slug> (no prefix)
7c — Collision check
$TMUX_EXEC has-session -t "<slug>" 2>/dev/null && echo EXISTS || echo OK
test -d "$PARENT_SHELL/<slug>" && echo EXISTS || echo OK
Stop and tell the user if either returns EXISTS.
7d — Create the worktree
if "$GIT" -C "$MAIN_NATIVE" rev-parse --verify "<branch>" >/dev/null 2>&1; then
"$GIT" -C "$MAIN_NATIVE" worktree add "$PARENT_NATIVE/<slug>" "<branch>"
else
"$GIT" -C "$MAIN_NATIVE" worktree add "$PARENT_NATIVE/<slug>" -b "<branch>"
fi
7e — Initialize submodules with local reference
In a git worktree, submodules already share the object store of the primary repository (.git/modules in the main worktree), so no --reference flag is needed.
Use -C with the native path instead of cd — avoids WSL/Windows path resolution issues:
"$GIT" -C "$PARENT_NATIVE/<slug>" submodule update --init --recursive
Tell the user this step is running; it may take up to a minute the first time.
7f — Create the tmux session
TMUX_C_PATH="$PARENT_SHELL/<slug>"
[ "$HOST" = "windows" ] && TMUX_C_PATH=$(wsl wslpath "$TMUX_C_PATH" | tr -d '\r')
$TMUX_EXEC new-session -d -s "<slug>" -c "$TMUX_C_PATH"
7g — Start Claude Code (or Codex)
Always prefix the agent command with an explicit cd to the worktree. On Windows,
the WSL bash shell may start in ~ regardless of the tmux -c directory, so relying
on the session's working directory is not safe.
TMUX_C_PATH is the WSL path set in Step 7f (e.g. /mnt/d/sbf/git/slang/<slug>).
$TMUX_EXEC send-keys -t "<slug>:0.0" "cd \"$TMUX_C_PATH\" && claude --dangerously-skip-permissions" Enter
$TMUX_EXEC send-keys -t "<slug>:0.0" "cd \"$TMUX_C_PATH\" && codex --dangerously-bypass-approvals-and-sandbox" Enter
Use whichever agent the user requests; default to claude if unspecified.
Wait 8 seconds, then capture the pane tail and look for the › prompt or model info
line. Retry every 5 seconds up to 3 times. If Claude still hasn't started, show the raw
pane content to the user and stop.
Once the › prompt is confirmed, run the YOLO mode check from Step 2 against the new
session's scrollback:
$TMUX_EXEC capture-pane -t "<slug>:0.0" -p -S 0 -E 500 \
| grep -qE "dangerously-skip-permissions|dangerously-bypass-approvals-and-sandbox|Bypassing permission" \
&& echo "yolo" || echo "normal"
If the result is normal, emit a warning and continue immediately to Step 7h.
Use the agent-type-specific bypass flag in the message:
⚠ Session '<slug>' is NOT running with its bypass flag.
The agent will pause and request approval for every tool call.
To fix: kill this session and restart with the appropriate flag:
Claude Code: claude --dangerously-skip-permissions
Codex: codex --dangerously-bypass-approvals-and-sandbox
7h — Send the task prompt
Write the prompt to a temp file and load it into tmux. On Windows (HOST=windows),
Git Bash auto-converts any argument starting with / (e.g. /tmp/...) to a Windows
path before passing it to wsl tmux, which breaks load-buffer. The fix: run the
file write AND all tmux buffer operations inside a single wsl bash heredoc so paths
are never seen by Git Bash.
Use a quoted outer heredoc delimiter ('WSLBLOCK') to prevent Git Bash from
expanding $ or backticks in the prompt text — pass <slug> as a positional argument
via -s and capture it with slug="$1" at the top of the script. The inner prompt
delimiter is single-quoted ('EOF_TMUX_AGENT_PROMPT') so WSL bash also does not
expand the prompt text.
if [ "$HOST" = "windows" ]; then
PRE_SEND_TAIL=$($TMUX_EXEC capture-pane -t "<slug>:0.0" -p -S -20)
wsl bash -s "<slug>" << 'WSLBLOCK'
slug="$1"
cat > "/tmp/agent_prompt_$slug.txt" << 'EOF_TMUX_AGENT_PROMPT'
<composed prompt text>
EOF_TMUX_AGENT_PROMPT
tmux load-buffer -b "agent_msg_$slug" "/tmp/agent_prompt_$slug.txt"
tmux paste-buffer -b "agent_msg_$slug" -t "$slug:0.0"
tmux delete-buffer -b "agent_msg_$slug" 2>/dev/null || true
sleep 1
tmux send-keys -t "$slug:0.0" Enter
WSLBLOCK
TMP_PAYLOAD="/tmp/agent_prompt_<slug>.txt"
else
TMP_PAYLOAD=$(mktemp /tmp/agent_prompt_<slug>.XXXXXX)
cat > "$TMP_PAYLOAD" << 'EOF_TMUX_AGENT_PROMPT'
<composed prompt text>
EOF_TMUX_AGENT_PROMPT
PRE_SEND_TAIL=$($TMUX_EXEC capture-pane -t "<slug>:0.0" -p -S -20)
$TMUX_EXEC load-buffer -b "agent_msg_<slug>" "$TMP_PAYLOAD"
$TMUX_EXEC paste-buffer -b "agent_msg_<slug>" -t "<slug>:0.0"
delete-buffer -b 2>/dev/null ||
1
send-keys -t Enter
Step 4b retry on Windows: TMP_PAYLOAD is set after the heredoc so Step 4b can
reference it to identify the file. However, the actual retry buffer ops must still run
inside a wsl bash heredoc — $TMUX_EXEC (which becomes wsl tmux) cannot accept
raw /tmp/... paths from Git Bash without mangling. Repeat the wsl bash block above
to reload and re-paste; the file persists in WSL /tmp for the duration of the session.
After sending, run Step 4b — Queue verification targeting <slug>:0.0 to confirm
the prompt was actually submitted, then run Step 4c — Post-send monitoring to confirm
the agent starts working (not blocked on a permission prompt or asking questions).
Use WORKING_GRACE=30 for new sessions since Claude Code takes a moment to start.
7i — Report to user
- Branch:
<branch>
- Worktree:
$PARENT_NATIVE/<slug>
- Tmux session:
<slug> — attach with $TMUX_EXEC attach -t <slug>
- Agent is running with the task prompt
Notes
- Session names are capped at 40 chars by the slug rule in Step 7a
- The Claude Code pane is always window 0, pane 0 unless the user specifies otherwise
- When a session has multiple windows, check window 0 for the agent; note other windows
separately if they show interesting activity (build output, test results)
- Never kill or restart a session without explicit user confirmation
- If tmux is not found at all (neither native nor via
wsl tmux), report that tmux must
be installed and stop