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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{
"name": "claude-status-hub",
"description": "Monitor what matters - PRs, music, custom alerts - right in your Claude Code statusline",
"version": "1.5.2",
"version": "1.5.4",
"author": {
"name": "Pavel Fadeev",
"email": "pavel.fadeev@gmail.com"
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-status-hub/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-status-hub",
"version": "1.5.2",
"version": "1.5.4",
"description": "Monitor and act - PRs, calendar, music, custom alerts with contextual actions via /hub-ack",
"author": {
"name": "Pavel Fadeev",
Expand Down
31 changes: 17 additions & 14 deletions plugins/claude-status-hub/bin/refresh-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ acquire_lock() {
get_intervals() {
local now_ms=$(($(date +%s) * 1000))
local last_activity=$(jq -r '.lastActivity // 0' "$BRIDGE" 2>/dev/null || echo 0)
[[ "$last_activity" =~ ^[0-9]+$ ]] || last_activity=0
local idle_ms=$((now_ms - last_activity))
local idle_min=$((idle_ms / 60000))

Expand All @@ -62,6 +63,9 @@ check_focus_break() {
local start_time=$(jq -r '.focus.startTime // 0' "$CONFIG" 2>/dev/null)
local break_after=$(jq -r '.focus.breakAfterMinutes // 75' "$CONFIG" 2>/dev/null)
local last_reminder=$(jq -r '.focus.lastBreakReminder // 0' "$CONFIG" 2>/dev/null)
[[ "$start_time" =~ ^[0-9]+$ ]] || start_time=0
[[ "$break_after" =~ ^[0-9]+$ ]] || break_after=75
[[ "$last_reminder" =~ ^[0-9]+$ ]] || last_reminder=0
local now_ms=$(($(date +%s) * 1000))
local duration_min=$(( (now_ms - start_time) / 60000 ))

Expand All @@ -76,6 +80,7 @@ check_focus_break() {

# Check for gap before next meeting (need at least 15 min)
local next_meeting_start=$(jq -r '.calendar.lastSeen[0].startTime // 0' "$CONFIG" 2>/dev/null)
[[ "$next_meeting_start" =~ ^[0-9]+$ ]] || next_meeting_start=0
local next_suffix=""

if [ "$next_meeting_start" -gt "$now_ms" ]; then
Expand Down Expand Up @@ -139,7 +144,7 @@ fi
acquire_lock || exit 0

# Cleanup on exit
trap "rm -rf '$LOCKDIR'" EXIT INT TERM
trap "rm -rf '$LOCKDIR'" EXIT INT TERM HUP PIPE

# Main loop
LAST_FULL_REFRESH=0
Expand All @@ -156,18 +161,17 @@ while true; do
exit 0
fi

# Self-check: exit if another daemon took over the lockfile
# Self-check: exit if another daemon took over the lock
# This handles the startup race condition: multiple sessions starting simultaneously
# all pass the initial lockfile check before any writes its PID. After one sleep cycle,
# only the last writer survives because all others see a mismatched lockfile.
# See docs/data-safety-guidelines.md for race condition prevention patterns.
if [ -f "$LOCKFILE" ]; then
CURRENT_LOCK=$(cat "$LOCKFILE" 2>/dev/null)
if [ "$CURRENT_LOCK" != "${PLUGIN_VERSION}:$$" ]; then
# all pass the initial lock check before any writes its PID. After one sleep cycle,
# only the last writer survives because all others see a mismatched PID.
if [ -d "$LOCKDIR" ]; then
CURRENT_PID=$(cat "$LOCKDIR/pid" 2>/dev/null)
if [ "$CURRENT_PID" != "$$" ]; then
exit 0 # Another daemon owns the lock, gracefully exit
fi
else
exit 0 # Lockfile gone, exit
exit 0 # Lock gone, exit
fi

[ -f "$CONFIG" ] || continue
Expand All @@ -176,6 +180,7 @@ while true; do
NOW_SEC=$(date +%s)
if [ -f "$BRIDGE" ]; then
BRIDGE_TS=$(jq -r '.timestamp // 0' "$BRIDGE" 2>/dev/null)
[[ "$BRIDGE_TS" =~ ^[0-9]+$ ]] || BRIDGE_TS=0
NOW_MS=$((NOW_SEC * 1000))
AGE_MS=$((NOW_MS - BRIDGE_TS))
# Skip if bridge was updated recently (< 60s)
Expand All @@ -194,11 +199,9 @@ while true; do
"$PR_SCRIPT" 2>/dev/null || true
fi

# Light refresh: music if configured
SERVICE=$(jq -r '.background.service // "off"' "$CONFIG" 2>/dev/null)
if [ "$SERVICE" != "off" ]; then
timeout 30 claude -p --chrome --allowedTools "Read,Write,Bash,mcp__claude-in-chrome__*" -- \
"Read and follow the hub-refresh-music skill at $MUSIC_SKILL" 2>/dev/null || true
# Light refresh: music if configured (zero-token, uses OS-level APIs)
if [ -x "$PLUGIN_ROOT/bin/refresh-music.sh" ]; then
"$PLUGIN_ROOT/bin/refresh-music.sh" 2>/dev/null || true
fi

# Light refresh: check if focus mode needs break reminder
Expand Down
82 changes: 82 additions & 0 deletions plugins/claude-status-hub/bin/refresh-music.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/bin/bash
# Zero-token music status refresh - replaces claude -p call in light cycle
# Uses macOS native APIs (osascript, nowplaying-cli) instead of Chrome MCP
# Falls back silently - full refresh cycle handles detection via Chrome MCP

CONFIG="$HOME/.claude/status-config.json"
BRIDGE="/tmp/status-hub.json"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}"

# Read music service config
[ -f "$CONFIG" ] || exit 0
SERVICE=$(jq -r '.background.service // "off"' "$CONFIG" 2>/dev/null)
[ "$SERVICE" = "off" ] && exit 0

TITLE=""
ARTIST=""
IS_PLAYING=false

case "$SERVICE" in
spotify)
# Native Spotify.app AppleScript (no browser needed)
if pgrep -xq "Spotify"; then
TITLE=$(osascript -e 'tell application "Spotify" to name of current track' 2>/dev/null || echo "")
ARTIST=$(osascript -e 'tell application "Spotify" to artist of current track' 2>/dev/null || echo "")
STATE=$(osascript -e 'tell application "Spotify" to player state as string' 2>/dev/null || echo "")
[ "$STATE" = "playing" ] && IS_PLAYING=true
fi
;;
apple-music)
# Native Music.app AppleScript
if pgrep -xq "Music"; then
TITLE=$(osascript -e 'tell application "Music" to name of current track' 2>/dev/null || echo "")
ARTIST=$(osascript -e 'tell application "Music" to artist of current track' 2>/dev/null || echo "")
STATE=$(osascript -e 'tell application "Music" to player state as string' 2>/dev/null || echo "")
[ "$STATE" = "playing" ] && IS_PLAYING=true
fi
;;
youtube-music)
# Browser-based - use nowplaying-cli if available
if command -v nowplaying-cli &>/dev/null; then
TITLE=$(nowplaying-cli get title 2>/dev/null || echo "")
ARTIST=$(nowplaying-cli get artist 2>/dev/null || echo "")
RATE=$(nowplaying-cli get playbackRate 2>/dev/null || echo "0")
[ "$RATE" != "0" ] && [ "$RATE" != "" ] && IS_PLAYING=true
fi
# If nowplaying-cli not available, exit silently - full refresh handles it
;;
*)
# Unknown service - try nowplaying-cli as generic fallback
if command -v nowplaying-cli &>/dev/null; then
TITLE=$(nowplaying-cli get title 2>/dev/null || echo "")
ARTIST=$(nowplaying-cli get artist 2>/dev/null || echo "")
RATE=$(nowplaying-cli get playbackRate 2>/dev/null || echo "0")
[ "$RATE" != "0" ] && [ "$RATE" != "" ] && IS_PLAYING=true
fi
;;
esac

# Nothing detected - exit silently (full refresh will handle it)
[ -z "$TITLE" ] && exit 0

# Determine icon
ICON="⏸"
[ "$IS_PLAYING" = true ] && ICON="▶"

# Truncate for statusline display
TITLE="${TITLE:0:30}"
ARTIST="${ARTIST:0:25}"

# Skip-if-unchanged: compare with current bridge data
if [ -f "$BRIDGE" ]; then
CURRENT_TITLE=$(jq -r '.background.title // ""' "$BRIDGE" 2>/dev/null)
CURRENT_DETAIL=$(jq -r '.background.detail // ""' "$BRIDGE" 2>/dev/null)
CURRENT_ICON=$(jq -r '.background.icon // ""' "$BRIDGE" 2>/dev/null)

if [ "$TITLE" = "$CURRENT_TITLE" ] && [ "$ARTIST" = "$CURRENT_DETAIL" ] && [ "$ICON" = "$CURRENT_ICON" ]; then
exit 0
fi
fi

# Update bridge with new music data (preserves foreground)
"$PLUGIN_ROOT/bin/update-bridge.sh" "$SERVICE" "$ICON" "$TITLE" "$ARTIST"
3 changes: 2 additions & 1 deletion plugins/claude-status-hub/skills/hub-music.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,5 @@ jq --argjson config "$NEW_CONFIG" '.music.contextual = $config' \
## Requirements

- Music service must be configured (`/hub-setup` → background service)
- Chrome MCP must be available for browser automation
- Chrome MCP must be available for browser automation (playback control)
- For browser-based players (YouTube Music): `brew install nowplaying-cli` recommended for zero-token status detection
Loading