#!/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 --subject "" [--from ] [--message ""] # coord-send.sh --broadcast --subject "" [--from ] [--message ""] # coord-send.sh --reply-to [--subject "Re: ..."] [--from ] [--message ""] # coord-send.sh --retract [--from ] # Body comes from --message, or from stdin (heredoc) when --message is omitted. # --reply-to replies to a message in THIS repo's inbox/archive: it # routes to the original sender and marks the original handled (coord-done). # --retract retires one of YOUR OWN broadcasts: it is archived out of # the broadcast queue so no future repo receives it. This is un-send, not # recall - repos that already received it are unaffected. # --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="" RETRACT="" # 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 ;; --retract) require_value --retract $#; RETRACT="$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 --- # git toplevel or an explicit --from, and nothing else. basename(pwd) used to # be the last resort, but every global surface (~/repos, $HOME) is a directory # without a repo, and the fallback quietly handed one an identity like "repos" - # a real message was delivered under exactly that name. An invented identity is # worse than none: it signs mail as a repo that does not exist and, on the read # side, opens a mailbox that may belong to someone else. Refuse and say how. if [ -z "$FROM" ]; then FROM="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)" fi if [ -z "$FROM" ]; then echo "coord-send: cannot resolve sender identity (not inside a git repo); pass --from to choose one explicitly" >&2 exit 2 fi # A leading _ is reserved for engine internals (_broadcast today; the rule # reserves the namespace so a later _seen or _config cannot reopen the hole). case "$FROM" in _*) echo "coord-send: invalid sender identity: $FROM (names starting with _ are reserved for the engine)" >&2; exit 2 ;; esac # 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")" # --- Retract mode: retire one of our own broadcasts --- # A broadcast has no per-repo owner that can close it: coord-done is directed-only # and never touches _broadcast/. Without this branch a stale announcement stays in # the queue and is delivered to every FUTURE repo forever, so the backlog can only # grow. Runs before every send-side validation and before the stdin body read: a # retract carries no subject and no body. # The sender check is an accident guard, not a security boundary - --from # redefines identity here exactly as it does everywhere else in this script. It # exists so a wrong filename cannot silently retire another repo's announcement. if [ -n "$RETRACT" ]; then if [ "$BROADCAST" -eq 1 ] || [ -n "$TO" ] || [ -n "$REPLYTO" ]; then echo "coord-send: --retract cannot be combined with --to/--broadcast/--reply-to" >&2; exit 2 fi case "$RETRACT" in */*|.|..|"") echo "coord-send: invalid --retract name: $RETRACT" >&2; exit 2 ;; esac BC_INBOX="$COORD/_broadcast/inbox" BC_ARCHIVE="$COORD/_broadcast/archive" if [ ! -e "$BC_INBOX/$RETRACT" ]; then # Idempotent like coord-done: retracting twice is a no-op, not an error. if [ -e "$BC_ARCHIVE/$RETRACT" ]; then echo "coord-send: broadcast already retracted ($RETRACT)"; exit 0 fi echo "coord-send: --retract broadcast not found: $RETRACT" >&2; exit 2 fi ORIG_FROM="$(grep -m1 '^from:' "$BC_INBOX/$RETRACT" 2>/dev/null | sed 's/^from:[[:space:]]*//')" if [ "$ORIG_FROM" != "$FROM" ]; then echo "coord-send: refusing to retract a broadcast sent by ${ORIG_FROM:-unknown} (you are $FROM); pass --from ${ORIG_FROM:-} if that is really you" >&2 exit 2 fi # Archived, never deleted (coord-done's rule), and the move stays inside # _broadcast/ so it is a same-filesystem rename: no half-retracted state. mkdir -p "$BC_ARCHIVE" 2>/dev/null || { echo "coord-send: cannot create $BC_ARCHIVE" >&2; exit 2; } if ! mv "$BC_INBOX/$RETRACT" "$BC_ARCHIVE/$RETRACT" 2>/dev/null; then echo "coord-send: retract failed for $RETRACT" >&2; exit 2 fi # _broadcast/seen/* is deliberately left alone: those entries are delivery # history, and one naming an archived file is inert. echo "coord-send: retracted broadcast ($RETRACT); repos that already received it are unaffected" exit 0 fi # --- 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 or --broadcast, not both" >&2; exit 2 fi if [ "$BROADCAST" -eq 0 ] && [ -z "$TO" ]; then echo "coord-send: missing --to / --broadcast / --reply-to" >&2; exit 2 fi if [ "$BROADCAST" -eq 0 ]; then # _* rather than the single literal _broadcast: the reserved namespace is a # rule, so a future internal directory is covered the day it is added. case "$TO" in */*|.*|_*) 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