# Tmux Agent Manager

> 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).

- Skill: `shader-slang/tmux-agent-manager` (Agent Skill)
- Install (CLI): `npx skillmds@latest add shader-slang/tmux-agent-manager`
- Raw SKILL.md: https://api.skillmd.com/api/skills/shader-slang/tmux-agent-manager/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: Apache-2.0
- Author: shader-slang (https://skillmd.com/u/shader-slang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/shader-slang/tmux-agent-manager

---


# 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:

```bash
ARGS="${ARGUMENTS:-}"
USE_WSL_TOOLS=false
if printf '%s\n' "$ARGS" | grep -Eq '(^|[[:space:]])--wsl([[:space:]]|$)'; then
    USE_WSL_TOOLS=true
fi

# How to reach tmux
if command -v tmux &>/dev/null; then
    TMUX_EXEC="tmux"
    SH="bash -c"        # run inline shell commands
elif wsl tmux -V >/dev/null 2>&1; then
    # tmux not in PATH but available via WSL — Windows host
    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
}

# Which git/gh binaries produce consistent paths and authentication.
# Inside WSL, use git.exe/gh.exe by default; --wsl opts into native WSL tools.
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"     # WSL temp files must be removed via wsl
else
    HOST="unix"         # native Linux or macOS
    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

```bash
$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:

```bash
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:

```bash
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")
    # Remove file if no active agent session has this sanitized name
    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:

```bash
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):

```bash
$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):

```bash
PREV_PANE_DIR="${HOME}/.cache/tmux-agent-manager/prev_pane"
mkdir -p "$PREV_PANE_DIR"

# Each iteration, after capturing current_tail for SESSION:
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:

```bash
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:

```bash
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:

```text
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:

```text
⚠ 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).

1. 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)
2. Run **Step 4a — Correlation check** before sending anything.
3. 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.

```bash
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_$SESSION" -t "$SESSION:0.0"
    $TMUX_EXEC delete-buffer -b "agent_msg_$SESSION" 2>/dev/null || true
    # Wait for the paste to land before sending Enter (paste-buffer is async).
    sleep 1
    $TMUX_EXEC send-keys -t "$SESSION:0.0" Enter
    # Do NOT rm TMP_PAYLOAD here — Step 4b may need it for a retry.
fi
```

4. Run **Step 4b — Queue verification** to confirm the message was actually submitted.
5. 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

```bash
$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>`):

1. 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.
2. Continue to the mismatch check in 4a-iii.

**Case B — Implicit-target form** (`send <message>` with no session token):

1. If there is exactly one active **agent** session → treat it as the target; skip to 4a-iii.
2. 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 **<inferred task>**, 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

```text
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:

```bash
WORKING_GRACE=30  # new sessions need more time to initialize
# then run Step 4c algorithm
```

### Algorithm

```text
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

<!-- TODO: implement cross-platform notifications (osascript, notify-send, PowerShell toast) -->

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

1. Run status workflow (Steps 1–3).
2. Mark every `needs_approval` or `stuck` session with `⚠ NEEDS ATTENTION` in the status table (notifications are not yet implemented — see Step 5).
3. Report status table to user.
4. 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):

```bash
# Get the main worktree path (first entry — always the primary checkout)
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

# Convert to the shell's native path if needed
if [ "$HOST" = "wsl_inside" ] && [ "${GIT%.exe}" != "$GIT" ]; then
    MAIN_SHELL=$(wslpath "$MAIN_NATIVE")       # D:/foo → /mnt/d/foo
elif [ "$HOST" = "windows" ]; then
    MAIN_SHELL="$MAIN_NATIVE"                  # Git Bash handles Windows paths; convert to WSL path at call sites that need it
else
    MAIN_SHELL="$MAIN_NATIVE"                  # already a POSIX path
fi

MAIN_NATIVE=$(printf "%s" "$MAIN_NATIVE" | tr '' '/')   # normalize any backslashes
PARENT_SHELL=$(dirname "$MAIN_SHELL")          # sibling worktrees live here
PARENT_NATIVE=$(dirname "$MAIN_NATIVE")

# Derive GitHub repo (owner/name) via gh CLI (robust across URL formats)
REPO=$(cd "$MAIN_SHELL" && "$GH" repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null | tr -d '\r')
if [ -z "$REPO" ]; then
    echo "Error: could not determine GitHub repository. Ensure $GH is authenticated and the directory is a GitHub repo."
    exit 1
fi
```

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):
```bash
"$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.

```bash
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

```bash
$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

```bash
if "$GIT" -C "$MAIN_NATIVE" rev-parse --verify "<branch>" >/dev/null 2>&1; then
    # Branch already exists — attach to it
    "$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:

```bash
"$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

```bash
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>`).

```bash
# Claude Code (default) — explicit cd ensures correct working directory
$TMUX_EXEC send-keys -t "<slug>:0.0" "cd \"$TMUX_C_PATH\" && claude --dangerously-skip-permissions" Enter

# Codex alternative
$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:

```bash
$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:

```text
⚠ 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.

```bash
if [ "$HOST" = "windows" ]; then
    # All tmux buffer ops run inside WSL — Git Bash would mangle /tmp/... paths
    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"
    $TMUX_EXEC delete-buffer -b "agent_msg_<slug>" 2>/dev/null || true
    sleep 1
    $TMUX_EXEC send-keys -t "<slug>:0.0" Enter
    # Do NOT rm TMP_PAYLOAD here — Step 4b may need it for a retry.
fi
```

**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

