From a35b583f851d2d064371b7f1b1a890057a2edad1 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Wed, 27 May 2026 20:20:57 -0700 Subject: [PATCH] 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 --- .claude/scripts/check-messages.sh | 120 +++++++++++++++++++++++------- .gitignore | 1 + 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/.claude/scripts/check-messages.sh b/.claude/scripts/check-messages.sh index 3fa6f98..b553afa 100644 --- a/.claude/scripts/check-messages.sh +++ b/.claude/scripts/check-messages.sh @@ -22,52 +22,118 @@ 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) +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) + 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 both result sets (combine .messages arrays, recompute total) +# 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=$(echo "$result_alias" | jq '.messages // []' 2>/dev/null) + 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=$(echo "$result" "$result_alias" | jq -s '{total: (.[0].total + .[1].total), messages: (.[0].messages + .[1].messages)}' 2>/dev/null) + 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 -if [ -n "$result" ]; then - count=$(echo "$result" | jq '.total' 2>/dev/null) - if [ -n "$count" ] && [ "$count" -gt 0 ]; then - echo "" - echo "============================================================" - echo "UNREAD COORD MESSAGES ($count)" - echo "============================================================" - echo "$result" | jq -r '.messages[] | "FROM: \(.from_session)\nDATE: \(.created_at)\nSUBJECT: \(.subject)\n\nMESSAGE:\n\(.body)\n---"' - echo "============================================================" - echo "" +# --- 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" - # Fire a Windows toast so the user sees it even if not watching the terminal - toast_body=$(echo "$result" | jq -r '[.messages[] | .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: $count new message(s)' -Message '$toast_body'" \ - >/dev/null 2>&1 & +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 - # Mark all fetched messages as read immediately - echo "$result" | jq -r '.messages[].id' | tr -d '\r' | while read -r id; do - curl -s -X PUT "${API}/api/coord/messages/${id}/read" >/dev/null 2>&1 - done +# --- 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) ------------------------------------------- @@ -78,13 +144,13 @@ current_mode="" if [ "$current_mode" = "dev" ]; then locks=$(curl -s --connect-timeout 3 "${API}/api/coord/locks" 2>/dev/null) if [ -n "$locks" ]; then - lock_count=$(echo "$locks" | jq '.total' 2>/dev/null) + 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 "============================================================" - echo "$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---"' + 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 diff --git a/.gitignore b/.gitignore index bfa7dac..ea1ad68 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ tmp-remediation/ .claude/settings.local.json .claude/identity.json .claude/current-mode +.claude/coord-broadcasts-seen # /autotask command — kept local/undistributed (Syncro is the default PSA; Autotask is opt-in). # Remove this line to distribute /autotask to the fleet. See .claude/memory/feedback_psa_default_syncro.md