agent-orchestrator/scripts/claude-dashboard

1372 lines
67 KiB
Bash
Executable File

#!/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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Integrator Orchestrator Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 32px; min-height: 100vh; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 1100px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 28px; }
.header h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.3px; }
.header h1 span { color: #7c8aff; }
.timestamp { color: #484f58; font-size: 12px; }
.stats { display: flex; gap: 32px; margin-bottom: 36px; padding: 0 4px; }
.stat { display: flex; align-items: baseline; gap: 8px; }
.stat .n { font-size: 28px; font-weight: 700; }
.stat .l { color: #484f58; font-size: 13px; }
.g { color: #3fb950; } .b { color: #58a6ff; } .y { color: #d29922; } .p { color: #bc8cff; } .r { color: #f85149; }
.section { margin-bottom: 36px; }
.section-head { font-size: 13px; font-weight: 600; color: #484f58; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 12px; padding-left: 4px; }
/* Session cards */
.sessions { display: flex; flex-direction: column; gap: 8px; }
.session-group { border: 1px solid #30363d; border-radius: 12px; overflow: hidden; }
.session-group > .card { border-radius: 0; border-left: none; border-right: none; border-top: none; border-bottom: 1px solid #21262d; }
.session-group > .card:last-child { border-bottom: none; }
.session-group > .card.working { border-left: 3px solid #3fb950; }
.session-group > .card.idle { border-left: 3px solid #30363d; }
.session-group > .card.exited { border-left: 3px solid #f85149; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 14px 18px; cursor: pointer; transition: border-color 0.15s; }
.card:hover { border-color: #484f58; }
.card.working { border-left: 3px solid #3fb950; }
.card.idle { border-left: 3px solid #30363d; }
.card.exited { border-left: 3px solid #f85149; }
.card.has-alerts { border-color: rgba(210,153,34,0.4); }
.card.has-alerts-red { border-color: rgba(248,81,73,0.35); }
.card-top { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.card-icon { font-size: 14px; flex-shrink: 0; width: 20px; text-align: center; }
.card-name { font-weight: 600; font-size: 13px; color: #7d8590; flex-shrink: 0; }
.card-purpose { font-size: 14px; font-weight: 500; color: #e6edf3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
.card-age { color: #484f58; font-size: 11px; flex-shrink: 0; margin-right: 4px; }
.card-meta { display: flex; align-items: center; gap: 8px; padding-left: 30px; flex-wrap: wrap; }
.card-branch { font-family: 'SF Mono', 'Menlo', monospace; font-size: 11px; color: #484f58; }
.pill { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; letter-spacing: 0.2px; }
.pill.ci-pass { background: rgba(63,185,80,0.1); color: #3fb950; }
.pill.ci-fail { background: rgba(248,81,73,0.15); color: #f85149; }
.pill.ci-pending { background: rgba(210,153,34,0.1); color: #d29922; }
.pill.approved { background: rgba(63,185,80,0.1); color: #3fb950; }
.pill.size { background: rgba(125,133,144,0.08); color: #484f58; }
.pill.pr-num { background: rgba(88,166,255,0.1); color: #58a6ff; }
.sep { color: #30363d; font-size: 10px; }
/* Alert tags — these are the big, prominent ones */
.card-alerts { display: flex; align-items: center; gap: 6px; padding-left: 30px; margin-top: 8px; flex-wrap: wrap; }
.alert-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; letter-spacing: 0.1px; cursor: pointer; }
.alert-tag:hover { filter: brightness(1.3); }
.alert-tag.review { background: rgba(210,153,34,0.15); color: #d29922; border: 1px solid rgba(210,153,34,0.3); }
.alert-tag.conflict { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
.alert-tag.comments { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
.alert-tag.changes { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
.alert-tag.ci-fail { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
.alert-tag.merge { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid rgba(63,185,80,0.4); font-size: 13px; padding: 4px 14px; }
.alert-tag.merged { background: rgba(163,113,247,0.15); color: #a371f7; border: 1px solid rgba(163,113,247,0.3); font-size: 13px; padding: 4px 14px; }
.alert-tag .alert-num { font-size: 14px; font-weight: 800; }
.card.ready-to-merge { border-color: rgba(63,185,80,0.5); }
.card.merged { border-color: rgba(163,113,247,0.4); opacity: 0.75; }
.action-btn { background: none; border: 1px solid rgba(88,166,255,0.3); color: #58a6ff; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; flex-shrink: 0; white-space: nowrap; }
.action-btn:hover { background: rgba(88,166,255,0.1); border-color: #58a6ff; }
.action-btn:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
.kill-btn { background: none; border: 1px solid rgba(248,81,73,0.4); color: #f85149; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; flex-shrink: 0; }
.kill-btn:hover { background: rgba(248,81,73,0.15); border-color: #f85149; }
.open-btn { margin-left: auto; background: none; border: 1px solid #30363d; color: #7d8590; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; flex-shrink: 0; }
.open-btn:hover { border-color: #58a6ff; color: #58a6ff; }
/* Expandable detail panel */
.card-detail { max-height: 0; overflow: hidden; transition: max-height 0.25s ease-out, padding 0.25s ease-out; padding: 0 18px 0 30px; }
.card.expanded .card-detail { max-height: 5000px; padding: 12px 18px 4px 30px; }
.detail-content { max-height: 150px; overflow: hidden; position: relative; }
.detail-content.expanded { max-height: none; }
.detail-content:not(.expanded)::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 40px; background: linear-gradient(transparent, #161b22); pointer-events: none; }
.show-more-btn { background: none; border: none; color: #58a6ff; font-size: 12px; cursor: pointer; padding: 4px 0; font-family: inherit; }
.show-more-btn:hover { text-decoration: underline; }
.detail-footer { border-top: 1px solid #21262d; margin-top: 8px; padding-top: 8px; display: flex; gap: 8px; }
.card.expanded { border-color: #484f58; }
.detail-section { margin-bottom: 10px; }
.detail-label { font-size: 10px; color: #484f58; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600; }
.detail-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 12px; }
.detail-row .check-icon { width: 14px; text-align: center; flex-shrink: 0; }
.detail-row .check-name { color: #7d8590; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.detail-row a { color: #58a6ff; font-size: 11px; flex-shrink: 0; }
.detail-summary { font-size: 12px; color: #7d8590; line-height: 1.5; }
/* PR table */
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; color: #484f58; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 12px; border-bottom: 1px solid #21262d; font-weight: 600; }
.tbl td { padding: 10px 12px; border-bottom: 1px solid #161b22; font-size: 13px; }
.tbl tr:hover td { background: rgba(88,166,255,0.03); }
.tbl tr.has-issues td { background: rgba(248,81,73,0.03); }
.tbl .title { font-weight: 500; max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tbl .dim { color: #484f58; }
.tbl .comments-cell { font-size: 15px; font-weight: 800; text-align: center; }
.tbl .comments-cell.red { color: #f85149; }
.tbl .comments-cell.zero { color: #30363d; font-weight: 400; }
.notes-section { margin-top: 10px; }
.notes-area { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-family: inherit; font-size: 12px; padding: 8px 10px; resize: vertical; min-height: 60px; line-height: 1.5; }
.notes-area:focus { outline: none; border-color: #58a6ff; }
.notes-area::placeholder { color: #484f58; }
.notes-bar { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.notes-save { background: none; border: 1px solid rgba(63,185,80,0.3); color: #3fb950; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; }
.notes-save:hover { background: rgba(63,185,80,0.1); border-color: #3fb950; }
.notes-save:disabled { opacity: 0.4; cursor: default; }
.notes-status { font-size: 11px; color: #484f58; }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
.pulse { animation: pulse 2s ease-in-out infinite; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><span>Integrator</span> Orchestrator</h1>
<div style="display:flex;align-items:center;gap:10px">
<button class="open-btn" onclick="openTerminal('orchestrator',event)" style="padding:4px 14px;font-size:12px">orchestrator terminal</button>
<div class="timestamp" id="timestamp"></div>
</div>
</div>
<div class="stats" id="stats"></div>
<div class="section">
<div class="section-head">Sessions</div>
<div class="sessions" id="sessions"></div>
</div>
<div class="section">
<div class="section-head">Pull Requests</div>
<table class="tbl"><thead><tr><th>PR</th><th>Title</th><th>Size</th><th>CI</th><th>Review</th><th>Unresolved</th><th>Age</th></tr></thead><tbody id="pr-tbody"></tbody></table>
</div>
</div>
<script>
HTMLEOF
# Inject data
cat >> "$DASHBOARD" << DATAEOF
const sessions = $sessions_json;
const prDetails = $pr_json;
const counts = {total:$total, working:$working, openPrs:$open_prs, needsReview:$needs_review};
DATAEOF
cat >> "$DASHBOARD" << 'JSEOF'
function timeAgo(d) {
const h = Math.floor((Date.now() - new Date(d)) / 3600000);
return h < 1 ? 'now' : h < 24 ? h+'h' : Math.floor(h/24)+'d';
}
function mergePR(repo, num, ev) {
ev.stopPropagation();
ev.preventDefault();
if (!confirm('Merge PR #' + num + '?')) return;
const btn = ev.target.closest('.alert-tag');
btn.textContent = 'merging...';
btn.style.pointerEvents = 'none';
fetch('/api/merge/' + encodeURIComponent(repo) + '/' + num).then(r => r.json()).then(r => {
if (r.ok) {
btn.textContent = 'merged!';
btn.className = 'alert-tag merged';
const card = btn.closest('.card');
card.classList.remove('ready-to-merge');
card.classList.add('merged');
card.style.opacity = '0.75';
} else {
btn.textContent = 'failed: ' + (r.error || 'unknown');
btn.style.pointerEvents = '';
}
});
}
function prNum(url) { return url?.match(/\/pull\/(\d+)/)?.[1] || null; }
function ci(s) {
if (!s || s === 'none') return {l:'—', c:''};
let ok=0,fail=0,pend=0;
s.split(',').forEach(p => { const m=p.trim().match(/(\d+)\s+(\w+)/); if(!m)return; const[,n,t]=m;
if(t==='success')ok+=+n; else if(t==='failure')fail+=+n; else if(t==='pending'||t==='in_progress')pend+=+n; });
if(fail) return {l:fail+' failed',c:'ci-fail'}; if(pend) return {l:pend+' pending',c:'ci-pending'};
if(ok) return {l:'passing',c:'ci-pass'}; return {l:'—',c:''};
}
function openSession(name) {
fetch('/api/open/' + encodeURIComponent(name)).then(r => r.json()).then(r => {
const btn = event.target.closest('.open-btn');
if (r.ok) {
btn.textContent = 'opened';
btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950';
} else {
btn.textContent = 'error';
btn.style.borderColor = '#f85149'; btn.style.color = '#f85149';
}
setTimeout(() => { btn.textContent = 'open'; btn.style.borderColor = ''; btn.style.color = ''; }, 1500);
});
}
function openTerminal(name, ev) {
ev.stopPropagation();
const btn = ev.target.closest('.open-btn');
btn.textContent = 'connecting...';
btn.disabled = true;
fetch('/api/terminal/' + encodeURIComponent(name)).then(r => r.json()).then(r => {
if (r.ok) {
window.open(r.url, '_blank');
btn.textContent = 'terminal';
btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950';
} else {
btn.textContent = r.error || 'error';
btn.style.borderColor = '#f85149'; btn.style.color = '#f85149';
}
btn.disabled = false;
setTimeout(() => { btn.textContent = 'terminal'; btn.style.borderColor = ''; btn.style.color = ''; }, 1500);
});
}
function killSession(name, ev) {
ev.stopPropagation();
if (!confirm('Kill session ' + name + '?')) return;
const card = ev.target.closest('.card');
fetch('/api/kill/' + encodeURIComponent(name)).then(r => r.json()).then(r => {
if (r.ok) {
card.style.transition = 'opacity 0.3s, max-height 0.3s';
card.style.opacity = '0';
card.style.maxHeight = card.offsetHeight + 'px';
setTimeout(() => { card.style.maxHeight = '0'; card.style.padding = '0'; card.style.margin = '0'; card.style.overflow = 'hidden'; }, 300);
setTimeout(() => card.remove(), 600);
} else {
ev.target.closest('.kill-btn').textContent = 'error';
}
});
}
function saveNotes(session) {
const textarea = document.getElementById('notes-' + session);
const btn = document.getElementById('notes-save-' + session);
const status = document.getElementById('notes-status-' + session);
btn.disabled = true;
btn.textContent = 'saving...';
status.textContent = '';
fetch('/api/notes/' + encodeURIComponent(session), {method:'POST', body: textarea.value})
.then(r => r.json())
.then(r => {
if (r.ok) { btn.textContent = 'saved'; status.textContent = ''; btn.style.borderColor = 'rgba(63,185,80,0.5)'; }
else { btn.textContent = 'error'; status.textContent = r.error || ''; btn.style.borderColor = 'rgba(248,81,73,0.4)'; }
setTimeout(() => { btn.textContent = 'save'; btn.disabled = false; btn.style.borderColor = ''; }, 1500);
})
.catch(() => { btn.textContent = 'error'; btn.disabled = false; });
}
function removePR(session, ev) {
ev.stopPropagation();
if (!confirm('Remove PR association from ' + session + '?')) return;
const btn = ev.target.closest('.action-btn');
btn.textContent = 'removing...';
btn.disabled = true;
fetch('/api/remove-pr/' + encodeURIComponent(session)).then(r => r.json()).then(r => {
if (r.ok) {
btn.textContent = 'removed';
btn.style.color = '#3fb950';
// Remove PR pills and alerts from the card
const card = btn.closest('.card');
const meta = card.querySelector('.card-meta');
if (meta) { const pills = meta.querySelectorAll('.pill.pr-num, .pill.size'); pills.forEach(p => p.remove()); }
const alerts = card.querySelector('.card-alerts');
if (alerts) alerts.innerHTML = '';
} else {
btn.textContent = 'error';
btn.disabled = false;
}
});
}
function askAgent(session, message, ev) {
ev.stopPropagation();
const btn = ev.target.closest('.action-btn');
const origText = btn.textContent;
btn.textContent = 'sending...';
btn.disabled = true;
fetch('/api/send/' + encodeURIComponent(session), {method:'POST', body: message})
.then(r => r.json())
.then(r => {
if (r.ok) { btn.textContent = 'sent!'; btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950'; }
else { btn.textContent = 'failed'; btn.style.borderColor = '#f85149'; btn.style.color = '#f85149'; }
})
.catch(() => { btn.textContent = 'error'; btn.disabled = false; });
}
// Merge-readiness score (lower = easier to merge = sorted first)
function mergeScore(prList) {
if (!prList.length) return 100; // no PR = bottom
if (prList.every(pr => pr.state === 'MERGED')) return 200; // merged = very bottom
let score = 0;
for (const pr of prList) {
if (pr.mergeable === 'CONFLICTING') score += 40;
const ciR = ci(pr.ciSummary);
if (ciR.c === 'ci-fail') score += 30;
else if (ciR.c === 'ci-pending') score += 5;
if (pr.reviewDecision === 'CHANGES_REQUESTED') score += 20;
else if (pr.reviewDecision !== 'APPROVED') score += 10; // needs review
score += (pr.unresolvedThreads || 0) * 5;
}
return score;
}
// Build enriched session data for sorting
function enrichSession(s) {
const prUrls = s.pr ? s.pr.split(',').filter(Boolean) : [];
const prNums = prUrls.map(u => prNum(u)).filter(Boolean);
const prList = prNums.map(n => ({num: n, url: prUrls.find(u => u.includes('/'+n)), ...prDetails[n]})).filter(p => p.title);
return { ...s, prList, _score: mergeScore(prList) };
}
// Stats
const st = document.getElementById('stats');
st.innerHTML = [
{n:counts.total, l:'sessions', c:'b'}, {n:counts.working, l:'working', c:'g'},
{n:counts.openPrs, l:'open PRs', c:'p'}, {n:counts.needsReview, l:'need review', c:'y'},
].map(s => `<div class="stat"><span class="n ${s.c}">${s.n}</span><span class="l">${s.l}</span></div>`).join('');
// Sessions — sorted by ease of merging (lowest score first)
const el = document.getElementById('sessions');
const enriched = sessions.map(enrichSession);
enriched.sort((a, b) => {
// Working sessions first
if (a.activity === 'working' && b.activity !== 'working') return -1;
if (b.activity === 'working' && a.activity !== 'working') return 1;
// Exited last
if (a.activity === 'exited' && b.activity !== 'exited') return 1;
if (b.activity === 'exited' && a.activity !== 'exited') return -1;
// Then by merge readiness
return a._score - b._score;
});
// Expand sessions into one card per PR (sessions with no PR get one card)
const cards = [];
enriched.forEach(s => {
if (s.prList.length <= 1) {
cards.push({session: s, pr: s.prList[0] || null});
} else {
s.prList.forEach(pr => cards.push({session: s, pr}));
}
});
cards.forEach(({session: s, pr: currentPr}) => {
const ico = s.activity==='working' ? '<span class="pulse">⚡</span>' : s.activity==='exited' ? '💀' : '💤';
const purpose = currentPr?.title || s.summary || s.status || s.branch;
const isMerged = currentPr?.state === 'MERGED';
const repo = 'ComposioHQ/integrator';
// Pills
let pills = [];
let alerts = [];
let hasRedAlert = false;
if (currentPr) {
pills.push(`<a href="${currentPr.url}" target="_blank" class="pill pr-num">#${currentPr.num}</a>`);
const sz = (currentPr.additions||0)+(currentPr.deletions||0);
const sl = sz>1000?'XL':sz>500?'L':sz>200?'M':sz>50?'S':'XS';
pills.push(`<span class="pill size">+${currentPr.additions||0} -${currentPr.deletions||0} ${sl}</span>`);
if (isMerged) {
pills.push(`<span class="pill" style="background:rgba(163,113,247,0.1);color:#a371f7">merged</span>`);
} else {
// CI
const prCi = ci(currentPr.ciSummary);
if (prCi.c === 'ci-pass') pills.push(`<span class="pill ci-pass">${prCi.l}</span>`);
else if (prCi.c === 'ci-fail') {
const failCheck = (currentPr.ciChecks||[]).find(c => c.status === 'FAILURE');
const fUrl = failCheck?.url || currentPr.url + '/checks';
const failCount = (currentPr.ciChecks||[]).filter(c => c.status === 'FAILURE').length;
alerts.push(`<a href="${fUrl}" target="_blank" class="alert-tag ci-fail" onclick="event.stopPropagation()">${failCount} CI check${failCount > 1 ? 's' : ''} failing</a>`);
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Please fix the failing CI checks on ${currentPr.url}',event)">ask to fix CI</button>`);
hasRedAlert = true;
}
else if (prCi.c === 'ci-pending') pills.push(`<span class="pill ci-pending">${prCi.l}</span>`);
// Review
if (currentPr.reviewDecision==='APPROVED') pills.push('<span class="pill approved">approved</span>');
else if (currentPr.reviewDecision==='CHANGES_REQUESTED') {
alerts.push(`<a href="${currentPr.url}" target="_blank" class="alert-tag changes" onclick="event.stopPropagation()">changes requested</a>`);
hasRedAlert = true;
} else {
alerts.push(`<a href="${currentPr.url}" target="_blank" class="alert-tag review" onclick="event.stopPropagation()">needs review</a>`);
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Post ${currentPr.url} on slack asking for a review. Do not ask for permission, just post it.',event)">ask to post for review</button>`);
}
// Merge conflict
if (currentPr.mergeable==='CONFLICTING') {
alerts.push(`<a href="${currentPr.url}" target="_blank" class="alert-tag conflict" onclick="event.stopPropagation()">merge conflict</a>`);
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Please resolve the merge conflicts on ${currentPr.url} by checking out the branch and rebasing on the base branch',event)">ask to fix conflicts</button>`);
hasRedAlert = true;
}
// Unresolved comments
const unresolvedList = currentPr.unresolvedList || [];
if ((currentPr.unresolvedThreads||0) > 0) {
const firstCommentUrl = unresolvedList[0]?.url || currentPr.url + '/files';
alerts.push(`<a href="${firstCommentUrl}" target="_blank" class="alert-tag comments" onclick="event.stopPropagation()"><span class="alert-num">${currentPr.unresolvedThreads}</span> unresolved comments</a>`);
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Please address all unresolved review comments on ${currentPr.url}',event)">ask to resolve</button>`);
hasRedAlert = true;
}
// Ready to merge
const prCi2 = ci(currentPr.ciSummary);
if (currentPr.reviewDecision === 'APPROVED' && prCi2.c === 'ci-pass' && currentPr.mergeable !== 'CONFLICTING' && (currentPr.unresolvedThreads||0) === 0) {
alerts = [`<a href="#" class="alert-tag merge" onclick="mergePR('${repo}','${currentPr.num}',event)">merge PR #${currentPr.num}</a>`];
pills = pills.filter(p => !p.includes('approved'));
}
}
}
// Slack pill
if (s.slack) pills.push(`<a href="${s.slack}" target="_blank" class="pill" style="background:rgba(88,166,255,0.08);color:#58a6ff" onclick="event.stopPropagation()">slack</a>`);
// Detail panel — scoped to this PR only
let detailHtml = '';
if (s.summary && currentPr?.title && s.summary !== currentPr.title) {
detailHtml += `<div class="detail-section"><div class="detail-label">Summary</div><div class="detail-summary">${s.summary}</div></div>`;
}
if (s.issue) detailHtml += `<div class="detail-section"><div class="detail-label">Issue</div><div class="detail-summary"><a href="${s.issue}" target="_blank">${s.issue}</a></div></div>`;
if (s.slack) detailHtml += `<div class="detail-section"><div class="detail-label">Slack</div><div class="detail-summary"><a href="${s.slack}" target="_blank">${s.slack}</a></div></div>`;
if (currentPr) {
const checks = currentPr.ciChecks || [];
if (checks.length) {
detailHtml += `<div class="detail-section"><div class="detail-label">CI Checks</div>`;
const order = {FAILURE: 0, IN_PROGRESS: 1, PENDING: 2, SUCCESS: 3, NEUTRAL: 4, SKIPPED: 5};
checks.sort((a,b) => (order[a.status]??9) - (order[b.status]??9));
checks.forEach(c => {
const icon = c.status === 'SUCCESS' ? '<span class="g">✓</span>' : c.status === 'FAILURE' ? '<span class="r">✗</span>' : c.status === 'PENDING' || c.status === 'IN_PROGRESS' ? '<span class="y">●</span>' : '<span class="dim">○</span>';
const link = c.url ? `<a href="${c.url}" target="_blank">view</a>` : '';
detailHtml += `<div class="detail-row">${icon} <span class="check-name">${c.name}</span> ${link}</div>`;
});
detailHtml += `</div>`;
}
const threads = currentPr.unresolvedList || [];
if (threads.length) {
detailHtml += `<div class="detail-section"><div class="detail-label">Unresolved Comments</div>`;
threads.forEach(t => {
const file = t.path || 'general';
const link = t.url ? `<a href="${t.url}" target="_blank">go to comment</a>` : '';
detailHtml += `<div class="detail-row"><span class="r">●</span> <span class="check-name">${file}</span> ${link}</div>`;
});
detailHtml += `</div>`;
}
detailHtml += `<div class="detail-section"><div class="detail-label">PR Details</div><div class="detail-summary"><a href="${currentPr.url}" target="_blank">${currentPr.title}</a><br><span class="g">+${currentPr.additions||0}</span> <span class="r">-${currentPr.deletions||0}</span> · mergeable: ${currentPr.mergeable || 'unknown'} · review: ${currentPr.reviewDecision || 'none'}${currentPr.createdAt ? ' · opened ' + timeAgo(currentPr.createdAt) : ''}</div></div>`;
}
if (!detailHtml && !currentPr) detailHtml = `<div class="detail-section"><div class="detail-summary dim">No PR associated with this session.</div></div>`;
// Notes (only on first card for this session to avoid duplicates)
const isFirstCard = cards.findIndex(c => c.session.name === s.name) === cards.indexOf(cards.find(c => c.session === s && c.pr === currentPr));
const cardIdx = cards.filter(c => c.session.name === s.name).indexOf(cards.find(c => c.session === s && c.pr === currentPr));
if (cardIdx === 0) {
const escapedNotes = (s.notes || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
detailHtml += `<div class="detail-section notes-section"><div class="detail-label">Notes</div><textarea class="notes-area" id="notes-${s.name}" placeholder="Add notes..." onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">${escapedNotes}</textarea><div class="notes-bar"><button class="notes-save" onclick="event.stopPropagation();saveNotes('${s.name}')" id="notes-save-${s.name}">save</button><span class="notes-status" id="notes-status-${s.name}"></span></div></div>`;
}
const uid = currentPr ? s.name + '-pr' + currentPr.num : s.name;
detailHtml = `<div class="detail-content" id="detail-${uid}">${detailHtml}</div><button class="show-more-btn" onclick="event.stopPropagation();const c=document.getElementById('detail-${uid}');c.classList.toggle('expanded');this.textContent=c.classList.contains('expanded')?'Show less':'Show more'">Show more</button><div class="detail-footer">${currentPr ? `<button class="action-btn" onclick="removePR('${s.name}', event)" style="border-color:rgba(210,153,34,0.4);color:#d29922">remove PR association</button>` : ''}<button class="kill-btn" onclick="killSession('${s.name}', event)">terminate session</button></div>`;
const readyToMerge = currentPr && !isMerged && currentPr.reviewDecision === 'APPROVED' && ci(currentPr.ciSummary).c === 'ci-pass' && currentPr.mergeable !== 'CONFLICTING' && (currentPr.unresolvedThreads||0) === 0;
const cardClass = `card ${s.activity}${isMerged ? ' merged' : readyToMerge ? ' ready-to-merge' : hasRedAlert ? ' has-alerts-red' : alerts.length ? ' has-alerts' : ''}`;
const d = document.createElement('div');
d.className = cardClass;
d.onclick = (e) => { if (e.target.closest('a, button, textarea')) return; d.classList.toggle('expanded'); };
d.innerHTML = `<div class="card-top"><span class="card-icon">${ico}</span><span class="card-name">${s.name}</span><span class="card-purpose">${purpose}</span>${currentPr?.createdAt ? '<span class="card-age">' + timeAgo(currentPr.createdAt) + '</span>' : ''}${isMerged || s.activity === 'exited' ? `<button class="kill-btn" onclick="killSession('${s.name}', event)">kill session</button>` : ''}${currentPr ? `<button class="open-btn" onclick="event.stopPropagation();window.open('${currentPr.url}','_blank')">open PR</button>` : ''}<button class="open-btn" onclick="openTerminal('${s.name}',event)">terminal</button><button class="open-btn" onclick="event.stopPropagation();openSession('${s.name}')">open</button></div><div class="card-meta"><span class="card-branch">${s.branch}</span><span class="sep">·</span>${pills.join('')}</div>${alerts.length ? '<div class="card-alerts">' + alerts.join('') + '</div>' : ''}<div class="card-detail">${detailHtml}</div>`;
// Group cards from same session into a shared container
const sessionCount = cards.filter(c => c.session.name === s.name).length;
if (sessionCount > 1) {
let group = el.querySelector(`[data-session-group="${s.name}"]`);
if (!group) {
group = document.createElement('div');
group.className = 'session-group';
group.dataset.sessionGroup = s.name;
el.appendChild(group);
}
group.appendChild(d);
} else {
el.appendChild(d);
}
});
// PR table — sorted by ease of merge (same logic)
const tbody = document.getElementById('pr-tbody');
Object.keys(prDetails)
.filter(n => prDetails[n].state==='OPEN')
.sort((a,b) => {
// Score: lower = easier to merge = top
function prScore(n) {
const pr = prDetails[n];
let sc = 0;
if (pr.mergeable === 'CONFLICTING') sc += 40;
const c = ci(pr.ciSummary);
if (c.c === 'ci-fail') sc += 30;
if (pr.reviewDecision === 'CHANGES_REQUESTED') sc += 20;
else if (pr.reviewDecision !== 'APPROVED') sc += 10;
sc += (pr.unresolvedThreads || 0) * 5;
return sc;
}
return prScore(a) - prScore(b);
})
.forEach(num => {
const pr = prDetails[num];
const sz = pr.additions+pr.deletions;
const sl = sz>1000?'XL':sz>500?'L':sz>200?'M':sz>50?'S':'XS';
const ciR = ci(pr.ciSummary);
const failCheck = (pr.ciChecks||[]).find(c => c.status === 'FAILURE');
const rv = pr.reviewDecision==='APPROVED' ? '<span class="pill approved">approved</span>'
: pr.reviewDecision==='CHANGES_REQUESTED' ? '<span class="alert-tag changes" style="font-size:11px;padding:2px 8px">changes requested</span>'
: '<span class="alert-tag review" style="font-size:11px;padding:2px 8px">needs review</span>';
const ciTd = ciR.c === 'ci-fail'
? `<a href="${failCheck?.url || '#'}" target="_blank" class="alert-tag ci-fail" style="font-size:11px;padding:2px 8px">${ciR.l}</a>`
: ciR.c ? `<span class="pill ${ciR.c}">${ciR.l}</span>` : '<span class="dim">—</span>';
const hasIssues = (pr.unresolvedThreads||0) > 0 || ciR.c === 'ci-fail' || pr.mergeable === 'CONFLICTING';
const sess = sessions.find(s => s.pr && s.pr.includes('/pull/'+num));
const repo = 'ComposioHQ/integrator';
const firstComment = (pr.unresolvedList||[])[0]?.url || `https://github.com/${repo}/pull/${num}/files`;
const tr = document.createElement('tr');
if (hasIssues) tr.className = 'has-issues';
tr.innerHTML = `
<td><a href="https://github.com/${repo}/pull/${num}" target="_blank">#${num}</a></td>
<td class="title">${pr.title}</td>
<td><span class="g">+${pr.additions}</span> <span class="r">-${pr.deletions}</span> <span class="dim">${sl}</span></td>
<td>${ciTd}</td>
<td>${rv}</td>
<td class="comments-cell ${(pr.unresolvedThreads||0) > 0 ? 'red' : 'zero'}">${(pr.unresolvedThreads||0) > 0 ? `<a href="${firstComment}" target="_blank" style="color:inherit">${pr.unresolvedThreads}</a>` : '0'}</td>
<td class="dim">${timeAgo(pr.createdAt)}</td>`;
tbody.appendChild(tr);
});
document.getElementById('timestamp').textContent = new Date().toLocaleString();
// Live activity polling
setInterval(() => {
fetch('/api/sessions').then(r => r.json()).then(data => {
let workingCount = 0;
document.querySelectorAll('.card').forEach(card => {
const nameEl = card.querySelector('.card-name');
if (!nameEl) return;
const name = nameEl.textContent;
const newActivity = data[name];
if (!newActivity) return;
const oldActivity = card.classList.contains('working') ? 'working'
: card.classList.contains('exited') ? 'exited' : 'idle';
if (newActivity === oldActivity) { if (newActivity === 'working') workingCount++; return; }
// Update card class
card.classList.remove('working', 'idle', 'exited');
card.classList.add(newActivity);
// Update icon
const iconEl = card.querySelector('.card-icon');
if (iconEl) {
iconEl.innerHTML = newActivity === 'working' ? '<span class="pulse">⚡</span>'
: newActivity === 'exited' ? '💀' : '💤';
}
// Show/hide action buttons based on new activity
card.querySelectorAll('.action-btn').forEach(btn => {
btn.style.display = newActivity === 'working' ? 'none' : '';
});
if (newActivity === 'working') workingCount++;
});
// Update working count in stats
const statNums = document.querySelectorAll('.stat .n.g');
if (statNums.length) statNums[0].textContent = workingCount;
document.getElementById('timestamp').textContent = new Date().toLocaleString();
}).catch(() => {});
}, 5000);
</script>
</body>
</html>
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"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Claude Orchestrator</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif; background:#0d1117; color:#e6edf3;
display:flex; align-items:center; justify-content:center; min-height:100vh; flex-direction:column; gap:16px; }
.spinner { width:32px; height:32px; border:3px solid #30363d; border-top-color:#7c8aff; border-radius:50%;
animation:spin 0.8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.text { color:#7d8590; font-size:14px; }
.sub { color:#484f58; font-size:12px; }
</style></head><body>
<div class="spinner"></div>
<div class="text">Refreshing dashboard...</div>
<div class="sub" id="elapsed"></div>
<script>
const t0 = Date.now();
setInterval(() => {
document.getElementById('elapsed').textContent = ((Date.now()-t0)/1000).toFixed(0) + 's';
fetch('/api/regen-status').then(r=>r.json()).then(d => {
if (d.done) {
fetch('/dashboard').then(r=>r.text()).then(html => {
document.open(); document.write(html); document.close();
history.replaceState(null, '', '/');
});
}
}).catch(()=>{});
}, 1000);
</script></body></html>"""
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)"