#!/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 # Resolve a Python interpreter for the JSON sanitizer. Prefer the value in # identity.json (.python.command — set during machine onboarding); fall back # to a PATH lookup. On ACG Windows boxes the Microsoft Store `python3` alias # is disabled and `py` is the canonical launcher, so an unconditional # `python3` call would silently fail and cause every coord message to be # dropped. If no Python is available at all, the sanitizer falls back to # `tr -d` (lossy on real \n/\t in string fields but keeps messages visible). PY="" if [ -f "$IDENTITY_FILE" ]; then PY=$(jq -r '.python.command // empty' "$IDENTITY_FILE" 2>/dev/null) fi if [ -z "$PY" ]; then PY=$(command -v python3 || command -v py || command -v python) 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() { if [ -n "$PY" ]; then "$PY" -c ' import json, sys try: print(json.dumps(json.loads(sys.stdin.read(), strict=False))) except Exception: pass ' 2>/dev/null else tr -d '\000-\037' fi } # --- 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