Created
February 4, 2026 17:33
-
-
Save kaushikgopal/3a67f71052cf10276315162012dcac1c to your computer and use it in GitHub Desktop.
tmux subagent spawner for CLI agents
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # This is a lightweight, agent-agnostic workflow for spawning parallel | |
| # worker agents from any CLI agent (codex, claude, gemini-cli) running in tmux. | |
| # | |
| # Usage: | |
| # tmux-subagent prompt-spawn PANE CWD AGENT | |
| # tmux-subagent spawn --master-pane PANE --cwd DIR --agent AGENT --task TASK | |
| # | |
| # Environment: | |
| # TMUX_SUBAGENT_CONTEXT_LINES Max lines captured from master pane (default: 1000) | |
| # TMUX_SUBAGENT_SUMMARIZE_MIN_LINES Summarize only if captured context exceeds this many lines (default: 500) | |
| # TMUX_SUBAGENT_SUMMARIZATION_TOOL Tool for summarization: claude or gemini (default: claude) | |
| # TMUX_SUBAGENT_SUMMARIZATION_MODEL_FOR_CLAUDE Model for claude summarization (default: sonnet) | |
| # TMUX_SUBAGENT_SUMMARY_PROMPT Prompt passed to summarization tool | |
| # TMUX_SUBAGENT_STARTUP_DELAY_SECS Delay before injecting prompt (default: 1) | |
| # TMUX_SUBAGENT_INJECT_WAIT_SECS Max seconds to wait for agent prompt before injecting (default: 20) | |
| # TMUX_SUBAGENT_INJECT_POLL_SECS Poll interval (seconds) while waiting for prompt (default: 0.5) | |
| # TMUX_SUBAGENT_POST_PASTE_DELAY_SECS Delay after paste before sending Enter (default: 0.4) | |
| # | |
| # Notes: | |
| # - Creates a new tmux window in the background (does not switch focus). | |
| # - Does not write any files; uses tmux buffers for prompt injection. | |
| # | |
| set -euo pipefail | |
| # Colors for output | |
| _c_gray=$'\033[90m' | |
| _c_reset=$'\033[0m' | |
| msg() { | |
| echo "${_c_gray}[tmux-subagent]${_c_reset} $*" | |
| } | |
| readonly DEFAULT_CONTEXT_LINES=1000 | |
| readonly DEFAULT_SUMMARIZE_MIN_LINES=500 | |
| readonly DEFAULT_SUMMARIZATION_TOOL=claude | |
| readonly DEFAULT_SUMMARIZATION_MODEL_FOR_CLAUDE=sonnet | |
| readonly DEFAULT_STARTUP_DELAY_SECS=1 | |
| readonly DEFAULT_INJECT_WAIT_SECS=20 | |
| readonly DEFAULT_INJECT_POLL_SECS=0.5 | |
| readonly DEFAULT_POST_PASTE_DELAY_SECS=0.4 | |
| readonly DEFAULT_SUMMARY_PROMPT=$'Write a concise markdown summary of the transcript, prioritizing information necessary to complete the task and excluding unrelated discussion.\n\nOutput format (use these headings in this order):\n1. Task — reproduce the task text verbatim.\n2. Current State — 3–7 bullets describing what is true now (key files/branches/config/state).\n3. Key Context (Task-Relevant) — up to 8 bullets; include only facts that change how the task should be done.\n4. Decisions / Constraints — up to 6 bullets (safety constraints, chosen options).\n5. Commands / Paths / Identifiers — exact literals (paths, commands, flags, env vars, branch/commit ids, relevant ids).\n6. Open Questions / Risks — up to 5 bullets; if missing, write `Unknown`.\n\nHard rules:\n- Output markdown to stdout only (no files).\n- Do not invent details; if not present, write `Unknown`.\n- Preserve exact file paths, command lines, flags, and error messages when present.\n- Prefer short bullets.\n' | |
| die() { | |
| echo "tmux-subagent: $*" >&2 | |
| exit 1 | |
| } | |
| require_command() { | |
| command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" | |
| } | |
| sh_escape_for_double_quotes() { | |
| local s="${1-}" | |
| s="${s//\\/\\\\}" | |
| s="${s//\"/\\\"}" | |
| s="${s//\$/\\\$}" | |
| s="${s//\`/\\\`}" | |
| printf '%s' "$s" | |
| } | |
| script_path() { | |
| local src="${BASH_SOURCE[0]:-$0}" | |
| if [[ "$src" != */* ]]; then | |
| src="$(command -v "$src" || true)" | |
| fi | |
| [[ -n "$src" ]] || die "cannot resolve script path" | |
| echo "$(cd "$(dirname "$src")" && pwd)/$(basename "$src")" | |
| } | |
| find_session_id_for_pane() { | |
| local pane="$1" | |
| tmux list-panes -a -F '#{pane_id} #{session_id}' | awk -v p="$pane" '$1==p {print $2; exit}' | |
| } | |
| cmd_bootstrap() { | |
| local master_pane="${1:-}" | |
| local agent="${2:-}" | |
| local task="${3:-}" | |
| [[ -n "$master_pane" ]] || die "bootstrap: master pane required" | |
| [[ -n "$agent" ]] || die "bootstrap: agent required" | |
| [[ -n "$task" ]] || die "bootstrap: task required" | |
| require_command tmux | |
| local context_lines summarize_min_lines startup_delay_secs inject_wait_secs inject_poll_secs post_paste_delay_secs | |
| context_lines="${TMUX_SUBAGENT_CONTEXT_LINES:-$DEFAULT_CONTEXT_LINES}" | |
| summarize_min_lines="${TMUX_SUBAGENT_SUMMARIZE_MIN_LINES:-$DEFAULT_SUMMARIZE_MIN_LINES}" | |
| startup_delay_secs="${TMUX_SUBAGENT_STARTUP_DELAY_SECS:-$DEFAULT_STARTUP_DELAY_SECS}" | |
| inject_wait_secs="${TMUX_SUBAGENT_INJECT_WAIT_SECS:-$DEFAULT_INJECT_WAIT_SECS}" | |
| inject_poll_secs="${TMUX_SUBAGENT_INJECT_POLL_SECS:-$DEFAULT_INJECT_POLL_SECS}" | |
| post_paste_delay_secs="${TMUX_SUBAGENT_POST_PASTE_DELAY_SECS:-$DEFAULT_POST_PASTE_DELAY_SECS}" | |
| local summary_prompt summarization_tool summarization_model_for_claude | |
| summary_prompt="${TMUX_SUBAGENT_SUMMARY_PROMPT:-$DEFAULT_SUMMARY_PROMPT}" | |
| summarization_tool="${TMUX_SUBAGENT_SUMMARIZATION_TOOL:-$DEFAULT_SUMMARIZATION_TOOL}" | |
| summarization_model_for_claude="${TMUX_SUBAGENT_SUMMARIZATION_MODEL_FOR_CLAUDE:-$DEFAULT_SUMMARIZATION_MODEL_FOR_CLAUDE}" | |
| if ! [[ "$context_lines" =~ ^[0-9]+$ ]]; then | |
| context_lines="$DEFAULT_CONTEXT_LINES" | |
| fi | |
| if ! [[ "$summarize_min_lines" =~ ^[0-9]+$ ]]; then | |
| summarize_min_lines="$DEFAULT_SUMMARIZE_MIN_LINES" | |
| fi | |
| if ! [[ "$startup_delay_secs" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then | |
| startup_delay_secs="$DEFAULT_STARTUP_DELAY_SECS" | |
| fi | |
| if ! [[ "$inject_wait_secs" =~ ^[0-9]+$ ]]; then | |
| inject_wait_secs="$DEFAULT_INJECT_WAIT_SECS" | |
| fi | |
| if ! [[ "$inject_poll_secs" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then | |
| inject_poll_secs="$DEFAULT_INJECT_POLL_SECS" | |
| fi | |
| if ! [[ "$post_paste_delay_secs" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then | |
| post_paste_delay_secs="$DEFAULT_POST_PASTE_DELAY_SECS" | |
| fi | |
| local raw_context line_count | |
| raw_context="$(tmux capture-pane -t "$master_pane" -p -S "-${context_lines}" 2>/dev/null | tmux-agent-output-clean || true)" | |
| line_count="$(printf '%s' "$raw_context" | wc -l | tr -d ' ')" | |
| msg "Captured ${line_count}/${context_lines} lines..." | |
| local context | |
| context="$raw_context" | |
| if [[ "$line_count" -gt "$summarize_min_lines" ]]; then | |
| if ! command -v "$summarization_tool" >/dev/null 2>&1; then | |
| msg "$summarization_tool not found; using raw context" | |
| else | |
| msg "Summarizing with $summarization_tool (${line_count} > ${summarize_min_lines})" | |
| local summary | |
| case "$summarization_tool" in | |
| claude) | |
| summary="$( | |
| printf '<task>\n%s\n</task>\n\n<transcript>\n%s\n</transcript>\n' "$task" "$raw_context" \ | |
| | claude -p --model "$summarization_model_for_claude" "$summary_prompt" 2>/dev/null | |
| )" || summary="" | |
| ;; | |
| gemini) | |
| summary="$( | |
| printf '<task>\n%s\n</task>\n\n<transcript>\n%s\n</transcript>\n' "$task" "$raw_context" \ | |
| | gemini -p "$summary_prompt" 2>/dev/null | |
| )" || summary="" | |
| ;; | |
| *) | |
| msg "Unknown summarization tool: $summarization_tool; using raw context" | |
| summary="" | |
| ;; | |
| esac | |
| if [[ -n "$summary" ]]; then | |
| context="$summary" | |
| else | |
| msg "$summarization_tool summarization failed; using raw context" | |
| fi | |
| fi | |
| else | |
| msg "Context < ${summarize_min_lines} lines; skipping summarization..." | |
| fi | |
| local prompt_payload buffer_name | |
| prompt_payload="$(printf '<context>\n%s\n</context>\n\n<task>\n%s\n\nConstraints:\n- Do not create or modify files unless the task explicitly asks you to.\n- Respond in this chat/session output (no writing to disk).\n</task>\n' "$context" "$task")" | |
| buffer_name="subagent-prompt-$$" | |
| printf '%s' "$prompt_payload" | tmux load-buffer -b "$buffer_name" - | |
| ( | |
| trap 'tmux delete-buffer -b "$buffer_name" 2>/dev/null || true' EXIT | |
| sleep "$startup_delay_secs" | |
| msg "Waiting for agent prompt (up to ${inject_wait_secs}s)..." | |
| local end_at prompt_re | |
| end_at=$((SECONDS + inject_wait_secs)) | |
| prompt_re='(^>($|[[:space:]]))|(^›($|[[:space:]]))|(^❯($|[[:space:]]))' | |
| while [[ $SECONDS -lt $end_at ]]; do | |
| local pane_tail last_nonempty | |
| pane_tail="$(tmux capture-pane -t "$TMUX_PANE" -p -S -120 2>/dev/null || true)" | |
| last_nonempty="$( | |
| printf '%s\n' "$pane_tail" \ | |
| | sed -E 's/\x1b\\[[0-9;]*m//g; s/\r$//' \ | |
| | sed '/^[[:space:]]*$/d' \ | |
| | tail -n 1 | |
| )" | |
| if printf '%s\n' "$last_nonempty" | grep -Eq "$prompt_re"; then | |
| msg "Detected prompt; injecting task" | |
| break | |
| fi | |
| sleep "$inject_poll_secs" | |
| done | |
| tmux paste-buffer -b "$buffer_name" -t "$TMUX_PANE" | |
| sleep "$post_paste_delay_secs" | |
| tmux send-keys -t "$TMUX_PANE" Enter | |
| ) & | |
| # Transform known agents to their auto-approve variants | |
| case "$agent" in | |
| claude) agent="claude --dangerously-skip-permissions" ;; | |
| codex) agent="codex --yolo" ;; | |
| esac | |
| local default_shell | |
| default_shell="$(tmux show-option -gqv default-shell 2>/dev/null || true)" | |
| if [[ -n "$default_shell" ]]; then | |
| exec "$default_shell" -lc "$agent" | |
| else | |
| if command -v bash >/dev/null 2>&1; then | |
| exec bash -lc "$agent" | |
| else | |
| exec sh -lc "$agent" | |
| fi | |
| fi | |
| } | |
| cmd_spawn() { | |
| local master_pane="" cwd="" task="" agent="" | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --master-pane) master_pane="$2"; shift 2 ;; | |
| --cwd) cwd="$2"; shift 2 ;; | |
| --agent) agent="$2"; shift 2 ;; | |
| --task) task="$2"; shift 2 ;; | |
| *) die "spawn: unknown argument: $1" ;; | |
| esac | |
| done | |
| [[ -n "$master_pane" ]] || die "spawn: --master-pane required" | |
| [[ -n "$cwd" ]] || die "spawn: --cwd required" | |
| [[ -n "$agent" ]] || die "spawn: --agent required" | |
| [[ -n "$task" ]] || die "spawn: --task required" | |
| require_command tmux | |
| local session_id | |
| session_id="$(find_session_id_for_pane "$master_pane")" | |
| [[ -n "$session_id" ]] || die "spawn: cannot find session for pane $master_pane" | |
| local script | |
| script="$(script_path)" | |
| local bootstrap_cmd | |
| bootstrap_cmd="$(printf '%q ' "$script" bootstrap "$master_pane" "$agent" "$task")" | |
| local ts window_name | |
| ts="$(date +%H%M%S)" | |
| window_name="subagent:${agent}:${ts}" | |
| tmux new-window -d -t "$session_id" -n "$window_name" -c "$cwd" bash -lc "$bootstrap_cmd" | |
| } | |
| cmd_prompt_spawn() { | |
| local cwd="" agent="" | |
| case $# in | |
| 3) cwd="${2:-}"; agent="${3:-}" ;; # legacy: prompt-spawn PANE CWD AGENT (PANE ignored) | |
| 2) cwd="${1:-}"; agent="${2:-}" ;; | |
| 1) cwd='#{pane_current_path}'; agent="${1:-}" ;; | |
| *) die "prompt-spawn: usage: prompt-spawn [PANE] [CWD] AGENT" ;; | |
| esac | |
| [[ -n "$cwd" ]] || die "prompt-spawn: cwd required" | |
| [[ -n "$agent" ]] || die "prompt-spawn: agent required" | |
| local script | |
| script="$(script_path)" | |
| local script_q cwd_q agent_q | |
| script_q="$(sh_escape_for_double_quotes "$script")" | |
| cwd_q="$(sh_escape_for_double_quotes "$cwd")" | |
| agent_q="$(sh_escape_for_double_quotes "$agent")" | |
| local task_buffer | |
| task_buffer="subagent-task-${RANDOM}-${SECONDS}-$$" | |
| local template | |
| template="$( | |
| printf 'set-buffer -b "%s" "%%%%%%"; run-shell -b "task=$(tmux show-buffer -b \\"%s\\" 2>/dev/null || true); tmux delete-buffer -b \\"%s\\" 2>/dev/null || true; exec \\"%s\\" spawn --master-pane \\"#{pane_id}\\" --cwd \\"%s\\" --agent \\"%s\\" --task \\"\\$task\\""' \ | |
| "$task_buffer" \ | |
| "$task_buffer" \ | |
| "$task_buffer" \ | |
| "$script_q" \ | |
| "$cwd_q" \ | |
| "$agent_q" | |
| )" | |
| tmux command-prompt -p 'task:' "$template" | |
| } | |
| cmd_help() { | |
| cat <<'EOF' | |
| tmux-subagent: Minimal tmux subagent spawner for CLI agents | |
| Commands: | |
| prompt-spawn [PANE] [CWD] AGENT | |
| Prompt for task, then spawn a background tmux window running AGENT | |
| spawn --master-pane PANE --cwd DIR --agent AGENT --task TASK | |
| Spawn a background tmux window running AGENT and inject context+task | |
| Environment: | |
| TMUX_SUBAGENT_CONTEXT_LINES Max context lines captured from master pane (default: 1000) | |
| TMUX_SUBAGENT_SUMMARIZE_MIN_LINES Summarize only if captured context exceeds this many lines (default: 500) | |
| TMUX_SUBAGENT_SUMMARIZATION_TOOL Tool for summarization: claude or gemini (default: claude) | |
| TMUX_SUBAGENT_SUMMARIZATION_MODEL_FOR_CLAUDE Model for claude summarization (default: sonnet) | |
| TMUX_SUBAGENT_SUMMARY_PROMPT Prompt passed to summarization tool | |
| TMUX_SUBAGENT_STARTUP_DELAY_SECS Delay before injecting prompt (default: 1) | |
| TMUX_SUBAGENT_INJECT_WAIT_SECS Max seconds to wait for agent prompt before injecting (default: 20) | |
| TMUX_SUBAGENT_INJECT_POLL_SECS Poll interval (seconds) while waiting for prompt (default: 0.5) | |
| TMUX_SUBAGENT_POST_PASTE_DELAY_SECS Delay after paste before sending Enter (default: 0.4) | |
| EOF | |
| } | |
| main() { | |
| local cmd="${1:-help}" | |
| shift || true | |
| case "$cmd" in | |
| bootstrap) cmd_bootstrap "$@" ;; | |
| spawn) cmd_spawn "$@" ;; | |
| prompt-spawn) cmd_prompt_spawn "$@" ;; | |
| help|-h|--help) cmd_help ;; | |
| *) die "unknown command: $cmd (try: help)" ;; | |
| esac | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment