diff --git a/plugins/claude-status-hub/bin/refresh-daemon.sh b/plugins/claude-status-hub/bin/refresh-daemon.sh index b17620a..c3b0163 100755 --- a/plugins/claude-status-hub/bin/refresh-daemon.sh +++ b/plugins/claude-status-hub/bin/refresh-daemon.sh @@ -10,7 +10,7 @@ BASE_FULL=270 # 4.5 min base (3x light) CEILING_LIGHT=3600 # 1 hour ceiling CEILING_FULL=10800 # 3 hours ceiling -LOCKFILE="/tmp/status-hub-daemon.lock" +LOCKDIR="/tmp/status-hub-daemon.lock.d" CONFIG="$HOME/.claude/status-config.json" BRIDGE="/tmp/status-hub.json" PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}" @@ -19,6 +19,21 @@ FULL_SKILL="${PLUGIN_ROOT}/skills/hub-refresh.md" PR_SCRIPT="${PLUGIN_ROOT}/bin/refresh-prs.sh" FULL_ALLOWED="Read,Write,Bash,mcp__claude-in-chrome__*,mcp__plugin_sentry_sentry__*,mcp__tradingview__*" +# Semver comparison: returns 0 if $1 > $2 +version_gt() { + [ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] && [ "$1" != "$2" ] +} + +# Atomic lock acquisition using mkdir (portable to macOS and Linux) +acquire_lock() { + if mkdir "$LOCKDIR" 2>/dev/null; then + echo "$$" > "$LOCKDIR/pid" + echo "$PLUGIN_VERSION" > "$LOCKDIR/version" + return 0 + fi + return 1 +} + # Calculate intervals based on idle time (grows linearly from base to ceiling) get_intervals() { local now_ms=$(($(date +%s) * 1000)) @@ -95,28 +110,36 @@ check_focus_break() { # Get plugin version for staleness detection PLUGIN_VERSION=$(jq -r '.version' "${PLUGIN_ROOT}/.claude-plugin/plugin.json" 2>/dev/null || echo "unknown") -# Prevent multiple daemons (version-aware) -if [ -f "$LOCKFILE" ]; then - LOCK_CONTENT=$(cat "$LOCKFILE" 2>/dev/null) - OLD_VERSION="${LOCK_CONTENT%%:*}" - OLD_PID="${LOCK_CONTENT##*:}" +# Prevent multiple daemons (version-aware, atomic locking) +if [ -d "$LOCKDIR" ]; then + OLD_PID=$(cat "$LOCKDIR/pid" 2>/dev/null) + OLD_VERSION=$(cat "$LOCKDIR/version" 2>/dev/null) if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then if [ "$OLD_VERSION" = "$PLUGIN_VERSION" ]; then - exit 0 # Same version daemon running, all good + # Same version daemon running, exit + exit 0 + fi + if version_gt "$PLUGIN_VERSION" "$OLD_VERSION"; then + # We're newer - kill old daemon and take over + kill "$OLD_PID" 2>/dev/null + sleep 1 + rm -rf "$LOCKDIR" + else + # Old daemon is same or newer version, exit + exit 0 fi - # Version mismatch - kill old daemon so we can start fresh - kill "$OLD_PID" 2>/dev/null - sleep 1 + else + # Stale lock (PID not running) - remove it + rm -rf "$LOCKDIR" fi - rm -f "$LOCKFILE" fi -# Write our version:PID to lockfile -echo "${PLUGIN_VERSION}:$$" > "$LOCKFILE" +# Acquire atomic lock +acquire_lock || exit 0 # Cleanup on exit -trap "rm -f $LOCKFILE" EXIT INT TERM +trap "rm -rf '$LOCKDIR'" EXIT INT TERM # Main loop LAST_FULL_REFRESH=0 @@ -129,7 +152,7 @@ while true; do # Self-check: exit if plugin was updated (new version will spawn fresh daemon) INSTALLED_VERSION=$(jq -r '.version' "${PLUGIN_ROOT}/.claude-plugin/plugin.json" 2>/dev/null || echo "unknown") if [ "$INSTALLED_VERSION" != "$PLUGIN_VERSION" ]; then - rm -f "$LOCKFILE" + rm -rf "$LOCKDIR" exit 0 fi diff --git a/plugins/claude-status-hub/commands/hub.md b/plugins/claude-status-hub/commands/hub.md index f352295..ad3b06e 100644 --- a/plugins/claude-status-hub/commands/hub.md +++ b/plugins/claude-status-hub/commands/hub.md @@ -6,171 +6,65 @@ argument-hint: # Status Hub - Main Router -Parse `$ARGUMENTS` and route to appropriate sub-skill: +Parse `$ARGUMENTS` and route: | Pattern | Route | |---------|-------| -| `list` | Use `hub-tree` skill | -| `ack` or `ack #N` | Use `hub-ack` skill with argument | -| `manage` | Use `hub-manage` skill | -| `play ` | Use `hub-play` skill with query | -| `off`, `clear`, `disable` | Use `hub-off` skill | -| `setup` | Use `hub-setup` skill | -| `custom ` | Use `hub-custom` skill | -| GitHub PR URL(s) | Track PR(s) - see below | -| Known service name | Set background - see below | -| Unknown service | Check for custom skill or redirect to `/hub-custom` | - -## Initialization (always first) - -Before any operation, ensure config files exist: - -1. If `~/.claude/status-config.json` doesn't exist, create it: - ```json - { - "background": null, - "foreground": [] - } - ``` - -2. If `/tmp/status-hub.json` doesn't exist, create it: - ```json - {"timestamp": null, "background": null, "foreground": []} - ``` +| `list` | `hub-tree` | +| `ack [#N]` | `hub-ack` | +| `manage` | `hub-manage` | +| `play ` | `hub-play` | +| `off`/`clear`/`disable` | `hub-off` | +| `setup` | `hub-setup` | +| `custom ` | `hub-custom` | +| GitHub PR URL(s) | Track PR(s) | +| Known service | Set background | +| Unknown | Check for custom skill or invoke `/hub-custom` | + +## Init + +Ensure `~/.claude/status-config.json` and `/tmp/status-hub.json` exist with default structure. ## Track GitHub PR(s) -If argument contains GitHub PR URL(s) (`github.com/.../pull/...`): +If URL contains `github.com/.../pull/...`: -1. Parse owner, repo, and PR number from each URL -2. Run gh CLI to get PR status: ```bash gh pr view --repo / --json state,isDraft,reviewDecision,statusCheckRollup,title,number,comments --jq '{ state,isDraft,reviewDecision,title,number, checksCount: (.statusCheckRollup | length), checksPassed: ([.statusCheckRollup[] | select(.conclusion == "SUCCESS")] | length), checksFailed: ([.statusCheckRollup[] | select(.conclusion == "FAILURE")] | length), - checksPending: ([.statusCheckRollup[] | select(.conclusion == null or .conclusion == "PENDING")] | length), - commentsCount: (.comments | length) + checksPending: ([.statusCheckRollup[] | select(.conclusion == null or .conclusion == "PENDING")] | length) }' ``` -3. Determine status icon (worst wins): - - X = Checks failing - - ~ = Checks pending - - ! = Changes requested - - ? = Review required - - D = Draft - - ✓ = Approved + all checks pass -4. Read `~/.claude/status-config.json` for lastSeen state -5. Update config with new PR(s) in `foreground` array: - - Check if PR already exists (match by owner/repo/number) - - If exists: update in place using jq `|=` operator - - If new: append to existing array with `+= [$new_item]` - - **NEVER replace the entire foreground array - always merge** (see `docs/data-safety-guidelines.md`) -6. Write bridge file `/tmp/status-hub.json` - set timestamp at root, preserve `background`, update `foreground` array: - - **CRITICAL:** Generate current timestamp in milliseconds: `$(($(date +%s) * 1000))` - - ```json - { - "timestamp": , - "background": { ... preserve existing ... }, - "foreground": [ - { - "site": "github-pr", - "icon": "X", - "title": "PR #123", - "detail": "2 failing", - "hasAlert": true - } - ] - } - ``` - Note: `hasAlert` drives display mode (expanded vs compact). Foreground is an array supporting multiple tracked items. - - **IMPORTANT:** When writing bridge, always fetch current background status from browser tab first - never use stale/placeholder values. -7. Say "Tracking PR #N: [status]" - -## Set Background Service - -If argument is a service name (`youtube-music`, `spotify`, `gmail`): -1. Get browser tabs via `mcp__claude-in-chrome__tabs_context_mcp` -2. Find tab for service or navigate to it -3. Extract status using service-specific JavaScript (see extraction details below) -4. Update `~/.claude/status-config.json` with `background.service`, `background.tabId`, and extracted details -5. Write bridge file `/tmp/status-hub.json` - set timestamp at root, update `background`, preserve `foreground`: +Icon priority: `X` fails, `~` pending, `!` changes requested, `?` review needed, `D` draft, `🚀` merge-ready, `✓` approved. - **CRITICAL:** Generate current timestamp in milliseconds: `$(($(date +%s) * 1000))` +Update config foreground array, write bridge with timestamp: `$(($(date +%s) * 1000))`. - ```json - { - "timestamp": , - "background": {"site": "youtube-music", "icon": ">", "title": "Song", "detail": "Artist"}, - "foreground": [ ... preserve existing array ... ] - } - ``` -6. Say "Background: [service] - [status]" +## Set Background Service -## Service Extraction +Get browser tab via `mcp__claude-in-chrome__tabs_context_mcp`, extract status via JavaScript. -### youtube-music +**YouTube Music:** ```javascript -(() => { - const title = document.querySelector('ytmusic-player-bar .title')?.textContent || ''; - const artist = document.querySelector('ytmusic-player-bar .byline')?.textContent || ''; - const isPlaying = document.querySelector('ytmusic-player-bar #play-pause-button')?.getAttribute('aria-label')?.toLowerCase().includes('pause') || false; - return JSON.stringify({ - site: 'youtube-music', - icon: isPlaying ? '>' : '||', - title, detail: artist - }); -})() +({ title: document.querySelector('.title.ytmusic-player-bar')?.textContent || '', + artist: (document.querySelector('.byline.ytmusic-player-bar')?.textContent || '').split('•')[0].trim(), + isPlaying: document.querySelector('#play-pause-button')?.getAttribute('aria-label')?.toLowerCase().includes('pause') }) ``` -### spotify +**Spotify:** ```javascript -(() => { - const track = document.querySelector('[data-testid="now-playing-widget"] [data-testid="context-item-link"]')?.textContent || ''; - const artist = document.querySelector('[data-testid="now-playing-widget"] [data-testid="context-item-info-artist"]')?.textContent || ''; - const isPlaying = document.querySelector('[data-testid="control-button-playpause"]')?.getAttribute('aria-label')?.toLowerCase().includes('pause') || false; - return JSON.stringify({ - site: 'spotify', - icon: isPlaying ? '>' : '||', - title: track, detail: artist - }); -})() +({ title: document.querySelector('[data-testid="context-item-link"]')?.textContent || '', + artist: document.querySelector('[data-testid="context-item-info-artist"]')?.textContent || '', + isPlaying: document.querySelector('[data-testid="control-button-playpause"]')?.getAttribute('aria-label')?.toLowerCase().includes('pause') }) ``` -### gmail -```javascript -(() => { - const match = document.title.match(/\(([0-9]+)\)/); - const unreadCount = match?.[1] || '0'; - const latestSubject = document.querySelector('tr.zE .bog')?.textContent?.substring(0, 30) || ''; - return JSON.stringify({ - site: 'gmail', - icon: 'M', - title: unreadCount + ' unread', - detail: latestSubject - }); -})() -``` - -## Unknown Service Handling - -If the argument doesn't match any known pattern above: +**Gmail:** Extract unread count from `document.title.match(/\(([0-9]+)\)/)?.[1]`. -1. Check if a custom skill exists: - ```bash - ls ${CLAUDE_PLUGIN_ROOT}/skills/hub-refresh-*.md 2>/dev/null - ``` +Update background in config and bridge. -2. If skill found (`hub-refresh-.md` or `hub-refresh-.user.md`): - - Use the skill to set up tracking - - Add to config and start monitoring +## Unknown Service -3. If no skill found: - - **Automatically invoke `/hub-custom` skill** with the user's request - - Do NOT tell the user to run `/hub-custom` themselves - just do it for them - - This provides better UX by seamlessly handling unknown services +Check for `${CLAUDE_PLUGIN_ROOT}/skills/hub-refresh-.md` or `.user.md`. If found, use it. Otherwise, invoke `/hub-custom` automatically. diff --git a/plugins/claude-status-hub/hooks/refresh.sh b/plugins/claude-status-hub/hooks/refresh.sh index ccceb58..b9cdb3a 100755 --- a/plugins/claude-status-hub/hooks/refresh.sh +++ b/plugins/claude-status-hub/hooks/refresh.sh @@ -1,66 +1,14 @@ #!/bin/bash -# Status Hub - Background Refresh Hook +# Status Hub - User Activity Hook +# ONLY updates lastActivity timestamp for adaptive daemon intervals. +# The daemon handles all refreshes - this hook does NOT spawn Claude CLI. -# Debug logging -DEBUG_LOG="/tmp/status-hub-debug.log" -log() { echo "[$(date '+%H:%M:%S')] $1" >> "$DEBUG_LOG"; } -log "Hook triggered, CLAUDE_PLUGIN_ROOT=$CLAUDE_PLUGIN_ROOT" - -CONFIG="$HOME/.claude/status-config.json" BRIDGE="/tmp/status-hub.json" -SKILL="${CLAUDE_PLUGIN_ROOT}/skills/hub-refresh.md" -LOG="/tmp/status-hub-refresh.log" -LOCKFILE="/tmp/status-hub.lock" -# Always update lastActivity (user activity tracking for adaptive intervals) +# Update lastActivity timestamp (resets daemon's adaptive interval) NOW_MS=$(($(date +%s) * 1000)) if [ -f "$BRIDGE" ]; then jq --argjson ts "$NOW_MS" '.lastActivity = $ts' "$BRIDGE" > "${BRIDGE}.tmp" && mv "${BRIDGE}.tmp" "$BRIDGE" - log "Updated lastActivity to $NOW_MS" -fi - -# No config = nothing to track -[ -f "$CONFIG" ] || { log "No config, exiting"; exit 0; } - -# Prevent pileup - skip if lock < 2min old -if [ -f "$LOCKFILE" ]; then - LOCK_MTIME=$(stat -f %m "$LOCKFILE" 2>/dev/null || stat -c %Y "$LOCKFILE" 2>/dev/null || echo 0) - LOCK_AGE=$(($(date +%s) - LOCK_MTIME)) - [ "$LOCK_AGE" -lt 120 ] && { log "Lock active (${LOCK_AGE}s), skipping"; exit 0; } -fi - -# Check bridge freshness - skip if < 60s old -if [ -f "$BRIDGE" ]; then - BRIDGE_TS=$(jq -r '.timestamp // 0' "$BRIDGE" 2>/dev/null) - NOW_MS=$(($(date +%s) * 1000)) - AGE_MS=$((NOW_MS - BRIDGE_TS)) - [ "$AGE_MS" -lt 60000 ] && { log "Bridge fresh (${AGE_MS}ms), skipping"; exit 0; } fi -# Check if anything to refresh -SERVICE=$(jq -r '.background.service // "off"' "$CONFIG" 2>/dev/null) -FG_COUNT=$(jq -r '.foreground | length' "$CONFIG" 2>/dev/null || echo 0) -[ "$SERVICE" = "off" ] && [ "$FG_COUNT" = "0" ] && { log "Nothing tracked, skipping"; exit 0; } - -# Create lock -touch "$LOCKFILE" -log "Starting refresh: service=$SERVICE, fg_count=$FG_COUNT" - -# Build allowed tools list -ALLOWED="Read,Write,Bash,mcp__claude-in-chrome__*,mcp__plugin_sentry_sentry__*,mcp__tradingview__*" - -log "Spawning background CLI with --chrome" - -# Detach completely from hook process -nohup bash -c ' - echo "[$(date "+%H:%M:%S")] Background process started" >> "'"$DEBUG_LOG"'" - OUTPUT=$(claude -p --chrome --allowedTools "'"$ALLOWED"'" -- \ - "Read and follow the hub-refresh skill at '"$SKILL"' to refresh status hub. Config: '"$CONFIG"', Bridge: '"$BRIDGE"'" 2>&1) - echo "$OUTPUT" > "'"$LOG"'" - echo "[$(date "+%H:%M:%S")] Background process completed" >> "'"$DEBUG_LOG"'" - rm -f "'"$LOCKFILE"'" -' > /dev/null 2>&1 & - -disown -log "Hook exiting, background spawned" exit 0 diff --git a/plugins/claude-status-hub/skills/connection-detect.md b/plugins/claude-status-hub/skills/connection-detect.md index 69a0dd4..25325e3 100644 --- a/plugins/claude-status-hub/skills/connection-detect.md +++ b/plugins/claude-status-hub/skills/connection-detect.md @@ -1,214 +1,77 @@ # Connection Detection -Shared logic for detecting and selecting the best available connection method for calendar and Slack integrations. +Shared logic for detecting and selecting connection methods. -## Connection Hierarchies +## Hierarchies | Service | Priority 1 | Priority 2 | Priority 3 | Priority 4 | |---------|------------|------------|------------|------------| | Calendar | Chrome MCP | Playwright | - | - | -| Slack | Slack MCP | Chrome MCP | Playwright | API (legacy) | - -## Config Structure - -```json -{ - "calendar": { - "connection": "auto", - "chrome": { "tabId": null }, - "playwright": { "profile": "default", "headless": false } - }, - "slack": { - "connection": "auto", - "workspace": "mycompany.slack.com", - "chrome": { "tabId": null }, - "playwright": { "profile": "default", "headless": false } - } -} -``` +| Slack | Slack MCP | Chrome MCP | Playwright | API | ## Service URLs -Default URLs for auto-opening tabs when not found: - ```javascript const SERVICE_URLS = { calendar: 'https://calendar.google.com/calendar/u/0/r/day', - slack: (config) => `https://${config.slack?.workspace || 'app.slack.com'}/client`, + slack: (cfg) => `https://${cfg.slack?.workspace || 'app.slack.com'}/client`, youtube_music: 'https://music.youtube.com', spotify: 'https://open.spotify.com' }; ``` -## Auto-Open Tab (Shared Logic) +## Auto-Open Tab -When a Chrome tab is not found, auto-open it instead of failing. This is the recovery flow used by all Chrome-bound services. +When Chrome tab missing, auto-open instead of failing: ```javascript -async function ensureTabOpen(service, config, configPath = '~/.claude/status-config.json') { +async function ensureTabOpen(service, config) { const storedTabId = config[service]?.chrome?.tabId; - - // Step 1: Check if stored tab is still valid if (storedTabId) { - try { - const context = await mcp__claude-in-chrome__tabs_context_mcp({ createIfEmpty: false }); - const tabExists = context?.tabs?.some(t => t.id === storedTabId); - if (tabExists) return { tabId: storedTabId, wasRecovered: false }; - } catch (e) { /* tab not found, will recover */ } + const ctx = await mcp__claude-in-chrome__tabs_context_mcp({ createIfEmpty: false }); + if (ctx?.tabs?.some(t => t.id === storedTabId)) return { tabId: storedTabId, wasRecovered: false }; } - - // Step 2: Tab not found - auto-open new one - const url = typeof SERVICE_URLS[service] === 'function' - ? SERVICE_URLS[service](config) - : SERVICE_URLS[service]; - - if (!url) return { error: `No URL configured for ${service}` }; - - // Create new tab and navigate + const url = typeof SERVICE_URLS[service] === 'function' ? SERVICE_URLS[service](config) : SERVICE_URLS[service]; + if (!url) return { error: `No URL for ${service}` }; const newTab = await mcp__claude-in-chrome__tabs_create_mcp(); - const tabId = newTab.tabId; - await mcp__claude-in-chrome__navigate({ url, tabId }); - - // Wait for page load + await mcp__claude-in-chrome__navigate({ url, tabId: newTab.tabId }); await new Promise(r => setTimeout(r, 2000)); - - // Step 3: Update config with new tabId - config[service] = config[service] || {}; - config[service].chrome = config[service].chrome || {}; - config[service].chrome.tabId = tabId; - // Config should be written by caller after successful operation - - return { tabId, wasRecovered: true, url }; + config[service] = config[service] || {}; config[service].chrome = { tabId: newTab.tabId }; + return { tabId: newTab.tabId, wasRecovered: true, url }; } ``` -**Usage in refresh skills:** -```javascript -const { tabId, wasRecovered, error } = await ensureTabOpen('calendar', config); -if (error) return { error }; -// Proceed with tabId - it's now guaranteed valid -// If wasRecovered, save config after successful refresh -``` - -## Generic MCP Check - -All MCP availability checks follow this pattern: +## MCP Check ```javascript async function checkMcpAvailable(testCall) { - try { - const result = await testCall(); - return { installed: true, ready: result && !result.error }; - } catch (e) { - const notFound = e.message?.includes('not found') || - e.message?.includes('unknown tool') || - e.message?.includes('MCP server'); - return { installed: !notFound, ready: false }; - } + try { const r = await testCall(); return { installed: true, ready: r && !r.error }; } + catch (e) { return { installed: !e.message?.includes('not found'), ready: false }; } } - -// Usage: -const chrome = await checkMcpAvailable(() => - mcp__claude-in-chrome__tabs_context_mcp({ createIfEmpty: false })); - -const playwright = await checkMcpAvailable(() => - mcp__playwright__browser_navigate({ url: 'about:blank' })); - -const slackMcp = await checkMcpAvailable(() => - mcp__slack__list_channels({ limit: 1 })); ``` ## Detection Logic ```javascript async function detectConnection(config, service) { - const preferred = config[service]?.connection || 'auto'; - if (preferred === 'disabled') return { method: 'disabled' }; - if (preferred !== 'auto') return { method: preferred }; - - // Auto-detect based on service hierarchy - const hierarchy = service === 'calendar' - ? ['chrome', 'playwright'] - : ['mcp', 'chrome', 'playwright', 'api']; - - for (const method of hierarchy) { - const status = await checkMethodAvailable(method, config, service); - if (status.ready) return { method, ...status }; - if (status.installed && method === 'chrome') { - // Auto-recover: ensure tab is open, creating if needed + const pref = config[service]?.connection || 'auto'; + if (pref === 'disabled') return { method: 'disabled' }; + if (pref !== 'auto') return { method: pref }; + const hierarchy = service === 'calendar' ? ['chrome', 'playwright'] : ['mcp', 'chrome', 'playwright', 'api']; + for (const m of hierarchy) { + const s = await checkMethodAvailable(m, config, service); + if (s.ready) return { method: m, ...s }; + if (s.installed && m === 'chrome') { const { tabId, wasRecovered, error } = await ensureTabOpen(service, config); - if (error) continue; // Try next method - return { method: 'chrome', tabId, wasRecovered }; + if (!error) return { method: 'chrome', tabId, wasRecovered }; } } - return { method: 'unavailable', error: 'No connection method available' }; -} -``` - -**Note:** When `wasRecovered: true`, the caller should save the updated config after a successful refresh to persist the new tabId. - -## API Credentials Check (Slack only) - -```javascript -function checkSlackApiCredentials() { - const tokenFile = `${HOME}/.claude/slack-token.json`; - const credsFile = `${HOME}/.claude/slack-credentials.json`; - try { - const token = JSON.parse(fs.readFileSync(tokenFile)); - const creds = JSON.parse(fs.readFileSync(credsFile)); - return token.token && token.cookie && creds.workspace; - } catch (e) { return false; } -} -``` - -## Connection Method Usage - -### Chrome MCP - -```javascript -const context = await mcp__claude-in-chrome__tabs_context_mcp({ createIfEmpty: true }); -const data = await mcp__claude-in-chrome__javascript_tool({ - action: 'javascript_exec', tabId, text: extractionScript -}); -``` - -### Playwright MCP - -```javascript -await mcp__playwright__browser_navigate({ url }); -const snapshot = await mcp__playwright__browser_snapshot(); -// Or: await mcp__playwright__browser_evaluate({ expression: script }); -``` - -### Slack MCP - -```javascript -const channels = await mcp__slack__list_channels({ limit: 100 }); -const messages = await mcp__slack__get_channel_history({ channel: 'C123', limit: 20 }); -``` - -## Error Handling & Fallback - -```javascript -async function withFallback(config, service, operation) { - const methods = service === 'calendar' ? ['chrome', 'playwright'] - : ['mcp', 'chrome', 'playwright', 'api']; - - for (const method of methods) { - try { return await operation(method); } - catch (e) { console.log(`${service}: ${method} failed - ${e.message}`); } - } - throw new Error(`All connection methods failed for ${service}`); + return { method: 'unavailable', error: 'No connection available' }; } ``` ## Setup Integration -When `needsSetup: true` returned, prompt user: -- Calendar: `/hub-setup-gcalendar` -- Slack: `/hub-setup-slack` +When `needsSetup: true`: prompt `/hub-setup-gcalendar` or `/hub-setup-slack`. -Status display in wizards: -- Installed + ready: "✓ Ready" -- Installed + not ready: "⚠️ Not connected" -- Not installed: "⚠️ Requires installation" +Status: Ready = "✓", Not connected = "⚠️ Not connected", Not installed = "⚠️ Requires installation". diff --git a/plugins/claude-status-hub/skills/hub-ack-github-pr.md b/plugins/claude-status-hub/skills/hub-ack-github-pr.md index 2a0fbec..d74373c 100644 --- a/plugins/claude-status-hub/skills/hub-ack-github-pr.md +++ b/plugins/claude-status-hub/skills/hub-ack-github-pr.md @@ -1,17 +1,16 @@ # Hub Ack - GitHub PR Actions -Handle GitHub PR alerts with contextual actions based on PR state. +Handle PR alerts with contextual actions based on state. ## Input -Receives PR item with: `owner`, `repo`, `number`, `icon` (X, ⚡, 🚀, !, ?, ✓) +Item with: `owner`, `repo`, `number`, `icon` (X, ⚡, 🚀, !, ?, ✓). -## Step 1: Get Fresh PR Data +## Step 1: Get Fresh Data ```bash -gh pr view --repo / --json state,isDraft,reviewDecision,statusCheckRollup,title,comments,mergeable,mergeStateStatus,headRefName,baseRefName --jq '{ +gh pr view --repo / --json state,isDraft,reviewDecision,statusCheckRollup,title,mergeable,mergeStateStatus,headRefName,baseRefName --jq '{ state, isDraft, reviewDecision, title, mergeable, mergeStateStatus, headRefName, baseRefName, - commentsCount: (.comments | length), checksPassed: ([.statusCheckRollup[] | select(.conclusion == "SUCCESS")] | length), checksFailed: ([.statusCheckRollup[] | select(.conclusion == "FAILURE")] | length), checksPending: ([.statusCheckRollup[] | select(.status == "IN_PROGRESS" or .status == "QUEUED")] | length), @@ -21,143 +20,52 @@ gh pr view --repo / --json state,isDraft,reviewDecision,st ## Step 2: Route by State -### Case A: CI Failure (checksFailed > 0) +### CI Failure (checksFailed > 0) -Check if flaky (also fails on main): -```bash -gh run list --repo / --branch main --limit 5 --json conclusion,name --jq '[.[] | select(.conclusion == "failure")] | .[0].name // "none"' -``` - -**If flaky:** Show warning, offer re-run or comment. - -**If not flaky:** ``` -❌ PR # CI Failed - - - [1] Re-run failed checks - [2] Re-run full CI - [3] Investigate & fix - [4] View logs - [5] 🔄 Fix loop - [d] Dismiss +❌ PR # CI Failed - +[1] Re-run failed [2] Re-run full [3] Investigate & fix [4] View logs [5] 🔄 Fix loop [d] Dismiss ``` -Actions: `gh run rerun --failed`, `gh run view --log-failed` - -**Buildkite support:** If detailsUrl contains "buildkite" and Buildkite MCP available, use `mcp__buildkite__get_job_logs` for richer output. - -### Fix Loop Mode - -Stay in session until CI passes: -1. Read failure logs -2. Propose fix -3. Apply & push: `git add -A && git commit && git push` -4. Poll CI every 30s until complete -5. If passed → check merge readiness, offer merge -6. If failed → loop back with new analysis - -Exit: CI passes, user exits, or user dismisses. +**Fix loop:** Read logs → propose fix → push → poll CI → repeat until pass or user exits. -### Case B: Merge Conflicts (mergeable == "CONFLICTING") +### Merge Conflicts (mergeable == "CONFLICTING") ``` -⚡ PR # - Merge Conflicts - - [1] 🤖 Resolve with AI - [2] Open PR in browser - [3] Resolve locally - [d] Dismiss +⚡ PR # - Merge Conflicts +[1] 🤖 Resolve with AI [2] Open in browser [3] Resolve locally [d] Dismiss ``` -**AI resolution:** Checkout, merge, analyze conflicts, propose resolutions, commit & push. - -### Case C: Ready to Merge (🚀) +### Ready to Merge (🚀) -Get merge strategy from config: `repos["owner/repo"]` > `orgs["owner"]` > `default` +Get strategy from `github.mergeStrategy` config. ``` -🚀 PR # - Ready to Merge! - - ✅ Approved | ✅ CI passing | ✅ No conflicts - - [1] Merge () - [2] Squash - [3] Rebase - [4] Auto-merge - [5] /aviator merge - [c] Custom command - [d] Dismiss +🚀 PR # - Ready to Merge! +[1] Merge () [2] Squash [3] Rebase [4] Auto-merge [5] /aviator merge [c] Custom [d] Dismiss ``` -**Per-PR auto-merge:** When `autoMerge: true` in config, daemon executes strategy automatically. Shows `🔁` indicator. - -### Case D: Changes Requested +### Changes Requested ``` -❗ PR # - Changes Requested - - [1] View comments - [2] Summarize changes - [d] Dismiss +❗ PR # - Changes Requested +[1] View comments [2] Summarize changes [d] Dismiss ``` -### Case E: Review Required +### Review Required ``` -? PR # - Waiting for Review - - [1] Request review - [2] Bump reviewers - [d] Dismiss +? PR # - Waiting for Review +[1] Request review [2] Bump reviewers [d] Dismiss ``` -### Case F: New Comments +### Merged ``` -💬 PR # - new comments - - [1] View in browser - [2] Summarize - [d] Mark read -``` - -### Case G: Merged PR - -``` -✅ PR # - Merged - - [1] Stop tracking (remove) - [d] Keep tracking -``` - -## Step 3: Execute Action - -Run appropriate gh command based on selection. - -## Step 4: Update Config and Bridge - -### 4a: Update Config - -Update `lastSeen` with current values. Special cases: -- Fix applied → set `lastSeen.checksFailed: 0` -- Stop tracking → remove from `foreground[]` -- Set `hasAlert: false` - -### 4b: Update Bridge - -**CRITICAL**: Statusline reads bridge, not config. Update immediately: - -```bash -FOREGROUND=$(jq -c '.foreground // []' ~/.claude/status-config.json) -BACKGROUND=$(jq -c '.background // {}' /tmp/status-hub.json) -${CLAUDE_PLUGIN_ROOT}/bin/update-bridge.sh \ - "$(echo "$BACKGROUND" | jq -r '.site')" \ - "$(echo "$BACKGROUND" | jq -r '.icon')" \ - "$(echo "$BACKGROUND" | jq -r '.title')" \ - "$(echo "$BACKGROUND" | jq -r '.detail')" \ - --foreground "$FOREGROUND" +✅ PR # - Merged +[1] Stop tracking [d] Keep tracking ``` -## Error Handling +## Step 3: Execute & Update -If gh command fails: show error, offer retry, don't update lastSeen. +Run `gh` command, then update config and bridge (see `lib-common.md`). diff --git a/plugins/claude-status-hub/skills/hub-ack-slack.md b/plugins/claude-status-hub/skills/hub-ack-slack.md index 4b06478..9d75c0f 100644 --- a/plugins/claude-status-hub/skills/hub-ack-slack.md +++ b/plugins/claude-status-hub/skills/hub-ack-slack.md @@ -1,212 +1,70 @@ # Hub Ack - Slack Message Actions -Handle Slack VIP DM and watched channel alerts with contextual actions. +Handle Slack VIP DM and watched channel alerts. ## Input -Receives Slack item with: `alerts[]` containing `type`, `from`/`channel`, `unreads` +Item with `alerts[]`: `type` (vip_dm/watched_channel), `from`/`channel`, `unreads`. ## Step 1: Get Connection -Check config for Slack connection: ```bash cat ~/.claude/status-config.json | jq '.slack // {}' ``` -Extract: -- `chrome.tabId` - Slack tab for Chrome MCP -- `workspace` - Slack workspace name - -If no `chrome.tabId`: "Run `/hub-setup-slack` to configure Slack connection" +Need `chrome.tabId`. If missing: "Run `/hub-setup-slack` to configure." ## Step 2: Navigate to Conversation -For VIP DM alerts, navigate to the DM conversation: - -```javascript -// Find the DM in sidebar and click -const dmName = ""; -const dmButton = Array.from(document.querySelectorAll('[data-qa="im_sidebar_name_button"]')) - .find(el => el.textContent?.toLowerCase().includes(dmName.toLowerCase())); -if (dmButton) dmButton.click(); -``` - -For watched channel alerts: ```javascript -const channelName = ""; -const chButton = Array.from(document.querySelectorAll('[data-qa="channel_sidebar_name_button"]')) - .find(el => el.textContent?.toLowerCase().includes(channelName.toLowerCase())); -if (chButton) chButton.click(); +// DM +const dm = Array.from(document.querySelectorAll('[data-qa="im_sidebar_name_button"]')) + .find(el => el.textContent?.toLowerCase().includes("")); +if (dm) dm.click(); +// Channel +const ch = Array.from(document.querySelectorAll('[data-qa="channel_sidebar_name_button"]')) + .find(el => el.textContent?.toLowerCase().includes("")); +if (ch) ch.click(); ``` -Wait 1s for conversation to load. - -## Step 3: Fetch Recent Messages +Wait 1s for load. -Use Chrome MCP to extract messages from the conversation: +## Step 3: Fetch Messages ```javascript -(() => { - const messages = []; - document.querySelectorAll('[data-qa="message_container"]').forEach(msg => { - const sender = msg.querySelector('[data-qa="message_sender_name"]')?.textContent; - const text = msg.querySelector('[data-qa="message-text"]')?.textContent; - const time = msg.querySelector('[data-qa="message_time"]')?.textContent; - if (text) messages.push({ - sender: sender?.trim(), - text: text.substring(0, 500), - time: time?.trim() - }); - }); - return messages.slice(-10); -})() +const msgs = []; document.querySelectorAll('[data-qa="message_container"]').forEach(m => { + const sender = m.querySelector('[data-qa="message_sender_name"]')?.textContent?.trim(); + const text = m.querySelector('[data-qa="message-text"]')?.textContent?.substring(0, 500); + if (text) msgs.push({ sender, text }); +}); return msgs.slice(-10); ``` -## Step 4: Analyze and Present Options +## Step 4: Present Options -### Case A: VIP DM - -Display: +**VIP DM:** ``` -💬 Message from - - - "" - - Reply options: - [1] "On it!" - [2] "Let me check and get back to you" - [3] "Thanks, will follow up" - [4] "Got it, working on this now" - [c] Custom reply - [v] View full thread - [d] Dismiss +💬 Message from : "" +[1] "On it!" [2] "Let me check..." [3] "Thanks, will follow up" [c] Custom [v] View [d] Dismiss ``` -**AI-adapted replies:** Analyze message context to prioritize options: -- Question (contains ?) → Prioritize "Let me check and get back to you" -- Request/task → Prioritize "On it!" or "Got it, working on this now" -- FYI/informational → Prioritize "Thanks, will follow up" - -### Case B: Watched Channel - -Display: +**Watched channel:** ``` -# - new messages - - Latest: "" - From: - - [1] Mark as read - [2] View thread - [c] Reply to thread - [d] Dismiss +# - new: "" +[1] Mark read [2] View [c] Reply [d] Dismiss ``` -## Step 5: Execute Reply - -If user selects a reply option: +## Step 5: Send Reply -### 5a: Focus Message Input - -```javascript -const input = document.querySelector('[data-qa="message_input"]'); -if (input) { - input.focus(); - return true; -} -return false; -``` - -### 5b: Type Reply - -Use `mcp__claude-in-chrome__form_input` or `computer` tool: -```javascript -// Type the reply text -mcp__claude-in-chrome__computer({ - action: 'type', - text: '', - tabId: -}) -``` - -### 5c: Send Message - -```javascript -// Press Enter to send -mcp__claude-in-chrome__computer({ - action: 'key', - text: 'Return', - tabId: -}) -``` +Focus `[data-qa="message_input"]`, type text, press Return. -**Confirmation:** Tell user "Message sent" after successful send. +## Step 6: Update State -## Step 6: Custom Reply +See `lib-common.md` for config update and bridge update patterns. -If user selects custom reply: - -1. Ask for their message text -2. Type and send via Steps 5a-5c - -## Step 7: View Full Thread - -If user selects view: - -1. Already navigated to conversation in Step 2 -2. Display last 10 messages with full text -3. Offer reply options again - -## Step 8: Update Config and Bridge - -After handling: - -### 8a: Update Config - -```bash -# Mark alert as handled -jq '.foreground |= map(if .site == "slack" then .hasAlert = false | .lastSeen.alerts = [] else . end)' \ - ~/.claude/status-config.json > /tmp/config-tmp.json && mv /tmp/config-tmp.json ~/.claude/status-config.json -``` - -### 8b: Update Bridge - -**CRITICAL**: Statusline reads bridge, not config. Update immediately: - -```bash -FOREGROUND=$(jq -c '.foreground // []' ~/.claude/status-config.json) -BACKGROUND=$(jq -c '.background // {}' /tmp/status-hub.json) -${CLAUDE_PLUGIN_ROOT}/bin/update-bridge.sh \ - "$(echo "$BACKGROUND" | jq -r '.site')" \ - "$(echo "$BACKGROUND" | jq -r '.icon')" \ - "$(echo "$BACKGROUND" | jq -r '.title')" \ - "$(echo "$BACKGROUND" | jq -r '.detail')" \ - --foreground "$FOREGROUND" -``` - -## Reply Templates - -| Context | Template | When to use | -|---------|----------|-------------| -| Acknowledgment | "On it!" | Task assignment, request | -| Investigating | "Let me check and get back to you" | Question, needs research | -| Received | "Thanks, will follow up" | FYI, update, informational | -| Active | "Got it, working on this now" | Urgent task, priority item | - -## Error Handling +## Errors | Error | Action | |-------|--------| -| Tab not found | "Slack tab closed. Run `/hub-setup-slack`" | -| Can't find DM | "Couldn't find conversation. Open Slack manually." | -| Send failed | "Couldn't send. Message copied to clipboard." | -| No connection | "Run `/hub-setup-slack` to configure" | - -## Clipboard Fallback - -If Chrome MCP fails to send: -```bash -echo "" | pbcopy # macOS -``` -Tell user: "Couldn't send via browser. Message copied to clipboard." +| Tab not found | Run `/hub-setup-slack` | +| Can't find DM | Open Slack manually | +| Send failed | Clipboard fallback: `echo "" | pbcopy` | diff --git a/plugins/claude-status-hub/skills/hub-ack.md b/plugins/claude-status-hub/skills/hub-ack.md index 277cdc5..d566795 100644 --- a/plugins/claude-status-hub/skills/hub-ack.md +++ b/plugins/claude-status-hub/skills/hub-ack.md @@ -1,153 +1,62 @@ # Hub Ack - Contextual Action Dispatcher -Handle alerts with context-aware actions. This skill is the main dispatcher that routes to service-specific ack skills. +Handle alerts with context-aware actions. Routes to service-specific ack skills. -## Overview - -The `/hub-ack` paradigm: -1. Alert appears in statusline (single line, non-blocking) -2. User continues working -3. User types `/hub-ack` when ready -4. System evaluates: What alert? What time? What's possible? -5. Smart wizard offers best actions for THIS moment - -## Step 1: Clear Any Error State +## Step 1: Clear Error ```bash ${CLAUDE_PLUGIN_ROOT}/bin/update-bridge.sh --clear-error ``` -## Step 2: Read Current State - -Read the bridge file to find alerting items: +## Step 2: Read State ```bash cat /tmp/status-hub.json -``` - -Extract: -- `foreground[]` array with items that have `hasAlert: true` -- `background` for music state (if any) - -## Step 3: Read Config for Context - -```bash cat ~/.claude/status-config.json ``` -Get additional context: -- `foreground[]` item details (owner, repo, number for PRs; tabId for browser items) -- `github.mergeStrategy` for merge preferences -- Service-specific settings - -## Step 4: Evaluate Alerts +Find items with `hasAlert: true`. -Check for items with `hasAlert: true` in the bridge file. +## Step 3: Priority Order -**Priority order** (handle most urgent first): -1. `github-pr` with CI failures (icon `X`) -2. `github-pr` with conflicts (icon `⚡`) -3. `github-pr` ready to merge (icon `🚀`) -4. `calendar` meetings (time-sensitive) -5. `focus` break reminders or interruptions +1. `github-pr` CI failures (X) +2. `github-pr` conflicts (⚡) +3. `github-pr` merge-ready (🚀) +4. `calendar` meetings +5. `focus` break/interruptions 6. `slack` VIP messages -7. `github-pr` with review activity -8. Other alerts - -## Step 5: Route to Service-Specific Skill - -For each alerting item, check for a service-specific ack skill: +7. `github-pr` review activity +8. Other -1. **Built-in**: `${CLAUDE_PLUGIN_ROOT}/skills/hub-ack-.md` -2. **User-authored**: `${CLAUDE_PLUGIN_ROOT}/skills/hub-ack-.user.md` +## Step 4: Route to Skill -Service mappings: -- `github-pr` → `hub-ack-github-pr.md` -- `calendar` → `hub-ack-calendar.md` -- `focus` → `hub-ack-focus.md` -- `slack` → `hub-ack-slack.md` -- `jira` → `hub-ack-jira.md` -- `finance` → Just dismiss (no contextual actions) +| Service | Skill | +|---------|-------| +| github-pr | hub-ack-github-pr.md | +| calendar | hub-ack-calendar.md | +| focus | hub-ack-focus.md | +| slack | hub-ack-slack.md | +| finance | Just dismiss | -If a skill exists, read and follow it for that item's ack actions. +Check for user-authored `.user.md` variants too. -## Step 6: No Alerts Case - -If no items have `hasAlert: true`: +## Step 5: No Alerts ``` ✓ No pending alerts - -Current status: - - -[r] Refresh status now -[d] Done + +[r] Refresh [d] Done ``` -## Step 7: Multiple Alerts - -If multiple items have alerts, present a selection: - -``` -📬 You have N alerts: - -[1] : -[2] : -... -[a] Handle all sequentially -[d] Dismiss all -``` - -Let user select which to handle, then route to the appropriate skill. - -## Step 8: Update Config and Bridge After Ack - -After successfully handling an alert: -1. Update `lastSeen` values in config -2. Set `hasAlert: false` for the item in config -3. Write updated config back to `~/.claude/status-config.json` -4. **CRITICAL**: Update the bridge file immediately so statusline reflects new state - -```bash -# Read the updated foreground array from config (with hasAlert: false) -FOREGROUND=$(jq -c '.foreground // []' ~/.claude/status-config.json) - -# Read current background from bridge -BACKGROUND=$(jq -c '.background // {}' /tmp/status-hub.json) -BG_SITE=$(echo "$BACKGROUND" | jq -r '.site // "hub"') -BG_ICON=$(echo "$BACKGROUND" | jq -r '.icon // "✓"') -BG_TITLE=$(echo "$BACKGROUND" | jq -r '.title // "Status Hub"') -BG_DETAIL=$(echo "$BACKGROUND" | jq -r '.detail // ""') - -# Update bridge with new foreground state -${CLAUDE_PLUGIN_ROOT}/bin/update-bridge.sh "$BG_SITE" "$BG_ICON" "$BG_TITLE" "$BG_DETAIL" --foreground "$FOREGROUND" -``` - -**Why this matters**: The statusline reads from `/tmp/status-hub.json` (bridge file), not the config. If you only update the config, the alert will persist in the statusline until the next daemon refresh cycle (up to 90 seconds). - -## AskUserQuestion Format - -Use AskUserQuestion for wizard interactions: +## Step 6: Multiple Alerts ``` -{ - "questions": [{ - "question": "", - "header": "Hub Ack", - "options": [ - {"label": "", "description": ""}, - {"label": "", "description": ""}, - {"label": "Dismiss", "description": "Mark as seen"} - ], - "multiSelect": false - }] -} +📬 N alerts: +[1] : +[2] ... +[a] Handle all [d] Dismiss all ``` -## Error Handling +## Step 7: Update After Ack -If any tool call fails: -1. Write error via update-bridge.sh --error -2. Inform user of the failure -3. Offer to retry or dismiss +See `lib-common.md` for config update and bridge update patterns. Bridge must be updated immediately for statusline. diff --git a/plugins/claude-status-hub/skills/hub-focus.md b/plugins/claude-status-hub/skills/hub-focus.md index e2063d3..30dba94 100644 --- a/plugins/claude-status-hub/skills/hub-focus.md +++ b/plugins/claude-status-hub/skills/hub-focus.md @@ -1,21 +1,10 @@ # Hub Focus - Smart Focus Mode -Proactive focus mode that checks calendar and offers smart options. +Proactive focus mode with calendar awareness. -## Step 1: Check Calendar for Conflicts +## Step 1: Check Calendar -First, get upcoming meetings: - -If calendar is configured with browser tab: -```javascript -// Run via javascript_tool on calendar tab -// Same extraction as hub-refresh-calendar.md -``` - -Or parse the calendar lastSeen data from config: -```bash -cat ~/.claude/status-config.json | jq '.calendar.lastSeen' -``` +Get upcoming meetings from `calendar.lastSeen` in config or extract via browser (see `tool-gcalendar.md`). ## Step 2: Read Focus Config @@ -23,332 +12,59 @@ cat ~/.claude/status-config.json | jq '.calendar.lastSeen' cat ~/.claude/status-config.json | jq '.focus // {}' ``` -Get defaults: -- `defaultDurationHours` (default: 2) -- `meetingConflictHandling` (default: "ask") -- `criticalChannels` (default: []) -- `defaultStatus` (default: "🎯 Deep focus until {end_time}") -- `defaultDeclineMessage` +Defaults: `defaultDurationHours` (2), `meetingConflictHandling` ("ask"), `criticalChannels` ([]), `defaultStatus` ("🎯 Deep focus until {end_time}"). ## Step 3: Calculate Conflicts ```javascript -const now = Date.now(); -const focusDurationMs = config.focus.defaultDurationHours * 60 * 60 * 1000; -const focusEndTime = now + focusDurationMs; - -const conflicts = meetings.filter(m => { - const start = m.startTime; - return start > now && start < focusEndTime; -}); +const focusEndTime = Date.now() + config.focus.defaultDurationHours * 3600000; +const conflicts = meetings.filter(m => m.startTime > Date.now() && m.startTime < focusEndTime); ``` -## Step 4: Present Smart Options - -### No Conflicts +## Step 4: Present Options +**No conflicts:** ``` 🎯 Starting Focus Mode - - 📅 No meetings for the next hours - - Focus duration: - [1] 30 minutes - [2] 1 hour - [3] 2 hours (default) - [4] Until next meeting (