Files
claudetools/.claude/scripts/check-messages.sh
Mike Swanson a35b583f85 sync: auto-sync from GURU-KALI at 2026-05-27 20:20:56
Author: Mike Swanson
Machine: GURU-KALI
Timestamp: 2026-05-27 20:20:56
2026-05-27 20:20:57 -07:00

161 lines
7.6 KiB
Bash

#!/usr/bin/env bash
# UserPromptSubmit hook — injects unread coord messages and (in dev mode) active locks.
# Strip .local suffix if present (macOS convention)
HOSTNAME_RAW="$(hostname)"
SESSION="${HOSTNAME_RAW%.local}/claude-main"
API="http://172.16.3.30:8001"
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
MODE_FILE="${SCRIPT_DIR}/current-mode"
# --- Initialize mode file if missing -----------------------------------------
# The mode file is machine-local (gitignored) and required by this hook.
# If missing, create it with "general" as the default mode.
if [ ! -f "$MODE_FILE" ]; then
echo "general" > "$MODE_FILE"
echo "[INFO] Created .claude/current-mode with default mode: general" >&2
fi
# Read short username alias from identity.json (if present)
IDENTITY_FILE="${SCRIPT_DIR}/identity.json"
USER_ALIAS=""
if [ -f "$IDENTITY_FILE" ]; then
USER_ALIAS=$(jq -r '.user // empty' "$IDENTITY_FILE" 2>/dev/null)
fi
# Sanitize JSON: the coord API sometimes returns message bodies with unescaped
# control chars (U+0000-U+001F) — invalid JSON per RFC 8259, which makes jq
# abort with "Invalid string: control characters ... must be escaped". Round-
# trip through python's json.loads(strict=False) (which accepts them) and
# re-emit properly escaped JSON so jq downstream works on every payload.
sanitize_json() {
python3 -c '
import json, sys
try:
print(json.dumps(json.loads(sys.stdin.read(), strict=False)))
except Exception:
pass
' 2>/dev/null
}
# --- Unread messages ---------------------------------------------------------
# Query for messages addressed to full session ID
result=$(curl -s --connect-timeout 3 "${API}/api/coord/messages?to_session=${SESSION}&unread_only=true" 2>/dev/null | sanitize_json)
# Also query for messages addressed to the short username alias (e.g. "howard")
result_alias=""
if [ -n "$USER_ALIAS" ] && [ "$USER_ALIAS" != "$SESSION" ]; then
result_alias=$(curl -s --connect-timeout 3 "${API}/api/coord/messages?to_session=${USER_ALIAS}&unread_only=true" 2>/dev/null | sanitize_json)
fi
# Merge personal + alias result sets (combine .messages arrays, recompute total).
# IMPORTANT: use `printf '%s'`, NOT `echo`, to pipe JSON to jq — bash `echo`
# interprets backslash escapes (e.g. \n inside string bodies) and corrupts JSON.
if [ -n "$result_alias" ]; then
alias_msgs=$(printf '%s' "$result_alias" | jq '.messages // []' 2>/dev/null)
if [ -n "$alias_msgs" ] && [ "$alias_msgs" != "[]" ] && [ "$alias_msgs" != "null" ]; then
if [ -n "$result" ]; then
result=$(printf '%s\n%s\n' "$result" "$result_alias" | jq -s '{total: (.[0].total + .[1].total), messages: (.[0].messages + .[1].messages)}' 2>/dev/null)
else
result="$result_alias"
fi
fi
fi
# --- Broadcasts (to_session=ALL_SESSIONS) -----------------------------------
# Broadcasts share a single server-side read_at field, so PUT /read on a
# broadcast would clobber it for every other machine that hasn't seen it yet.
# Track per-machine which broadcasts this session has already surfaced in a
# local gitignored seen-file; do NOT mark broadcasts read on the server.
SEEN_FILE="${SCRIPT_DIR}/coord-broadcasts-seen"
[ -f "$SEEN_FILE" ] || : > "$SEEN_FILE"
bcast_raw=$(curl -s --connect-timeout 3 "${API}/api/coord/messages?to_session=ALL_SESSIONS&unread_only=true&limit=100" 2>/dev/null | sanitize_json)
bcast_msgs="[]"
if [ -n "$bcast_raw" ]; then
# JSON array of already-seen broadcast IDs (lines from seen-file; empty -> [])
seen_json=$(jq -R . "$SEEN_FILE" | jq -s 'map(select(length>0))' 2>/dev/null)
[ -z "$seen_json" ] && seen_json='[]'
# Normalize this session for self-broadcast filter (drop .local for compare)
session_norm=$(printf '%s' "$SESSION" | tr 'A-Z' 'a-z')
# Bind each message to $m so .id / .from_session inside index() and sub()
# resolve against the message, not against $seen (jq scoping gotcha).
bcast_msgs=$(printf '%s' "$bcast_raw" | jq --argjson seen "$seen_json" --arg self "$session_norm" '
(.messages // [])
| map(. as $m | select(
(($seen | index($m.id)) == null)
and (($m.from_session | ascii_downcase | sub("\\.local/"; "/")) != $self)
))
' 2>/dev/null)
[ -z "$bcast_msgs" ] && bcast_msgs="[]"
fi
# --- Combined display (personal + alias + filtered broadcasts) --------------
# NB: do NOT use ${result:-{}} as a default — bash parameter expansion treats
# the first '}' as the close, producing '{}}' and breaking jq downstream.
result_safe="$result"
[ -z "$result_safe" ] && result_safe='{"messages":[]}'
personal_msgs=$(printf '%s' "$result_safe" | jq '.messages // []' 2>/dev/null)
[ -z "$personal_msgs" ] && personal_msgs="[]"
total_count=$(jq -n --argjson p "$personal_msgs" --argjson b "$bcast_msgs" '($p|length) + ($b|length)' 2>/dev/null)
[ -z "$total_count" ] && total_count=0
if [ "$total_count" -gt 0 ] 2>/dev/null; then
combined=$(jq -n --argjson p "$personal_msgs" --argjson b "$bcast_msgs" '$p + $b')
bcast_count=$(printf '%s' "$bcast_msgs" | jq 'length' 2>/dev/null)
[ -z "$bcast_count" ] && bcast_count=0
echo ""
echo "============================================================"
if [ "$bcast_count" -gt 0 ] 2>/dev/null; then
echo "UNREAD COORD MESSAGES (${total_count}, including ${bcast_count} broadcast)"
else
echo "UNREAD COORD MESSAGES (${total_count})"
fi
echo "============================================================"
printf '%s' "$combined" | jq -r '.[] | "FROM: \(.from_session)\nDATE: \(.created_at)\nSUBJECT: \(.subject)\n\nMESSAGE:\n\(.body)\n---"'
echo "============================================================"
echo ""
# Fire a Windows toast so the user sees it even if not watching the terminal
toast_body=$(printf '%s' "$combined" | jq -r '[.[] | .from_session + ": " + .subject] | join(", ")' | tr -d '\r')
notify_ps1=$(cygpath -w "${SCRIPT_DIR}/scripts/notify.ps1" 2>/dev/null || echo "${SCRIPT_DIR}/scripts/notify.ps1" | sed 's|^/\([a-zA-Z]\)/|\1:/|')
powershell.exe -NonInteractive -NoProfile -Command \
"& '$notify_ps1' -Title 'ClaudeTools: ${total_count} new message(s)' -Message '$toast_body'" \
>/dev/null 2>&1 &
# Mark personal + alias messages as read on the server (NOT broadcasts).
printf '%s' "$personal_msgs" | jq -r '.[].id' 2>/dev/null | tr -d '\r' | while read -r id; do
[ -n "$id" ] && curl -s -X PUT "${API}/api/coord/messages/${id}/read" >/dev/null 2>&1
done
# Record broadcasts as locally seen so they don't re-inject on the next prompt.
# Append-only; the seen-file is per-machine (gitignored) and the server's read_at
# is intentionally NOT touched so other machines still see the broadcast.
printf '%s' "$bcast_msgs" | jq -r '.[].id' 2>/dev/null | tr -d '\r' | while read -r id; do
[ -n "$id" ] && printf '%s\n' "$id" >> "$SEEN_FILE"
done
fi
# --- Active locks (dev mode only) -------------------------------------------
current_mode=""
[ -f "$MODE_FILE" ] && current_mode=$(cat "$MODE_FILE" | tr -d '[:space:]')
if [ "$current_mode" = "dev" ]; then
locks=$(curl -s --connect-timeout 3 "${API}/api/coord/locks" 2>/dev/null)
if [ -n "$locks" ]; then
lock_count=$(printf '%s' "$locks" | jq '.total' 2>/dev/null)
if [ -n "$lock_count" ] && [ "$lock_count" -gt 0 ]; then
echo ""
echo "============================================================"
echo "[WARNING] ACTIVE LOCKS ($lock_count) — check before editing"
echo "============================================================"
printf '%s' "$locks" | jq -r '.locks[] | "[DEV MODE] LOCK: \(.project_key) / \(.resource)\n Held by: \(.session_id)\n Reason: \(.description // "none")\n Expires: \(.expires_at // "unknown")\n---"'
echo "============================================================"
echo ""
fi
fi
fi
exit 0