Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from bundle
Launch and manage bounded Codex or Claude workers in tmux with inspectable artifacts. Use it when work splits cleanly by file ownership, when a dedicated review
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/ta.sh
1#!/usr/bin/env bash23set -euo pipefail45readonly TAG_ORCHESTRATOR="TA_ORCHESTRATOR"6readonly TAG_AGENT_TYPE="TA_AGENT_TYPE"7readonly TAG_SESSION_ID="TA_SESSION_ID"8readonly TAG_WORKSPACE_DIR="TA_WORKSPACE_DIR"9readonly TAG_AGENT_CWD="TA_AGENT_CWD"10readonly TAG_JSONL_PATH="TA_JSONL_PATH"11readonly TAG_LAUNCH_TS="TA_LAUNCH_TS"12readonly TAG_LAST_SEND_TS="TA_LAST_SEND_TS"13readonly TAG_LAST_STATE="TA_LAST_STATE"1415readonly DEFAULT_CLAUDE_CMD='claude --dangerously-skip-permissions'16readonly DEFAULT_CODEX_CMD='codex --dangerously-bypass-approvals-and-sandbox'1718TA_CLAUDE_CMD="${TA_CLAUDE_CMD:-$DEFAULT_CLAUDE_CMD}"19TA_CODEX_CMD="${TA_CODEX_CMD:-$DEFAULT_CODEX_CMD}"20TA_CWD="${TA_CWD:-$PWD}"21TA_TMUX_WIDTH="${TA_TMUX_WIDTH:-200}"22TA_TMUX_HEIGHT="${TA_TMUX_HEIGHT:-50}"23TA_CLAUDE_START_DELAY="${TA_CLAUDE_START_DELAY:-5}"24TA_PASTE_SETTLE_DELAY="${TA_PASTE_SETTLE_DELAY:-0.15}"25TA_CODEX_PASTE_SETTLE_DELAY="${TA_CODEX_PASTE_SETTLE_DELAY:-1}"26TA_CODEX_SUBMIT_DELAY="${TA_CODEX_SUBMIT_DELAY:-0.3}"27TA_WAIT_INTERVAL="${TA_WAIT_INTERVAL:-5}"28TA_CAPTURE_LINES="${TA_CAPTURE_LINES:-2000}"29TA_WORKSPACE_ROOT="${TA_WORKSPACE_ROOT:-.tmux-multi-agent/sessions}"30TA_STATE_DIRNAME="${TA_STATE_DIRNAME:-ta-state}"3132usage() {33cat <<'EOF'34usage:35ta claude [name]36ta codex [name]37ta send [--file|--text] <session> <file-or-text>38ta watch <session> [timeout-seconds]39ta read <session> [lines]40ta ls41ta wait <session> [timeout-seconds]42ta attach <session>43ta kill <session|all>44ta help4546env:47TA_SESSION_ID or SESSION_ID Prefix for tmux session names and kill-all scope48TA_CWD Working directory for launched workers49TA_CLAUDE_CMD Default: claude --dangerously-skip-permissions50TA_CODEX_CMD Default: codex --dangerously-bypass-approvals-and-sandbox51TA_PASTE_SETTLE_DELAY Delay after paste before submit (default: 0.15)52TA_CODEX_PASTE_SETTLE_DELAY Codex delay after paste before submit (default: 1)5354notes:55These flags are required for non-interactive orchestration. Override via env vars if your56setup uses different approval modes.5758`ta send` defaults to auto mode: existing path => file contents, otherwise literal text.59Use `--file` or `--text` to avoid ambiguity.60EOF61}6263short_id() {64head -c2 /dev/urandom | xxd -p | tr -d '\n'65}6667now_ms() {68python3 - <<'PY'69import time70print(int(time.time() * 1000))71PY72}7374current_session_id() {75printf '%s' "${TA_SESSION_ID:-${SESSION_ID:-}}"76}7778session_workspace_dir() {79local sid80sid="$(current_session_id)"81if [[ -n "$sid" ]]; then82printf '%s/%s' "$TA_WORKSPACE_ROOT" "$sid"83else84printf '%s' "$TA_WORKSPACE_ROOT"85fi86}8788ta_state_root_dir() {89printf '%s/%s' "$(session_workspace_dir)" "$TA_STATE_DIRNAME"90}9192session_state_dir_path() {93printf '%s/%s' "$(ta_state_root_dir)" "$1"94}9596session_state_file_path() {97printf '%s/session.json' "$(session_state_dir_path "$1")"98}99100session_events_file_path() {101printf '%s/events.ndjson' "$(session_state_dir_path "$1")"102}103104ensure_session_state_dir() {105mkdir -p "$(session_state_dir_path "$1")"106}107108qualify_session_name() {109local name="$1"110local sid111sid="$(current_session_id)"112if [[ -n "$sid" && "$name" != "${sid}-"* ]]; then113printf '%s-%s\n' "$sid" "$name"114else115printf '%s\n' "$name"116fi117}118119resolve_session() {120local requested="$1"121if tmux has-session -t "$requested" 2>/dev/null; then122printf '%s\n' "$requested"123return 0124fi125126local qualified127qualified="$(qualify_session_name "$requested")"128if tmux has-session -t "$qualified" 2>/dev/null; then129printf '%s\n' "$qualified"130return 0131fi132133echo "session not found: $requested" >&2134exit 1135}136137session_matches_scope() {138local session="$1"139local sid140sid="$(current_session_id)"141if [[ -z "$sid" ]]; then142return 0143fi144[[ "$session" == "${sid}-"* ]]145}146147is_tagged_session() {148local session="$1"149tmux show-environment -t "$session" "$TAG_ORCHESTRATOR" 2>/dev/null | grep -q '^TA_ORCHESTRATOR=tmux-multi-agent$'150}151152require_managed_session() {153local session="$1"154if ! is_tagged_session "$session"; then155echo "session is not managed by tmux-multi-agent: $session" >&2156exit 1157fi158if ! session_matches_scope "$session"; then159echo "session is outside current TA_SESSION_ID scope: $session" >&2160exit 1161fi162}163164resolve_managed_session() {165local session166session="$(resolve_session "$1")"167require_managed_session "$session"168printf '%s\n' "$session"169}170171agent_type_for_session() {172local session="$1"173tmux show-environment -t "$session" "$TAG_AGENT_TYPE" 2>/dev/null | sed 's/^[^=]*=//'174}175176launch_cwd_for_session() {177local session="$1"178local cwd179cwd="$(tmux show-environment -t "$session" "$TAG_AGENT_CWD" 2>/dev/null | sed 's/^[^=]*=//' || true)"180if [[ -n "$cwd" ]]; then181printf '%s\n' "$cwd"182else183tmux display-message -p -t "$session" '#{pane_current_path}' 2>/dev/null || true184fi185}186187capture_non_empty_tail() {188local session="$1"189local lines="$2"190tmux capture-pane -t "$session" -p -S "-${TA_CAPTURE_LINES}" 2>/dev/null | awk 'NF' | tail -n "$lines"191}192193session_metadata_update() {194local session="$1"195shift196local path197ensure_session_state_dir "$session"198path="$(session_state_file_path "$session")"199python3 - "$path" "$session" "$@" <<'PY'200import json201import os202import sys203from tempfile import NamedTemporaryFile204205path = sys.argv[1]206session = sys.argv[2]207args = sys.argv[3:]208209data = {}210if os.path.exists(path):211try:212with open(path) as fh:213loaded = json.load(fh)214if isinstance(loaded, dict):215data = loaded216except Exception:217data = {}218219data.setdefault("session", session)220for i in range(0, len(args), 2):221key = args[i]222value = args[i + 1]223if key.endswith(("_ms", "_epoch")):224try:225data[key] = int(value)226continue227except ValueError:228pass229data[key] = None if value == "__null__" else value230231with NamedTemporaryFile("w", delete=False, dir=os.path.dirname(path)) as tmp:232json.dump(data, tmp, indent=2, sort_keys=True)233tmp.write("\n")234os.replace(tmp.name, path)235PY236}237238session_event_append() {239local session="$1"240local event="$2"241shift 2242local path243ensure_session_state_dir "$session"244path="$(session_events_file_path "$session")"245python3 - "$path" "$session" "$event" "$@" <<'PY'246import json247import sys248import time249250path = sys.argv[1]251session = sys.argv[2]252event = sys.argv[3]253args = sys.argv[4:]254255record = {"ts_ms": int(time.time() * 1000), "session": session, "event": event}256if args:257fields = {}258for i in range(0, len(args), 2):259key = args[i]260value = args[i + 1]261if key.endswith(("_ms", "_epoch")):262try:263fields[key] = int(value)264continue265except ValueError:266pass267fields[key] = value268record["fields"] = fields269270with open(path, "a") as fh:271fh.write(json.dumps(record, ensure_ascii=False) + "\n")272PY273}274275record_session_state() {276local session="$1"277local state="$2"278local source="${3:-unknown}"279local now280now="$(now_ms)"281tmux set-environment -t "$session" "$TAG_LAST_STATE" "$state" 2>/dev/null || true282session_metadata_update "$session" state "$state" state_source "$source" last_activity_ms "$now"283session_event_append "$session" "state" state "$state" source "$source"284}285286pane_pid_for_session() {287tmux display-message -p -t "$1" '#{pane_pid}' 2>/dev/null || true288}289290list_descendant_pids() {291local root="$1"292local queue=("$root")293local pid child294295while ((${#queue[@]} > 0)); do296pid="${queue[0]}"297queue=("${queue[@]:1}")298[[ -n "$pid" ]] || continue299printf '%s\n' "$pid"300while IFS= read -r child; do301[[ -n "$child" ]] || continue302queue+=("$child")303done < <(pgrep -P "$pid" 2>/dev/null || true)304done305}306307jsonl_path_via_process_fds() {308local session="$1"309local agent_type launch_cwd pane_pid pid fd_path target310agent_type="$(agent_type_for_session "$session")"311launch_cwd="$(launch_cwd_for_session "$session")"312pane_pid="$(pane_pid_for_session "$session")"313[[ -n "$agent_type" && -n "$pane_pid" ]] || return 1314315while IFS= read -r pid; do316[[ -d "/proc/$pid/fd" ]] || continue317for fd_path in /proc/"$pid"/fd/*; do318[[ -e "$fd_path" ]] || continue319target="$(readlink -f "$fd_path" 2>/dev/null || true)"320[[ -n "$target" && -f "$target" ]] || continue321322if [[ "$agent_type" == "codex" && "$target" == "$HOME"/.codex/sessions/*.jsonl ]]; then323printf '%s\n' "$target"324return 0325fi326if [[ "$agent_type" == "claude" && "$target" == "$HOME"/.claude/projects/*.jsonl ]]; then327printf '%s\n' "$target"328return 0329fi330done331done < <(list_descendant_pids "$pane_pid")332333return 1334}335336discover_jsonl_path_for_session() {337local session="$1"338local agent_type launch_epoch339agent_type="$(agent_type_for_session "$session")"340launch_epoch="$(tmux show-environment -t "$session" "$TAG_LAUNCH_TS" 2>/dev/null | sed 's/^[^=]*=//' || true)"341[[ -n "$agent_type" && -n "$launch_epoch" ]] || return 1342343if [[ "$agent_type" == "codex" ]]; then344find "$HOME/.codex/sessions" -name '*.jsonl' -type f -newermt "@$launch_epoch" 2>/dev/null | sort | tail -n 1345return 0346fi347if [[ "$agent_type" == "claude" ]]; then348find "$HOME/.claude/projects" -name '*.jsonl' -type f -newermt "@$launch_epoch" 2>/dev/null | sort | tail -n 1349return 0350fi351return 1352}353354jsonl_path_for_session() {355local session="$1"356local cached discovered357cached="$(tmux show-environment -t "$session" "$TAG_JSONL_PATH" 2>/dev/null | sed 's/^[^=]*=//' || true)"358if [[ -n "$cached" && -f "$cached" ]]; then359printf '%s\n' "$cached"360return 0361fi362363discovered="$(jsonl_path_via_process_fds "$session" || true)"364if [[ -z "$discovered" ]]; then365discovered="$(discover_jsonl_path_for_session "$session" || true)"366fi367if [[ -n "$discovered" && -f "$discovered" ]]; then368tmux set-environment -t "$session" "$TAG_JSONL_PATH" "$discovered" 2>/dev/null || true369session_metadata_update "$session" jsonl_path "$discovered"370session_event_append "$session" "jsonl_bound" path "$discovered"371printf '%s\n' "$discovered"372return 0373fi374375return 1376}377378jsonl_completion_snapshot() {379local jsonl_path="$1"380local agent_type="$2"381local last_send_ms="${3:-0}"382383python3 - "$jsonl_path" "$agent_type" "$last_send_ms" <<'PY'384import base64385import datetime as dt386import json387import sys388from collections import deque389390path = sys.argv[1]391agent_type = sys.argv[2]392last_send_ms = int(sys.argv[3] or "0")393records = deque(maxlen=400)394395def parse_ms(value):396if not value:397return 0398try:399return int(dt.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)400except Exception:401return 0402403try:404with open(path) as fh:405for line in fh:406line = line.strip()407if not line:408continue409try:410records.append(json.loads(line))411except Exception:412pass413except OSError:414print("busy\t")415sys.exit(0)416417recent = [obj for obj in records if parse_ms(obj.get("timestamp")) >= last_send_ms]418if not recent:419print("busy\t")420sys.exit(0)421422def encode(text):423return base64.b64encode((text or "").encode("utf-8")).decode("ascii")424425if agent_type == "codex":426message = ""427for obj in recent:428payload = obj.get("payload")429if isinstance(payload, dict) and payload.get("phase") == "final_answer":430if isinstance(payload.get("message"), str):431message = payload.get("message") or ""432else:433content = payload.get("content")434if isinstance(content, list):435parts = []436for item in content:437if isinstance(item, dict) and item.get("type") == "output_text" and isinstance(item.get("text"), str):438parts.append(item["text"])439message = "\n".join(parts)440if message:441print(f"idle\t{encode(message)}")442else:443print("busy\t")444sys.exit(0)445446if agent_type == "claude":447last = None448for obj in recent:449if obj.get("type") == "assistant":450last = obj451elif obj.get("type") == "user":452last = None453if last is None:454print("busy\t")455sys.exit(0)456message = last.get("message")457stop_reason = message.get("stop_reason") if isinstance(message, dict) else None458if stop_reason != "end_turn":459print("busy\t")460sys.exit(0)461parts = []462content = message.get("content") if isinstance(message, dict) else None463if isinstance(content, list):464for item in content:465if isinstance(item, dict) and item.get("type") in {"text", "output_text"} and isinstance(item.get("text"), str):466parts.append(item["text"])467print(f"idle\t{encode(chr(10).join(parts))}")468sys.exit(0)469470print("busy\t")471PY472}473474emit_b64_message() {475local encoded="${1:-}"476[[ -n "$encoded" ]] || return 0477python3 - "$encoded" <<'PY'478import base64479import sys480encoded = sys.argv[1]481if encoded:482sys.stdout.write(base64.b64decode(encoded).decode("utf-8", "replace"))483PY484}485486session_state_via_pane() {487local session="$1"488local tail_text489tail_text="$(capture_non_empty_tail "$session" 20)"490if grep -Eq 'Working \(|Churned|Computing|Flowing|• Ran |• Explored|• Searched|• Called|Esc to interrupt' <<<"$tail_text"; then491record_session_state "$session" "busy" "pane"492printf 'busy\n'493else494record_session_state "$session" "idle" "pane"495printf 'idle\n'496fi497}498499session_state() {500local session="$1"501local agent_type jsonl_path last_send_ms snapshot state encoded502agent_type="$(agent_type_for_session "$session")"503jsonl_path="$(jsonl_path_for_session "$session" || true)"504last_send_ms="$(tmux show-environment -t "$session" "$TAG_LAST_SEND_TS" 2>/dev/null | sed 's/^[^=]*=//' || true)"505last_send_ms="${last_send_ms:-0}"506507if [[ -n "$jsonl_path" && -f "$jsonl_path" ]]; then508snapshot="$(jsonl_completion_snapshot "$jsonl_path" "$agent_type" "$last_send_ms")"509state="${snapshot%%$'\t'*}"510if [[ "$state" == "idle" || "$state" == "busy" ]]; then511record_session_state "$session" "$state" "jsonl"512printf '%s\n' "$state"513return 0514fi515fi516517session_state_via_pane "$session"518}519520watch_session_until_idle() {521local session="$1"522local timeout="${2:-300}"523local emit_output="${3:-false}"524local resolved_session elapsed=0 state jsonl_path agent_type last_send_ms snapshot encoded525526resolved_session="$(resolve_managed_session "$session")"527while (( elapsed <= timeout )); do528state="$(session_state "$resolved_session")"529if [[ "$state" == "idle" ]]; then530if [[ "$emit_output" == "true" ]]; then531agent_type="$(agent_type_for_session "$resolved_session")"532jsonl_path="$(jsonl_path_for_session "$resolved_session" || true)"533last_send_ms="$(tmux show-environment -t "$resolved_session" "$TAG_LAST_SEND_TS" 2>/dev/null | sed 's/^[^=]*=//' || true)"534if [[ -n "$jsonl_path" && -f "$jsonl_path" ]]; then535snapshot="$(jsonl_completion_snapshot "$jsonl_path" "$agent_type" "${last_send_ms:-0}")"536encoded="${snapshot#*$'\t'}"537if [[ -n "$encoded" ]]; then538emit_b64_message "$encoded"539fi540else541capture_non_empty_tail "$resolved_session" 50542fi543fi544return 0545fi546(( elapsed == timeout )) && break547sleep "$TA_WAIT_INTERVAL"548elapsed=$((elapsed + TA_WAIT_INTERVAL))549done550return 1551}552553load_payload_into_buffer() {554local mode="$1"555local input="$2"556local buffer_name="$3"557local temp_file_var_name="$4"558local temp_file=""559560if [[ "$mode" == "file" ]]; then561[[ -f "$input" ]] || { echo "file not found: $input