#!/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)"