From 0273e8f3d027b988c472d070d3276d937f0eca62 Mon Sep 17 00:00:00 2001 From: Prateek Date: Fri, 13 Feb 2026 15:25:31 +0530 Subject: [PATCH 0001/1114] feat: initial commit with orchestrator scripts and dev instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import all 18 production orchestrator scripts from ~/. These are direct copies with hardcoded paths — generalization is the next step. Includes CLAUDE.local.md with project overview, architecture notes, and roadmap. Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.local.md | 110 +++ scripts/claude-batch-spawn | 183 ++++ scripts/claude-bugbot-fix | 88 ++ scripts/claude-dashboard | 1371 +++++++++++++++++++++++++++++ scripts/claude-integrator-session | 242 +++++ scripts/claude-open-all | 133 +++ scripts/claude-review-check | 165 ++++ scripts/claude-session-status | 87 ++ scripts/claude-spawn | 119 +++ scripts/claude-spawn-on-branch | 77 ++ scripts/claude-spawn-with-context | 109 +++ scripts/claude-spawn-with-prompt | 32 + scripts/claude-splitly-session | 216 +++++ scripts/claude-status | 212 +++++ scripts/get-claude-session-info | 63 ++ scripts/notify-session | 30 + scripts/open-iterm-tab | 58 ++ scripts/open-tmux-session | 53 ++ scripts/send-to-session | 112 +++ 19 files changed, 3460 insertions(+) create mode 100644 CLAUDE.local.md create mode 100755 scripts/claude-batch-spawn create mode 100755 scripts/claude-bugbot-fix create mode 100755 scripts/claude-dashboard create mode 100755 scripts/claude-integrator-session create mode 100755 scripts/claude-open-all create mode 100755 scripts/claude-review-check create mode 100755 scripts/claude-session-status create mode 100755 scripts/claude-spawn create mode 100755 scripts/claude-spawn-on-branch create mode 100755 scripts/claude-spawn-with-context create mode 100755 scripts/claude-spawn-with-prompt create mode 100755 scripts/claude-splitly-session create mode 100755 scripts/claude-status create mode 100755 scripts/get-claude-session-info create mode 100755 scripts/notify-session create mode 100755 scripts/open-iterm-tab create mode 100755 scripts/open-tmux-session create mode 100755 scripts/send-to-session diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 000000000..23241f52c --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,110 @@ +# CLAUDE.local.md - Agent Orchestrator Development + +You are working on **agent-orchestrator**, an open-source multi-agent orchestrator for Claude Code. This tool manages parallel coding agents across projects from a single control plane. + +> **Dog-fooding**: This orchestrator is being built using a copy of itself. The `~/` home directory orchestrator manages sessions that work on this very repo. + +## Project Overview + +Agent Orchestrator lets you: +- Spawn and manage dozens of Claude Code agents working in parallel +- Track progress via a live HTML dashboard with PR status, CI checks, and merge actions +- Send commands to agents, kill/cleanup sessions, and view terminals in-browser +- Organize agents by project with worktree-based isolation + +## Repo Structure + +``` +agent-orchestrator/ +├── scripts/ # Core orchestrator scripts +│ ├── claude-status # Unified dashboard (CLI) +│ ├── claude-batch-spawn # Spawn multiple sessions at once +│ ├── claude-spawn # Spawn single session in new terminal tab +│ ├── claude-dashboard # HTML dashboard with live PR status (web UI) +│ ├── claude-open-all # Open terminal tabs for all sessions +│ ├── claude-review-check # Trigger agents to address PR review comments +│ ├── claude-bugbot-fix # Fix bugbot comments across sessions +│ ├── claude-session-status # Health monitor (working/idle/blocked/waiting) +│ ├── claude-spawn-with-context # Spawn with custom prompt file +│ ├── claude-spawn-on-branch # Spawn on existing branch +│ ├── claude-spawn-with-prompt # Spawn and deliver prompt after ready +│ ├── get-claude-session-info # Extract session metadata from tmux +│ ├── open-tmux-session # Switch to terminal tab for tmux session +│ ├── open-iterm-tab # iTerm2 tab management +│ ├── notify-session # iTerm2 notifications from sessions +│ ├── send-to-session # Smart message delivery to tmux sessions +│ ├── claude-integrator-session # Example: project session manager (Linear/integrator) +│ └── claude-splitly-session # Example: project session manager (GitHub Issues/SafeSplit) +├── CLAUDE.local.md # This file (dev instructions) +├── CLAUDE.md # Repo-level instructions for contributors +└── README.md # Project README +``` + +## Current State + +The scripts in `scripts/` are direct copies from the production orchestrator (`~/`). They currently have hardcoded paths and project-specific references (integrator, splitly). The work ahead is to: + +1. **Generalize** — Remove hardcoded project names, make scripts project-agnostic +2. **Configuration** — Add a config file (e.g., `orchestrator.yaml`) that defines projects, repos, branches, issue trackers +3. **Installation** — Create an install script that symlinks scripts to `~/` or adds them to PATH +4. **Documentation** — Write a comprehensive README with setup guide and examples +5. **Self-hosting** — Make this repo use its own orchestrator for development + +## Project Info + +- **Repo**: ComposioHQ/agent-orchestrator (GitHub) +- **Issue Tracker**: Linear +- **Visibility**: Internal (ComposioHQ org) +- **Everything runs locally** — no Datadog, no cloud infra + +## Development Workflow + +```bash +# This repo is managed by the home directory orchestrator +# Sessions are spawned via: +~/claude-spawn-with-context i TICKET /tmp/prompt.txt --open + +# Linear tickets for this project +# (use Rube MCP LINEAR_CREATE_LINEAR_ISSUE to create tickets) + +# GitHub issues (secondary): +gh issue list --repo ComposioHQ/agent-orchestrator --state open +``` + +## Key Design Principles + +1. **tmux-based** — All agent sessions run in tmux. This gives persistence, detach/attach, and scriptability. +2. **Metadata files** — Each session has a flat metadata file (`key=value` format) tracking branch, PR, issue, status, notes. +3. **Live dashboard** — HTML dashboard served locally with real-time activity detection via Claude's JSONL session files. +4. **Project-agnostic shared scripts** — Core scripts (spawn, status, dashboard) take project as an argument. +5. **Project-specific session managers** — Each project gets a session manager script that handles worktrees, naming, and cleanup. +6. **iTerm2 integration** — AppleScript-based tab management for macOS (should be made terminal-agnostic). + +## Architecture Notes + +### Session Lifecycle +``` +spawn → tmux session created → Claude started → working on issue + ↓ +metadata file written (branch, issue, status) + ↓ +agent creates PR → metadata updated (pr=URL) + ↓ +dashboard shows PR status, CI, review state + ↓ +PR merged → cleanup kills session, archives metadata +``` + +### Activity Detection +The dashboard detects if agents are working/idle/exited by: +1. Checking Claude's JSONL session file modification time and last message type +2. Walking the process tree from tmux pane PID to find `claude` processes +3. Polling every 5 seconds via `/api/sessions` endpoint + +### Dashboard Server +- Python HTTP server serving static HTML + JSON API +- `/api/sessions` — live activity status +- `/api/terminal/:name` — spawn ttyd web terminal for session +- `/api/kill/:name` — kill session + close iTerm tab + archive metadata +- `/api/merge/:repo/:num` — merge PR via `gh` +- `/api/send/:name` — send message to agent via `send-to-session` diff --git a/scripts/claude-batch-spawn b/scripts/claude-batch-spawn new file mode 100755 index 000000000..1cfda311c --- /dev/null +++ b/scripts/claude-batch-spawn @@ -0,0 +1,183 @@ +#!/bin/bash + +# Claude Batch Session Spawner (v2 - worktree-based) +# Delegates to session managers for worktree creation, symlinks, and MCP setup. +# Adds duplicate detection: skips issues that already have active sessions. +# Usage: ~/claude-batch-spawn [issue2] [issue3] ... + +PROJECT="${1:-}" +shift +ISSUES=("$@") + +if [ -z "$PROJECT" ] || [ ${#ISSUES[@]} -eq 0 ]; then + echo "Usage: ~/claude-batch-spawn [issue2] [issue3] ..." + echo "" + echo "Examples:" + echo " ~/claude-batch-spawn splitly 298 299 300 301" + echo " ~/claude-batch-spawn integrator INT-1020 INT-1021" + echo "" + echo "This creates worktree-based sessions for each issue." + echo "After creation, attach with: ~/claude-*-session attach " + exit 1 +fi + +# Determine project settings +case "$PROJECT" in + integrator|i) + SESSION_PREFIX="integrator" + SESSION_MANAGER="$HOME/claude-integrator-session" + SESSION_DATA_DIR="$HOME/.integrator-sessions" + GITHUB_REPO="" + ;; + splitly|s|safesplit) + SESSION_PREFIX="splitly" + SESSION_MANAGER="$HOME/claude-splitly-session" + SESSION_DATA_DIR="$HOME/.splitly-sessions" + GITHUB_REPO="UniverseOfThings/SafeSplit" + ;; + *) + echo "Unknown project: $PROJECT" + echo "Use 'integrator' (or 'i') or 'splitly' (or 's')" + exit 1 + ;; +esac + +mkdir -p "$SESSION_DATA_DIR" + +# Duplicate detection: check if an issue already has an active session +issue_has_session() { + local issue="$1" + local issue_lower=$(echo "$issue" | tr '[:upper:]' '[:lower:]') + + for meta_file in "$SESSION_DATA_DIR"/*; do + [ -f "$meta_file" ] || continue + local session_name=$(basename "$meta_file") + + # Check if tmux session is still active + tmux has-session -t "$session_name" 2>/dev/null || continue + + # Check issue field in metadata + local meta_issue=$(grep "^issue=" "$meta_file" 2>/dev/null | cut -d= -f2-) + local meta_issue_lower=$(echo "$meta_issue" | tr '[:upper:]' '[:lower:]') + + # Match by issue ID in the URL or direct match + if echo "$meta_issue_lower" | grep -qi "$issue_lower"; then + echo "$session_name" + return 0 + fi + done + return 1 +} + +echo "╔══════════════════════════════════════════════════════════════════════════════╗" +echo "║ BATCH SESSION SPAWNER (v2) ║" +echo "╚══════════════════════════════════════════════════════════════════════════════╝" +echo "" +echo "Project: $PROJECT" +echo "Issues to spawn: ${ISSUES[*]}" +echo "" + +CREATED=() +SKIPPED=() +FAILED=() + +for ISSUE in "${ISSUES[@]}"; do + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Duplicate detection + existing=$(issue_has_session "$ISSUE") + if [ $? -eq 0 ]; then + echo " ⏭️ Skipping $ISSUE — already has session: $existing" + SKIPPED+=("$ISSUE:$existing") + continue + fi + + echo " Creating session for: $ISSUE" + + # Delegate to the session manager + output=$("$SESSION_MANAGER" new "$ISSUE" 2>&1) + + if [ $? -ne 0 ]; then + echo " ❌ Error creating session!" + echo "$output" | sed 's/^/ /' + FAILED+=("$ISSUE") + continue + fi + + # Extract session name from session manager output + SESSION=$(echo "$output" | grep "^SESSION=" | cut -d= -f2) + + if [ -z "$SESSION" ]; then + echo " ❌ Error: Could not determine session name!" + echo "$output" | sed 's/^/ /' + FAILED+=("$ISSUE") + continue + fi + + # Update metadata with issue info + if [ "$SESSION_PREFIX" = "splitly" ]; then + ISSUE_NUM=$(echo "$ISSUE" | sed 's/#//' | grep -o '[0-9]*') + ISSUE_URL="https://github.com/$GITHUB_REPO/issues/$ISSUE_NUM" + else + ISSUE_URL="$ISSUE" + fi + # Append issue to metadata (session manager may have already set it) + grep -q "^issue=" "$SESSION_DATA_DIR/$SESSION" 2>/dev/null || \ + echo "issue=$ISSUE_URL" >> "$SESSION_DATA_DIR/$SESSION" + + # Wait for Claude to start, then send the work prompt + sleep 1 + + # Build initial prompt + if [ "$SESSION_PREFIX" = "splitly" ]; then + INITIAL_PROMPT="Please start working on GitHub issue #$ISSUE_NUM. First, fetch the issue details using: gh issue view $ISSUE_NUM --repo $GITHUB_REPO. Then create an appropriate feature branch (e.g., fix/issue-$ISSUE_NUM-short-description or feat/issue-$ISSUE_NUM-short-description), and start implementing the solution." + else + INITIAL_PROMPT="Please start working on $ISSUE, fetch ticket info, create the appropriate branch so that github auto links to linear, and start working on the task" + fi + + # Send prompt to the running Claude session + tmux send-keys -t "$SESSION" "$INITIAL_PROMPT" Enter + + echo " ✅ Session $SESSION created and prompted" + CREATED+=("$SESSION:$ISSUE") + + # Small delay between spawns + sleep 0.5 +done + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "📊 SUMMARY" +echo " Created: ${#CREATED[@]} sessions" +echo " Skipped: ${#SKIPPED[@]} (duplicate)" +echo " Failed: ${#FAILED[@]}" +echo "" + +if [ ${#CREATED[@]} -gt 0 ]; then + echo "Created sessions:" + for item in "${CREATED[@]}"; do + SESSION=$(echo "$item" | cut -d: -f1) + ISSUE=$(echo "$item" | cut -d: -f2-) + echo " • $SESSION → $ISSUE" + done +fi + +if [ ${#SKIPPED[@]} -gt 0 ]; then + echo "" + echo "Skipped (already has session):" + for item in "${SKIPPED[@]}"; do + ISSUE=$(echo "$item" | cut -d: -f1) + EXISTING=$(echo "$item" | cut -d: -f2-) + echo " • $ISSUE → existing session: $EXISTING" + done +fi + +if [ ${#FAILED[@]} -gt 0 ]; then + echo "" + echo "Failed issues: ${FAILED[*]}" +fi + +echo "" +echo "To see all sessions: ~/claude-status" +echo "To open all tabs: ~/claude-open-all $SESSION_PREFIX" diff --git a/scripts/claude-bugbot-fix b/scripts/claude-bugbot-fix new file mode 100755 index 000000000..71c4a0f6f --- /dev/null +++ b/scripts/claude-bugbot-fix @@ -0,0 +1,88 @@ +#!/bin/bash +# Check open PRs for unaddressed bugbot (cursor[bot]) comments +# and send fix instructions to the relevant sessions. +# +# Usage: ~/claude-bugbot-fix [project] [--dry-run] +# +# Examples: +# ~/claude-bugbot-fix integrator # Check and send fix instructions +# ~/claude-bugbot-fix integrator --dry-run # Just report, don't send +# ~/claude-bugbot-fix splitly + +PROJECT="${1:-integrator}" +DRY_RUN=false +[ "$2" = "--dry-run" ] && DRY_RUN=true + +case "$PROJECT" in + integrator|i) + REPO="ComposioHQ/integrator" + SESSIONS_DIR="$HOME/.integrator-sessions" + SESSION_PREFIX="integrator" + ;; + splitly|s|safesplit) + REPO="UniverseOfThings/SafeSplit" + SESSIONS_DIR="$HOME/.splitly-sessions" + SESSION_PREFIX="splitly" + ;; + *) + echo "Usage: $0 [integrator|splitly] [--dry-run]" + exit 1 + ;; +esac + +echo "Checking $REPO for bugbot comments..." +echo "" + +# Build PR → session map from metadata +declare -A PR_SESSION_MAP +for s in $(ls "$SESSIONS_DIR" 2>/dev/null | grep "^${SESSION_PREFIX}-"); do + pr_num=$(grep "^pr=" "$SESSIONS_DIR/$s" 2>/dev/null | grep -oE "[0-9]+" | tail -1) + [ -n "$pr_num" ] && PR_SESSION_MAP[$pr_num]="$s" +done + +# Get all open PRs +OPEN_PRS=$(gh pr list --repo "$REPO" --state open --json number -q '.[].number' --limit 50) + +FOUND=0 +for pr in $OPEN_PRS; do + # Check if latest cursor[bot] review reports issues + latest_review=$(gh api "repos/$REPO/pulls/$pr/reviews" --jq '[.[] | select(.user.login == "cursor[bot]")] | last | .body // ""' 2>/dev/null) + + if echo "$latest_review" | grep -q "potential issue"; then + issue_count=$(echo "$latest_review" | grep -oE "found [0-9]+" | grep -oE "[0-9]+") + session="${PR_SESSION_MAP[$pr]:-none}" + + # Get the actual inline comments + review_id=$(gh api "repos/$REPO/pulls/$pr/reviews" --jq '[.[] | select(.user.login == "cursor[bot]")] | last | .id' 2>/dev/null) + comments="" + if [ -n "$review_id" ]; then + comments=$(gh api "repos/$REPO/pulls/$pr/reviews/$review_id/comments" --jq '.[] | "- [\(.path)] \(.body | split("\n")[0] | gsub("### "; ""))"' 2>/dev/null) + fi + + echo "PR #$pr: $issue_count bugbot issue(s) → session: $session" + echo "$comments" | head -5 + echo "" + + FOUND=$((FOUND + 1)) + + # Send fix instruction if session exists and not dry-run + if [ "$session" != "none" ] && [ "$DRY_RUN" = "false" ]; then + if tmux has-session -t "$session" 2>/dev/null; then + # Build a concise fix message + comment_summary=$(gh api "repos/$REPO/pulls/$pr/reviews/$review_id/comments" --jq '.[] | "(\(.path | split("/") | last)): \(.body | split("\n")[0] | gsub("### "; ""))"' 2>/dev/null | tr '\n' '; ') + + tmux send-keys -t "$session" "You have $issue_count unaddressed bugbot comment(s) on PR #$pr. Please run: gh api repos/$REPO/pulls/$pr/comments --jq '.[] | select(.user.login == \"cursor[bot]\") | .body' to see full details. Issues: $comment_summary Fix and push." Enter + echo " → Sent fix instruction to $session" + else + echo " → Session $session not running" + fi + fi + fi +done + +if [ "$FOUND" -eq 0 ]; then + echo "No unaddressed bugbot comments found." +fi + +echo "" +echo "Done. $FOUND PR(s) with bugbot issues." diff --git a/scripts/claude-dashboard b/scripts/claude-dashboard new file mode 100755 index 000000000..391a45756 --- /dev/null +++ b/scripts/claude-dashboard @@ -0,0 +1,1371 @@ +#!/bin/bash +# Generate and open an HTML dashboard for all Claude orchestrator sessions +# Usage: ~/claude-dashboard [--no-open] + +NO_OPEN=false +REGEN_ONLY=false +for arg in "$@"; do + case "$arg" in + --no-open) NO_OPEN=true ;; + --regen-only) REGEN_ONLY=true; NO_OPEN=true ;; + esac +done + +DASHBOARD="/tmp/claude-dashboard.html" + +echo "Gathering session data..." + +# ─── Gather session data ─────────────────────────────────────────────────────── + +sessions_json="[" +first=true + +for s in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep -E "^integrator-" | sort -V); do + project="integrator" + meta_file="$HOME/.integrator-sessions/$s" + repo="ComposioHQ/integrator" + + # Get branch + # Get pane working directory (used for both branch detection and activity) + pane_cwd=$(tmux display-message -t "$s" -p "#{pane_current_path}" 2>/dev/null) + + # Branch detection: pane cwd (most reliable) → worktree → secondary_checkouts → metadata + branch="" + if [ -n "$pane_cwd" ] && [ -d "$pane_cwd/.git" -o -f "$pane_cwd/.git" ]; then + branch=$(cd "$pane_cwd" && git branch --show-current 2>/dev/null) + fi + if [ -z "$branch" ]; then + worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + if [ -d "$worktree" ]; then + branch=$(cd "$worktree" && git branch --show-current 2>/dev/null) + fi + fi + if [ -z "$branch" ]; then + num=$(echo "$s" | sed 's/[^0-9-]//g' | cut -d- -f1) + checkout="$HOME/secondary_checkouts/integrator-$num" + [ -d "$checkout" ] && branch=$(cd "$checkout" && git branch --show-current 2>/dev/null) + fi + [ -z "$branch" ] && branch=$(grep "^branch=" "$meta_file" 2>/dev/null | cut -d= -f2-) + [ -z "$branch" ] && branch="unknown" + + meta_status=$(grep "^status=" "$meta_file" 2>/dev/null | cut -d= -f2-) + summary=$(grep "^summary=" "$meta_file" 2>/dev/null | cut -d= -f2-) + # Capture all PRs (sessions can have multiple) + pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2- | paste -sd',' -) + issue=$(grep "^issue=" "$meta_file" 2>/dev/null | cut -d= -f2- | head -1) + slack=$(grep "^slack=" "$meta_file" 2>/dev/null | cut -d= -f2- | head -1) + notes=$(grep "^notes=" "$meta_file" 2>/dev/null | cut -d= -f2-) + + # Activity detection: JSONL file + process check + activity="idle" + if [ -n "$pane_cwd" ]; then + proj_key=$(echo "$pane_cwd" | tr '/.' '--') + session_dir="$HOME/.claude/projects/$proj_key" + jsonl=$(ls -t "$session_dir"/*.jsonl 2>/dev/null | grep -v agent- | head -1) + if [ -n "$jsonl" ]; then + mod_age=$(( $(date +%s) - $(stat -f %m "$jsonl") )) + last_type=$(tail -c 4096 "$jsonl" | grep -o '"type":"[^"]*"' | tail -1 | cut -d'"' -f4) + if [ "$mod_age" -lt 30 ] && [ "$last_type" != "assistant" ] && [ "$last_type" != "system" ]; then + activity="working" + fi + fi + fi + # If not working, check if claude process exists + if [ "$activity" != "working" ]; then + pane_pid=$(tmux list-panes -t "$s" -F "#{pane_pid}" 2>/dev/null | head -1) + if [ -n "$pane_pid" ]; then + has_claude=false + # Check if pane process itself is claude (batch-spawned sessions) + pane_comm=$(ps -o comm= -p "$pane_pid" 2>/dev/null) + if echo "$pane_comm" | grep -q claude; then + has_claude=true + else + # Check children and grandchildren + for child in $(pgrep -P "$pane_pid" 2>/dev/null); do + child_comm=$(ps -o comm= -p "$child" 2>/dev/null) + if echo "$child_comm" | grep -q claude; then + has_claude=true + break + fi + if pgrep -P "$child" -f claude > /dev/null 2>&1; then + has_claude=true + break + fi + done + fi + if [ "$has_claude" = false ]; then + activity="exited" + fi + fi + fi + pane=$(tmux capture-pane -t "$s" -p -S -5 2>/dev/null) + + # Extract PR number from status bar if not in metadata + pr_from_bar=$(echo "$pane" | grep -oE 'PR #[0-9]+' | tail -1 | sed 's/PR #//') + [ -z "$pr" ] && [ -n "$pr_from_bar" ] && pr="https://github.com/$repo/pull/$pr_from_bar" + + # Launch background PR lookup from branch name + if [ -n "$branch" ] && [ "$branch" != "unknown" ] && [ "$branch" != "next" ] && [ "$branch" != "main" ]; then + mkdir -p /tmp/claude-dashboard-prlookup.$$ + ( gh pr list --repo "$repo" --head "$branch" --json url --jq '.[0].url' 2>/dev/null > "/tmp/claude-dashboard-prlookup.$$/$s" ) & + fi + + # Store session data for later JSON assembly (after PR lookups complete) + echo "$s|$project|$branch|$meta_status|$summary|$pr|$issue|$activity|$slack|$notes" >> /tmp/claude-dashboard-sessions.$$ +done + +# Wait for all background PR lookups to finish +wait + +# Now build sessions_json, merging auto-detected PRs with metadata PRs +sessions_json="[" +first=true +while IFS='|' read -r s project branch meta_status summary pr issue activity slack notes; do + # Merge auto-detected PR from branch + detected_pr="" + if [ -f "/tmp/claude-dashboard-prlookup.$$/$s" ]; then + detected_pr=$(cat "/tmp/claude-dashboard-prlookup.$$/$s" | tr -d '[:space:]') + fi + if [ -n "$detected_pr" ] && [ "$detected_pr" != "null" ]; then + if [ -z "$pr" ]; then + pr="$detected_pr" + elif ! echo "$pr" | grep -qF "$detected_pr"; then + pr="$pr,$detected_pr" + fi + echo " $s: branch PR $detected_pr" + fi + + # Escape quotes for JSON + summary=$(echo "$summary" | sed 's/"/\\"/g') + meta_status=$(echo "$meta_status" | sed 's/"/\\"/g') + slack=$(echo "$slack" | sed 's/"/\\"/g') + notes=$(echo "$notes" | sed 's/"/\\"/g') + + [ "$first" = true ] && first=false || sessions_json+="," + sessions_json+="{\"name\":\"$s\",\"project\":\"$project\",\"branch\":\"$branch\",\"status\":\"$meta_status\",\"summary\":\"$summary\",\"pr\":\"$pr\",\"issue\":\"$issue\",\"activity\":\"$activity\",\"slack\":\"$slack\",\"notes\":\"$notes\"}" +done < /tmp/claude-dashboard-sessions.$$ +sessions_json+="]" +rm -rf /tmp/claude-dashboard-prlookup.$$ /tmp/claude-dashboard-sessions.$$ + +# ─── Gather PR details (with review threads, CI, comments) ──────────────────── + +echo "Fetching PR details..." + +# Extract unique PR URLs +pr_urls=$(echo "$sessions_json" | grep -oE 'https://github.com/[^",]+/pull/[0-9]+' | sort -u) + +# Fetch all PRs in parallel +pr_tmp=$(mktemp -d /tmp/claude-dashboard-pr-XXXXXX) + +for url in $pr_urls; do + num=$(echo "$url" | grep -oE '[0-9]+$') + repo=$(echo "$url" | sed 's|https://github.com/||;s|/pull/.*||') + owner=$(echo "$repo" | cut -d/ -f1) + reponame=$(echo "$repo" | cut -d/ -f2) + [ -z "$num" ] || [ -z "$owner" ] && continue + + # Launch each PR fetch in background + ( + info=$(gh pr view "$num" --repo "$repo" --json title,state,mergeable,reviewDecision,additions,deletions,createdAt 2>/dev/null) + [ -z "$info" ] && exit 0 + + gql=$(gh api graphql -f query="query { + repository(owner: \"$owner\", name: \"$reponame\") { + pullRequest(number: $num) { + reviewThreads(first: 100) { + nodes { isResolved comments(first: 1) { nodes { url path } } } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: 50) { + nodes { + ... on CheckRun { crName: name conclusion status: status detailsUrl } + ... on StatusContext { scName: context cstate: state targetUrl } + } + } + } + } + } + } + } + } + }" 2>/dev/null) + + printf '%s\n%s' "$info" "$gql" | python3 -c " +import sys, json +lines = sys.stdin.read().strip().split('\n') +d = json.loads(lines[0]) +try: + gql = json.loads(lines[1]) + pr = gql['data']['repository']['pullRequest'] + threads = pr['reviewThreads']['nodes'] + unresolved = [{'url': t.get('comments',{}).get('nodes',[{}])[0].get('url',''), 'path': t.get('comments',{}).get('nodes',[{}])[0].get('path','')} for t in threads if not t['isResolved']] + d['unresolvedThreads'] = len(unresolved) + d['unresolvedList'] = unresolved + nodes = pr['commits']['nodes'][0]['commit']['statusCheckRollup']['contexts']['nodes'] + counts, checks = {}, [] + for n in nodes: + name = n.get('crName') or n.get('scName') or 'unknown' + s = (n.get('conclusion') or n.get('status') or n.get('cstate') or 'UNKNOWN').upper() + url = n.get('detailsUrl') or n.get('targetUrl') or '' + counts[s] = counts.get(s, 0) + 1 + checks.append({'name': name, 'status': s, 'url': url}) + parts = [] + for k in ['SUCCESS', 'FAILURE', 'PENDING', 'NEUTRAL', 'SKIPPED', 'IN_PROGRESS']: + if k in counts: parts.append(f'{counts[k]} {k.lower()}') + for k in counts: + if k not in ['SUCCESS', 'FAILURE', 'PENDING', 'NEUTRAL', 'SKIPPED', 'IN_PROGRESS']: + parts.append(f'{counts[k]} {k.lower()}') + d['ciSummary'] = ','.join(parts) if parts else 'none' + d['ciChecks'] = checks +except: d.update({'unresolvedThreads': 0, 'unresolvedList': [], 'ciSummary': 'none', 'ciChecks': []}) +print(json.dumps(d)) +" > "$pr_tmp/$num.json" 2>/dev/null + echo " PR #$num: done" + ) & +done +wait + +# Merge results +pr_json="{" +first_pr=true +for f in "$pr_tmp"/*.json; do + [ -f "$f" ] || continue + num=$(basename "$f" .json) + unresolved=$(python3 -c "import sys,json; print(json.load(open('$f')).get('unresolvedThreads',0))" 2>/dev/null) + ci_label=$(python3 -c "import sys,json; print(json.load(open('$f')).get('ciSummary','none'))" 2>/dev/null) + echo " PR #$num: $unresolved unresolved threads, CI: $ci_label" + [ "$first_pr" = true ] && first_pr=false || pr_json+="," + pr_json+="\"$num\":$(cat "$f")" +done +pr_json+="}" +rm -rf "$pr_tmp" + +# ─── Counts ──────────────────────────────────────────────────────────────────── + +total=$(echo "$sessions_json" | grep -o '"name"' | wc -l | tr -d ' ') +working=$(echo "$sessions_json" | grep -o '"activity":"working"' | wc -l | tr -d ' ') +open_prs=$(echo "$pr_json" | grep -o '"OPEN"' | wc -l | tr -d ' ') +needs_review=$(echo "$pr_json" | grep -o '"REVIEW_REQUIRED"' | wc -l | tr -d ' ') + +# ─── Generate Markdown (machine-readable for orchestrator agent) ─────────────── + +MD_FILE="$HOME/.claude-dashboard.md" + +python3 -c " +import json, sys +from datetime import datetime, timezone + +sessions = json.loads('''$sessions_json''') +prs = json.loads('''$pr_json''') + +now = datetime.now(timezone.utc) +lines = [] +lines.append('# Claude Orchestrator Dashboard') +lines.append(f'Generated: {now.strftime(\"%Y-%m-%d %H:%M UTC\")}') +lines.append('') +lines.append(f'**{len(sessions)} sessions** | **{sum(1 for s in sessions if s[\"activity\"]==\"working\")} working** | **$open_prs open PRs** | **$needs_review needs review**') +lines.append('') + +# Sessions grouped by activity +for activity in ['working', 'idle', 'exited']: + group = [s for s in sessions if s['activity'] == activity] + if not group: + continue + lines.append(f'## {activity.upper()} ({len(group)})') + lines.append('') + for s in sorted(group, key=lambda x: x['name']): + pr_num = s['pr'].split('/')[-1] if s['pr'] else None + pr = prs.get(pr_num, {}) if pr_num else {} + + ci = pr.get('ciSummary', 'none') + unresolved = pr.get('unresolvedThreads', 0) + review = pr.get('reviewDecision', '') + additions = pr.get('additions', 0) + deletions = pr.get('deletions', 0) + + # CI status summary + ci_short = 'none' + if 'failure' in ci: ci_short = 'FAILING' + elif 'pending' in ci or 'in_progress' in ci: ci_short = 'PENDING' + elif 'success' in ci: ci_short = 'PASSING' + + # Review summary + review_short = '' + if review == 'APPROVED': review_short = 'approved' + elif review == 'CHANGES_REQUESTED': review_short = 'changes-requested' + elif review == 'REVIEW_REQUIRED': review_short = 'needs-review' + + lines.append(f'- **{s[\"name\"]}** ({s[\"project\"]})') + lines.append(f' - Branch: \`{s[\"branch\"]}\`') + if s.get('summary'): lines.append(f' - Summary: {s[\"summary\"]}') + if pr_num: + pr_line = f' - PR: [#{pr_num}]({s[\"pr\"]}) (+{additions}/-{deletions})' + if review_short: pr_line += f' | {review_short}' + if ci_short != 'none': pr_line += f' | CI: {ci_short}' + if unresolved > 0: pr_line += f' | **{unresolved} unresolved comments**' + lines.append(pr_line) + else: + lines.append(' - PR: none') + lines.append('') + +# PR summary table +open_pr_nums = [n for n, p in prs.items() if p.get('state') == 'OPEN'] +if open_pr_nums: + lines.append('## Open PRs') + lines.append('') + lines.append('| PR | Title | Size | CI | Review | Unresolved |') + lines.append('|----|-------|------|----|--------|------------|') + for num in sorted(open_pr_nums, key=int, reverse=True): + pr = prs[num] + ci = pr.get('ciSummary', 'none') + ci_short = 'FAIL' if 'failure' in ci else 'PENDING' if 'pending' in ci else 'PASS' if 'success' in ci else '—' + review = pr.get('reviewDecision', '') + review_short = 'approved' if review == 'APPROVED' else 'changes' if review == 'CHANGES_REQUESTED' else 'needs review' + unresolved = pr.get('unresolvedThreads', 0) + size = pr.get('additions', 0) + pr.get('deletions', 0) + size_label = 'XL' if size > 1000 else 'L' if size > 500 else 'M' if size > 200 else 'S' if size > 50 else 'XS' + sess = next((s for s in sessions if s['pr'].endswith(f'/{num}')), None) + repo = 'ComposioHQ/integrator' + title = pr.get('title', '')[:60] + lines.append(f'| [#{num}](https://github.com/{repo}/pull/{num}) | {title} | +{pr.get(\"additions\",0)}/-{pr.get(\"deletions\",0)} ({size_label}) | {ci_short} | {review_short} | {unresolved} |') + lines.append('') + +print('\n'.join(lines)) +" > "$MD_FILE" 2>/dev/null + +echo "Markdown dashboard: $MD_FILE" + +# ─── Generate HTML ───────────────────────────────────────────────────────────── + +echo "Generating HTML..." + +cat > "$DASHBOARD" << 'HTMLEOF' + + + + + +Integrator Orchestrator Dashboard + + + +
+
+

Integrator Orchestrator

+
+ +
+
+
+
+
+
Sessions
+
+
+
+
Pull Requests
+
PRTitleSizeCIReviewUnresolvedAge
+
+
+ + + +JSEOF + +echo "Dashboard generated: $DASHBOARD" + +if [ "$REGEN_ONLY" = true ]; then + echo "Regeneration complete." + exit 0 +fi + +# Kill any previous dashboard server +pkill -f 'claude-dashboard-server' 2>/dev/null +sleep 0.3 + +# Start a tiny local server that serves the dashboard + handles /api/open/:session +PORT=9847 +cat > /tmp/claude-dashboard-server.py << 'PYEOF' +import http.server, subprocess, json, os, urllib.parse, signal, sys, re, threading, time + +PORT = 9847 +DASHBOARD = "/tmp/claude-dashboard.html" +regen_lock = threading.Lock() +regen_proc = None +regen_started = 0 + +TTYD_BASE_PORT = 7682 +ttyd_procs = {} # session_name -> {'port': int, 'proc': subprocess.Popen} + +def get_ttyd_port(): + used = {v['port'] for v in ttyd_procs.values()} + port = TTYD_BASE_PORT + while port in used: + port += 1 + return port + +def cleanup_ttyd(): + for info in ttyd_procs.values(): + try: + info['proc'].terminate() + except Exception: + pass + ttyd_procs.clear() + +LOADER_HTML = b""" +Claude Orchestrator + +
+
Refreshing dashboard...
+
+""" + +def get_session_activities(): + """Fast scan of tmux sessions — returns {session_name: activity} using Claude session JSONL files""" + import time, glob + + home = os.path.expanduser('~') + tmux = '/opt/homebrew/bin/tmux' + try: + result = subprocess.run( + [tmux, 'list-sessions', '-F', '#{session_name}'], + capture_output=True, text=True, timeout=5 + ) + sessions = [s for s in result.stdout.strip().split('\n') + if re.match(r'^integrator-', s)] + except Exception: + return {} + + activities = {} + now = time.time() + for s in sessions: + # Get pane working directory directly from tmux (most reliable) + try: + cwd = subprocess.run( + [tmux, 'display-message', '-t', s, '-p', '#{pane_current_path}'], + capture_output=True, text=True, timeout=3 + ).stdout.strip() + except Exception: + cwd = '' + + found_jsonl = False + if cwd: + # Convert CWD to Claude's project dir format: tr '/.' '--' + proj_key = cwd.translate(str.maketrans('/.', '--')) + session_dir = os.path.join(home, '.claude', 'projects', proj_key) + jsonls = sorted(glob.glob(os.path.join(session_dir, '*.jsonl')), + key=os.path.getmtime, reverse=True) + jsonls = [j for j in jsonls if 'agent-' not in os.path.basename(j)] + if jsonls: + found_jsonl = True + jsonl = jsonls[0] + mod_age = now - os.path.getmtime(jsonl) + last_type = '' + try: + with open(jsonl, 'rb') as f: + f.seek(0, 2) + f.seek(max(0, f.tell() - 4096)) + lines = f.read().split(b'\n') + for line in reversed(lines): + line = line.strip() + if line: + last_type = json.loads(line).get('type', '') + break + except Exception: + pass + + if mod_age < 30 and last_type not in ('assistant', 'system'): + activities[s] = 'working' + continue + + # Fallback: check if claude process exists (pane itself or descendants) + try: + pane_pid = subprocess.run( + [tmux, 'list-panes', '-t', s, '-F', '#{pane_pid}'], + capture_output=True, text=True, timeout=3 + ).stdout.strip().split('\n')[0] + if pane_pid: + ps_out = subprocess.run( + ['ps', '-o', 'pid,ppid,comm'], + capture_output=True, text=True, timeout=3 + ).stdout + has_claude = False + # Check if pane process itself is claude + for line in ps_out.strip().split('\n')[1:]: + parts = line.split() + if len(parts) >= 3 and parts[0] == pane_pid and 'claude' in parts[2]: + has_claude = True + break + # Walk descendants + if not has_claude: + pids_to_check = {pane_pid} + for _ in range(3): + next_pids = set() + for line in ps_out.strip().split('\n')[1:]: + parts = line.split() + if len(parts) >= 3 and parts[1] in pids_to_check: + next_pids.add(parts[0]) + if 'claude' in parts[2]: + has_claude = True + if has_claude: + break + pids_to_check = next_pids + if not pids_to_check: + break + if not has_claude: + activities[s] = 'exited' + continue + except Exception: + pass + + activities[s] = 'idle' + return activities + +class Handler(http.server.SimpleHTTPRequestHandler): + def _json(self, code, data): + self.send_response(code) + self.send_header('Content-Type', 'application/json') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def do_GET(self): + if self.path == '/api/sessions': + try: + self._json(200, get_session_activities()) + except Exception as e: + self._json(500, {'error': str(e)}) + elif self.path.startswith('/api/open/'): + session = urllib.parse.unquote(self.path[len('/api/open/'):]) + try: + result = subprocess.run( + [os.path.expanduser('~/open-tmux-session'), session], + capture_output=True, text=True, timeout=10 + ) + self._json(200, {'ok': result.returncode == 0, 'error': result.stderr.strip()}) + except Exception as e: + self._json(500, {'ok': False, 'error': str(e)}) + elif self.path.startswith('/api/terminal/'): + session = urllib.parse.unquote(self.path[len('/api/terminal/'):]) + # Check if ttyd already running for this session + if session in ttyd_procs: + proc = ttyd_procs[session]['proc'] + if proc.poll() is None: + self._json(200, {'ok': True, 'url': f'http://localhost:{ttyd_procs[session]["port"]}'}) + return + else: + del ttyd_procs[session] + # Verify tmux session exists (auto-create for orchestrator) + check = subprocess.run( + ['/opt/homebrew/bin/tmux', 'has-session', '-t', session], + capture_output=True, timeout=5 + ) + if check.returncode != 0: + if session == 'orchestrator': + env = {k: v for k, v in os.environ.items() if k != 'CLAUDECODE'} + subprocess.run( + ['/opt/homebrew/bin/tmux', 'new-session', '-d', '-s', 'orchestrator', + '-c', os.path.expanduser('~'), '-e', 'CLAUDECODE='], + capture_output=True, timeout=5, env=env + ) + # Verify it was created + verify = subprocess.run( + ['/opt/homebrew/bin/tmux', 'has-session', '-t', 'orchestrator'], + capture_output=True, timeout=5 + ) + if verify.returncode != 0: + self._json(500, {'ok': False, 'error': 'failed to create orchestrator session'}) + return + else: + self._json(404, {'ok': False, 'error': 'tmux session not found'}) + return + port = get_ttyd_port() + try: + proc = subprocess.Popen( + ['/opt/homebrew/bin/ttyd', '--writable', '--port', str(port), + '/opt/homebrew/bin/tmux', 'attach-session', '-t', session], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + ttyd_procs[session] = {'port': port, 'proc': proc} + time.sleep(0.3) + if proc.poll() is not None: + del ttyd_procs[session] + self._json(500, {'ok': False, 'error': 'ttyd failed to start'}) + else: + self._json(200, {'ok': True, 'url': f'http://localhost:{port}'}) + except Exception as e: + self._json(500, {'ok': False, 'error': str(e)}) + elif self.path.startswith('/api/remove-pr/'): + session = urllib.parse.unquote(self.path[len('/api/remove-pr/'):]) + try: + meta_file = None + meta_file = os.path.expanduser(f'~/.integrator-sessions/{session}') + if not os.path.exists(meta_file): + self._json(404, {'ok': False, 'error': 'session not found'}) + return + with open(meta_file, 'r') as f: + lines = f.readlines() + new_lines = [l for l in lines if not l.startswith('pr=')] + with open(meta_file, 'w') as f: + f.writelines(new_lines) + self._json(200, {'ok': True}) + except Exception as e: + self._json(500, {'ok': False, 'error': str(e)}) + elif self.path.startswith('/api/kill/'): + session = urllib.parse.unquote(self.path[len('/api/kill/'):]) + try: + # Kill any ttyd for this session + if session in ttyd_procs: + try: + ttyd_procs[session]['proc'].terminate() + except Exception: + pass + del ttyd_procs[session] + # Close iTerm2 tab (before killing tmux, so we can still find it) + # Get TTY of attached tmux client first + tty_result = subprocess.run( + ['/opt/homebrew/bin/tmux', 'list-clients', '-t', session, '-F', '#{client_tty}'], + capture_output=True, text=True, timeout=5 + ) + target_tty = tty_result.stdout.strip().split('\n')[0] if tty_result.stdout.strip() else '' + if target_tty: + # Find iTerm2 tab by TTY + subprocess.run(['osascript', '-e', f''' + tell application "iTerm2" + repeat with aWindow in windows + repeat with aTab in tabs of aWindow + repeat with aSession in sessions of aTab + try + if tty of aSession is equal to "{target_tty}" then + tell aTab to close + return + end if + end try + end repeat + end repeat + end repeat + end tell + '''], capture_output=True, text=True, timeout=5) + else: + # Fallback: try matching by profile name + subprocess.run(['osascript', '-e', f''' + tell application "iTerm2" + repeat with aWindow in windows + repeat with aTab in tabs of aWindow + repeat with aSession in sessions of aTab + try + if profile name of aSession is equal to "{session}" then + tell aTab to close + return + end if + end try + end repeat + end repeat + end repeat + end tell + '''], capture_output=True, text=True, timeout=5) + # Kill tmux session + subprocess.run(['/opt/homebrew/bin/tmux', 'kill-session', '-t', session], + capture_output=True, text=True, timeout=5) + # Archive metadata file (instead of deleting) + meta = os.path.expanduser(f'~/.integrator-sessions/{session}') + if os.path.exists(meta): + archive_dir = os.path.expanduser(f'~/.integrator-sessions/archive') + os.makedirs(archive_dir, exist_ok=True) + ts = time.strftime('%Y%m%d-%H%M%S') + archive_path = os.path.join(archive_dir, f'{session}_{ts}') + import shutil + shutil.move(meta, archive_path) + self._json(200, {'ok': True}) + # Regenerate HTML in background so next reload is fresh + subprocess.Popen([os.path.expanduser('~/claude-dashboard'), '--regen-only'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + self._json(500, {'ok': False, 'error': str(e)}) + elif self.path.startswith('/api/merge/'): + # /api/merge/owner/repo/num + parts = urllib.parse.unquote(self.path[len('/api/merge/'):]).split('/') + if len(parts) == 3: + repo = parts[0] + '/' + parts[1] + num = parts[2] + try: + result = subprocess.run( + ['gh', 'pr', 'merge', num, '--repo', repo, '--squash', '--auto'], + capture_output=True, text=True, timeout=30, + env={**os.environ, 'PATH': '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'} + ) + if result.returncode == 0: + self._json(200, {'ok': True}) + else: + self._json(200, {'ok': False, 'error': result.stderr.strip()}) + except Exception as e: + self._json(500, {'ok': False, 'error': str(e)}) + else: + self._json(400, {'ok': False, 'error': 'bad path, expected /api/merge/owner/repo/num'}) + elif self.path == '/dashboard': + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + with open(DASHBOARD, 'rb') as f: + self.wfile.write(f.read()) + elif self.path == '/api/regen-status': + global regen_proc, regen_started + done = True + if regen_proc is not None: + done = regen_proc.poll() is not None + elapsed = time.time() - regen_started if regen_started else 0 + self._json(200, {'done': done, 'elapsed': round(elapsed, 1)}) + elif self.path.split('?')[0] in ('/', '/index.html'): + # Always trigger regeneration and show loader + with regen_lock: + if regen_proc is None or regen_proc.poll() is not None: + regen_started = time.time() + regen_proc = subprocess.Popen( + [os.path.expanduser('~/claude-dashboard'), '--regen-only'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(LOADER_HTML) + else: + self.send_error(404) + + def do_POST(self): + if self.path.startswith('/api/notes/'): + session = urllib.parse.unquote(self.path[len('/api/notes/'):]) + length = int(self.headers.get('Content-Length', 0)) + notes = self.rfile.read(length).decode('utf-8') if length else '' + try: + # Find the metadata file + meta_file = None + meta_file = os.path.expanduser(f'~/.integrator-sessions/{session}') + if not os.path.exists(meta_file): + self._json(404, {'ok': False, 'error': 'session not found'}) + return + # Read existing metadata, update notes line + with open(meta_file, 'r') as f: + lines = f.readlines() + new_lines = [l for l in lines if not l.startswith('notes=')] + if notes.strip(): + new_lines.append(f'notes={notes.strip()}\n') + with open(meta_file, 'w') as f: + f.writelines(new_lines) + self._json(200, {'ok': True}) + except Exception as e: + self._json(500, {'ok': False, 'error': str(e)}) + elif self.path.startswith('/api/send/'): + session = urllib.parse.unquote(self.path[len('/api/send/'):]) + length = int(self.headers.get('Content-Length', 0)) + message = self.rfile.read(length).decode('utf-8') if length else '' + if not message: + self._json(400, {'ok': False, 'error': 'empty message'}) + return + try: + result = subprocess.run( + [os.path.expanduser('~/send-to-session'), session, message], + capture_output=True, text=True, timeout=30 + ) + self._json(200, {'ok': result.returncode == 0, 'error': result.stderr.strip()}) + except Exception as e: + self._json(500, {'ok': False, 'error': str(e)}) + else: + self._json(404, {'ok': False, 'error': 'not found'}) + + def log_message(self, fmt, *args): + pass # quiet + +def _shutdown(*a): + cleanup_ttyd() + sys.exit(0) +signal.signal(signal.SIGTERM, _shutdown) +signal.signal(signal.SIGINT, _shutdown) +import atexit +atexit.register(cleanup_ttyd) +print(f"Dashboard server on http://localhost:{PORT}") +http.server.HTTPServer(('127.0.0.1', PORT), Handler).serve_forever() +PYEOF + +python3 /tmp/claude-dashboard-server.py & +SERVERPID=$! +echo "$SERVERPID" > /tmp/claude-dashboard-server.pid +sleep 0.5 + +if [ "$NO_OPEN" = false ]; then + open "http://localhost:$PORT" + echo "Opened http://localhost:$PORT" +fi +echo "Server PID: $SERVERPID (kill with: pkill -f claude-dashboard-server)" diff --git a/scripts/claude-integrator-session b/scripts/claude-integrator-session new file mode 100755 index 000000000..6a677e614 --- /dev/null +++ b/scripts/claude-integrator-session @@ -0,0 +1,242 @@ +#!/bin/bash +# claude-integrator-session-v2 - Worktree-based session manager for integrator + +set -e + +MAIN_REPO=~/integrator +WORKTREE_DIR=~/.worktrees/integrator +SESSION_DIR=~/.integrator-sessions +ENV_TEMPLATE=~/.env-templates/integrator +DEFAULT_BRANCH="next" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +usage() { + echo "Usage: ~/claude-integrator-session-v2 [args]" + echo "" + echo "Commands:" + echo " new [LINEAR-ID] [--open] Create new session (optionally with Linear ticket)" + echo " ls List all sessions" + echo " attach Attach to session" + echo " kill Kill session and remove worktree" + echo " cleanup Kill sessions where PR is merged or ticket is done" + echo " update Update current session metadata" + exit 1 +} + +get_next_session_number() { + local max=0 + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^integrator-[0-9]*$" | sed 's/integrator-//'); do + if [ "$session" -gt "$max" ] 2>/dev/null; then + max=$session + fi + done + echo $((max + 1)) +} + +cmd_new() { + local linear_id="" + local open_warp=false + + # Parse args + for arg in "$@"; do + if [ "$arg" = "--open" ]; then + open_warp=true + elif [ -z "$linear_id" ] && [ "$arg" != "--open" ]; then + linear_id="$arg" + fi + done + + local session_num=$(get_next_session_number) + local session_name="integrator-$session_num" + local worktree_path="$WORKTREE_DIR/$session_name" + + echo -e "${BLUE}Creating session: $session_name${NC}" + + cd "$MAIN_REPO" + git fetch origin --quiet + + if [ -n "$linear_id" ]; then + local branch_name="feat/$linear_id" + git worktree add -b "$branch_name" "$worktree_path" "origin/$DEFAULT_BRANCH" 2>/dev/null || \ + git worktree add "$worktree_path" "origin/$DEFAULT_BRANCH" + else + git worktree add "$worktree_path" "origin/$DEFAULT_BRANCH" --detach + fi + + echo -e "${GREEN}✓ Created worktree at $worktree_path${NC}" + + # Symlink shared resources from main repo + [ -f "$ENV_TEMPLATE/.env" ] && ln -sf "$ENV_TEMPLATE/.env" "$worktree_path/.env" + [ -f "$ENV_TEMPLATE/.envrc" ] && ln -sf "$ENV_TEMPLATE/.envrc" "$worktree_path/.envrc" && \ + (cd "$worktree_path" && direnv allow 2>/dev/null || true) + [ -d "$MAIN_REPO/.venv" ] && ln -sf "$MAIN_REPO/.venv" "$worktree_path/.venv" + [ -f "$MAIN_REPO/CLAUDE.local.md" ] && ln -sf "$MAIN_REPO/CLAUDE.local.md" "$worktree_path/CLAUDE.local.md" + [ -d "$MAIN_REPO/.claude" ] && rm -rf "$worktree_path/.claude" && ln -s "$MAIN_REPO/.claude" "$worktree_path/.claude" + + # Add Rube MCP before starting session + (cd "$worktree_path" && claude mcp add rube --transport http https://rube.app/mcp &>/dev/null) || true + + # Create tmux session and start Claude (unset CLAUDECODE to avoid nested session detection) + tmux new-session -d -s "$session_name" -c "$worktree_path" -e "INTEGRATOR_SESSION=$session_name" -e "DIRENV_LOG_FORMAT=" + tmux send-keys -t "$session_name" "unset CLAUDECODE && claude --dangerously-skip-permissions" Enter + + # Create metadata file + mkdir -p "$SESSION_DIR" + cat > "$SESSION_DIR/$session_name" << EOF +worktree=$worktree_path +branch=$(cd "$worktree_path" && git branch --show-current || echo "detached") +status=starting +EOF + [ -n "$linear_id" ] && echo "issue=https://linear.app/composio/issue/$linear_id" >> "$SESSION_DIR/$session_name" + + echo -e "${GREEN}✓ Created tmux session: $session_name${NC}" + echo -e "${GREEN}✓ Started Claude in session${NC}" + echo "" + echo "Attach with: tmux attach -t $session_name" + echo "Or open in iTerm2: ~/open-iterm-tab $session_name" + + # Clean output for scripting (no ANSI codes) + echo "SESSION=$session_name" + + # Open in iTerm2 if requested + if [ "$open_warp" = true ]; then + ~/open-iterm-tab "$session_name" + fi +} + +cmd_ls() { + echo -e "${BLUE}Integrator Sessions:${NC}" + echo "" + + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^integrator-" | sort -V); do + local meta_file="$SESSION_DIR/$session" + local worktree="" branch="" status="" pr="" + + if [ -f "$meta_file" ]; then + worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + branch=$(grep "^branch=" "$meta_file" 2>/dev/null | cut -d= -f2-) + status=$(grep "^status=" "$meta_file" 2>/dev/null | cut -d= -f2-) + pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2-) + fi + + [ -d "$worktree" ] && branch=$(cd "$worktree" && git branch --show-current 2>/dev/null || echo "$branch") + + echo -e "${GREEN}$session${NC}" + [ -n "$branch" ] && echo " Branch: $branch" + [ -n "$status" ] && echo " Status: $status" + [ -n "$pr" ] && echo " PR: $pr" + echo "" + done +} + +cmd_attach() { + [ -z "$1" ] && echo "Usage: ~/claude-integrator-session-v2 attach " && exit 1 + tmux attach -t "$1" +} + +cmd_kill() { + local session="$1" + [ -z "$session" ] && echo "Usage: ~/claude-integrator-session-v2 kill " && exit 1 + + local meta_file="$SESSION_DIR/$session" + local worktree="" + [ -f "$meta_file" ] && worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + + # Close iTerm tab BEFORE killing tmux (tab name changes after tmux dies) + osascript -e " + tell application \"iTerm2\" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if name of s contains \"$session\" then + close t + return + end if + end repeat + end repeat + end repeat + end tell + " 2>/dev/null && echo -e "${GREEN}✓ Closed iTerm tab: $session${NC}" + + tmux kill-session -t "$session" 2>/dev/null && echo -e "${GREEN}✓ Killed tmux session: $session${NC}" + + if [ -n "$worktree" ] && [ -d "$worktree" ]; then + cd "$MAIN_REPO" + git worktree remove --force "$worktree" 2>/dev/null && echo -e "${GREEN}✓ Removed worktree: $worktree${NC}" + fi + + # Archive metadata instead of deleting + if [ -f "$meta_file" ]; then + mkdir -p "$SESSION_DIR/archive" + mv "$meta_file" "$SESSION_DIR/archive/${session}_$(date +%Y%m%d-%H%M%S)" + echo -e "${GREEN}✓ Archived metadata${NC}" + fi +} + +cmd_cleanup() { + echo "Checking for completed sessions..." + + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^integrator-"); do + local meta_file="$SESSION_DIR/$session" + local pr="" issue="" + + [ -f "$meta_file" ] && { + pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2-) + issue=$(grep "^issue=" "$meta_file" 2>/dev/null | cut -d= -f2-) + } + + if [ -n "$pr" ]; then + local pr_num=$(echo "$pr" | grep -o '[0-9]*$') + if [ -n "$pr_num" ]; then + local state=$(gh pr view "$pr_num" --repo ComposioHQ/integrator --json state -q '.state' 2>/dev/null) + [ "$state" = "MERGED" ] && echo -e "${YELLOW}PR #$pr_num merged - killing $session${NC}" && cmd_kill "$session" && continue + fi + fi + + # Check Linear ticket status if we have an issue URL + if [ -n "$issue" ] && [ -n "${LINEAR_API_KEY:-}" ]; then + local ticket_id=$(echo "$issue" | grep -o 'INT-[0-9]*') + if [ -n "$ticket_id" ]; then + local ticket_state=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"{ issueSearch(filter: { identifier: { eq: \\\"$ticket_id\\\" } }) { nodes { state { type } } } }\"}" \ + 2>/dev/null | jq -r '.data.issueSearch.nodes[0].state.type // empty' 2>/dev/null) + if [ "$ticket_state" = "completed" ] || [ "$ticket_state" = "canceled" ]; then + echo -e "${YELLOW}$ticket_id $ticket_state - killing $session${NC}" + cmd_kill "$session" && continue + fi + fi + fi + done + echo "Cleanup complete." +} + +cmd_update() { + local session="${INTEGRATOR_SESSION:-}" + [ -z "$session" ] && echo "Error: Not in an integrator session" && exit 1 + + local meta_file="$SESSION_DIR/$session" + local worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + [ -z "$worktree" ] || [ ! -d "$worktree" ] && worktree="$PWD" + + local branch=$(cd "$worktree" && git branch --show-current 2>/dev/null) + [ -n "$branch" ] && sed -i '' "s|^branch=.*|branch=$branch|" "$meta_file" 2>/dev/null + + echo "Updated metadata for $session" +} + +case "${1:-}" in + new) shift; cmd_new "$@" ;; + ls) cmd_ls ;; + attach) cmd_attach "$2" ;; + kill) cmd_kill "$2" ;; + cleanup) cmd_cleanup ;; + update) cmd_update ;; + *) usage ;; +esac diff --git a/scripts/claude-open-all b/scripts/claude-open-all new file mode 100755 index 000000000..ba6162806 --- /dev/null +++ b/scripts/claude-open-all @@ -0,0 +1,133 @@ +#!/bin/bash + +# Open iTerm2 tabs for all active sessions (or a specific project) +# Usage: ~/claude-open-all [--new-window] [integrator|splitly|all] + +NEW_WINDOW=false +PROJECT="" + +# Parse arguments +for arg in "$@"; do + case "$arg" in + --new-window|-w) + NEW_WINDOW=true + ;; + integrator|i|splitly|s|safesplit|all) + PROJECT="$arg" + ;; + *) + echo "Unknown argument: $arg" + echo "Usage: ~/claude-open-all [--new-window] [integrator|splitly|all]" + exit 1 + ;; + esac +done + +PROJECT="${PROJECT:-all}" + +get_sessions() { + local prefix="$1" + tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${prefix}-" | sort +} + +open_session_new_window() { + local session="$1" + osascript -e " + tell application \"iTerm2\" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + set name to \"$session\" + write text \"printf '\\\\033]0;$session\\\\007' && tmux attach -t $session\" + end tell + end tell + " +} + +open_session_new_tab() { + local session="$1" + osascript -e " + tell application \"iTerm2\" + activate + tell current window + create tab with default profile + tell current session + set name to \"$session\" + write text \"printf '\\\\033]0;$session\\\\007' && tmux attach -t $session\" + end tell + end tell + end tell + " +} + +switch_to_existing_tab() { + local session="$1" + osascript -e " + tell application \"iTerm2\" + activate + repeat with aWindow in windows + repeat with aTab in tabs of aWindow + repeat with aSession in sessions of aTab + try + if profile name of aSession is equal to \"$session\" then + select aWindow + select aTab + return \"FOUND\" + end if + end try + end repeat + end repeat + end repeat + return \"NOT_FOUND\" + end tell + " +} + +SESSIONS=() + +case "$PROJECT" in + integrator|i) + SESSIONS=($(get_sessions "integrator")) + echo "Opening ${#SESSIONS[@]} integrator sessions..." + ;; + splitly|s|safesplit) + SESSIONS=($(get_sessions "splitly")) + echo "Opening ${#SESSIONS[@]} splitly sessions..." + ;; + all|"") + SESSIONS=($(get_sessions "integrator") $(get_sessions "splitly")) + echo "Opening ${#SESSIONS[@]} total sessions..." + ;; +esac + +if [ ${#SESSIONS[@]} -eq 0 ]; then + echo "No sessions found." + exit 0 +fi + +if [ "$NEW_WINDOW" = true ]; then + echo "(in new window)" +fi +echo "" + +FIRST=true +for session in "${SESSIONS[@]}"; do + # Check if tab already exists + existing=$(switch_to_existing_tab "$session" 2>/dev/null) + + if [ "$existing" = "FOUND" ]; then + echo " Switched to existing: $session" + elif [ "$NEW_WINDOW" = true ] && [ "$FIRST" = true ]; then + echo " Opening (new window): $session" + open_session_new_window "$session" + FIRST=false + else + echo " Opening (new tab): $session" + open_session_new_tab "$session" + fi + sleep 0.3 +done + +echo "" +echo "Done! Opened ${#SESSIONS[@]} iTerm2 tabs." +echo "Use Cmd+Shift+[ or ] to switch between tabs." diff --git a/scripts/claude-review-check b/scripts/claude-review-check new file mode 100755 index 000000000..70b447418 --- /dev/null +++ b/scripts/claude-review-check @@ -0,0 +1,165 @@ +#!/bin/bash + +# Check all sessions with PRs for review comments and trigger agents to address them +# Usage: ~/claude-review-check [integrator|splitly] + +PROJECT="${1:-splitly}" + +case "$PROJECT" in + integrator|i) + SESSION_PREFIX="integrator" + SESSION_DATA_DIR="$HOME/.integrator-sessions" + ;; + splitly|s|safesplit) + SESSION_PREFIX="splitly" + SESSION_DATA_DIR="$HOME/.splitly-sessions" + ;; + *) + echo "Usage: ~/claude-review-check [integrator|splitly]" + exit 1 + ;; +esac + +echo "╔══════════════════════════════════════════════════════════════════════════════╗" +echo "║ PR REVIEW COMMENT CHECKER ║" +echo "╚══════════════════════════════════════════════════════════════════════════════╝" +echo "" +echo "Project: $PROJECT" +echo "" + +# Get all sessions +sessions=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${SESSION_PREFIX}-" | sort) + +if [ -z "$sessions" ]; then + echo "No active sessions found." + exit 0 +fi + +TRIGGERED=() +SKIPPED_NO_PR=() +SKIPPED_NO_COMMENTS=() +ERRORS=() + +for session in $sessions; do + data_file="$SESSION_DATA_DIR/$session" + + if [ ! -f "$data_file" ]; then + SKIPPED_NO_PR+=("$session (no metadata)") + continue + fi + + pr_url=$(grep "^pr=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + branch=$(grep "^branch=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + + if [ -z "$pr_url" ]; then + SKIPPED_NO_PR+=("$session") + continue + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📍 $session" + echo " Branch: ${branch:--}" + echo " PR: $pr_url" + + # Extract PR number and repo from URL + # Format: https://github.com/owner/repo/pull/123 + if [[ "$pr_url" =~ github\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then + owner="${BASH_REMATCH[1]}" + repo="${BASH_REMATCH[2]}" + pr_number="${BASH_REMATCH[3]}" + else + echo " ⚠️ Could not parse PR URL" + ERRORS+=("$session: invalid PR URL") + continue + fi + + # Check for review comments using gh + # Get pending review comments (not resolved) + echo " Checking for review comments..." + + # Get PR review comments + review_data=$(gh pr view "$pr_number" --repo "$owner/$repo" --json reviewDecision,reviews,comments 2>/dev/null) + + if [ $? -ne 0 ]; then + echo " ⚠️ Failed to fetch PR data" + ERRORS+=("$session: gh command failed") + continue + fi + + # Check review decision + review_decision=$(echo "$review_data" | jq -r '.reviewDecision // "NONE"') + + # Count reviews that requested changes + changes_requested=$(echo "$review_data" | jq '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | length') + + # Get review comments (these are inline code comments) + # We'll use the gh api to get pending review comments + pending_comments=$(gh api "repos/$owner/$repo/pulls/$pr_number/comments" --jq '[.[] | select(.in_reply_to_id == null)] | length' 2>/dev/null || echo "0") + + # Also check for regular PR comments that might be review feedback + pr_comments=$(gh pr view "$pr_number" --repo "$owner/$repo" --json comments --jq '.comments | length' 2>/dev/null || echo "0") + + echo " Review decision: $review_decision" + echo " Review comments: $pending_comments" + echo " PR comments: $pr_comments" + + # Determine if we need to trigger the agent + should_trigger=false + trigger_reason="" + + if [ "$review_decision" = "CHANGES_REQUESTED" ]; then + should_trigger=true + trigger_reason="Changes requested" + elif [ "$changes_requested" -gt 0 ]; then + should_trigger=true + trigger_reason="$changes_requested review(s) requested changes" + elif [ "$pending_comments" -gt 0 ]; then + should_trigger=true + trigger_reason="$pending_comments review comment(s) to address" + fi + + if [ "$should_trigger" = true ]; then + echo " 🔔 $trigger_reason - triggering agent..." + + # Send prompt to the tmux session + prompt="There are review comments on your PR ($pr_url). Please check the PR review comments using 'gh pr view $pr_number --repo $owner/$repo --comments' and 'gh api repos/$owner/$repo/pulls/$pr_number/comments', address each comment, commit and push your fixes, then reply to the comments indicating what you fixed." + + # Send to tmux - first send Ctrl+C to interrupt if busy, then the prompt + tmux send-keys -t "$session" C-c 2>/dev/null + sleep 0.5 + tmux send-keys -t "$session" "$prompt" Enter + + TRIGGERED+=("$session: $trigger_reason") + echo " ✅ Agent triggered" + else + echo " ✓ No pending review comments" + SKIPPED_NO_COMMENTS+=("$session") + fi +done + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "📊 SUMMARY" +echo " Triggered: ${#TRIGGERED[@]}" +echo " No PR: ${#SKIPPED_NO_PR[@]}" +echo " No comments: ${#SKIPPED_NO_COMMENTS[@]}" +echo " Errors: ${#ERRORS[@]}" + +if [ ${#TRIGGERED[@]} -gt 0 ]; then + echo "" + echo "Triggered sessions:" + for item in "${TRIGGERED[@]}"; do + echo " • $item" + done +fi + +if [ ${#ERRORS[@]} -gt 0 ]; then + echo "" + echo "Errors:" + for item in "${ERRORS[@]}"; do + echo " • $item" + done +fi + +echo "" diff --git a/scripts/claude-session-status b/scripts/claude-session-status new file mode 100755 index 000000000..db51beafe --- /dev/null +++ b/scripts/claude-session-status @@ -0,0 +1,87 @@ +#!/bin/bash +# Usage: ~/claude-session-status [session-pattern] +# Shows status of Claude sessions: working, idle, or blocked + +PATTERN="${1:-integrator-}" + +echo "Session Status Report - $(date '+%H:%M:%S')" +echo "===========================================" +echo "" + +working=() +idle=() +blocked=() +not_running=() + +for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^$PATTERN" | sort -V); do + # Capture last 15 lines of the pane + output=$(tmux capture-pane -t "$session" -p -S -15 2>/dev/null) + + if [[ -z "$output" ]]; then + not_running+=("$session") + continue + fi + + # Check for active processing indicators + if echo "$output" | grep -qE "Thinking|Roosting|Garnishing|Levitating|Baking|Whirring|⏺|esc to interrupt"; then + # Get what it's doing (last status line) + status=$(echo "$output" | grep -oE "(Thinking|Roosting|Garnishing|Levitating|Baking|Whirring|Reading|Writing|Editing|Running).*" | tail -1 | cut -c1-50) + working+=("$session|${status:-processing...}") + # Check for prompt (idle/waiting for input) + elif echo "$output" | grep -qE "^❯\s*$|^❯$"; then + # Get last assistant message snippet + last_msg=$(echo "$output" | grep -v "^❯" | grep -v "^─" | grep -v "^$" | tail -1 | cut -c1-60) + idle+=("$session|${last_msg:-ready}") + # Check for error/blocked states + elif echo "$output" | grep -qiE "error|failed|permission denied|blocked|quota|rate limit"; then + error=$(echo "$output" | grep -iE "error|failed|permission denied|blocked|quota|rate limit" | tail -1 | cut -c1-60) + blocked+=("$session|${error:-unknown error}") + # Check for yes/no prompts or questions + elif echo "$output" | grep -qE "\[y/N\]|\[Y/n\]|Continue\?|Proceed\?"; then + blocked+=("$session|waiting for confirmation") + else + # Unknown state - probably idle + last_line=$(echo "$output" | grep -v "^$" | tail -1 | cut -c1-60) + idle+=("$session|${last_line:-unknown}") + fi +done + +# Print results +if [[ ${#working[@]} -gt 0 ]]; then + echo "🔄 WORKING (${#working[@]})" + for item in "${working[@]}"; do + session="${item%%|*}" + status="${item#*|}" + printf " %-20s %s\n" "$session" "$status" + done + echo "" +fi + +if [[ ${#idle[@]} -gt 0 ]]; then + echo "✅ IDLE/DONE (${#idle[@]})" + for item in "${idle[@]}"; do + session="${item%%|*}" + status="${item#*|}" + printf " %-20s %s\n" "$session" "$status" + done + echo "" +fi + +if [[ ${#blocked[@]} -gt 0 ]]; then + echo "⚠️ BLOCKED/ERROR (${#blocked[@]})" + for item in "${blocked[@]}"; do + session="${item%%|*}" + status="${item#*|}" + printf " %-20s %s\n" "$session" "$status" + done + echo "" +fi + +if [[ ${#not_running[@]} -gt 0 ]]; then + echo "💤 NOT RUNNING (${#not_running[@]})" + printf " %s\n" "${not_running[@]}" + echo "" +fi + +echo "===========================================" +echo "Total: $((${#working[@]} + ${#idle[@]} + ${#blocked[@]})) active sessions" diff --git a/scripts/claude-spawn b/scripts/claude-spawn new file mode 100755 index 000000000..f70d3c691 --- /dev/null +++ b/scripts/claude-spawn @@ -0,0 +1,119 @@ +#!/bin/bash + +# Claude Session Spawner +# Opens a new iTerm2 window and starts a Claude session +# Usage: ~/claude-spawn [integrator|splitly] [issue-url-or-number] + +PROJECT="${1:-}" +ISSUE="${2:-}" + +show_help() { + cat << 'EOF' +CLAUDE-SPAWN(1) User Commands CLAUDE-SPAWN(1) + +NAME + claude-spawn - Spawn a new Claude session in a new iTerm2 window + +SYNOPSIS + ~/claude-spawn [issue] + +DESCRIPTION + Opens a new iTerm2 terminal window and starts a Claude Code session for the + specified project. Useful when you want to spawn work without leaving + your current terminal. + +ARGUMENTS + project Required. One of: + integrator - Start an integrator session (Linear-based) + splitly - Start a splitly/SafeSplit session (GitHub-based) + i - Alias for integrator + s - Alias for splitly + + issue Optional. Issue to work on: + - Linear URL for integrator (e.g., https://linear.app/team/issue/INT-123) + - GitHub issue number or URL for splitly (e.g., 294) + +EXAMPLES + # Spawn a new integrator session (auto-picks checkout) + ~/claude-spawn integrator + + # Spawn integrator session for a Linear ticket + ~/claude-spawn integrator https://linear.app/composio/issue/INT-1020 + + # Spawn splitly session for GitHub issue #294 + ~/claude-spawn splitly 294 + + # Using aliases + ~/claude-spawn i INT-1020 + ~/claude-spawn s 294 + +REQUIREMENTS + - iTerm2 must be installed + +SEE ALSO + ~/claude-integrator-session-v2 + ~/claude-splitly-session-v2 + ~/claude-status + + 2026-02-01 CLAUDE-SPAWN(1) +EOF +} + +# Handle help +if [[ "$PROJECT" == "help" || "$PROJECT" == "--help" || "$PROJECT" == "-h" ]]; then + show_help + exit 0 +fi + +# Validate project +case "$PROJECT" in + integrator|i) + SESSION_SCRIPT="$HOME/claude-integrator-session-v2" + PROJECT_NAME="integrator" + ;; + splitly|s|safesplit) + SESSION_SCRIPT="$HOME/claude-splitly-session-v2" + PROJECT_NAME="splitly" + ;; + "") + echo "Usage: ~/claude-spawn [issue]" + echo "" + echo "Examples:" + echo " ~/claude-spawn integrator" + echo " ~/claude-spawn integrator https://linear.app/composio/issue/INT-1020" + echo " ~/claude-spawn splitly 294" + echo "" + echo "Run '~/claude-spawn help' for more information." + exit 1 + ;; + *) + echo "Unknown project: $PROJECT" + echo "Use 'integrator' (or 'i') or 'splitly' (or 's')" + exit 1 + ;; +esac + +# Build the command to run in the new window +if [ -n "$ISSUE" ]; then + COMMAND="$SESSION_SCRIPT new '$ISSUE'" +else + COMMAND="$SESSION_SCRIPT new" +fi + +echo "Spawning $PROJECT_NAME session in new iTerm2 window..." +if [ -n "$ISSUE" ]; then + echo " Issue: $ISSUE" +fi + +# Open new iTerm2 window and run the command +osascript -e " +tell application \"iTerm2\" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + write text \"$COMMAND\" + end tell +end tell +" + +echo "Done! Check your iTerm2 windows." diff --git a/scripts/claude-spawn-on-branch b/scripts/claude-spawn-on-branch new file mode 100755 index 000000000..e0829db78 --- /dev/null +++ b/scripts/claude-spawn-on-branch @@ -0,0 +1,77 @@ +#!/bin/bash +# Usage: ~/claude-spawn-on-branch [--open] +# Creates session on branch - reuses existing worktree if one exists for the branch + +set -e + +PROJECT="$1" +BRANCH="$2" +PROMPT="$3" +OPEN_TAB=false +[ "$4" = "--open" ] && OPEN_TAB=true + +# Normalize project name +case "$PROJECT" in + i|integrator) + PROJECT="integrator" + WORKTREE_BASE=~/.worktrees/integrator + MAIN_REPO=~/secondary_checkouts/integrator-1 + ;; + s|splitly) + PROJECT="splitly" + WORKTREE_BASE=~/.worktrees/splitly + MAIN_REPO=~/projects/SafeSplit + ;; + *) echo "Usage: ~/claude-spawn-on-branch [--open]"; exit 1 ;; +esac + +[ -z "$BRANCH" ] || [ -z "$PROMPT" ] && { echo "Usage: ~/claude-spawn-on-branch [--open]"; exit 1; } + +# Check if worktree already exists for this branch +cd "$MAIN_REPO" +git fetch origin -q + +EXISTING_WORKTREE=$(git worktree list | grep "\[$BRANCH\]" | awk '{print $1}' | head -1) + +if [ -n "$EXISTING_WORKTREE" ]; then + # Reuse existing worktree - name session as -N + OWNER_SESSION=$(basename "$EXISTING_WORKTREE") + + # Find next sub-session number for this worktree + MAX_SUB=1 + for s in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^${OWNER_SESSION}-[0-9]*$" | sed "s/${OWNER_SESSION}-//"); do + [ "$s" -gt "$MAX_SUB" ] 2>/dev/null && MAX_SUB=$s + done + SESSION="${OWNER_SESSION}-$((MAX_SUB + 1))" + + tmux new-session -d -s "$SESSION" -c "$EXISTING_WORKTREE" + tmux send-keys -t "$SESSION" "claude --dangerously-skip-permissions" Enter + + echo "Reusing worktree: $EXISTING_WORKTREE" +else + # Create new session (which creates new worktree) + output=$(~/claude-${PROJECT}-session new 2>&1) + SESSION=$(echo "$output" | grep "^SESSION=" | cut -d= -f2) + + [ -z "$SESSION" ] && { echo "Failed to create session"; echo "$output"; exit 1; } + + cd "$WORKTREE_BASE/$SESSION" && git checkout "$BRANCH" -q && git pull origin "$BRANCH" -q 2>/dev/null || true +fi + +# Open tab if requested +[ "$OPEN_TAB" = true ] && ~/open-iterm-tab "$SESSION" + +# Wait for Claude prompt +for i in {1..60}; do + if tmux capture-pane -t "$SESSION" -p 2>/dev/null | grep -q "^❯"; then + break + fi + sleep 0.5 +done + +sleep 0.2 +tmux send-keys -t "$SESSION" "$PROMPT" +sleep 0.1 +tmux send-keys -t "$SESSION" Enter + +echo "✓ $SESSION on $BRANCH - prompt sent" diff --git a/scripts/claude-spawn-with-context b/scripts/claude-spawn-with-context new file mode 100755 index 000000000..193edc449 --- /dev/null +++ b/scripts/claude-spawn-with-context @@ -0,0 +1,109 @@ +#!/bin/bash +# Spawn a Claude session with custom context/prompt (v2 - worktree-based) +# Delegates to session managers for worktree creation, symlinks, and MCP setup. +# Usage: ~/claude-spawn-with-context [--open] +# +# Examples: +# ~/claude-spawn-with-context integrator INT-1020 /tmp/prompt.txt --open +# ~/claude-spawn-with-context i INT-1020 /tmp/prompt.txt --open +# ~/claude-spawn-with-context splitly 295 /tmp/prompt.txt +# ~/claude-spawn-with-context s 295 /tmp/prompt.txt + +set -e + +# Parse args - support --open anywhere +PROJECT="" +TICKET="" +PROMPT_FILE="" +OPEN_TAB=false + +for arg in "$@"; do + if [ "$arg" = "--open" ]; then + OPEN_TAB=true + elif [ -z "$PROJECT" ]; then + PROJECT="$arg" + elif [ -z "$TICKET" ]; then + TICKET="$arg" + elif [ -z "$PROMPT_FILE" ]; then + PROMPT_FILE="$arg" + fi +done + +if [ -z "$PROJECT" ] || [ -z "$TICKET" ] || [ -z "$PROMPT_FILE" ]; then + echo "Usage: ~/claude-spawn-with-context [--open]" + echo "" + echo " project: integrator (or i) / splitly (or s)" + echo " ticket-id: Linear ticket (INT-XXXX) or GitHub issue number" + echo " prompt-file: path to file containing the prompt" + echo " --open: open in new iTerm2 tab after spawning" + echo "" + echo "Examples:" + echo " ~/claude-spawn-with-context i INT-1020 /tmp/prompt.txt --open" + echo " ~/claude-spawn-with-context s 295 /tmp/prompt.txt" + exit 1 +fi + +if [ ! -f "$PROMPT_FILE" ]; then + echo "Error: prompt file not found: $PROMPT_FILE" + exit 1 +fi + +# Determine session manager +case "$PROJECT" in + integrator|i) + PROJECT="integrator" + SESSION_MANAGER="$HOME/claude-integrator-session" + SESSIONS_DIR="$HOME/.integrator-sessions" + ;; + splitly|s|safesplit) + PROJECT="splitly" + SESSION_MANAGER="$HOME/claude-splitly-session" + SESSIONS_DIR="$HOME/.splitly-sessions" + ;; + *) + echo "Error: unknown project '$PROJECT'. Use 'integrator' (i) or 'splitly' (s)." + exit 1 + ;; +esac + +echo "Creating session via $PROJECT session manager..." + +# Delegate session creation to the session manager (handles worktree, symlinks, MCP, tmux, Claude) +OPEN_FLAG="" +[ "$OPEN_TAB" = true ] && OPEN_FLAG="--open" +output=$("$SESSION_MANAGER" new "$TICKET" $OPEN_FLAG 2>&1) +echo "$output" + +# Extract session name from manager output +SESSION=$(echo "$output" | grep "^SESSION=" | cut -d= -f2) + +if [ -z "$SESSION" ]; then + echo "Error: Could not determine session name from session manager output!" + exit 1 +fi + +# Update metadata with ticket info +if [ "$PROJECT" = "integrator" ]; then + grep -q "^issue=" "$SESSIONS_DIR/$SESSION" 2>/dev/null || \ + echo "issue=https://linear.app/composio/issue/$TICKET" >> "$SESSIONS_DIR/$SESSION" + grep -q "^summary=" "$SESSIONS_DIR/$SESSION" 2>/dev/null && \ + sed -i '' "s|^summary=.*|summary=Starting work on $TICKET (custom context)|" "$SESSIONS_DIR/$SESSION" || \ + echo "summary=Starting work on $TICKET (custom context)" >> "$SESSIONS_DIR/$SESSION" +else + grep -q "^issue=" "$SESSIONS_DIR/$SESSION" 2>/dev/null || \ + echo "issue=https://github.com/UniverseOfThings/SafeSplit/issues/$TICKET" >> "$SESSIONS_DIR/$SESSION" +fi + +# Wait for Claude to start, then send the custom prompt +sleep 2 + +# Use load-buffer + paste-buffer for multi-line prompts (handles special chars safely) +tmux load-buffer "$PROMPT_FILE" +tmux paste-buffer -t "$SESSION" +sleep 0.3 +tmux send-keys -t "$SESSION" Enter + +echo "" +echo "Custom prompt sent to $SESSION" +echo "Attach with: tmux attach -t $SESSION" +echo "Done!" diff --git a/scripts/claude-spawn-with-prompt b/scripts/claude-spawn-with-prompt new file mode 100755 index 000000000..0c2b810d5 --- /dev/null +++ b/scripts/claude-spawn-with-prompt @@ -0,0 +1,32 @@ +#!/bin/bash +# Usage: ~/claude-spawn-with-prompt [--open] +set -e + +PROJECT="$1"; PROMPT="$2"; OPEN_TAB=false +[[ "$3" == "--open" ]] && OPEN_TAB=true + +case "$PROJECT" in + i|integrator) SCRIPT=~/claude-integrator-session ;; + s|splitly) SCRIPT=~/claude-splitly-session ;; + *) echo "Usage: ~/claude-spawn-with-prompt \"prompt\" [--open]"; exit 1 ;; +esac + +[[ -z "$PROMPT" ]] && { echo "Usage: ~/claude-spawn-with-prompt \"prompt\" [--open]"; exit 1; } + +# Create session WITHOUT --open to avoid race condition +# We'll open the tab AFTER sending the message +SESSION=$($SCRIPT new 2>&1 | grep "^SESSION=" | cut -d= -f2) +[[ -z "$SESSION" ]] && { echo "Failed to create session"; exit 1; } + +# Wait for Claude prompt to appear (up to 6 seconds) +for _ in {1..30}; do tmux capture-pane -t "$SESSION" -p 2>/dev/null | grep -q "^❯" && break; sleep 0.2; done + +# Send the message +~/send-to-session "$SESSION" "$PROMPT" + +# NOW open the iTerm tab if requested (after message is sent) +if [[ "$OPEN_TAB" == true ]]; then + ~/open-iterm-tab "$SESSION" +fi + +echo "✓ $SESSION" diff --git a/scripts/claude-splitly-session b/scripts/claude-splitly-session new file mode 100755 index 000000000..e8ad482d9 --- /dev/null +++ b/scripts/claude-splitly-session @@ -0,0 +1,216 @@ +#!/bin/bash +# claude-splitly-session-v2 - Worktree-based session manager for splitly + +set -e + +MAIN_REPO=~/projects/SafeSplit +WORKTREE_DIR=~/.worktrees/splitly +SESSION_DIR=~/.splitly-sessions +ENV_TEMPLATE=~/.env-templates/splitly +DEFAULT_BRANCH="main" +GITHUB_REPO="UniverseOfThings/SafeSplit" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +usage() { + echo "Usage: ~/claude-splitly-session-v2 [args]" + echo "" + echo "Commands:" + echo " new [ISSUE-NUM] [--open] Create new session (optionally with GitHub issue)" + echo " ls List all sessions" + echo " attach Attach to session" + echo " kill Kill session and remove worktree" + echo " cleanup Kill sessions where PR is merged or issue closed" + echo " update Update current session metadata" + exit 1 +} + +get_next_session_number() { + local max=0 + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^splitly-[0-9]*$" | sed 's/splitly-//'); do + if [ "$session" -gt "$max" ] 2>/dev/null; then + max=$session + fi + done + echo $((max + 1)) +} + +cmd_new() { + local issue_num="" + local open_warp=false + + for arg in "$@"; do + if [ "$arg" = "--open" ]; then + open_warp=true + elif [ -z "$issue_num" ] && [ "$arg" != "--open" ]; then + issue_num="$arg" + fi + done + + local session_num=$(get_next_session_number) + local session_name="splitly-$session_num" + local worktree_path="$WORKTREE_DIR/$session_name" + + echo -e "${BLUE}Creating session: $session_name${NC}" + + cd "$MAIN_REPO" + git fetch origin --quiet + + if [ -n "$issue_num" ]; then + local branch_name="feat/issue-$issue_num" + git worktree add -b "$branch_name" "$worktree_path" "origin/$DEFAULT_BRANCH" 2>/dev/null || \ + git worktree add "$worktree_path" "origin/$DEFAULT_BRANCH" + else + git worktree add "$worktree_path" "origin/$DEFAULT_BRANCH" --detach + fi + + echo -e "${GREEN}✓ Created worktree at $worktree_path${NC}" + + # Symlink env files + [ -f "$ENV_TEMPLATE/.env" ] && ln -sf "$ENV_TEMPLATE/.env" "$worktree_path/.env" + [ -f "$ENV_TEMPLATE/.envrc" ] && ln -sf "$ENV_TEMPLATE/.envrc" "$worktree_path/.envrc" && \ + (cd "$worktree_path" && direnv allow 2>/dev/null || true) + + # Symlink .claude directory for MCP access (remove if exists as dir) + [ -d "$MAIN_REPO/.claude" ] && rm -rf "$worktree_path/.claude" && ln -s "$MAIN_REPO/.claude" "$worktree_path/.claude" + + # Add Rube MCP before starting session + (cd "$worktree_path" && claude mcp add rube --transport http https://rube.app/mcp &>/dev/null) || true + + # Create tmux session and start Claude (unset CLAUDECODE to avoid nested session detection) + tmux new-session -d -s "$session_name" -c "$worktree_path" -e "SPLITLY_SESSION=$session_name" -e "DIRENV_LOG_FORMAT=" + tmux send-keys -t "$session_name" "unset CLAUDECODE && claude --dangerously-skip-permissions" Enter + + # Create metadata file + mkdir -p "$SESSION_DIR" + cat > "$SESSION_DIR/$session_name" << EOF +worktree=$worktree_path +branch=$(cd "$worktree_path" && git branch --show-current || echo "detached") +status=starting +EOF + [ -n "$issue_num" ] && echo "issue=$issue_num" >> "$SESSION_DIR/$session_name" + + echo -e "${GREEN}✓ Created tmux session: $session_name${NC}" + echo -e "${GREEN}✓ Started Claude in session${NC}" + echo "" + echo "Attach with: tmux attach -t $session_name" + echo "Or open in iTerm2: ~/open-iterm-tab $session_name" + + # Clean output for scripting + echo "SESSION=$session_name" + + if [ "$open_warp" = true ]; then + ~/open-iterm-tab "$session_name" + fi +} + +cmd_ls() { + echo -e "${BLUE}Splitly Sessions:${NC}" + echo "" + + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^splitly-" | sort -V); do + local meta_file="$SESSION_DIR/$session" + local worktree="" branch="" status="" pr="" issue="" + + if [ -f "$meta_file" ]; then + worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + branch=$(grep "^branch=" "$meta_file" 2>/dev/null | cut -d= -f2-) + status=$(grep "^status=" "$meta_file" 2>/dev/null | cut -d= -f2-) + pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2-) + issue=$(grep "^issue=" "$meta_file" 2>/dev/null | cut -d= -f2-) + fi + + [ -d "$worktree" ] && branch=$(cd "$worktree" && git branch --show-current 2>/dev/null || echo "$branch") + + echo -e "${GREEN}$session${NC}" + [ -n "$branch" ] && echo " Branch: $branch" + [ -n "$issue" ] && echo " Issue: #$issue" + [ -n "$status" ] && echo " Status: $status" + [ -n "$pr" ] && echo " PR: $pr" + echo "" + done +} + +cmd_attach() { + [ -z "$1" ] && echo "Usage: ~/claude-splitly-session-v2 attach " && exit 1 + tmux attach -t "$1" +} + +cmd_kill() { + local session="$1" + [ -z "$session" ] && echo "Usage: ~/claude-splitly-session-v2 kill " && exit 1 + + local meta_file="$SESSION_DIR/$session" + local worktree="" + [ -f "$meta_file" ] && worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + + tmux kill-session -t "$session" 2>/dev/null && echo -e "${GREEN}✓ Killed tmux session: $session${NC}" + + if [ -n "$worktree" ] && [ -d "$worktree" ]; then + cd "$MAIN_REPO" + git worktree remove --force "$worktree" 2>/dev/null && echo -e "${GREEN}✓ Removed worktree: $worktree${NC}" + fi + + # Archive metadata instead of deleting + if [ -f "$meta_file" ]; then + mkdir -p "$SESSION_DIR/archive" + mv "$meta_file" "$SESSION_DIR/archive/${session}_$(date +%Y%m%d-%H%M%S)" + echo -e "${GREEN}✓ Archived metadata${NC}" + fi +} + +cmd_cleanup() { + echo "Checking for completed sessions..." + + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^splitly-"); do + local meta_file="$SESSION_DIR/$session" + local pr="" issue="" + + [ -f "$meta_file" ] && { + pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2-) + issue=$(grep "^issue=" "$meta_file" 2>/dev/null | cut -d= -f2-) + } + + if [ -n "$pr" ]; then + local pr_num=$(echo "$pr" | grep -o '[0-9]*$') + if [ -n "$pr_num" ]; then + local state=$(gh pr view "$pr_num" --repo "$GITHUB_REPO" --json state -q '.state' 2>/dev/null) + [ "$state" = "MERGED" ] && echo -e "${YELLOW}PR #$pr_num merged - killing $session${NC}" && cmd_kill "$session" && continue + fi + fi + + if [ -n "$issue" ]; then + local issue_state=$(gh issue view "$issue" --repo "$GITHUB_REPO" --json state -q '.state' 2>/dev/null) + [ "$issue_state" = "CLOSED" ] && echo -e "${YELLOW}Issue #$issue closed - killing $session${NC}" && cmd_kill "$session" + fi + done + echo "Cleanup complete." +} + +cmd_update() { + local session="${SPLITLY_SESSION:-}" + [ -z "$session" ] && echo "Error: Not in a splitly session" && exit 1 + + local meta_file="$SESSION_DIR/$session" + local worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + [ -z "$worktree" ] || [ ! -d "$worktree" ] && worktree="$PWD" + + local branch=$(cd "$worktree" && git branch --show-current 2>/dev/null) + [ -n "$branch" ] && sed -i '' "s|^branch=.*|branch=$branch|" "$meta_file" 2>/dev/null + + echo "Updated metadata for $session" +} + +case "${1:-}" in + new) shift; cmd_new "$@" ;; + ls) cmd_ls ;; + attach) cmd_attach "$2" ;; + kill) cmd_kill "$2" ;; + cleanup) cmd_cleanup ;; + update) cmd_update ;; + *) usage ;; +esac diff --git a/scripts/claude-status b/scripts/claude-status new file mode 100755 index 000000000..b26d15e45 --- /dev/null +++ b/scripts/claude-status @@ -0,0 +1,212 @@ +#!/bin/bash + +# Claude Unified Status +# Shows all sessions across all projects with summary stats + +# Helper function to get Claude session info directly from Claude's data +get_claude_info() { + local TMUX_SESSION="$1" + + # Get the TTY for this tmux session + local TTY=$(tmux list-panes -t "$TMUX_SESSION" -F '#{pane_tty}' 2>/dev/null | head -1) + [ -z "$TTY" ] && return 1 + + # Find the Claude PID running on that TTY + local TTY_SHORT="${TTY#/dev/}" + local CLAUDE_PID=$(ps -eo pid,tty,comm 2>/dev/null | awk -v tty="$TTY_SHORT" '$2 == tty && $3 == "claude" {print $1; exit}') + [ -z "$CLAUDE_PID" ] && return 1 + + # Get the working directory of that process + local CWD=$(lsof -p "$CLAUDE_PID" 2>/dev/null | awk '$4 == "cwd" {print $NF}') + [ -z "$CWD" ] && return 1 + + # Get current git branch from that directory + local BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + + # Convert to Claude's project path format and find active session + local PROJECT_PATH=$(echo "$CWD" | tr '/_' '--') + local PROJECT_DIR="$HOME/.claude/projects/$PROJECT_PATH" + + local SESSION_ID="" + local SUMMARY="" + + if [ -d "$PROJECT_DIR" ]; then + # Find the most recently modified session file (exclude agent- files) + local ACTIVE_SESSION_FILE=$(ls -t "$PROJECT_DIR"/*.jsonl 2>/dev/null | grep -v "agent-" | head -1) + if [ -n "$ACTIVE_SESSION_FILE" ]; then + SESSION_ID=$(basename "$ACTIVE_SESSION_FILE" .jsonl) + # Extract the most recent summary from the session file + SUMMARY=$(grep '"type":"summary"' "$ACTIVE_SESSION_FILE" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null) + fi + fi + + # Output as key=value pairs (properly quoted for eval) + printf 'claude_branch=%q\n' "$BRANCH" + printf 'claude_session_id=%q\n' "$SESSION_ID" + printf 'claude_summary=%q\n' "$SUMMARY" +} + +echo "╔══════════════════════════════════════════════════════════════════════════════╗" +echo "║ CLAUDE ORCHESTRATOR STATUS ║" +echo "╚══════════════════════════════════════════════════════════════════════════════╝" +echo "" + +# Count sessions +INTEGRATOR_COUNT=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -c "^integrator-" || echo 0) +SPLITLY_COUNT=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -c "^splitly-" || echo 0) +TOTAL=$((INTEGRATOR_COUNT + SPLITLY_COUNT)) + +# Count empty checkouts +INTEGRATOR_EMPTY=0 +for i in {0..9}; do + count=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -c "^integrator-${i}-" 2>/dev/null || true) + count=${count:-0} + count=$(echo "$count" | tr -d '[:space:]') + [ "$count" -eq 0 ] 2>/dev/null && ((INTEGRATOR_EMPTY++)) +done + +SPLITLY_EMPTY=0 +for i in {0..12}; do + count=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -c "^splitly-${i}-" 2>/dev/null || true) + count=${count:-0} + count=$(echo "$count" | tr -d '[:space:]') + [ "$count" -eq 0 ] 2>/dev/null && ((SPLITLY_EMPTY++)) +done + +echo "📊 SUMMARY: $TOTAL active sessions" +echo " Integrator: $INTEGRATOR_COUNT sessions, $INTEGRATOR_EMPTY empty checkouts" +echo " Splitly: $SPLITLY_COUNT sessions, $SPLITLY_EMPTY empty checkouts" +echo "" + +# Show integrator sessions +echo "┌──────────────────────────────────────────────────────────────────────────────┐" +echo "│ INTEGRATOR │" +echo "└──────────────────────────────────────────────────────────────────────────────┘" + +if [ "$INTEGRATOR_COUNT" -eq 0 ]; then + echo " (no active sessions)" +else + # Get session details from the session data files + for session in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^integrator-" | sort); do + activity=$(tmux display-message -t "$session" -p '#{session_activity}' 2>/dev/null) + now=$(date +%s) + if [ -n "$activity" ]; then + diff=$((now - activity)) + if [ $diff -lt 60 ]; then + last_active="${diff}s ago" + elif [ $diff -lt 3600 ]; then + last_active="$((diff / 60))m ago" + elif [ $diff -lt 86400 ]; then + last_active="$((diff / 3600))h ago" + else + last_active="$((diff / 86400))d ago" + fi + else + last_active="-" + fi + + data_file="$HOME/.integrator-sessions/$session" + meta_branch=$(grep "^branch=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + meta_status=$(grep "^status=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + meta_summary=$(grep "^summary=" "$data_file" 2>/dev/null | cut -d'=' -f2- | cut -c1-60) + + # Get live Claude session info + eval "$(get_claude_info "$session" 2>/dev/null)" + + echo " 📍 $session ($last_active)" + + # Show branch - prefer live, show if different from metadata + if [ -n "$claude_branch" ]; then + if [ -n "$meta_branch" ] && [ "$claude_branch" != "$meta_branch" ]; then + echo " Branch: $claude_branch (meta: $meta_branch)" + else + echo " Branch: $claude_branch" + fi + elif [ -n "$meta_branch" ]; then + echo " Branch: $meta_branch (stale)" + fi + + # Show summary - prefer Claude's auto-generated, fall back to metadata + if [ -n "$claude_summary" ]; then + echo " Claude: ${claude_summary:0:65}" + fi + if [ -n "$meta_summary" ]; then + echo " Status: ${meta_summary}..." + fi + + # Show session ID if available (useful for resuming) + [ -n "$claude_session_id" ] && echo " Session: ${claude_session_id:0:8}..." + done +fi + +echo "" + +# Show splitly sessions +echo "┌──────────────────────────────────────────────────────────────────────────────┐" +echo "│ SPLITLY / SAFESPLIT │" +echo "└──────────────────────────────────────────────────────────────────────────────┘" + +if [ "$SPLITLY_COUNT" -eq 0 ]; then + echo " (no active sessions)" +else + for session in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^splitly-" | sort); do + activity=$(tmux display-message -t "$session" -p '#{session_activity}' 2>/dev/null) + now=$(date +%s) + if [ -n "$activity" ]; then + diff=$((now - activity)) + if [ $diff -lt 60 ]; then + last_active="${diff}s ago" + elif [ $diff -lt 3600 ]; then + last_active="$((diff / 60))m ago" + elif [ $diff -lt 86400 ]; then + last_active="$((diff / 3600))h ago" + else + last_active="$((diff / 86400))d ago" + fi + else + last_active="-" + fi + + data_file="$HOME/.splitly-sessions/$session" + meta_branch=$(grep "^branch=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + meta_status=$(grep "^status=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + meta_summary=$(grep "^summary=" "$data_file" 2>/dev/null | cut -d'=' -f2- | cut -c1-60) + + # Get live Claude session info + eval "$(get_claude_info "$session" 2>/dev/null)" + + echo " 📍 $session ($last_active)" + + # Show branch - prefer live, show if different from metadata + if [ -n "$claude_branch" ]; then + if [ -n "$meta_branch" ] && [ "$claude_branch" != "$meta_branch" ]; then + echo " Branch: $claude_branch (meta: $meta_branch)" + else + echo " Branch: $claude_branch" + fi + elif [ -n "$meta_branch" ]; then + echo " Branch: $meta_branch (stale)" + fi + + # Show summary - prefer Claude's auto-generated, fall back to metadata + if [ -n "$claude_summary" ]; then + echo " Claude: ${claude_summary:0:65}" + fi + if [ -n "$meta_summary" ]; then + echo " Status: ${meta_summary}..." + fi + + # Show session ID if available (useful for resuming) + [ -n "$claude_session_id" ] && echo " Session: ${claude_session_id:0:8}..." + done +fi + +echo "" +echo "┌──────────────────────────────────────────────────────────────────────────────┐" +echo "│ QUICK ACTIONS │" +echo "└──────────────────────────────────────────────────────────────────────────────┘" +echo " ~/claude-spawn i [linear-url] - New integrator session in new iTerm2 tab" +echo " ~/claude-spawn s [issue-num] - New splitly session in new iTerm2 tab" +echo " ~/claude-integrator-session cleanup" +echo " ~/claude-splitly-session cleanup" +echo "" diff --git a/scripts/get-claude-session-info b/scripts/get-claude-session-info new file mode 100755 index 000000000..b381765f8 --- /dev/null +++ b/scripts/get-claude-session-info @@ -0,0 +1,63 @@ +#!/bin/bash +# Get Claude session info for a tmux session +# Extracts session ID, branch, and summary directly from Claude's data + +TMUX_SESSION="$1" +if [ -z "$TMUX_SESSION" ]; then + echo "Usage: $0 " + exit 1 +fi + +# 1. Get the TTY for this tmux session +TTY=$(tmux list-panes -t "$TMUX_SESSION" -F '#{pane_tty}' 2>/dev/null | head -1) +if [ -z "$TTY" ]; then + echo "error: tmux session '$TMUX_SESSION' not found" + exit 1 +fi + +# 2. Find the Claude PID running on that TTY +TTY_SHORT="${TTY#/dev/}" +CLAUDE_PID=$(ps -eo pid,tty,comm 2>/dev/null | awk -v tty="$TTY_SHORT" '$2 == tty && $3 == "claude" {print $1; exit}') +if [ -z "$CLAUDE_PID" ]; then + echo "error: no claude process on $TTY" + exit 1 +fi + +# 3. Get the working directory of that process +CWD=$(lsof -p "$CLAUDE_PID" 2>/dev/null | awk '$4 == "cwd" {print $NF}') + +# 4. Get current git branch from that directory +BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + +# 5. Convert to Claude's project path format and find active session +PROJECT_PATH=$(echo "$CWD" | tr '/.' '--') +PROJECT_DIR="$HOME/.claude/projects/$PROJECT_PATH" + +if [ ! -d "$PROJECT_DIR" ]; then + echo "session_id: unknown" + echo "branch: $BRANCH" + echo "summary: (no claude project found)" + exit 0 +fi + +# 6. Find the most recently modified session file +ACTIVE_SESSION_FILE=$(ls -t "$PROJECT_DIR"/*.jsonl 2>/dev/null | grep -v "agent-" | head -1) +if [ -z "$ACTIVE_SESSION_FILE" ]; then + echo "session_id: unknown" + echo "branch: $BRANCH" + echo "summary: (no session file)" + exit 0 +fi + +SESSION_ID=$(basename "$ACTIVE_SESSION_FILE" .jsonl) + +# 7. Extract the most recent summary from the session file +SUMMARY=$(grep '"type":"summary"' "$ACTIVE_SESSION_FILE" 2>/dev/null | tail -1 | jq -r '.summary // empty') +if [ -z "$SUMMARY" ]; then + # Fall back to first user message + SUMMARY=$(grep '"type":"user"' "$ACTIVE_SESSION_FILE" 2>/dev/null | head -1 | jq -r '.message.content' 2>/dev/null | head -c 80) +fi + +echo "session_id: $SESSION_ID" +echo "branch: $BRANCH" +echo "summary: ${SUMMARY:-"(no summary)"}" diff --git a/scripts/notify-session b/scripts/notify-session new file mode 100755 index 000000000..689de866e --- /dev/null +++ b/scripts/notify-session @@ -0,0 +1,30 @@ +#!/bin/bash +# Usage: ~/notify-session [message] +# +# Sends an iTerm2 notification that navigates to THIS tab when clicked. +# Works when called from Claude Code's Bash tool (which captures stdout). +# +# Examples: +# ~/notify-session "PR comments addressed, ready for review" +# ~/notify-session "Build complete" +# ~/notify-session # defaults to "Task complete" +# +# Writes directly to the tmux pane's TTY so the escape sequence reaches +# iTerm2 even when stdout is captured by Claude's Bash tool. + +MESSAGE="${1:-Task complete}" +SESSION_NAME=$(tmux display-message -p '#S' 2>/dev/null || echo "Claude") + +# Get the TTY of the current tmux pane +PANE_TTY=$(tmux display-message -p '#{pane_tty}' 2>/dev/null) + +if [ -n "$PANE_TTY" ]; then + # Write directly to the pane's TTY with tmux passthrough + printf '\033Ptmux;\033\033]9;%s\007\033\\' "$MESSAGE" > "$PANE_TTY" +elif [ -n "$TMUX" ]; then + # Fallback: we're in tmux but can't find pane TTY + printf '\033Ptmux;\033\033]9;%s\007\033\\' "$MESSAGE" +else + # Outside tmux: send OSC 9 directly + printf '\033]9;%s\007' "$MESSAGE" +fi diff --git a/scripts/open-iterm-tab b/scripts/open-iterm-tab new file mode 100755 index 000000000..98f3ba3ba --- /dev/null +++ b/scripts/open-iterm-tab @@ -0,0 +1,58 @@ +#!/bin/bash +# Usage: ~/open-iterm-tab +# Opens a tmux session in iTerm2 - reuses existing tab if found, otherwise opens new +# Sets both the profile name (for detection) and tab title (for visual display) + +SESSION="$1" +if [ -z "$SESSION" ]; then + echo "Usage: ~/open-iterm-tab " + exit 1 +fi + +# Verify tmux session exists +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Error: tmux session '$SESSION' does not exist" + exit 1 +fi + +# Check if there's already an iTerm2 tab with this session's profile name +result=$(osascript -e " +tell application \"iTerm2\" + activate + repeat with aWindow in windows + repeat with aTab in tabs of aWindow + repeat with aSession in sessions of aTab + try + if profile name of aSession is equal to \"$SESSION\" then + select aWindow + select aTab + return \"EXISTING\" + end if + end try + end repeat + end repeat + end repeat + return \"NOT_FOUND\" +end tell +" 2>&1) + +if [ "$result" = "EXISTING" ]; then + echo "✓ Switched to existing tab for $SESSION" +else + echo "Opening new tab for $SESSION..." + osascript -e " + tell application \"iTerm2\" + activate + tell current window + create tab with default profile + tell current session + -- Set profile name for programmatic detection + set name to \"$SESSION\" + -- Set visible tab title via escape code, then attach to tmux + write text \"printf '\\\\033]0;$SESSION\\\\007' && tmux attach -t $SESSION\" + end tell + end tell + end tell + " 2>&1 + echo "✓ Opened $SESSION in new iTerm2 tab" +fi diff --git a/scripts/open-tmux-session b/scripts/open-tmux-session new file mode 100755 index 000000000..b5c1fbaed --- /dev/null +++ b/scripts/open-tmux-session @@ -0,0 +1,53 @@ +#!/bin/bash +# Switch to the iTerm tab running a given tmux session +# Usage: ~/open-tmux-session + +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" + +SESSION="$1" +[ -z "$SESSION" ] && echo "Usage: open-tmux-session " && exit 1 + +# Find the tty attached to this tmux session +TARGET_TTY=$(tmux list-clients -t "$SESSION" -F "#{client_tty}" 2>/dev/null | head -1) + +if [ -n "$TARGET_TTY" ]; then + # Find and switch to the iTerm tab with this tty + osascript << APPLESCRIPT +tell application "iTerm2" + activate + set foundIt to false + repeat with w in windows + tell w + set tabCount to count of tabs + repeat with i from 1 to tabCount + set theTab to item i of tabs + repeat with theSession in sessions of theTab + set sessionTTY to tty of theSession + if sessionTTY is "$TARGET_TTY" then + set index of w to 1 + select theTab + set foundIt to true + exit repeat + end if + end repeat + if foundIt then exit repeat + end repeat + end tell + if foundIt then exit repeat + end repeat +end tell +APPLESCRIPT +else + # No client attached — create a new iTerm tab and attach + osascript << APPLESCRIPT +tell application "iTerm2" + activate + tell current window + set newTab to (create tab with default profile) + tell current session of newTab + write text "tmux attach -t $SESSION" + end tell + end tell +end tell +APPLESCRIPT +fi diff --git a/scripts/send-to-session b/scripts/send-to-session new file mode 100755 index 000000000..b81ecb5ee --- /dev/null +++ b/scripts/send-to-session @@ -0,0 +1,112 @@ +#!/bin/bash +# Usage: ~/send-to-session +# or: ~/send-to-session -f +# Sends a message to a Claude agent in a tmux session and submits it. +# +# If the session is busy (Claude is thinking), waits for it to become idle +# before sending. Retries Enter if the message wasn't picked up. + +SESSION="$1" +shift + +if [ -z "$SESSION" ]; then + echo "Usage: ~/send-to-session " + echo " or: ~/send-to-session -f " + exit 1 +fi + +# Verify session exists +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Error: Session '$SESSION' does not exist" + exit 1 +fi + +is_busy() { + # Deterministic: check if the last non-empty line has a prompt (❯) or shell ($) — means idle + local last_line=$(tmux capture-pane -t "$SESSION" -p -S -5 2>/dev/null | grep -v '^$' | tail -1) + # Idle indicators: prompt char or permission mode line + if echo "$last_line" | grep -qE '❯|^\$|⏵⏵|bypass permissions'; then + return 1 # not busy + fi + # Active indicators: "esc to interrupt" only appears during live processing + local output=$(tmux capture-pane -t "$SESSION" -p -S -3 2>/dev/null) + echo "$output" | grep -qF "esc to interrupt" +} + +is_processing() { + local output=$(tmux capture-pane -t "$SESSION" -p -S -10 2>/dev/null) + echo "$output" | grep -qE "Thinking|Roosting|Garnishing|Levitating|Baking|Whirring|Musing|Cooked|Churned|Brewed|Worked|Cogitated|Frolicking|Prestidigitating|Whisking|Mustering|Percolating|Running…|esc to interrupt|⏺" +} + +has_queued_message() { + local output=$(tmux capture-pane -t "$SESSION" -p -S -5 2>/dev/null) + echo "$output" | grep -qE "Press up to edit queued messages" +} + +# Wait for session to be idle (up to 10 minutes) +WAIT_COUNT=0 +MAX_WAIT=120 # 120 * 5s = 10 minutes +while is_busy; do + if [ $WAIT_COUNT -eq 0 ]; then + echo "⏳ Waiting for $SESSION to finish current task..." + fi + WAIT_COUNT=$((WAIT_COUNT + 1)) + if [ $WAIT_COUNT -ge $MAX_WAIT ]; then + echo "⚠ Timeout waiting for $SESSION to become idle. Sending anyway." + break + fi + sleep 5 +done + +# Clear any partial input +tmux send-keys -t "$SESSION" C-u +sleep 0.2 + +# Send the message +send_message() { + if [ "$1" = "-f" ]; then + local file="$2" + if [ ! -f "$file" ]; then + echo "Error: File not found: $file" + exit 1 + fi + tmux load-buffer "$file" + tmux paste-buffer -t "$SESSION" + else + local msg="$*" + if echo "$msg" | grep -q $'\n' || [ ${#msg} -gt 200 ]; then + local tmpfile=$(mktemp /tmp/send-to-session-XXXXXX.txt) + printf '%s' "$msg" > "$tmpfile" + tmux load-buffer "$tmpfile" + tmux paste-buffer -t "$SESSION" + rm -f "$tmpfile" + else + tmux send-keys -t "$SESSION" "$msg" + fi + fi +} + +send_message "$@" +sleep 0.3 +tmux send-keys -t "$SESSION" Enter + +# Verify it's processing, retry Enter if needed +for attempt in 1 2 3; do + sleep 2 + if is_processing; then + echo "✓ Message sent and processing" + exit 0 + fi + # Check if message is sitting in queue (Claude was still wrapping up) + if has_queued_message; then + echo "✓ Message queued (session is finishing previous task)" + exit 0 + fi + # Message might be in input buffer — retry Enter + if [ $attempt -lt 3 ]; then + tmux send-keys -t "$SESSION" Enter + sleep 1 + fi +done + +echo "⚠ Message sent - could not confirm it was received" From c16103fb4dde31689ab8afdb14f8df4117c44338 Mon Sep 17 00:00:00 2001 From: Prateek Date: Fri, 13 Feb 2026 15:34:24 +0530 Subject: [PATCH 0002/1114] rename CLAUDE.local.md to CLAUDE.orchestrator.md This file contains the shared orchestrator instructions that ship with the repo. It's loaded explicitly via --append-system-prompt-file when launching an orchestrator session, rather than auto-discovered as a CLAUDE.local.md. Users can still have their own CLAUDE.local.md for personal overrides. Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.local.md => CLAUDE.orchestrator.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CLAUDE.local.md => CLAUDE.orchestrator.md (100%) diff --git a/CLAUDE.local.md b/CLAUDE.orchestrator.md similarity index 100% rename from CLAUDE.local.md rename to CLAUDE.orchestrator.md From 1601ad75c0c47b58be5a54c2fb903a8dd28e78de Mon Sep 17 00:00:00 2001 From: Prateek Date: Fri, 13 Feb 2026 15:44:17 +0530 Subject: [PATCH 0003/1114] feat: add agent-orchestrator (ao) as a self-hosting project - Create claude-ao-session: worktree-based session manager for ao (Linear tickets, GitHub PRs, AO_SESSION env var) - Add ao|agent-orchestrator case to all shared scripts: claude-batch-spawn, claude-spawn, claude-status, claude-open-all, claude-review-check, claude-bugbot-fix - Rewrite CLAUDE.orchestrator.md with full self-hosting instructions: commands reference, workflows, architecture, tips - Session prefix: ao, metadata: ~/.ao-sessions/, worktrees: ~/.worktrees/ao/ Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.orchestrator.md | 248 +++++++++++++++++++++++++----------- scripts/claude-ao-session | 235 ++++++++++++++++++++++++++++++++++ scripts/claude-batch-spawn | 8 +- scripts/claude-bugbot-fix | 7 +- scripts/claude-open-all | 10 +- scripts/claude-review-check | 6 +- scripts/claude-spawn | 9 +- scripts/claude-status | 68 +++++++++- 8 files changed, 506 insertions(+), 85 deletions(-) create mode 100755 scripts/claude-ao-session diff --git a/CLAUDE.orchestrator.md b/CLAUDE.orchestrator.md index 23241f52c..481ecba47 100644 --- a/CLAUDE.orchestrator.md +++ b/CLAUDE.orchestrator.md @@ -1,90 +1,158 @@ -# CLAUDE.local.md - Agent Orchestrator Development +# CLAUDE.orchestrator.md - Agent Orchestrator -You are working on **agent-orchestrator**, an open-source multi-agent orchestrator for Claude Code. This tool manages parallel coding agents across projects from a single control plane. +You are the **orchestrator agent** for the agent-orchestrator project. You manage parallel Claude Code agents that build this very tool (dog-fooding). -> **Dog-fooding**: This orchestrator is being built using a copy of itself. The `~/` home directory orchestrator manages sessions that work on this very repo. +## Project Info -## Project Overview +- **Repo**: ComposioHQ/agent-orchestrator (GitHub) +- **Issue Tracker**: Linear (AO team) +- **Main Branch**: `main` +- **Session Prefix**: `ao` +- **Session Naming**: `ao-1`, `ao-2`, etc. +- **Metadata Dir**: `~/.ao-sessions/` +- **Worktrees**: `~/.worktrees/ao/` -Agent Orchestrator lets you: -- Spawn and manage dozens of Claude Code agents working in parallel -- Track progress via a live HTML dashboard with PR status, CI checks, and merge actions -- Send commands to agents, kill/cleanup sessions, and view terminals in-browser -- Organize agents by project with worktree-based isolation +## Quick Start + +```bash +# See all sessions +~/claude-status + +# Spawn sessions for Linear tickets +~/claude-batch-spawn ao AO-1 AO-2 AO-3 + +# Spawn single session (new iTerm2 tab) +~/claude-spawn ao AO-1 + +# List ao sessions +~/claude-ao-session ls + +# Attach to a session +~/claude-ao-session attach ao-1 + +# Kill a session +~/claude-ao-session kill ao-1 + +# Cleanup completed work (merged PRs / done tickets) +~/claude-ao-session cleanup +``` + +## Agent Hierarchy + +``` +~/agent-orchestrator/ <- YOU (Orchestrator) +└── ao agents <- Managed via ~/claude-ao-session + ├── ao-1 (~/.worktrees/ao/ao-1) + ├── ao-2 (~/.worktrees/ao/ao-2) + └── ao-N +``` + +## Commands Reference + +| Task | Command | +|------|---------| +| **See all sessions** | `~/claude-status` | +| **Batch spawn** | `~/claude-batch-spawn ao AO-1 AO-2 AO-3` | +| **Single spawn** | `~/claude-spawn ao AO-1` | +| **List sessions** | `~/claude-ao-session ls` | +| **Attach** | `~/claude-ao-session attach ao-1` | +| **Kill** | `~/claude-ao-session kill ao-1` | +| **Cleanup** | `~/claude-ao-session cleanup` | +| **Open all tabs** | `~/claude-open-all ao` | +| **PR review fixes** | `~/claude-review-check ao` | +| **Peek at screen** | `tmux capture-pane -t "ao-1" -p -S -30` | +| **Send message** | `~/send-to-session ao-1 "your message"` | +| **Spawn with context** | `~/claude-spawn-with-context ao AO-1 /tmp/prompt.txt --open` | + +## Typical Workflows + +### Spawn Work for Linear Tickets +```bash +# 1. Check what's already running +~/claude-status + +# 2. Spawn sessions (auto-deduplicates) +~/claude-batch-spawn ao AO-1 AO-2 AO-3 + +# 3. Open all in iTerm2 +~/claude-open-all ao +``` + +### Check Progress +```bash +~/claude-status # Quick overview +~/claude-ao-session ls # AO sessions only +tmux capture-pane -t "ao-1" -p -S -30 # Peek at session +``` + +### Ask a Session to Do Something +```bash +# Short message +~/send-to-session ao-1 "address the unresolved comments on your PR" + +# Long prompt via file +cat > /tmp/prompt.txt << 'PROMPT' +Your detailed instructions here... +PROMPT +~/claude-spawn-with-context ao AO-1 /tmp/prompt.txt --open +``` + +### Cleanup +```bash +~/claude-ao-session cleanup # Kills sessions with merged PRs / completed tickets +~/claude-ao-session kill ao-3 # Kill specific session +``` + +## Session Data + +### Metadata Files +Each session has a flat file at `~/.ao-sessions/ao-N`: +``` +worktree=/Users/equinox/.worktrees/ao/ao-1 +branch=feat/AO-1 +status=starting +issue=https://linear.app/composio/issue/AO-1 +pr=https://github.com/ComposioHQ/agent-orchestrator/pull/5 +``` + +### Environment Variables (inside sessions) +- `AO_SESSION` — e.g., `ao-1` +- `LINEAR_API_KEY` — required for cleanup to check ticket status ## Repo Structure ``` agent-orchestrator/ -├── scripts/ # Core orchestrator scripts -│ ├── claude-status # Unified dashboard (CLI) -│ ├── claude-batch-spawn # Spawn multiple sessions at once -│ ├── claude-spawn # Spawn single session in new terminal tab -│ ├── claude-dashboard # HTML dashboard with live PR status (web UI) -│ ├── claude-open-all # Open terminal tabs for all sessions -│ ├── claude-review-check # Trigger agents to address PR review comments -│ ├── claude-bugbot-fix # Fix bugbot comments across sessions -│ ├── claude-session-status # Health monitor (working/idle/blocked/waiting) +├── scripts/ # All orchestrator scripts +│ ├── claude-ao-session # Session manager for this project +│ ├── claude-status # Unified CLI dashboard +│ ├── claude-batch-spawn # Spawn multiple sessions +│ ├── claude-spawn # Spawn single session (new tab) +│ ├── claude-dashboard # HTML dashboard with live PR status +│ ├── claude-open-all # Open iTerm2 tabs for sessions +│ ├── claude-review-check # Trigger PR review fixes +│ ├── claude-bugbot-fix # Fix bugbot comments +│ ├── claude-session-status # Health monitor │ ├── claude-spawn-with-context # Spawn with custom prompt file │ ├── claude-spawn-on-branch # Spawn on existing branch -│ ├── claude-spawn-with-prompt # Spawn and deliver prompt after ready +│ ├── claude-spawn-with-prompt # Spawn + deliver prompt after ready │ ├── get-claude-session-info # Extract session metadata from tmux -│ ├── open-tmux-session # Switch to terminal tab for tmux session +│ ├── open-tmux-session # Switch to terminal tab │ ├── open-iterm-tab # iTerm2 tab management -│ ├── notify-session # iTerm2 notifications from sessions -│ ├── send-to-session # Smart message delivery to tmux sessions -│ ├── claude-integrator-session # Example: project session manager (Linear/integrator) -│ └── claude-splitly-session # Example: project session manager (GitHub Issues/SafeSplit) -├── CLAUDE.local.md # This file (dev instructions) -├── CLAUDE.md # Repo-level instructions for contributors -└── README.md # Project README +│ ├── notify-session # iTerm2 notifications +│ ├── send-to-session # Smart message delivery to sessions +│ ├── claude-integrator-session # Example: Linear-based session manager +│ └── claude-splitly-session # Example: GitHub Issues session manager +├── CLAUDE.orchestrator.md # This file (orchestrator instructions) +├── CLAUDE.md # Repo instructions for contributors +└── README.md # Project README ``` -## Current State - -The scripts in `scripts/` are direct copies from the production orchestrator (`~/`). They currently have hardcoded paths and project-specific references (integrator, splitly). The work ahead is to: - -1. **Generalize** — Remove hardcoded project names, make scripts project-agnostic -2. **Configuration** — Add a config file (e.g., `orchestrator.yaml`) that defines projects, repos, branches, issue trackers -3. **Installation** — Create an install script that symlinks scripts to `~/` or adds them to PATH -4. **Documentation** — Write a comprehensive README with setup guide and examples -5. **Self-hosting** — Make this repo use its own orchestrator for development - -## Project Info - -- **Repo**: ComposioHQ/agent-orchestrator (GitHub) -- **Issue Tracker**: Linear -- **Visibility**: Internal (ComposioHQ org) -- **Everything runs locally** — no Datadog, no cloud infra - -## Development Workflow - -```bash -# This repo is managed by the home directory orchestrator -# Sessions are spawned via: -~/claude-spawn-with-context i TICKET /tmp/prompt.txt --open - -# Linear tickets for this project -# (use Rube MCP LINEAR_CREATE_LINEAR_ISSUE to create tickets) - -# GitHub issues (secondary): -gh issue list --repo ComposioHQ/agent-orchestrator --state open -``` - -## Key Design Principles - -1. **tmux-based** — All agent sessions run in tmux. This gives persistence, detach/attach, and scriptability. -2. **Metadata files** — Each session has a flat metadata file (`key=value` format) tracking branch, PR, issue, status, notes. -3. **Live dashboard** — HTML dashboard served locally with real-time activity detection via Claude's JSONL session files. -4. **Project-agnostic shared scripts** — Core scripts (spawn, status, dashboard) take project as an argument. -5. **Project-specific session managers** — Each project gets a session manager script that handles worktrees, naming, and cleanup. -6. **iTerm2 integration** — AppleScript-based tab management for macOS (should be made terminal-agnostic). - -## Architecture Notes +## Architecture ### Session Lifecycle ``` -spawn → tmux session created → Claude started → working on issue +spawn → tmux session created → Claude started → working on ticket ↓ metadata file written (branch, issue, status) ↓ @@ -101,10 +169,38 @@ The dashboard detects if agents are working/idle/exited by: 2. Walking the process tree from tmux pane PID to find `claude` processes 3. Polling every 5 seconds via `/api/sessions` endpoint -### Dashboard Server -- Python HTTP server serving static HTML + JSON API -- `/api/sessions` — live activity status -- `/api/terminal/:name` — spawn ttyd web terminal for session -- `/api/kill/:name` — kill session + close iTerm tab + archive metadata -- `/api/merge/:repo/:num` — merge PR via `gh` -- `/api/send/:name` — send message to agent via `send-to-session` +### Key Design Principles +1. **tmux-based** — persistence, detach/attach, scriptability +2. **Flat metadata files** — `key=value` format, easy to parse and update +3. **Worktree isolation** — each session gets its own git worktree +4. **Project-agnostic shared scripts** — core scripts take project as argument +5. **Project-specific session managers** — each project gets its own (e.g., `claude-ao-session`) + +## Roadmap + +1. **Generalize** — Remove remaining hardcoded project names from shared scripts +2. **Configuration** — `orchestrator.yaml` defining projects, repos, branches, issue trackers +3. **Installation** — Install script that symlinks scripts to `~/` or adds to PATH +4. **Documentation** — Comprehensive README with setup guide and examples +5. **Terminal-agnostic** — Replace iTerm2 AppleScript with generic terminal support + +## Tips + +1. **Delegate, don't duplicate** — When asking a session to fix PR comments, just send "address the unresolved comments on your PR". The session has `gh` access. +2. **Check before spawning** — `~/claude-status` to avoid duplicate sessions. +3. **Detach, don't kill** — `Ctrl-b d` detaches from tmux. Session keeps running. +4. **Peek without attaching** — `tmux capture-pane -t "ao-1" -p -S -30` +5. **Verify message delivery** — After sending to a session, check for thinking indicators, not just `[Pasted text]`. + +## Linear Integration + +Create tickets via Rube MCP: +``` +RUBE_SEARCH_TOOLS: queries=[{use_case: "create an issue in Linear"}] + +LINEAR_CREATE_LINEAR_ISSUE: + team_id: "" + title: "Your ticket title" + description: "Markdown description" + priority: 2 # 1=Urgent, 2=High, 3=Normal, 4=Low +``` diff --git a/scripts/claude-ao-session b/scripts/claude-ao-session new file mode 100755 index 000000000..0e3ce4605 --- /dev/null +++ b/scripts/claude-ao-session @@ -0,0 +1,235 @@ +#!/bin/bash +# claude-ao-session - Worktree-based session manager for agent-orchestrator + +set -e + +MAIN_REPO=~/agent-orchestrator +WORKTREE_DIR=~/.worktrees/ao +SESSION_DIR=~/.ao-sessions +DEFAULT_BRANCH="main" +GITHUB_REPO="ComposioHQ/agent-orchestrator" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +usage() { + echo "Usage: ~/claude-ao-session [args]" + echo "" + echo "Commands:" + echo " new [LINEAR-ID] [--open] Create new session (optionally with Linear ticket)" + echo " ls List all sessions" + echo " attach Attach to session" + echo " kill Kill session and remove worktree" + echo " cleanup Kill sessions where PR is merged or ticket is done" + echo " update Update current session metadata" + exit 1 +} + +get_next_session_number() { + local max=0 + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^ao-[0-9]*$" | sed 's/ao-//'); do + if [ "$session" -gt "$max" ] 2>/dev/null; then + max=$session + fi + done + echo $((max + 1)) +} + +cmd_new() { + local linear_id="" + local open_tab=false + + # Parse args + for arg in "$@"; do + if [ "$arg" = "--open" ]; then + open_tab=true + elif [ -z "$linear_id" ] && [ "$arg" != "--open" ]; then + linear_id="$arg" + fi + done + + local session_num=$(get_next_session_number) + local session_name="ao-$session_num" + local worktree_path="$WORKTREE_DIR/$session_name" + + echo -e "${BLUE}Creating session: $session_name${NC}" + + cd "$MAIN_REPO" + git fetch origin --quiet + + if [ -n "$linear_id" ]; then + local branch_name="feat/$linear_id" + git worktree add -b "$branch_name" "$worktree_path" "origin/$DEFAULT_BRANCH" 2>/dev/null || \ + git worktree add "$worktree_path" "origin/$DEFAULT_BRANCH" + else + git worktree add "$worktree_path" "origin/$DEFAULT_BRANCH" --detach + fi + + echo -e "${GREEN}✓ Created worktree at $worktree_path${NC}" + + # Symlink shared resources from main repo + [ -f "$MAIN_REPO/CLAUDE.local.md" ] && ln -sf "$MAIN_REPO/CLAUDE.local.md" "$worktree_path/CLAUDE.local.md" + [ -d "$MAIN_REPO/.claude" ] && rm -rf "$worktree_path/.claude" && ln -s "$MAIN_REPO/.claude" "$worktree_path/.claude" + + # Create tmux session and start Claude (unset CLAUDECODE to avoid nested session detection) + tmux new-session -d -s "$session_name" -c "$worktree_path" -e "AO_SESSION=$session_name" -e "DIRENV_LOG_FORMAT=" + tmux send-keys -t "$session_name" "unset CLAUDECODE && claude --dangerously-skip-permissions" Enter + + # Create metadata file + mkdir -p "$SESSION_DIR" + cat > "$SESSION_DIR/$session_name" << EOF +worktree=$worktree_path +branch=$(cd "$worktree_path" && git branch --show-current || echo "detached") +status=starting +EOF + [ -n "$linear_id" ] && echo "issue=https://linear.app/composio/issue/$linear_id" >> "$SESSION_DIR/$session_name" + + echo -e "${GREEN}✓ Created tmux session: $session_name${NC}" + echo -e "${GREEN}✓ Started Claude in session${NC}" + echo "" + echo "Attach with: tmux attach -t $session_name" + echo "Or open in iTerm2: ~/open-iterm-tab $session_name" + + # Clean output for scripting (no ANSI codes) + echo "SESSION=$session_name" + + # Open in iTerm2 if requested + if [ "$open_tab" = true ]; then + ~/open-iterm-tab "$session_name" + fi +} + +cmd_ls() { + echo -e "${BLUE}Agent-Orchestrator Sessions:${NC}" + echo "" + + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^ao-" | sort -V); do + local meta_file="$SESSION_DIR/$session" + local worktree="" branch="" status="" pr="" + + if [ -f "$meta_file" ]; then + worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + branch=$(grep "^branch=" "$meta_file" 2>/dev/null | cut -d= -f2-) + status=$(grep "^status=" "$meta_file" 2>/dev/null | cut -d= -f2-) + pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2-) + fi + + [ -d "$worktree" ] && branch=$(cd "$worktree" && git branch --show-current 2>/dev/null || echo "$branch") + + echo -e "${GREEN}$session${NC}" + [ -n "$branch" ] && echo " Branch: $branch" + [ -n "$status" ] && echo " Status: $status" + [ -n "$pr" ] && echo " PR: $pr" + echo "" + done +} + +cmd_attach() { + [ -z "$1" ] && echo "Usage: ~/claude-ao-session attach " && exit 1 + tmux attach -t "$1" +} + +cmd_kill() { + local session="$1" + [ -z "$session" ] && echo "Usage: ~/claude-ao-session kill " && exit 1 + + local meta_file="$SESSION_DIR/$session" + local worktree="" + [ -f "$meta_file" ] && worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + + # Close iTerm tab BEFORE killing tmux (tab name changes after tmux dies) + osascript -e " + tell application \"iTerm2\" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if name of s contains \"$session\" then + close t + return + end if + end repeat + end repeat + end repeat + end tell + " 2>/dev/null && echo -e "${GREEN}✓ Closed iTerm tab: $session${NC}" + + tmux kill-session -t "$session" 2>/dev/null && echo -e "${GREEN}✓ Killed tmux session: $session${NC}" + + if [ -n "$worktree" ] && [ -d "$worktree" ]; then + cd "$MAIN_REPO" + git worktree remove --force "$worktree" 2>/dev/null && echo -e "${GREEN}✓ Removed worktree: $worktree${NC}" + fi + + # Archive metadata instead of deleting + if [ -f "$meta_file" ]; then + mkdir -p "$SESSION_DIR/archive" + mv "$meta_file" "$SESSION_DIR/archive/${session}_$(date +%Y%m%d-%H%M%S)" + echo -e "${GREEN}✓ Archived metadata${NC}" + fi +} + +cmd_cleanup() { + echo "Checking for completed sessions..." + + for session in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^ao-"); do + local meta_file="$SESSION_DIR/$session" + local pr="" issue="" + + [ -f "$meta_file" ] && { + pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2-) + issue=$(grep "^issue=" "$meta_file" 2>/dev/null | cut -d= -f2-) + } + + if [ -n "$pr" ]; then + local pr_num=$(echo "$pr" | grep -o '[0-9]*$') + if [ -n "$pr_num" ]; then + local state=$(gh pr view "$pr_num" --repo "$GITHUB_REPO" --json state -q '.state' 2>/dev/null) + [ "$state" = "MERGED" ] && echo -e "${YELLOW}PR #$pr_num merged - killing $session${NC}" && cmd_kill "$session" && continue + fi + fi + + # Check Linear ticket status if we have an issue URL + if [ -n "$issue" ] && [ -n "${LINEAR_API_KEY:-}" ]; then + local ticket_id=$(echo "$issue" | grep -o 'AO-[0-9]*') + if [ -n "$ticket_id" ]; then + local ticket_state=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"{ issueSearch(filter: { identifier: { eq: \\\"$ticket_id\\\" } }) { nodes { state { type } } } }\"}" \ + 2>/dev/null | jq -r '.data.issueSearch.nodes[0].state.type // empty' 2>/dev/null) + if [ "$ticket_state" = "completed" ] || [ "$ticket_state" = "canceled" ]; then + echo -e "${YELLOW}$ticket_id $ticket_state - killing $session${NC}" + cmd_kill "$session" && continue + fi + fi + fi + done + echo "Cleanup complete." +} + +cmd_update() { + local session="${AO_SESSION:-}" + [ -z "$session" ] && echo "Error: Not in an ao session" && exit 1 + + local meta_file="$SESSION_DIR/$session" + local worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-) + [ -z "$worktree" ] || [ ! -d "$worktree" ] && worktree="$PWD" + + local branch=$(cd "$worktree" && git branch --show-current 2>/dev/null) + [ -n "$branch" ] && sed -i '' "s|^branch=.*|branch=$branch|" "$meta_file" 2>/dev/null + + echo "Updated metadata for $session" +} + +case "${1:-}" in + new) shift; cmd_new "$@" ;; + ls) cmd_ls ;; + attach) cmd_attach "$2" ;; + kill) cmd_kill "$2" ;; + cleanup) cmd_cleanup ;; + update) cmd_update ;; + *) usage ;; +esac diff --git a/scripts/claude-batch-spawn b/scripts/claude-batch-spawn index 1cfda311c..107e59394 100755 --- a/scripts/claude-batch-spawn +++ b/scripts/claude-batch-spawn @@ -35,9 +35,15 @@ case "$PROJECT" in SESSION_DATA_DIR="$HOME/.splitly-sessions" GITHUB_REPO="UniverseOfThings/SafeSplit" ;; + agent-orchestrator|ao) + SESSION_PREFIX="ao" + SESSION_MANAGER="$HOME/claude-ao-session" + SESSION_DATA_DIR="$HOME/.ao-sessions" + GITHUB_REPO="ComposioHQ/agent-orchestrator" + ;; *) echo "Unknown project: $PROJECT" - echo "Use 'integrator' (or 'i') or 'splitly' (or 's')" + echo "Use 'integrator' (or 'i'), 'splitly' (or 's'), or 'agent-orchestrator' (or 'ao')" exit 1 ;; esac diff --git a/scripts/claude-bugbot-fix b/scripts/claude-bugbot-fix index 71c4a0f6f..6e071e016 100755 --- a/scripts/claude-bugbot-fix +++ b/scripts/claude-bugbot-fix @@ -24,8 +24,13 @@ case "$PROJECT" in SESSIONS_DIR="$HOME/.splitly-sessions" SESSION_PREFIX="splitly" ;; + agent-orchestrator|ao) + REPO="ComposioHQ/agent-orchestrator" + SESSIONS_DIR="$HOME/.ao-sessions" + SESSION_PREFIX="ao" + ;; *) - echo "Usage: $0 [integrator|splitly] [--dry-run]" + echo "Usage: $0 [integrator|splitly|ao] [--dry-run]" exit 1 ;; esac diff --git a/scripts/claude-open-all b/scripts/claude-open-all index ba6162806..221233abb 100755 --- a/scripts/claude-open-all +++ b/scripts/claude-open-all @@ -12,12 +12,12 @@ for arg in "$@"; do --new-window|-w) NEW_WINDOW=true ;; - integrator|i|splitly|s|safesplit|all) + integrator|i|splitly|s|safesplit|agent-orchestrator|ao|all) PROJECT="$arg" ;; *) echo "Unknown argument: $arg" - echo "Usage: ~/claude-open-all [--new-window] [integrator|splitly|all]" + echo "Usage: ~/claude-open-all [--new-window] [integrator|splitly|ao|all]" exit 1 ;; esac @@ -94,8 +94,12 @@ case "$PROJECT" in SESSIONS=($(get_sessions "splitly")) echo "Opening ${#SESSIONS[@]} splitly sessions..." ;; + agent-orchestrator|ao) + SESSIONS=($(get_sessions "ao")) + echo "Opening ${#SESSIONS[@]} ao sessions..." + ;; all|"") - SESSIONS=($(get_sessions "integrator") $(get_sessions "splitly")) + SESSIONS=($(get_sessions "integrator") $(get_sessions "splitly") $(get_sessions "ao")) echo "Opening ${#SESSIONS[@]} total sessions..." ;; esac diff --git a/scripts/claude-review-check b/scripts/claude-review-check index 70b447418..2f581d2ab 100755 --- a/scripts/claude-review-check +++ b/scripts/claude-review-check @@ -14,8 +14,12 @@ case "$PROJECT" in SESSION_PREFIX="splitly" SESSION_DATA_DIR="$HOME/.splitly-sessions" ;; + agent-orchestrator|ao) + SESSION_PREFIX="ao" + SESSION_DATA_DIR="$HOME/.ao-sessions" + ;; *) - echo "Usage: ~/claude-review-check [integrator|splitly]" + echo "Usage: ~/claude-review-check [integrator|splitly|ao]" exit 1 ;; esac diff --git a/scripts/claude-spawn b/scripts/claude-spawn index f70d3c691..ea99ce66e 100755 --- a/scripts/claude-spawn +++ b/scripts/claude-spawn @@ -75,20 +75,25 @@ case "$PROJECT" in SESSION_SCRIPT="$HOME/claude-splitly-session-v2" PROJECT_NAME="splitly" ;; + agent-orchestrator|ao) + SESSION_SCRIPT="$HOME/claude-ao-session" + PROJECT_NAME="ao" + ;; "") - echo "Usage: ~/claude-spawn [issue]" + echo "Usage: ~/claude-spawn [issue]" echo "" echo "Examples:" echo " ~/claude-spawn integrator" echo " ~/claude-spawn integrator https://linear.app/composio/issue/INT-1020" echo " ~/claude-spawn splitly 294" + echo " ~/claude-spawn ao AO-1" echo "" echo "Run '~/claude-spawn help' for more information." exit 1 ;; *) echo "Unknown project: $PROJECT" - echo "Use 'integrator' (or 'i') or 'splitly' (or 's')" + echo "Use 'integrator' (or 'i'), 'splitly' (or 's'), or 'agent-orchestrator' (or 'ao')" exit 1 ;; esac diff --git a/scripts/claude-status b/scripts/claude-status index b26d15e45..6bb8e56ed 100755 --- a/scripts/claude-status +++ b/scripts/claude-status @@ -54,7 +54,8 @@ echo "" # Count sessions INTEGRATOR_COUNT=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -c "^integrator-" || echo 0) SPLITLY_COUNT=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -c "^splitly-" || echo 0) -TOTAL=$((INTEGRATOR_COUNT + SPLITLY_COUNT)) +AO_COUNT=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -c "^ao-" || echo 0) +TOTAL=$((INTEGRATOR_COUNT + SPLITLY_COUNT + AO_COUNT)) # Count empty checkouts INTEGRATOR_EMPTY=0 @@ -76,6 +77,7 @@ done echo "📊 SUMMARY: $TOTAL active sessions" echo " Integrator: $INTEGRATOR_COUNT sessions, $INTEGRATOR_EMPTY empty checkouts" echo " Splitly: $SPLITLY_COUNT sessions, $SPLITLY_EMPTY empty checkouts" +echo " AO: $AO_COUNT sessions" echo "" # Show integrator sessions @@ -201,12 +203,76 @@ else done fi +echo "" + +# Show ao sessions +echo "┌──────────────────────────────────────────────────────────────────────────────┐" +echo "│ AGENT-ORCHESTRATOR │" +echo "└──────────────────────────────────────────────────────────────────────────────┘" + +if [ "$AO_COUNT" -eq 0 ]; then + echo " (no active sessions)" +else + for session in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^ao-" | sort); do + activity=$(tmux display-message -t "$session" -p '#{session_activity}' 2>/dev/null) + now=$(date +%s) + if [ -n "$activity" ]; then + diff=$((now - activity)) + if [ $diff -lt 60 ]; then + last_active="${diff}s ago" + elif [ $diff -lt 3600 ]; then + last_active="$((diff / 60))m ago" + elif [ $diff -lt 86400 ]; then + last_active="$((diff / 3600))h ago" + else + last_active="$((diff / 86400))d ago" + fi + else + last_active="-" + fi + + data_file="$HOME/.ao-sessions/$session" + meta_branch=$(grep "^branch=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + meta_status=$(grep "^status=" "$data_file" 2>/dev/null | cut -d'=' -f2-) + meta_summary=$(grep "^summary=" "$data_file" 2>/dev/null | cut -d'=' -f2- | cut -c1-60) + + # Get live Claude session info + eval "$(get_claude_info "$session" 2>/dev/null)" + + echo " 📍 $session ($last_active)" + + # Show branch - prefer live, show if different from metadata + if [ -n "$claude_branch" ]; then + if [ -n "$meta_branch" ] && [ "$claude_branch" != "$meta_branch" ]; then + echo " Branch: $claude_branch (meta: $meta_branch)" + else + echo " Branch: $claude_branch" + fi + elif [ -n "$meta_branch" ]; then + echo " Branch: $meta_branch (stale)" + fi + + # Show summary - prefer Claude's auto-generated, fall back to metadata + if [ -n "$claude_summary" ]; then + echo " Claude: ${claude_summary:0:65}" + fi + if [ -n "$meta_summary" ]; then + echo " Status: ${meta_summary}..." + fi + + # Show session ID if available (useful for resuming) + [ -n "$claude_session_id" ] && echo " Session: ${claude_session_id:0:8}..." + done +fi + echo "" echo "┌──────────────────────────────────────────────────────────────────────────────┐" echo "│ QUICK ACTIONS │" echo "└──────────────────────────────────────────────────────────────────────────────┘" echo " ~/claude-spawn i [linear-url] - New integrator session in new iTerm2 tab" echo " ~/claude-spawn s [issue-num] - New splitly session in new iTerm2 tab" +echo " ~/claude-spawn ao [linear-id] - New agent-orchestrator session" echo " ~/claude-integrator-session cleanup" echo " ~/claude-splitly-session cleanup" +echo " ~/claude-ao-session cleanup" echo "" From 74001b22d0492328f21879c2973a3c51ddb3ee1f Mon Sep 17 00:00:00 2001 From: Prateek Date: Fri, 13 Feb 2026 16:35:46 +0530 Subject: [PATCH 0004/1114] docs: add competitive research and architecture design artifacts Comprehensive analysis of 16+ competing agent orchestration tools (Gas Town, OpenHands, SWE-ReX, Par, claude-flow, etc.) and detailed architecture design with 8-slot plugin system, session lifecycle state machine, and human attention optimization patterns. Co-Authored-By: Claude Opus 4.6 --- artifacts/architecture-design.md | 666 ++++++++++++++++++++++++++++++ artifacts/competitive-research.md | 409 ++++++++++++++++++ 2 files changed, 1075 insertions(+) create mode 100644 artifacts/architecture-design.md create mode 100644 artifacts/competitive-research.md diff --git a/artifacts/architecture-design.md b/artifacts/architecture-design.md new file mode 100644 index 000000000..edf6a6461 --- /dev/null +++ b/artifacts/architecture-design.md @@ -0,0 +1,666 @@ +# Architecture Design — Agent Orchestrator + +*Compiled: 2026-02-13* + +## Core Philosophy + +**Optimize human attention.** The orchestrator's job is to ensure humans spend their limited attention on the right things at the right time — not babysitting agents, not polling dashboards, not wondering "what's stuck?" + +### Design Principles + +1. **Server-centric**: All agents report to a central server. The server coordinates everything. +2. **Plugin everything**: 8 pluggable abstraction slots. Swap any component. +3. **Works out of the box**: Default config (tmux + claude-code + worktree + github) requires zero setup beyond `npx agent-orchestrator init`. +4. **Human attention optimization**: Proactive notifications when agents need input, are stuck, are done, or when PRs need review. Silence when everything is fine. +5. **Runtime agnostic**: tmux is just one way to run agents. Docker, K8s, cloud, SSH, child processes — all through the same interface. + +--- + +## Nomenclature + +| Term | Definition | Examples | +|------|-----------|---------| +| **Orchestrator** | The central server that manages everything | The Next.js app | +| **Project** | A configured repository to work on | `my-app`, `backend-api` | +| **Session** | A running agent instance working on a task | `my-app-1`, `my-app-2` | +| **Runtime** | Where/how the session executes | tmux, docker, k8s, process | +| **Agent** | The AI coding tool being used | claude-code, codex, aider | +| **Workspace** | Isolated code copy for a session | git worktree, clone, volume | +| **Tracker** | Issue/task tracking system | github, linear, jira | +| **SCM** | Source code management platform | github, gitlab, bitbucket | +| **Notifier** | Communication/alert channel | slack, discord, desktop, webhook | +| **Terminal** | Human interaction interface | iterm2, web terminal, none | + +--- + +## System Architecture + +``` + ┌──────────────────────────────────────┐ + CLI ───REST───► │ Orchestrator Server │ + │ (Next.js) │ + Web ───REST/───► │ │ + SSE │ ┌────────────┐ ┌────────────────┐ │ + │ │ Session │ │ Plugin │ │ + Agents ────────► │ │ Manager │ │ Registry │ │ + (heartbeat/ │ └──────┬─────┘ └───────┬────────┘ │ + webhook) │ │ │ │ + │ ┌──────┴─────┐ ┌───────┴────────┐ │ + │ │ Lifecycle │ │ Config │ │ + │ │ Manager │ │ Manager │ │ + │ └──────┬─────┘ └────────────────┘ │ + │ │ │ + │ ┌──────┴──────────────────────────┐ │ + │ │ Event Bus │ │ + │ │ (pub/sub + persistence) │ │ + │ └──┬──────┬──────┬──────┬────────┘ │ + └─────┼──────┼──────┼──────┼──────────┘ + │ │ │ │ + ┌───────┘ │ │ └───────┐ + ▼ ▼ ▼ ▼ + ┌─────────┐ ┌────────┐ ┌────────┐ ┌─────────┐ + │ SSE → │ │Notifier│ │Reaction│ │ Event │ + │ Web UI │ │Plugins │ │ Engine │ │ Log │ + └─────────┘ └────────┘ └────────┘ └─────────┘ +``` + +### Data Flow + +1. **Agent → Server**: Heartbeats, status updates, "need input" signals +2. **Server → Dashboard**: SSE stream of session state changes +3. **Server → Notifiers**: Alerts when human attention is needed +4. **Server → Agents**: Commands via runtime-specific channels (tmux send-keys, docker exec, HTTP POST, etc.) +5. **CLI → Server**: REST API calls for spawn, kill, send, status +6. **SCM → Server**: PR state, CI checks, review comments (polled or webhooks) + +--- + +## The 8 Plugin Slots + +### 1. Runtime — Where sessions execute + +```typescript +interface Runtime { + readonly name: string; + + // Lifecycle + create(session: SessionConfig): Promise; + destroy(handle: RuntimeHandle): Promise; + + // Communication + sendMessage(handle: RuntimeHandle, message: string): Promise; + getOutput(handle: RuntimeHandle, lines?: number): Promise; + + // Health + isAlive(handle: RuntimeHandle): Promise; + getMetrics(handle: RuntimeHandle): Promise; + + // Optional: interactive access + attach?(handle: RuntimeHandle): Promise; +} +``` + +| Implementation | How it works | Best for | +|---------------|-------------|----------| +| `tmux` (default) | tmux sessions + send-keys | Local development, interactive | +| `process` | Child processes + stdin/stdout | Headless, CI/CD, scripting | +| `docker` | Docker containers + exec | Isolation, reproducibility | +| `kubernetes` | K8s pods/jobs | Scale, enterprise | +| `ssh` | SSH to remote + tmux/process | Remote machines | +| `e2b` | E2B SDK (Firecracker microVMs) | Cloud sandboxes | +| `fly` | Fly.io Machines API | Cost-effective cloud | +| `modal` | Modal Sandboxes | GPU, autoscaling | + +### 2. Agent — AI coding tool + +```typescript +interface Agent { + readonly name: string; + readonly processName: string; // for detection + + // Launch + getLaunchCommand(session: SessionConfig, project: ProjectConfig): string; + getEnvironment(session: SessionConfig): Record; + + // Activity detection + detectActivity(session: Session): Promise; + isProcessRunning(runtimeHandle: RuntimeHandle): Promise; + + // Introspection + introspect(session: Session): Promise; + + // Optional + postLaunchSetup?(session: Session): Promise; + estimateCost?(session: Session): Promise; +} +``` + +| Implementation | Launch command | Activity detection | +|---------------|---------------|-------------------| +| `claude-code` (default) | `claude --dangerously-skip-permissions` | JSONL mtime + process tree | +| `claude-headless` | `claude -p --output-format stream-json` | stdout parsing | +| `codex` | `codex` | Process detection | +| `aider` | `aider --no-auto-commits` | Process detection | +| `goose` | `goose session` | Process detection | +| `custom` | User-defined command | Configurable | + +### 3. Workspace — Code isolation + +```typescript +interface Workspace { + readonly name: string; + + create(project: ProjectConfig, session: SessionConfig): Promise; + destroy(path: WorkspacePath): Promise; + list(project: ProjectConfig): Promise; + + // Optional hooks + postCreate?(path: WorkspacePath, project: ProjectConfig): Promise; +} +``` + +| Implementation | How | Tradeoff | +|---------------|-----|----------| +| `worktree` (default) | `git worktree add` | Fast, shared objects, requires same repo | +| `clone` | `git clone` | Full isolation, slower, more disk | +| `copy` | `cp -r` | No git dependency, heaviest | +| `volume` | Docker/K8s volume mounts | For container runtimes | + +### 4. Tracker — Issue/task tracking + +```typescript +interface Tracker { + readonly name: string; + + getIssue(identifier: string): Promise; + isCompleted(identifier: string): Promise; + issueUrl(identifier: string): string; + branchName(identifier: string): string; + generatePrompt(identifier: string, project: ProjectConfig): string; + + // Optional + listIssues?(filters?: IssueFilters): Promise; + updateIssue?(identifier: string, update: IssueUpdate): Promise; + createIssue?(input: CreateIssueInput): Promise; +} +``` + +| Implementation | API | Auth | +|---------------|-----|------| +| `github` (default) | `gh` CLI | GitHub token | +| `linear` | GraphQL API | Linear API key | +| `jira` | REST API | Jira token | +| `plain` | Local files | None | + +### 5. SCM — Source code platform (PR, CI, Reviews) + +```typescript +interface SCM { + readonly name: string; + + // PR lifecycle + detectPR(session: Session): Promise; + getPRState(pr: PRInfo): Promise; + createPR(session: Session, title: string, body: string): Promise; + mergePR(pr: PRInfo, method?: MergeMethod): Promise; + closePR(pr: PRInfo): Promise; + + // CI tracking + getCIChecks(pr: PRInfo): Promise; + getCISummary(pr: PRInfo): Promise; + + // Review tracking + getReviews(pr: PRInfo): Promise; + getReviewDecision(pr: PRInfo): Promise; + getPendingComments(pr: PRInfo): Promise; + getAutomatedComments(pr: PRInfo): Promise; + + // Merge readiness + getMergeability(pr: PRInfo): Promise; +} +``` + +| Implementation | API | Features | +|---------------|-----|----------| +| `github` (default) | `gh` CLI + REST API | Full PR/CI/review support | +| `gitlab` | REST API | MR/pipeline/review support | +| `bitbucket` | REST API | PR/pipeline support | + +### 6. Notifier — Alerts and communication + +```typescript +interface Notifier { + readonly name: string; + + notify(event: OrchestratorEvent): Promise; + + // Optional: richer communication + post?(message: string, context?: NotifyContext): Promise; +} +``` + +| Implementation | Channel | Best for | +|---------------|---------|----------| +| `desktop` (default) | OS notifications | Solo developer | +| `slack` | Slack messages | Teams | +| `discord` | Discord messages | Communities | +| `webhook` | HTTP POST | Custom integrations | +| `email` | Email | Async/digest | + +### 7. Terminal — Human interaction interface + +```typescript +interface Terminal { + readonly name: string; + + openSession(session: Session): Promise; + openAll(sessions: Session[]): Promise; + + // Optional + isSessionOpen?(session: Session): Promise; +} +``` + +| Implementation | How | Platform | +|---------------|-----|----------| +| `auto` (default) | Detect best available | Any | +| `iterm2` | AppleScript API | macOS | +| `web` | xterm.js in browser | Any | +| `tmux-attach` | `tmux attach` in terminal | Any with tmux | +| `none` | Headless | CI/CD | + +### 8. Lifecycle Manager (Core — not pluggable) + +The Lifecycle Manager is the orchestrator's brain. It: +- Polls SCM + Agent plugins on configurable intervals +- Maintains state machine per session +- Emits events on state transitions +- Runs configured reactions +- Feeds real-time data to dashboard via SSE + +--- + +## Session Lifecycle State Machine + +``` + ┌──────────┐ + │ SPAWNING │ + └────┬─────┘ + │ runtime.create() + agent launched + ▼ + ┌──────────┐ + ┌─────│ WORKING │◄─────────────────────────┐ + │ └────┬─────┘ │ + │ │ PR detected │ + │ ▼ │ + │ ┌──────────────┐ │ + │ │ PR_OPEN │ │ + │ └────┬─────────┘ │ + │ │ │ + │ ┌────┴────────────┐ │ + │ ▼ ▼ │ + │ ┌──────────┐ ┌─────────────────┐ │ + │ │ CI_FAILED│ │ REVIEW_PENDING │ │ + │ └────┬─────┘ └────┬────────────┘ │ + │ │ │ │ + │ │ ┌──────────┴──────┐ │ + │ │ ▼ ▼ │ + │ │ ┌──────────────┐ ┌──────────┐ │ + │ │ │CHANGES_REQ'D │ │ APPROVED │ │ + │ │ └──────┬───────┘ └────┬─────┘ │ + │ │ │ │ │ + │ └────────┼───────────────┘ │ + │ │ agent fixes │ + │ └──────────────────────────┘ + │ + │ When approved + CI green + no conflicts: + │ ▼ + │ ┌──────────┐ + │ │MERGEABLE │──► auto-merge or notify human + │ └────┬─────┘ + │ │ + │ ▼ + │ ┌──────────┐ + │ │ MERGED │ + │ └────┬─────┘ + │ │ + │ ▼ + │ ┌──────────┐ + │ │ CLEANUP │──► destroy workspace + archive metadata + │ └──────────┘ + │ + │ At any point: + │ ┌───────────────┐ + ├────►│ NEEDS_INPUT │──► notify human + │ └───────────────┘ + │ ┌───────────────┐ + ├────►│ STUCK/IDLE │──► notify human after threshold + │ └───────────────┘ + │ ┌───────────────┐ + ├────►│ ERRORED │──► notify human + │ └───────────────┘ + │ ┌───────────────┐ + └────►│ KILLED │──► cleanup + └───────────────┘ +``` + +--- + +## Human Attention Optimization + +The core differentiator. The system proactively manages human attention: + +### Notification Events (when to alert humans) + +| Event | Priority | Default Action | +|-------|----------|---------------| +| **Agent needs input** (permission prompt, question) | HIGH | Desktop + Slack notification | +| **Agent stuck** (idle > threshold, no progress) | HIGH | Desktop notification after 5min, Slack after 15min | +| **Agent errored** (crashed, unhandled error) | HIGH | Immediate notification | +| **PR ready for review** | MEDIUM | Notification with PR link | +| **CI failed** | LOW | Auto-send fix prompt to agent, notify if 2nd failure | +| **Review comments** | LOW | Auto-send to agent, notify if not addressed in 30min | +| **PR approved + CI green** | MEDIUM | Notification with merge button | +| **Session completed** | LOW | Summary notification | +| **All sessions idle** | INFO | "All agents done" digest | + +### Reactions (configurable auto-responses) + +```yaml +# agent-orchestrator.yaml +reactions: + ci-failed: + auto: true # automatically handled + action: send-to-agent + message: "CI is failing. Run `gh pr checks` to see failures, fix them, and push." + retries: 2 + escalate-after: 2 # notify human after 2 failed attempts + + changes-requested: + auto: true + action: send-to-agent + message: "Review comments on your PR. Check with `gh pr view --comments` and address each one." + escalate-after: 30m # notify human if not resolved + + bugbot-comments: + auto: true + action: send-to-agent + message: "Automated review comments found. Fix the issues flagged by the bot." + + approved-and-green: + auto: false # require human confirmation by default + action: notify + message: "PR is ready to merge" + # Set auto: true to enable auto-merge + + agent-stuck: + threshold: 10m # idle for 10 minutes + action: notify + message: "Agent appears stuck" + + agent-needs-input: + action: notify + priority: high + message: "Agent is waiting for input" + + agent-exited: + action: notify + message: "Agent process has exited" +``` + +### Attention Dashboard + +The web dashboard is designed around attention priority: +- **Red zone** (top): Sessions needing human input RIGHT NOW +- **Yellow zone**: Sessions with issues (CI failed, stuck, review comments) +- **Green zone**: Sessions working normally +- **Grey zone**: Completed/merged sessions + +--- + +## Configuration + +### Minimal Config (works out of the box) + +```yaml +# agent-orchestrator.yaml +projects: + my-app: + repo: org/repo + path: ~/my-app +``` + +Everything else uses sensible defaults: +- Runtime: tmux +- Agent: claude-code +- Workspace: worktree +- Tracker: github (inferred from repo) +- SCM: github (inferred from repo) +- Notifier: desktop +- Terminal: auto-detect + +### Full Config + +```yaml +# agent-orchestrator.yaml +dataDir: ~/.agent-orchestrator # metadata storage +worktreeDir: ~/.worktrees # workspace root +port: 3000 # web dashboard port + +defaults: + runtime: tmux + agent: claude-code + workspace: worktree + notifiers: [desktop] + +projects: + my-app: + name: My App + repo: org/repo + path: ~/my-app + defaultBranch: main + sessionPrefix: app + + # Override defaults per project + agent: claude-code + runtime: tmux + + # Issue tracker + tracker: + plugin: linear + teamId: "abc-123" + + # SCM (usually inferred from repo) + scm: + plugin: github + + # Symlinks to copy into workspaces + symlinks: [.env, .claude] + + # Commands to run after workspace creation + postCreate: + - "pnpm install" + - "claude mcp add rube --transport http https://rube.app/mcp" + + # Agent-specific config + agentConfig: + permissions: skip # --dangerously-skip-permissions + model: opus + + # Reaction overrides + reactions: + approved-and-green: + auto: true # enable auto-merge for this project + +# Notification channels +notifiers: + slack: + plugin: slack + webhook: ${SLACK_WEBHOOK_URL} + channel: "#agent-updates" + desktop: + plugin: desktop + +# Reaction defaults (can be overridden per project) +reactions: + ci-failed: + auto: true + retries: 2 + escalate-after: 2 + changes-requested: + auto: true + escalate-after: 30m + approved-and-green: + auto: false + agent-stuck: + threshold: 10m + agent-needs-input: + priority: high +``` + +--- + +## Tech Stack + +| Segment | Choice | Why | +|---------|--------|-----| +| **Core library** | TypeScript | Shared types across all packages | +| **Web + API** | Next.js 15 (App Router) | SSR + API routes in one process | +| **Styling** | Tailwind CSS | Dark theme, responsive | +| **Real-time** | Server-Sent Events | One-way push, auto-reconnect, simpler than WebSocket | +| **CLI** | TypeScript + Commander.js | Shares types with core | +| **Config** | YAML + Zod validation | Human-readable, type-safe | +| **State** | Flat metadata files + Event log (JSONL) | Stateless orchestrator, crash recovery | +| **Package manager** | pnpm workspaces | Fast, monorepo-native | +| **Distribution** | npm (`npx agent-orchestrator`) | Zero install | + +### Why TypeScript Throughout + +1. **One language** — Plugin authors only need TypeScript/JavaScript +2. **Shared types** — No serialization boundaries between core, web, CLI, plugins +3. **npm distribution** — `npx agent-orchestrator` works everywhere +4. **Next.js** — Web + API server in one process, great DX +5. **Largest ecosystem** — More packages on npm than any other registry +6. **Performance is fine** — Bottleneck is AI agents, not orchestrator. We shell out to tmux/git/docker anyway. + +--- + +## Directory Structure + +``` +agent-orchestrator/ +├── package.json +├── pnpm-workspace.yaml +├── tsconfig.base.json +├── agent-orchestrator.yaml.example +│ +├── packages/ +│ ├── core/ # @agent-orchestrator/core +│ │ └── src/ +│ │ ├── types.ts # All interfaces + types +│ │ ├── config.ts # YAML config loader + Zod validation +│ │ ├── session-manager.ts # Session CRUD +│ │ ├── lifecycle-manager.ts # State machine + reactions +│ │ ├── event-bus.ts # Pub/sub + JSONL persistence +│ │ ├── plugin-registry.ts # Plugin discovery + loading +│ │ ├── metadata.ts # Flat-file read/write +│ │ └── index.ts +│ │ +│ ├── cli/ # @agent-orchestrator/cli → `ao` binary +│ │ └── src/ +│ │ ├── index.ts # Commander.js setup +│ │ └── commands/ +│ │ ├── init.ts # ao init +│ │ ├── status.ts # ao status +│ │ ├── spawn.ts # ao spawn [issue] +│ │ ├── batch-spawn.ts # ao batch-spawn +│ │ ├── session.ts # ao session [ls|kill|cleanup] +│ │ ├── send.ts # ao send +│ │ ├── review-check.ts # ao review-check [project] +│ │ ├── dashboard.ts # ao dashboard (starts web) +│ │ └── open.ts # ao open [session|all] +│ │ +│ ├── web/ # @agent-orchestrator/web +│ │ ├── next.config.ts +│ │ └── src/ +│ │ ├── app/ +│ │ │ ├── layout.tsx +│ │ │ ├── page.tsx # Dashboard (attention-prioritized) +│ │ │ └── sessions/[id]/ +│ │ │ └── page.tsx # Session detail +│ │ ├── api/ +│ │ │ ├── sessions/ # CRUD + actions +│ │ │ ├── spawn/ # POST spawn +│ │ │ ├── events/ # SSE stream +│ │ │ └── health/ # Server health +│ │ └── components/ +│ │ ├── SessionCard.tsx +│ │ ├── AttentionZone.tsx +│ │ ├── PRStatus.tsx +│ │ ├── CIBadge.tsx +│ │ └── Terminal.tsx # xterm.js +│ │ +│ └── plugins/ # Built-in plugins +│ ├── runtime-tmux/ +│ ├── runtime-process/ +│ ├── runtime-docker/ +│ ├── agent-claude-code/ +│ ├── agent-codex/ +│ ├── agent-aider/ +│ ├── workspace-worktree/ +│ ├── workspace-clone/ +│ ├── tracker-github/ +│ ├── tracker-linear/ +│ ├── scm-github/ +│ ├── notifier-desktop/ +│ ├── notifier-slack/ +│ ├── terminal-iterm2/ +│ └── terminal-web/ +│ +├── artifacts/ # Research + design docs +│ ├── competitive-research.md +│ └── architecture-design.md +│ +├── scripts/ # Original bash scripts (reference) +│ +├── CLAUDE.md +└── CLAUDE.orchestrator.md +``` + +--- + +## Implementation Phases + +### Phase 1: Foundation (Dog-food ready) +- Monorepo scaffolding +- Core types + interfaces +- Config loader +- Session manager + lifecycle manager + event bus +- tmux runtime, claude-code agent, worktree workspace +- GitHub SCM (PR/CI/review tracking) +- GitHub tracker +- Desktop notifier +- CLI (init, status, spawn, session, send, dashboard) +- Web dashboard with attention-prioritized view +- SSE real-time updates +- Reaction engine (CI failed, changes requested, agent stuck) + +### Phase 2: Multi-Runtime + More Plugins +- Process runtime (headless claude -p) +- Docker runtime +- Codex + Aider agent adapters +- Linear + Jira trackers +- Slack notifier +- Web terminal (xterm.js) + +### Phase 3: Cloud + Scale +- Kubernetes runtime +- E2B / Fly.io runtimes +- Cost tracking +- Webhook-triggered spawning + +### Phase 4: Team + Enterprise +- Dashboard auth +- Role-based access +- Remote session support +- Audit log diff --git a/artifacts/competitive-research.md b/artifacts/competitive-research.md new file mode 100644 index 000000000..f0e166e8f --- /dev/null +++ b/artifacts/competitive-research.md @@ -0,0 +1,409 @@ +# Competitive Research — Agent Orchestration Tools + +*Compiled: 2026-02-13* + +## Overview + +Research into 16+ projects that orchestrate AI coding agents. The goal: understand abstractions, architectures, and gaps to build the best, most extensible agent orchestrator. + +--- + +## Tier 1: Direct Competitors (Multi-Agent Orchestrators) + +### Gas Town (Steve Yegge) + +- **GitHub**: https://github.com/steveyegge/gastown +- **Stack**: Go 1.23+ (~189K LOC), SQLite3, Git 2.25+, tmux 3.0+ +- **Stars**: Growing rapidly (released Jan 2026) + +**Architecture — MEOW Stack (Molecular Expression of Work):** + +| Layer | What | How | +|-------|------|-----| +| **Beads** | Atomic work units | JSONL files tracked in Git. IDs like `gt-abc12`. Universal data/control plane. | +| **Epics** | Hierarchical collections | Organize beads into tree structures for parallel/sequential execution | +| **Molecules** | Workflow graphs | Sequenced beads with dependencies, gates, loops | +| **Protomolecules & Formulas** | Reusable templates | TOML format workflow definitions | + +**Agent Roles (7 roles, 2 scopes):** + +| Role | Scope | Purpose | +|------|-------|---------| +| **Mayor** | Town | Chief AI coordinator with full workspace context | +| **Deacon** | Town | Health daemon running patrol loops | +| **Dogs** | Town | Maintenance helpers | +| **Crew** | Rig | Named, persistent agents for sustained design/review work | +| **Polecats** | Rig | Ephemeral "cattle" workers spawned for specific tasks | +| **Refinery** | Rig | Merge queue manager handling conflicts | +| **Witness** | Rig | Supervises polecats, unblocks stuck work | + +**Other Abstractions:** +- **Town** — Workspace directory (`~/gt/`) housing all projects +- **Rigs** — Project containers wrapping git repositories +- **Hooks** — Git worktree-based persistent storage surviving crashes +- **Convoys** — Work-tracking bundles grouping multiple beads for an agent +- **GUPP** — Agents must execute work on their hooks; scheduling persists across restarts + +**Runtime Backends:** claude, gemini, codex, cursor, auggie, amp (per-rig config) + +**Communication/Isolation:** +- Git worktrees for filesystem isolation per agent +- Beads/Hooks for coordination (external state, not shared context windows) +- GUPP: deterministic handoffs through version control, not LLM-judged phase gates + +**Strengths:** Most architecturally ambitious. Crash recovery via git-backed Beads. Role-based agent hierarchy. Multi-agent support. +**Weaknesses:** ~$100/hr token burn, auto-merged failing tests, agents causing unexpected deletions. Go-only ecosystem. No web dashboard. Optimized for autonomous, not human-in-the-loop. + +--- + +### Par (Coplane) + +- **GitHub**: https://github.com/coplane/par +- **Stack**: Python 3.12+ +- **Closest to our current approach** + +**Key Abstractions:** +- **Sessions**: Single-repo isolated branches via git worktrees + tmux sessions +- **Workspaces**: Multi-repo synchronized development contexts +- **Control Center**: Unified tmux session with windows for each context +- **Labels**: Globally unique, human-readable names + +**Features:** +- `par start my-feature` — creates worktree + branch + tmux session +- `par send