Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions plugins/claude-status-hub/bin/refresh-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")")}"
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
172 changes: 33 additions & 139 deletions plugins/claude-status-hub/commands/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,171 +6,65 @@ argument-hint: <service|pr-url|list|ack|manage|play|off>

# 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 <query>` | Use `hub-play` skill with query |
| `off`, `clear`, `disable` | Use `hub-off` skill |
| `setup` | Use `hub-setup` skill |
| `custom <service>` | 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 <query>` | `hub-play` |
| `off`/`clear`/`disable` | `hub-off` |
| `setup` | `hub-setup` |
| `custom <service>` | `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 <number> --repo <owner>/<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": <current_time_ms>,
"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": <current_time_ms>,
"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-<service>*.md 2>/dev/null
```
Update background in config and bridge.

2. If skill found (`hub-refresh-<service>.md` or `hub-refresh-<service>.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-<service>.md` or `.user.md`. If found, use it. Otherwise, invoke `/hub-custom` automatically.
60 changes: 4 additions & 56 deletions plugins/claude-status-hub/hooks/refresh.sh
Original file line number Diff line number Diff line change
@@ -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
Loading