A --broadcast landed in _broadcast/inbox with no seen-set entry for the sender, so the announcing repo got its own announcement injected at its next session start. Pure noise: the sender already knows its own news. coord-send now records the delivered filename in _broadcast/seen/<sender> at delivery time, reusing the existing per-repo seen set rather than introducing a second exclusion mechanism. Filtering on the from: field at read time was rejected: from: is sender-controlled, so it would let any repo suppress a broadcast for another by forging the field. The seen file is keyed by the RAW sender name because coord-inbox keys it by the unsanitized repo name; using SAFE_FROM would silently miss for names like "my repo". A raw name in a path needs a guard, so senders containing a slash or equal to . / .. skip the marking instead. Selftest 64 -> 68 checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvTviFeoMCKJcALATRempy
154 lines
6.4 KiB
Bash
Executable file
154 lines
6.4 KiB
Bash
Executable file
#!/bin/bash
|
|
# coord-send.sh - deliver an inter-repo coordination message into the local
|
|
# mailbox (~/.claude/coord). Model-invoked; no network, no external service.
|
|
#
|
|
# Usage:
|
|
# coord-send.sh --to <repo> --subject "<subject>" [--from <repo>] [--message "<text>"]
|
|
# coord-send.sh --broadcast --subject "<subject>" [--from <repo>] [--message "<text>"]
|
|
# coord-send.sh --reply-to <file> [--subject "Re: ..."] [--from <repo>] [--message "<text>"]
|
|
# Body comes from --message, or from stdin (heredoc) when --message is omitted.
|
|
# --reply-to <basename> replies to a message in THIS repo's inbox/archive: it
|
|
# routes to the original sender and marks the original handled (coord-done).
|
|
# --from overrides the sender/self identity (default: basename of git toplevel/cwd).
|
|
#
|
|
# Exit: 0 delivered, 2 usage/IO error. ASCII only, bash 3.2 safe.
|
|
set -u
|
|
export LC_ALL=C
|
|
|
|
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
|
|
|
|
TO=""; BROADCAST=0; SUBJECT=""; FROM=""; MESSAGE=""; HAVE_MESSAGE=0; REPLYTO=""; REPLY_ORIG=""
|
|
|
|
# bash 3.2: `shift 2` past the end of $# is a no-op, so a trailing value-flag
|
|
# without its value would loop forever. Every two-arg flag must check first.
|
|
require_value() {
|
|
if [ "$2" -lt 2 ]; then echo "coord-send: $1 requires a value" >&2; exit 2; fi
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--to) require_value --to $#; TO="$2"; shift 2 ;;
|
|
--broadcast) BROADCAST=1; shift ;;
|
|
--reply-to) require_value --reply-to $#; REPLYTO="$2"; shift 2 ;;
|
|
--subject) require_value --subject $#; SUBJECT="$2"; shift 2 ;;
|
|
--from) require_value --from $#; FROM="$2"; shift 2 ;;
|
|
--message) require_value --message $#; MESSAGE="$2"; HAVE_MESSAGE=1; shift 2 ;;
|
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
*) echo "coord-send: unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
# --- Resolve sender / self identity ---
|
|
if [ -z "$FROM" ]; then
|
|
FROM="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)"
|
|
[ -z "$FROM" ] && FROM="$(basename "$(pwd)" 2>/dev/null)"
|
|
fi
|
|
[ -z "$FROM" ] && FROM="unknown"
|
|
|
|
# Frontmatter is line-oriented: a CR/LF inside a field would inject extra
|
|
# frontmatter lines or a premature '---' terminator. Collapse newlines to
|
|
# spaces and drop remaining control characters. Send-side input hygiene;
|
|
# the read side independently treats all content as untrusted.
|
|
sanitize_field() { printf '%s' "$1" | tr '\r\n' ' ' | tr -d '\000-\037'; }
|
|
FROM="$(sanitize_field "$FROM")"
|
|
|
|
# --- Reply mode: resolve target + default subject from the original message ---
|
|
if [ -n "$REPLYTO" ]; then
|
|
if [ "$BROADCAST" -eq 1 ] || [ -n "$TO" ]; then
|
|
echo "coord-send: --reply-to cannot be combined with --to/--broadcast" >&2; exit 2
|
|
fi
|
|
case "$REPLYTO" in */*|.|..|"") echo "coord-send: invalid --reply-to name: $REPLYTO" >&2; exit 2 ;; esac
|
|
REPLY_ORIG="$COORD/$FROM/inbox/$REPLYTO"
|
|
[ -e "$REPLY_ORIG" ] || REPLY_ORIG="$COORD/$FROM/archive/$REPLYTO"
|
|
if [ ! -e "$REPLY_ORIG" ]; then
|
|
echo "coord-send: --reply-to message not found for $FROM: $REPLYTO" >&2; exit 2
|
|
fi
|
|
TO="$(grep -m1 '^from:' "$REPLY_ORIG" 2>/dev/null | sed 's/^from:[[:space:]]*//')"
|
|
[ -z "$TO" ] && { echo "coord-send: cannot resolve sender of $REPLYTO" >&2; exit 2; }
|
|
if [ -z "$SUBJECT" ]; then
|
|
osub="$(grep -m1 '^subject:' "$REPLY_ORIG" 2>/dev/null | sed 's/^subject:[[:space:]]*//')"
|
|
SUBJECT="Re: $osub"
|
|
fi
|
|
fi
|
|
SUBJECT="$(sanitize_field "$SUBJECT")"
|
|
|
|
# --- Validate target: exactly one of --to / --broadcast (reply mode sets --to above) ---
|
|
if [ "$BROADCAST" -eq 1 ] && [ -n "$TO" ]; then
|
|
echo "coord-send: use either --to <repo> or --broadcast, not both" >&2; exit 2
|
|
fi
|
|
if [ "$BROADCAST" -eq 0 ] && [ -z "$TO" ]; then
|
|
echo "coord-send: missing --to <repo> / --broadcast / --reply-to" >&2; exit 2
|
|
fi
|
|
if [ "$BROADCAST" -eq 0 ]; then
|
|
case "$TO" in
|
|
*/*|.*|_broadcast) echo "coord-send: invalid target repo name: $TO" >&2; exit 2 ;;
|
|
esac
|
|
fi
|
|
if [ -z "$SUBJECT" ]; then
|
|
echo "coord-send: missing --subject" >&2; exit 2
|
|
fi
|
|
|
|
# --- Body: --message or stdin ---
|
|
if [ "$HAVE_MESSAGE" -eq 1 ]; then BODY="$MESSAGE"; else BODY="$(cat)"; fi
|
|
if [ -z "$BODY" ]; then
|
|
echo "coord-send: empty message body (pass --message or pipe text on stdin)" >&2; exit 2
|
|
fi
|
|
|
|
# --- Resolve destination ---
|
|
if [ "$BROADCAST" -eq 1 ]; then
|
|
TARGET_LABEL="broadcast"; DEST_DIR="$COORD/_broadcast/inbox"
|
|
else
|
|
TARGET_LABEL="$TO"; DEST_DIR="$COORD/$TO/inbox"
|
|
fi
|
|
mkdir -p "$DEST_DIR" 2>/dev/null || { echo "coord-send: cannot create $DEST_DIR" >&2; exit 2; }
|
|
|
|
# The filename must be shell-clean: it round-trips through coord-inbox's
|
|
# reply/resolve hints and coord-done's positional args. Sanitize the sender
|
|
# for the filename only; the raw name stays in the from: frontmatter.
|
|
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
SAFE_FROM="$(printf '%s' "$FROM" | tr -c 'A-Za-z0-9._-' '-')"
|
|
FNAME="${TS}-$$${RANDOM}-from-${SAFE_FROM}.md"
|
|
DEST="$DEST_DIR/$FNAME"
|
|
DATE_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
|
|
# Temp file lives INSIDE the destination dir (dot-prefixed so the inbox
|
|
# *.md glob never sees it): the final mv is then a same-filesystem rename,
|
|
# so readers never observe a half-written message.
|
|
TMP="$(mktemp "$DEST_DIR/.coord-send.XXXXXX" 2>/dev/null)"
|
|
[ -n "$TMP" ] || { echo "coord-send: cannot create temp file in $DEST_DIR" >&2; exit 2; }
|
|
{
|
|
echo "---"
|
|
echo "from: $FROM"
|
|
echo "to: $TARGET_LABEL"
|
|
echo "subject: $SUBJECT"
|
|
echo "date: $DATE_ISO"
|
|
echo "---"
|
|
printf '%s\n' "$BODY"
|
|
} > "$TMP"
|
|
if ! mv "$TMP" "$DEST" 2>/dev/null; then
|
|
/bin/rm -f "$TMP" 2>/dev/null; echo "coord-send: write failed" >&2; exit 2
|
|
fi
|
|
|
|
echo "coord-send: delivered to $TARGET_LABEL ($FNAME)"
|
|
|
|
# A broadcast reaches every repo INCLUDING the sender, so mark it seen for the
|
|
# sender now: the announcing repo already knows its own news, and re-injecting
|
|
# it would burn operator attention at every session start. Keyed by the RAW
|
|
# sender name, because coord-inbox keys the seen file by the unsanitized repo
|
|
# name - using SAFE_FROM here would silently miss for names like "my repo".
|
|
# Raw means it must be a safe path component: skip rather than escape the dir.
|
|
if [ "$BROADCAST" -eq 1 ]; then
|
|
case "$FROM" in
|
|
*/*|.|..) : ;;
|
|
*) SEEN_DIR="$COORD/_broadcast/seen"
|
|
mkdir -p "$SEEN_DIR" 2>/dev/null &&
|
|
printf '%s\n' "$FNAME" >> "$SEEN_DIR/$FROM" 2>/dev/null ;;
|
|
esac
|
|
fi
|
|
|
|
# --- Reply mode: mark the original handled ---
|
|
if [ -n "$REPLY_ORIG" ]; then
|
|
"$(dirname "$0")/coord-done.sh" --repo "$FROM" "$REPLYTO" >/dev/null 2>&1
|
|
echo "coord-send: original ($REPLYTO) marked handled"
|
|
fi
|
|
exit 0
|