#!/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
Pull Requests
| PR | Title | Size | CI | Review | Unresolved | Age |
|---|
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)"