#!/bin/sh
# party: share a tmux session with the people you already work with.
#
# Single-file POSIX shell.
#
# Project by veg & kol3rby for the UNIX Social Club <https://club.unix.rocks>

set -eu

PARTY_VERSION=0.1.0

# Overridable defaults
# ====================

# $USER may be unset in stripped environments (cron, systemd unit shells,
# `env -i` invocations). Every cmd_* path references it, so guarantee it
# now via id -un rather than crashing later under set -eu.
: "${USER:=$(id -un)}"
# Group default. Remember whether the caller supplied TMUX_PARTY_GROUP
# explicitly: cmd_host auto-selects a candidate group only when it was
# not (an empty value counts as unset; an empty group name gates
# nothing).
if [ -n "${TMUX_PARTY_GROUP:-}" ]; then
    TMUX_PARTY_GROUP_EXPLICIT=1
else
    TMUX_PARTY_GROUP_EXPLICIT=0
    TMUX_PARTY_GROUP=party
fi
: "${PARTY_SOCKET_DIR:=/tmp}"
: "${PARTY_TMUX:=tmux}"
# Candidate groups for hosting when none was named: the admin
# convention ('staff', also every macOS user's primary group), the
# classic shared-users group, the BSD admin group, then the
# recommended custom group as the fallback. Caller membership is the
# only test; whether *guests* are members is checked where it can
# actually be answered, at invite time (cmd_invite warns).
: "${PARTY_GROUP_CANDIDATES:=staff users wheel party}"

# Usage
# =====

usage() {
    cat <<'EOF'
Usage: party <subcommand> [args]

Hosting:
  host [name] [--group <g>]
                          Start a party on a dedicated tmux server.
                          Names: letters, digits, '_', '-' (max 63).
  close [name]            Tear down the party you host (name it when
                          you host several).

Joining:
  join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
                          --passive lands in the host's session (mirrored view).
                          Read-only invitees always join passive.
  leave                   Detach from the party you joined.
  role [active|passive|switch]
                          Flip your clients between guest session and host session.

Moderation (host only; --party <name> disambiguates multiple parties):
  invite <user> [-r]      Add user to allowlist (read/write; -r for read-only).
  voice <user>            Promote user to read/write (alias: rw).
  mute <user>             Demote user to read-only (alias: ro).
  kick <user>             Revoke invite + disconnect + clean up guest session.
  detach <user>           Disconnect user; keep them on the allowlist.

Info:
  list                    List discoverable parties on this host.
  who [--short] [--party <name>]
                          Show invited and attached users for a party.
  status                  Show the caller's own party state.
  --help                  This message.
  --version               Print version.
EOF
}

# Library mode
# ============

# Sourced from tests with __PARTY_LIB_ONLY=1 to expose helpers without
# triggering dispatch. The marker MUST be checked before the dispatch
# block at the bottom of the file.

# Helpers
# =======

# Validate a party name. Accepts [A-Za-z0-9_-]{1,63}, not starting with
# '-'. Reject empty, paths, shell metas, and overlong names: the name
# lands in a file path component, a tmux session name, and shell
# command lines. Dots are rejected because tmux's session_check_name()
# silently rewrites '.' (and ':') to '_', which would desync the party
# name from its tmux session and break every later `-t <name>` target.
# Leading '-' is rejected because every subcommand parser would eat the
# name as a flag, so such a party could never be addressed.
validate_party_name() {
    name="$1"
    case "$name" in
        '' )  echo "party: name must not be empty" >&2; return 2 ;;
    esac
    # Length cap: 63 keeps the full socket path comfortably below the
    # 108-char sun_path limit for typical USERs.
    if [ "${#name}" -gt 63 ]; then
        echo "party: name '$name' too long (max 63 chars)" >&2
        return 2
    fi
    case "$name" in
        -* )
            echo "party: name must not start with '-'" >&2
            return 2 ;;
        *[!A-Za-z0-9_-]* )
            echo "party: name '$name' has invalid characters" >&2
            echo "       allowed: letters, digits, '_', '-'" >&2
            return 2 ;;
    esac
    return 0
}

# UTC ISO 8601, portable across BSD/GNU date.
iso_now() {
    date -u '+%Y-%m-%dT%H:%M:%SZ'
}

# Boolean: does the named OS user exist?
user_exists() {
    id -u "$1" >/dev/null 2>&1
}

# Boolean: is user $1 a member of group $2 (supplementary or primary)?
# id -nG is the one portable membership probe: no getent on macOS, no
# dscl anywhere else, and it answers primary-GID membership too.
user_in_group() {
    id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"
}

# Boolean: is the named user in TMUX_PARTY_GROUP?
user_in_party_group() {
    user_in_group "$1" "$TMUX_PARTY_GROUP"
}

# Print the first PARTY_GROUP_CANDIDATES entry the caller belongs to;
# print nothing and return 1 when the caller is in none of them.
pick_host_group() {
    for _pg_g in $PARTY_GROUP_CANDIDATES; do
        if user_in_group "$USER" "$_pg_g"; then
            printf '%s\n' "$_pg_g"
            return 0
        fi
    done
    return 1
}

# Soft assertion: caller is in TMUX_PARTY_GROUP. Fails loudly with
# remediation instructions when not. Called by cmd_host before any
# filesystem work; the other subcommands don't need it: non-members
# are stopped by the FS perimeter itself (EACCES at the per-party dir)
# and get the friendlier group-gate message in cmd_join.
require_caller_in_group() {
    if ! user_in_party_group "$USER"; then
        cat >&2 <<EOF
party: you ($USER) are not in the '$TMUX_PARTY_GROUP' group.

One-time setup (as root), your OS may differ:
  Linux:        groupadd $TMUX_PARTY_GROUP && usermod -aG $TMUX_PARTY_GROUP $USER
  FreeBSD:      pw groupadd $TMUX_PARTY_GROUP && pw groupmod $TMUX_PARTY_GROUP -m $USER
  OpenBSD:      groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
  illumos:      groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
Then log out and back in.

Or gate on any existing group you are already in:
  party host --group <group>
EOF
        exit 1
    fi
}

# Roster paths
# ------------

# Path separators are ':' rather than '-' because party names may contain
# '-' (validate_party_name allows it) and we don't want USER-foo-bar to
# collide with USER:foo-bar / USER-foo:bar etc. POSIX usernames cannot
# contain ':' and validate_party_name rejects it, so the separator is
# unambiguous.

# Per-party private directory. Created at host time, removed at close.
# Holds the tmux socket, the notify helper, and the roster record. The
# directory's group + mode IS the discovery perimeter: members of
# $TMUX_PARTY_GROUP can traverse and read; non-members get EACCES at
# the dir, so the roster inside is invisible to them. No shared
# install directory: each party is a self-contained perimeter.
party_dir_path() {
    printf '%s/party-%s:%s.d\n' "$PARTY_SOCKET_DIR" "$1" "$2"
}

roster_record_path() {
    printf '%s/roster\n' "$(party_dir_path "$1" "$2")"
}

socket_path() {
    printf '%s/sock\n' "$(party_dir_path "$1" "$2")"
}

# Roster I/O
# ----------
#
# Records are flat shell-sourceable key=value files:
#
#   HOST_USER=veg
#   PARTY_NAME=debug-the-deploy
#   SOCKET=/tmp/party-veg:debug-the-deploy.d/sock
#   SERVER_PID=12345
#   GROUP=party
#   CREATED=2026-04-26T18:42:00Z
#
# Schema: keys MUST be from the set above. Values are single-line, no
# spaces or special chars (we control all writes).

# Atomic write: temp file then rename.
# Usage: roster_write <path> <KEY1> <VAL1> <KEY2> <VAL2> ...
roster_write() {
    rec="$1"; shift
    tmp="${rec}.tmp.$$"
    : > "$tmp"
    while [ $# -ge 2 ]; do
        printf '%s=%s\n' "$1" "$2" >> "$tmp"
        shift 2
    done
    chmod 0640 "$tmp" 2>/dev/null || true
    mv "$tmp" "$rec"
}

# Read a roster record, exporting RR_<KEY> into the calling shell.
#
# Trust model: security-relevant fields (RR_HOST_USER, RR_PARTY_NAME,
# RR_SOCKET) come from the filesystem, the dir basename and the dir's
# owner, never from the file's content. The roster file itself supplies
# advisory metadata (RR_SERVER_PID, RR_GROUP, RR_CREATED) which is fine
# to display but not to act on.
#
# This anchors trust in unforgeable primitives: mkdir(2) and bind(2) set
# the owner to the calling euid, and `chown` requires root. A group
# member who plants a forged dir cannot fake its ownership, cannot
# redirect RR_SOCKET (always `$d/sock`), and cannot forge a sock
# elsewhere (we reject `$d` and `$d/sock` symlinks; we require both
# inodes to be owned by the user named in the basename, which catches
# hardlinks pointing at sibling-party sockets).
#
# What we deliberately don't try to defend against: a group member
# hosting a real party that appears in discovery (that's just hosting),
# and TOCTOU swaps inside an attacker's own dir between roster_read and
# the subsequent tmux call (window is short; damage cap is "any tmux op
# the caller was already authorized to perform").
#
# That damage cap holds because the swap only reaches discovery paths
# (join/leave/status), which act on the caller's own clients/guest
# session, never on someone else's server. The host-only moderation
# paths (kick/detach/voice/mute) can't be redirected at all: they route
# through resolve_authoritative_party, which gates on [ -O "$d" ], so the
# dir is the caller's own (mode 0750, group r-x, no group write) under a
# sticky parent, a group member can neither replace its sock nor rename
# the dir, so there is no socket to swap out from under the tmux call.
roster_read() {
    rec="$1"
    [ -f "$rec" ]    || return 1

    d="${rec%/roster}"
    [ -L "$d" ]      && return 1
    [ -L "$d/sock" ] && return 1
    [ -e "$d/sock" ] || return 1

    base="${d##*/}"
    case "$base" in
        party-*:*.d) ;;
        *) return 1 ;;
    esac
    rest="${base#party-}"
    rest="${rest%.d}"
    RR_HOST_USER="${rest%%:*}"
    RR_PARTY_NAME="${rest#*:}"
    [ -n "$RR_HOST_USER" ]  || return 1
    [ -n "$RR_PARTY_NAME" ] || return 1

    # Pattern parity with cmd_host. The basename glob (party-*:*.d) is
    # permissive: names like 'a b' or 'my.party' match the glob but
    # validate_party_name rejects them (dots are banned: tmux rewrites
    # them in session names). Run the canonical validator so the
    # invariant cmd_host enforces on write is also checked on read.
    # Side effect: pre-existing dotted-party dirs from older builds
    # become undiscoverable, which is the pre-release migration policy
    # (README §Status).
    validate_party_name "$RR_PARTY_NAME" 2>/dev/null || return 1

    # Both the dir and its sock must be owned by the user named in the
    # basename. -prune halts find's descent so we examine just the path
    # itself. find errors (unknown user, missing path) suppress to no
    # output → reject.
    [ -n "$(find "$d"      -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1
    [ -n "$(find "$d/sock" -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1

    # Mode hardening for cross-user discovery. cmd_host writes the
    # per-party dir as mode 0750, no other-bits set. A non-member
    # outside TMUX_PARTY_GROUP cannot read or traverse such a dir, so
    # anything surfacing in roster_list with other-readable bits is
    # suspect: a local user planted a world-readable
    # party-attacker:foo.d under a sticky parent (e.g. /tmp) to inject
    # a fake party into our `party list` / `party join` flow. Reject
    # so the spoof never reaches a tmux command. POSIX find lacks "/"
    # or "+" perm operators (BSD/GNU extensions), so test each
    # other-bit alone.
    [ -n "$(find "$d" -prune -perm -0001 2>/dev/null)" ] && return 1  # o+x
    [ -n "$(find "$d" -prune -perm -0002 2>/dev/null)" ] && return 1  # o+w
    [ -n "$(find "$d" -prune -perm -0004 2>/dev/null)" ] && return 1  # o+r

    RR_SOCKET="$d/sock"
    RR_SERVER_PID='' RR_GROUP='' RR_CREATED=''
    while IFS='=' read -r k v; do
        # shellcheck disable=SC2034  # RR_CREATED is part of the public schema
        case "$k" in
            SERVER_PID) RR_SERVER_PID=$v ;;
            GROUP)      RR_GROUP=$v ;;
            CREATED)    RR_CREATED=$v ;;
            '' | \#*)   ;;
        esac
    done < "$rec"
    return 0
}

# List full roster record paths discoverable to the caller. The glob
# walks every per-party private dir under PARTY_SOCKET_DIR; entries
# whose group the caller isn't in fail the inner [ -f ] check (EACCES
# on the dir) and are silently skipped.
roster_list() {
    [ -d "$PARTY_SOCKET_DIR" ] || return 0
    # No ownership/symlink gate here: roster_list is the cross-user
    # discovery primitive (party list, party join, party leave, status),
    # so it must surface dirs hosted by other group members. Host-only
    # walkers (resolve_authoritative_party, no-arg cmd_close) apply
    # their own owner+symlink filter before trusting HOST_USER.
    for d in "$PARTY_SOCKET_DIR"/party-*:*.d; do
        [ -d "$d" ] || continue
        rec="$d/roster"
        [ -f "$rec" ] || continue
        printf '%s\n' "$rec"
    done
}


# Liveness
# --------

# Classify the caller's relationship to a party socket. Prints exactly
# one of:
#   ok           : live tmux server, the caller is authorized.
#   unauthorized : live tmux server, but it refused us. CAUTION, the
#                  reason this function exists: tmux answers a
#                  non-allowlisted user with "access not allowed" on
#                  stderr and EXIT STATUS 0 (verified live on 3.3a,
#                  3.5a, and 3.6/SunOS), for every command, including
#                  has-session, which then reports any target as
#                  existing. Exit codes alone cannot distinguish
#                  authorized from unauthorized. We deliberately do NOT
#                  match the denial text either: wording could drift
#                  across tmux versions and silently reopen the
#                  false-authorized paths. A clean authorized
#                  list-clients writes nothing to stderr, so rc 0 plus
#                  ANY stderr output classifies as unauthorized. Should
#                  tmux ever warn on an authorized call, that degrades
#                  to a hidden party, never to a false "ok".
#   dead         : nothing speaking the tmux protocol at that socket
#                  (stale roster, killed server, AF_UNIX impostor).
#
# This probe is the ONLY liveness primitive. `tmux -S <sock>
# list-clients` exits 0 only against a real tmux server bound to that
# exact socket path, which rules out stale roster pointers, AF_UNIX
# impostors (a same-group user planting a hand-rolled listener via
# `nc -lU`), and PID-reuse leftovers in one shot. Earlier revisions had
# a separate is_party_alive() that also format-checked the roster's
# SERVER_PID, a vestige of a `kill -0` pre-filter that broke cross-user
# discovery on every non-Linux POSIX target: per POSIX, kill(2) signal
# 0 may return EPERM when the caller lacks send-permission, and
# illumos, all the BSDs, and macOS honor that. Linux (and LX-branded
# zones) return 0 for "exists, even if not signalable", which is why
# the bug hid in the test matrix until a native illumos run surfaced
# it. SERVER_PID stays in the roster as display-only metadata.
party_conn_state() {
    _cs_err=$("$PARTY_TMUX" -S "$1" list-clients 2>&1 >/dev/null) \
        && _cs_rc=0 || _cs_rc=$?
    if [ "$_cs_rc" -ne 0 ]; then
        printf 'dead\n'
    elif [ -n "$_cs_err" ]; then
        printf 'unauthorized\n'
    else
        printf 'ok\n'
    fi
}

# FS perms
# --------
#
# The OS group is the perimeter, tmux's server-access allowlist is the
# per-user filter. Both are universal across the supported platforms
# (Linux, FreeBSD, NetBSD, OpenBSD, illumos, Solaris, macOS), so the
# mechanism is one liner: chgrp + chmod. No ACL syscalls invoked.
#
# ACL-shadowing on filesystems with NFSv4-style extended ACLs (macOS
# HFS+/APFS, FreeBSD/illumos ZFS): inherited ACEs on PARTY_SOCKET_DIR
# can override the mode bits we set here, either widening access (an
# inherited `everyone@:rx` survives our chmod 0750 because mode bits
# don't displace ACEs) or narrowing it (a `deny everyone:` blocks the
# group members our chgrp tried to admit). We deliberately do NOT
# invoke `getfacl` / `getextattr` / `ls -le` to verify effective
# access. Two reasons:
#
#   1. Re-introducing per-platform ACL plumbing would unwind the
#      simplification this redesign committed to: one universal
#      mechanism, no OS branching in the trust path.
#
#   2. The dual gate keeps the FS layer from being the sole
#      authorization check. tmux server-access is the authoritative
#      perimeter on every platform; the FS gate is defense in depth.
#      An ACL-shadowed FS gate is a *weakening of depth*, not a
#      privilege escalation: a user who wins the FS-gate race still
#      has to be on the auth-list to attach, and the auth-list is
#      only edited by the host.
#
# Treat the FS gate as best-effort discoverability and traversal
# control. If you operate on a filesystem with restrictive inherited
# ACLs that chmod 0750 can't override, document it in your deployment
# notes, server-access still keeps strangers out, but advertised
# group members may need ACL-level access to traverse PARTY_SOCKET_DIR.
# See README §Trust model for the user-facing version of this caveat.
# WONTFIX, by design.

apply_party_perms_file() {
    p="$1"
    chgrp "$TMUX_PARTY_GROUP" "$p"
    chmod g+rw "$p"
}

# Refuse to operate when PARTY_SOCKET_DIR is somewhere an attacker could
# rename our per-party directory between mkdir and use. Trustworthy is
# either: sticky (mode +t, like /tmp), or owned by the caller and neither
# group- nor world-writable. Default /tmp passes by virtue of the sticky
# bit. Group-writable parents are rejected because the parent's group
# may differ from TMUX_PARTY_GROUP, in that case its members would be
# outside our trust perimeter yet able to rename or unlink our per-party
# directory.
validate_socket_dir_parent() {
    p="$PARTY_SOCKET_DIR"
    # Whitespace in the parent breaks every `for rec in $(roster_list)`
    # walker and the unquoted paths embedded in tmux hook/status-right
    # strings. Refuse at host time, where the dir is chosen, instead of
    # corrupting quietly later.
    case "$p" in
        *[[:space:]]*)
            echo "party: PARTY_SOCKET_DIR must not contain whitespace: $p" >&2
            return 1 ;;
    esac
    if [ ! -d "$p" ]; then
        echo "party: PARTY_SOCKET_DIR $p does not exist" >&2
        return 1
    fi
    if [ -k "$p" ]; then
        # Sticky bit only protects entries against non-owners; the parent's
        # OWNER can still rename or unlink our per-party dir. Accept sticky
        # only if the parent is owned by us or by root.
        [ -O "$p" ] && return 0
        # shellcheck disable=SC2012  # ls -ldn is portable; find -printf is not
        p_uid=$(ls -ldn "$p" 2>/dev/null | awk '{print $3; exit}')
        [ "$p_uid" = 0 ] && return 0
    fi
    # Two separate -perm checks: POSIX/BSD find uses `+` for "any of",
    # GNU find uses `/`, but both accept `-mode` ("all of these bits set"),
    # so we ask the question twice instead of OR-ing in one expression.
    if [ -O "$p" ] \
       && [ -z "$(find "$p" -prune -perm -0002 2>/dev/null)" ] \
       && [ -z "$(find "$p" -prune -perm -0020 2>/dev/null)" ]; then
        return 0
    fi
    cat >&2 <<EOF
party: refusing to operate in PARTY_SOCKET_DIR=$p
  Parent must be either:
    (a) sticky (mode +t, like /tmp) AND owned by you ($USER) or root, or
    (b) owned by you ($USER) AND not group/world-writable.
  Set PARTY_SOCKET_DIR to a directory meeting one of these.
EOF
    return 1
}

# Default name when user runs `party host` with no args. Random hex
# suffix; no hostname involvement (parties are scoped to the host the
# script runs on, so the user already knows where they are).
compute_default_party_name() {
    # 3 random bytes as hex. The `|| ...` can't catch a read failure on
    # a pipeline (its exit status is tr's, not od's), so test the result
    # and fall back to the PID when /dev/urandom gave us nothing.
    rand=$(od -An -N3 -tx1 /dev/urandom 2>/dev/null | tr -d ' ')
    [ -n "$rand" ] || rand=$$
    printf 'party-%s\n' "$rand"
}

# Absolute path to this script, for embedding in tmux server state that
# outlives this invocation (the status-right #() widget runs with the
# tmux server's PATH, which need not contain an installed `party`).
# $0 forms: absolute, take it; relative-with-slash, anchor to cwd;
# bare word, resolve via PATH, falling back to $0 verbatim. No `--`
# after command -v/dirname/basename: old illumos userland predates it,
# and $0 here never starts with '-'.
party_self_path() {
    case "$0" in
        /*)  printf '%s\n' "$0" ;;
        */*) printf '%s/%s\n' "$(cd "$(dirname "$0")" && pwd)" \
                              "$(basename "$0")" ;;
        *)   _sp=$(command -v "$0" 2>/dev/null) || _sp="$0"
             # command -v may itself answer relatively (PATH with "." or
             # an empty entry); anchor those to cwd like the */* case.
             case "$_sp" in
                 /*) printf '%s\n' "$_sp" ;;
                 *)  printf '%s/%s\n' "$PWD" "$_sp" ;;
             esac ;;
    esac
}

# After spawning the party server, configure status line and hooks.
# All commands run against -S "$sock". Every literal name embedded
# is shell-quoted to survive tmux's parser.
tmux_party_setup_server() {
    sock="$1" name="$2"

    # Write a tiny notification helper next to the socket. The leading dot
    # keeps it out of typical `ls` listings. The per-party private dir
    # hosts at most one socket so a fixed name is safe.
    notify_script="${sock%/*}/.party-notify"
    # Two heredocs: the first (expanding) pins the tmux binary the party
    # was hosted with: a bare `tmux` from the server's PATH may be a
    # different, older binary than $PARTY_TMUX, which is the whole reason
    # PARTY_TMUX exists. The second (quoted) is the static body. A
    # PARTY_TMUX containing a double quote, $, or backslash would break
    # the pin; that's accepted: it's the host's own env var on their
    # own party.
    cat > "$notify_script" <<EOF
#!/bin/sh
tmux="$PARTY_TMUX"
EOF
    cat >> "$notify_script" <<'EOF'
# Auto-generated by party. Fan a display-message out to every client
# of the party server. Args: $1 = socket, $2 = client_user, $3 = "joined"|"left"
sock="$1"; user="$2"; verb="$3"
"$tmux" -S "$sock" list-clients -F '#{client_name}' 2>/dev/null \
  | while read -r c; do
        "$tmux" -S "$sock" display-message -c "$c" "$user $verb the party" 2>/dev/null || :
    done
EOF
    chmod 0755 "$notify_script"
    # Match the rest of the per-party dir's group ownership so guests'
    # debugging (`ls -l` inside a party dir they can traverse) doesn't
    # surface this one file as the odd `:other` outlier. The script is
    # invoked by tmux run-shell as the host, so guests don't need
    # execute on it; setting the group is purely for consistency.
    chgrp "$TMUX_PARTY_GROUP" "$notify_script"

    "$PARTY_TMUX" -S "$sock" set-option  -s exit-empty       on
    party_self=$(party_self_path)
    # The #() body is run by the tmux server via sh, so the embedded
    # paths are single-quoted: a path containing a single quote, a `)`,
    # or `#{` (tmux's #() parser ends at the first unescaped `)`) is
    # accepted breakage (host's own install path).
    "$PARTY_TMUX" -S "$sock" set-option  -g status-right \
        "party: #('$party_self' who --short --socket '$sock' 2>/dev/null)"
    # Join/leave broadcast. Hook bodies fan a display-message out to
    # every attached client of THIS server via the notify helper script.
    # tmux substitutes #{client_user} before running the shell command.
    "$PARTY_TMUX" -S "$sock" set-hook -g client-attached \
        "run-shell '$notify_script $sock #{client_user} joined'"
    "$PARTY_TMUX" -S "$sock" set-hook -g client-detached \
        "run-shell '$notify_script $sock #{client_user} left'"
}

# Find the live roster record for a party named "$name". Searches across
# all hosts. On match, sets RR_*. Returns 0 on found, 1 on not found,
# 2 on ambiguous (same name from multiple hosts).
find_party_by_name() {
    target="$1"
    set --
    for rec in $(roster_list); do
        roster_read "$rec" || continue
        [ "$(party_conn_state "$RR_SOCKET")" = dead ] && continue
        [ "$RR_PARTY_NAME" = "$target" ] && set -- "$@" "$rec"
    done
    case $# in
        0) return 1 ;;
        1) roster_read "$1"; return 0 ;;
        *) echo "party: '$target' is ambiguous (multiple hosts running it)" >&2
           return 2 ;;
    esac
}

# Pick the single live party, or run a numbered prompt.
pick_live_party() {
    set --
    for rec in $(roster_list); do
        roster_read "$rec" || continue
        [ "$(party_conn_state "$RR_SOCKET")" = dead ] && continue
        set -- "$@" "$rec"
    done
    case $# in
        0) echo "party: no parties found on this host." >&2; return 1 ;;
        1) roster_read "$1"; return 0 ;;
    esac
    # 2+ parties need the interactive picker; without a tty read would
    # EOF and set -e would kill us with no message.
    if [ ! -t 0 ]; then
        echo "party: multiple parties running; pass a name (see 'party list')." >&2
        return 1
    fi
    i=0
    for r in "$@"; do
        i=$((i+1))
        roster_read "$r"
        printf '%d) %s, %s\n' "$i" "$RR_HOST_USER" "$RR_PARTY_NAME" >&2
    done
    printf 'Choose: ' >&2
    read -r pick
    case "$pick" in ''|*[!0-9]*)
        echo "party: invalid choice" >&2; return 1 ;;
    esac
    if [ "$pick" -lt 1 ] || [ "$pick" -gt $# ]; then
        echo "party: out of range" >&2; return 1
    fi
    eval "roster_read \"\$$pick\""
}

# Subcommand dispatch
# ===================

cmd_host() {
    name=
    group_arg=
    while [ $# -gt 0 ]; do
        case "$1" in
            --group)
                [ $# -ge 2 ] || { echo "party host: --group needs an argument" >&2; exit 2; }
                group_arg="$2"; shift 2 ;;
            --group=*) group_arg="${1#--group=}"; shift ;;
            -h|--help)
                cat <<'EOF'
Usage: party host [name] [--group <name>]
  name           Party name (default: random).
  --group <g>    Group used to gate this party. The host must be a
                 member; only members can discover or join. Precedence:
                 --group flag > $TMUX_PARTY_GROUP env > auto-selection:
                 the first of staff, users, wheel, party the host
                 belongs to (candidate list overridable via
                 $PARTY_GROUP_CANDIDATES).
EOF
                exit 0 ;;
            -*)
                echo "party host: unknown flag '$1'" >&2; exit 2 ;;
            *)
                if [ -z "$name" ]; then name="$1"; shift
                else echo "party host: unexpected arg '$1'" >&2; exit 2; fi
                ;;
        esac
    done

    # Group precedence: --group flag > $TMUX_PARTY_GROUP env >
    # auto-selection over PARTY_GROUP_CANDIDATES ('party' is the last
    # candidate, a fallback rather than a hardcoded default). The
    # chosen group becomes the per-party perimeter: chgrp on the
    # private dir, on the socket, and on the roster. No shared install
    # directory, pick any existing group the host is in.
    if [ -n "$group_arg" ]; then
        TMUX_PARTY_GROUP="$group_arg"
        export TMUX_PARTY_GROUP
    elif [ "$TMUX_PARTY_GROUP_EXPLICIT" = 0 ]; then
        auto_group=$(pick_host_group || true)
        if [ -n "$auto_group" ]; then
            TMUX_PARTY_GROUP="$auto_group"
            export TMUX_PARTY_GROUP
            echo "party host: gating on group '$TMUX_PARTY_GROUP' (auto-selected; --group overrides)."
        fi
        # In none of the candidates: keep the 'party' default so
        # require_caller_in_group prints its remediation message.
    fi

    require_caller_in_group
    validate_socket_dir_parent || exit 1

    # A trustworthy parent isn't necessarily a discoverable parent. With
    # mode 0700, hosting and inviting succeed but no other user can
    # traverse PARTY_SOCKET_DIR to glob for parties, `party join` and
    # `party list` will report not-found for everyone but the host.
    # Warn so the user notices before sending invites. Octal 0050 = group
    # r+x; 0005 = other r+x. Non-empty `find -prune -perm -<bits>` means
    # those bits are set on the parent.
    #
    # Known blind spot (WONTFIX for now): we check that *some* group bits
    # are set, not that the parent's group matches TMUX_PARTY_GROUP. With
    # PARTY_SOCKET_DIR mode 0750 owned by user:wheel and TMUX_PARTY_GROUP=party,
    # this branch is skipped (group r+x is set, for wheel members), but
    # party-group members who aren't in wheel still can't traverse. The
    # mismatch self-announces on the first failed `party join`, and a
    # portable name→gid map is awkward in POSIX shell (no getent on macOS,
    # stat flags split BSD/Linux). Default PARTY_SOCKET_DIR=/tmp (1777)
    # never trips this; only custom dirs with restrictive group perms do.
    if [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0050 2>/dev/null)" ] \
       && [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0005 2>/dev/null)" ]; then
        cat >&2 <<EOF
party host: warning, PARTY_SOCKET_DIR=$PARTY_SOCKET_DIR is not readable
  or searchable by group/other. Guests will not be able to discover this
  party (party join / party list will report not-found). To make it
  discoverable: chmod g+rx $PARTY_SOCKET_DIR and chgrp $TMUX_PARTY_GROUP
  $PARTY_SOCKET_DIR, or use a parent like /tmp.
EOF
    fi

    [ -z "$name" ] && name=$(compute_default_party_name)
    validate_party_name "$name" || exit $?

    party_dir=$(party_dir_path "$USER" "$name")
    sock="$party_dir/sock"
    rec="$party_dir/roster"

    # AF_UNIX sun_path is 108 bytes on Linux/illumos and 104 on the BSDs
    # (incl. macOS). The validator caps the name at 63, but the per-party
    # dir adds `party-USER:NAME.d/sock` overhead on top of PARTY_SOCKET_DIR:
    # with a 32-byte USER and a long name this overflows. Bail before
    # bind(2) returns ENAMETOOLONG via tmux. 100 leaves headroom on the
    # tightest platform (104 - NUL - small buffer).
    if [ "${#sock}" -gt 100 ]; then
        cat >&2 <<EOF
party: socket path too long (${#sock} bytes; AF_UNIX sun_path max ~104).
  $sock
  Try a shorter party name, a shorter PARTY_SOCKET_DIR, or both.
EOF
        exit 1
    fi

    # Pre-mkdir trust + liveness checks. We diagnose two cases here for a
    # better error message; everything else (including stale leftovers
    # from crashed prior runs) falls through to the mkdir lock below.
    if [ -L "$party_dir" ] || { [ -e "$party_dir" ] && [ ! -O "$party_dir" ]; }; then
        echo "party: $party_dir exists and is not a directory you own" >&2
        exit 1
    fi
    if [ -d "$party_dir" ] && [ -f "$rec" ]; then
        roster_read "$rec" 2>/dev/null || true
        if [ -n "${RR_SOCKET:-}" ] \
           && [ "$(party_conn_state "$RR_SOCKET")" != dead ]; then
            echo "party: '$name' is already running" >&2
            exit 1
        fi
    fi

    # mkdir is the atomic acquisition primitive. POSIX mkdir(2) on an
    # existing path returns EEXIST without modifying anything, so it
    # serializes concurrent `party host <same-name>` cleanly: exactly one
    # process owns the directory, the rest see EEXIST and bail.
    #
    # We deliberately do NOT auto-clean leftover dirs (e.g. from a prior
    # SIGKILLed cmd_host that couldn't run its rollback trap). Auto-cleanup
    # races with a concurrent in-flight setup: two processes both seeing a
    # stale dir would both rm -rf it, and the loser's late rm -rf would
    # wipe the winner's freshly-created replacement.
    # The recovery path for genuine crash leftovers is a one-shot manual
    # `rm -rf $party_dir` followed by retry; the error message says so.
    if ! mkdir -m 0700 "$party_dir" 2>/dev/null; then
        cat >&2 <<EOF
party: $party_dir already exists. Either another host is starting '$name'
  right now, or a prior crashed attempt left this directory behind. If no
  other host is in flight, remove the directory and retry:
    rm -rf $party_dir
EOF
        exit 1
    fi

    # Catches symlink-swap between mkdir and use on non-sticky parents.
    # We own the dir at this point, so it's safe to rm -rf on failure.
    if [ -L "$party_dir" ] || [ ! -O "$party_dir" ]; then
        rm -rf "$party_dir"
        echo "party: $party_dir failed post-mkdir trust check" >&2
        exit 1
    fi

    # Arm rollback ONLY after mkdir succeeded. Earlier (before mkdir),
    # arming the trap would let a concurrent host attempt that lost the
    # mkdir race tear down the winner's directory and tmux server via
    # set -e + EXIT trap. Now the trap fires only on resources this
    # process actually created. Disarmed once roster_write commits.
    trap '
        "$PARTY_TMUX" -S "$sock" kill-server 2>/dev/null || true
        rm -rf "$party_dir"
    ' EXIT INT TERM HUP

    chgrp "$TMUX_PARTY_GROUP" "$party_dir"
    chmod 0750 "$party_dir"

    # The per-party dir is now group-traversable (0750), but the tmux
    # socket from `new-session` below lands at 0600, tmux's server.c
    # calls umask(S_IXUSR|S_IRWXG|S_IRWXO) before bind(2), so group/other
    # bits are stripped regardless of our umask or process group. Group
    # members can stat the socket but cannot connect() until
    # apply_party_perms_file widens it AFTER server-access -aw runs.
    "$PARTY_TMUX" -S "$sock" new-session -d -s "$name"

    tmux_party_setup_server "$sock" "$name"

    # Auth gate FIRST, server-access runs BEFORE we widen FS perms.
    # This closes the connect-before-allowlist race: without this order,
    # a group member could connect() the socket between chmod g+rw and
    # server-access -a, and tmux would honor an in-flight connection
    # that wasn't yet on the allowlist.

    # Capability probe. server-access requires tmux ≥ 3.3; without it
    # the auth gate doesn't exist and the FS gate alone would let any
    # group member attach. README states the version requirement, but
    # silently swallowing errors here would degrade the gate to nothing
    # under version skew. Refuse to host
    # rather than ship an unenforced auth layer; the rollback trap
    # tears down the dedicated tmux server we just started.
    if ! "$PARTY_TMUX" -S "$sock" server-access -l >/dev/null 2>&1; then
        ver=$("$PARTY_TMUX" -V 2>/dev/null || echo "?")
        echo "party: tmux ($ver) does not support 'server-access' (requires tmux ≥ 3.3)." >&2
        exit 1
    fi

    # Add host to allowlist. tmux refuses to modify the server owner's
    # own ACL entry ("owns the server, can't change access"), the owner
    # has implicit write access, so that specific message is benign
    # idempotency. Anything else is a real failure: rollback fires.
    err=$("$PARTY_TMUX" -S "$sock" server-access -aw "$USER" 2>&1) || true
    case "$err" in
        '' | *"owns the server"* | *"can't change access"* ) ;;
        *)  echo "party: server-access -aw $USER failed: $err" >&2; exit 1 ;;
    esac

    # FS gate AFTER the access list is populated. The per-party private
    # directory was already chgrp'd + chmod 0750 above, so only the
    # socket itself needs widening.
    apply_party_perms_file "$sock"

    pid=$("$PARTY_TMUX" -S "$sock" display-message -p '#{pid}')

    roster_write "$rec" \
        HOST_USER  "$USER" \
        PARTY_NAME "$name" \
        SOCKET     "$sock" \
        SERVER_PID "$pid" \
        GROUP      "$TMUX_PARTY_GROUP" \
        CREATED    "$(iso_now)"

    # Party committed. Disarm rollback, from here on, a failure (e.g.
    # chgrp on the roster) leaves a fully-formed party that the host can
    # tear down explicitly with `party close`. Tearing it down on the
    # caller's behalf for a stray permission warning would be worse.
    trap - EXIT INT TERM HUP

    # Roster: chgrp only. roster_write already left mode 0640 (g+r, no g+w).
    # Guests read records to discover/join; only the host writes them. Keeping
    # them non-group-writable means a tampered SOCKET=... can't redirect
    # cmd_close's destructive cleanup at peer-supplied paths.
    chgrp "$TMUX_PARTY_GROUP" "$rec"

    if [ -z "${TMUX:-}" ] && [ -t 1 ]; then
        echo "Party '$name' started. Attaching..."
        exec "$PARTY_TMUX" -S "$sock" attach -t "$name"
    fi
    echo "Party '$name' started. Attach from a fresh shell with: party join $name"
}

cmd_close() {
    name=
    while [ $# -gt 0 ]; do
        case "$1" in
            -h|--help)
                echo "Usage: party close [name]"; exit 0 ;;
            -*) echo "party close: unknown flag '$1'" >&2; exit 2 ;;
            *)
                if [ -z "$name" ]; then name="$1"; shift
                else echo "party close: unexpected arg '$1'" >&2; exit 2; fi
                ;;
        esac
    done

    if [ -z "$name" ]; then
        # No name → close the only party we host, if exactly one.
        # Filter forged dirs (symlinks or non-caller-owned) before
        # trusting HOST_USER so another local user can't seed a fake
        # party-$USER:junk.d/roster and turn this into a "multiple
        # parties" DoS.
        set --
        for rec in $(roster_list); do
            d="${rec%/roster}"
            [ -L "$d" ] && continue
            [ -O "$d" ] || continue
            roster_read "$rec" || continue
            [ "$RR_HOST_USER" = "$USER" ] || continue
            set -- "$@" "$RR_PARTY_NAME"
        done
        case $# in
            0) echo "party close: you are not hosting any party." >&2; exit 1 ;;
            1) name="$1" ;;
            *) echo "party close: you host multiple parties; pass a name:" >&2
               echo "  $*" >&2; exit 1 ;;
        esac
    fi

    # Parity with cmd_host's validate_party_name call: reject path
    # separators, whitespace, shell metas, oversized input before path
    # derivation.
    # Hygiene, not the security gate; [ -O ] below is.
    validate_party_name "$name" || exit $?

    expected_dir=$(party_dir_path "$USER" "$name")
    expected_sock="$expected_dir/sock"
    rec="$expected_dir/roster"

    # Refuse before any tmux work if the per-party dir isn't unambiguously
    # ours. With a sticky parent (e.g. /tmp), another local user can
    # pre-create party-$USER:$name.d as a symlink before we ever host;
    # without this gate, kill-server would fire against a forged socket
    # path and rm -rf would chase the symlink.
    if [ -L "$expected_dir" ]; then
        echo "party close: $expected_dir is a symlink; refusing." >&2
        exit 1
    fi
    if [ ! -d "$expected_dir" ]; then
        echo "party close: no party named '$name'." >&2; exit 1
    fi
    if [ ! -O "$expected_dir" ]; then
        echo "party close: $expected_dir is not yours; refusing." >&2
        exit 1
    fi

    # Trust anchor (deliberate): paths are derived
    # from $USER + $name and gated on [ -L ]/[ -O ]. mkdir(2) sets owner
    # to caller's euid; chown requires root; so a same-group attacker
    # can't forge a dir that satisfies [ -O ] for our $USER. We do NOT
    # consult the roster file here; file content is advisory in this
    # codebase, never trusted for destructive operations.
    # PARTY_SOCKET_DIR is caller-controlled env, not a privilege
    # boundary, so we don't canonicalize it.
    "$PARTY_TMUX" -S "$expected_sock" kill-server 2>/dev/null || true
    rm -rf "$expected_dir"
    echo "Party '$name' closed."
}
cmd_join() {
    passive=0
    name=
    while [ $# -gt 0 ]; do
        case "$1" in
            --passive) passive=1; shift ;;
            -h|--help)
                cat <<'EOF'
Usage: party join [name] [--passive]
  name       Party to join. Auto-picks if exactly one is running.
  --passive  Attach read-only to the host's session (mirrored view)
             rather than your own guest session. Switch later with
             `party role` (note: role changes are session-level, not a
             read-only boundary, see `party role --help`).
             Invitees added with `party invite -r` are read-only and
             always join passive (tmux refuses session creation from
             read-only clients).
EOF
                exit 0 ;;
            -*) echo "party join: unknown flag '$1'" >&2; exit 2 ;;
            *)
                if [ -z "$name" ]; then name="$1"; shift
                else echo "party join: unexpected arg '$1'" >&2; exit 2; fi
                ;;
        esac
    done

    if [ -n "${TMUX:-}" ]; then
        echo "party join: detach from your current tmux first." >&2
        exit 1
    fi

    if [ -n "$name" ]; then
        # Capture rc explicitly: under set -e a bare `find_party_by_name`
        # returning 1 or 2 would exit before the case ran, swallowing the
        # error message.
        find_party_by_name "$name" && rc=0 || rc=$?
        case $rc in
            0) ;;
            1) echo "party: '$name' not found." >&2; exit 1 ;;
            2) exit 1 ;;   # find_party_by_name already printed the ambiguity hint
        esac
    else
        pick_live_party || exit 1
    fi

    # Group gate. Check membership against the party's *recorded* group
    # (RR_GROUP), the host may have hosted under --group <other>. The
    # FS perimeter on the per-party dir already blocks non-group readers
    # at roster_list; this preflight just turns that into a friendlier
    # error than "party not found" when membership is the actual cause.
    party_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
    if ! id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$party_group"; then
        cat >&2 <<EOF
party: '$RR_PARTY_NAME' uses group '$party_group'; you ($USER) are not a member.

Ask the host or your sysadmin to add you, then log out and back in.
EOF
        exit 1
    fi

    # Auth gate. The joiner must already be on the access list (added
    # by the host via `party invite`). Pre-flight here so the user gets
    # a helpful message instead of an opaque tmux refusal. For an
    # unauthorized caller server-access -l prints its refusal on stderr
    # with EMPTY stdout (and exit 0, see party_conn_state), so the awk
    # match comes up empty and we land in the exit-13 branch.
    acl_line=$("$PARTY_TMUX" -S "$RR_SOCKET" server-access -l 2>/dev/null \
        | awk -v u="$USER" '$1 == u { print; exit }')
    if [ -z "$acl_line" ]; then
        echo "party: '$RR_PARTY_NAME', ask $RR_HOST_USER to invite you." >&2
        exit 13
    fi
    # Read-only invitees cannot create the guest session: tmux refuses
    # with "client is read-only" (verified 3.3a/3.5a), which would kill
    # an active join mid-new-session under set -eu. Detect the R flag
    # on our own ACL line and take the mirrored view instead. Flag
    # format is "user (R)" on 3.3–3.5 and "user (U,R)" on newer tmux;
    # both end the flag list in "R)".
    case "$acl_line" in
        *"R)"*)
            if [ "$passive" != 1 ]; then
                echo "party: you are read-only on '$RR_PARTY_NAME'; joining passive (mirrored view)." >&2
                passive=1
            fi ;;
    esac

    if [ "$passive" = 1 ]; then
        # Mirror the host's pane focus, read-only at the tmux client
        # layer. `-r` is the watcher boundary on the bootstrap path: a
        # write-capable invitee who chose --passive cannot send keys
        # into the host's panes regardless of their server-access ACL
        # entry. Role switches done later via `party role` do NOT carry
        # this flag (tmux switch-client cannot set read-only state),
        # so role-switching is documented as session-level only, not
        # a watcher boundary.
        set -- attach-session -r -t "$RR_PARTY_NAME"
    else
        # Active: own session group with an independent window cursor.
        # An active guest session survives a client detach, so create it
        # only if it isn't already there, a plain new-session on re-join
        # would hit "duplicate session" and abort under set -e.
        #
        # Reusing by name is an accepted insider risk, not a defended one:
        # any write-capable invitee can pre-create a session called
        # __party_guest_$USER (and even group it to the party), so on a
        # shared server no session name is unforgeable. We trust invitees
        # by design, that's the whole model. A read-only invitee (-r)
        # can't create sessions, so it's not exposed. See README §Trust
        # model.
        guest="__party_guest_$USER"
        "$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "$guest" 2>/dev/null \
            || "$PARTY_TMUX" -S "$RR_SOCKET" new-session -d -t "$RR_PARTY_NAME" -s "$guest"
        set -- attach-session -t "$guest"
    fi

    if [ "${PARTY_DRY_RUN:-0}" = 1 ]; then
        echo "$PARTY_TMUX -S $RR_SOCKET $*"
        return 0
    fi
    # exec with discrete, quoted args: the old flat-string + unquoted
    # `exec $attach_cmd` word-split on any whitespace in PARTY_TMUX or
    # the socket path.
    exec "$PARTY_TMUX" -S "$RR_SOCKET" "$@"
}
cmd_leave() {
    case "${1:-}" in
        -h|--help) echo "Usage: party leave"; exit 0 ;;
    esac

    # Walk every live party; for each one we have any presence on
    # (either via __party_guest_$USER or as a client of the host's
    # session in passive mode), kill our guest session and detach
    # all our clients.
    found=0
    for rec in $(roster_list); do
        roster_read "$rec" || continue
        # Presence checks below (has-session, list-clients) are only
        # meaningful on a server that accepted our connection: for an
        # unauthorized caller has-session answers rc 0 for ANY target,
        # which made leave "succeed" against parties we never joined.
        [ "$(party_conn_state "$RR_SOCKET")" = ok ] || continue
        attached=0
        "$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "__party_guest_$USER" 2>/dev/null \
            && attached=1
        "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \
            | grep -qx "$USER" && attached=1
        [ "$attached" = 1 ] || continue
        _disconnect_user "$USER"
        found=$((found+1))
    done

    [ "$found" -gt 0 ] || { echo "party: not joined to any party." >&2; exit 1; }
}
# Resolve the party the caller hosts. If exactly one matches, sets RR_*;
# if zero or multiple, error. Argument: optional party name to disambiguate.
resolve_authoritative_party() {
    target="$1"
    set --
    for rec in $(roster_list); do
        # Host-only trust: gate before reading the roster so a forged
        # party-$USER:fake.d planted by another local user (symlink, or
        # a real dir owned by them) cannot drive HOST_USER=$USER through
        # this walker.
        d="${rec%/roster}"
        [ -L "$d" ] && continue
        [ -O "$d" ] || continue
        roster_read "$rec" || continue
        [ "$(party_conn_state "$RR_SOCKET")" != dead ] || continue
        [ "$RR_HOST_USER" = "$USER" ] || continue
        [ -z "$target" ] || [ "$target" = "$RR_PARTY_NAME" ] || continue
        set -- "$@" "$rec"
    done
    case $# in
        0) if [ -n "$target" ]; then
               echo "party: '$target' not found, or you are not its host." >&2
           else
               echo "party: you do not host any active party." >&2
           fi
           return 1 ;;
        1) roster_read "$1"; return 0 ;;
        *) echo "party: multiple parties match, pass a name." >&2
           return 1 ;;
    esac
}

cmd_invite() {
    user='' readonly=0 partyname=''
    while [ $# -gt 0 ]; do
        case "$1" in
            -r|--read-only) readonly=1; shift ;;
            --party)
                [ $# -ge 2 ] || { echo "party invite: --party needs an argument" >&2; exit 2; }
                partyname="$2"; shift 2 ;;
            -h|--help)
                echo "Usage: party invite <user> [-r] [--party name]"; exit 0 ;;
            -*) echo "party invite: unknown flag '$1'" >&2; exit 2 ;;
            *)
                if [ -z "$user" ]; then user="$1"; shift
                else echo "party invite: unexpected arg '$1'" >&2; exit 2; fi
                ;;
        esac
    done

    [ -n "$user" ] || { echo "Usage: party invite <user>" >&2; exit 2; }
    user_exists "$user" || { echo "party invite: no such user '$user'" >&2; exit 1; }

    resolve_authoritative_party "$partyname" || exit 1

    # The auth gate (server-access) accepts any user, but the FS gate
    # (per-party dir mode 0750, group=$RR_GROUP) won't let a non-member
    # traverse in. Without this warning, the host sees "Invited X" and
    # the guest sees a write(1) ping, but `party join` reports not-found.
    # Warn-and-proceed (rather than hard-fail) so a later usermod -aG
    # doesn't require a re-invite.
    invite_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
    if ! id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$invite_group"; then
        cat >&2 <<EOF
party invite: warning, '$user' is not in group '$invite_group'.
  They will not be able to discover or join until added.
  Run: sudo usermod -aG $invite_group $user (and have them relog).
EOF
    fi

    if [ "$readonly" = 1 ]; then
        "$PARTY_TMUX" -S "$RR_SOCKET" server-access -ar "$user"
    else
        "$PARTY_TMUX" -S "$RR_SOCKET" server-access -aw "$user"
    fi

    # Best-effort guest-side ping. Ignore failure (write(1) may be absent).
    write "$user" 2>/dev/null <<EOF || :
$USER invited you to a party named '$RR_PARTY_NAME'.
Run: party join $RR_PARTY_NAME
EOF

    echo "Invited $user to '$RR_PARTY_NAME'."
}

# Parse the "<user> [--party <name>]" argv shared by voice/mute/kick/
# detach. Sets mod_user and mod_party in the calling shell (POSIX sh
# has no locals; the mod_ prefix keeps them out of callers' way).
# Exits 2 on usage errors.
_parse_user_party() {
    label="$1"; shift
    mod_user='' mod_party=''
    while [ $# -gt 0 ]; do
        case "$1" in
            --party)
                [ $# -ge 2 ] || { echo "party $label: --party needs an argument" >&2; exit 2; }
                mod_party="$2"; shift 2 ;;
            -h|--help)
                echo "Usage: party $label <user> [--party <name>]"; exit 0 ;;
            -*) echo "party $label: unknown flag '$1'" >&2; exit 2 ;;
            *)
                if [ -z "$mod_user" ]; then mod_user="$1"; shift
                else echo "party $label: unexpected arg '$1'" >&2; exit 2; fi
                ;;
        esac
    done
    [ -n "$mod_user" ] || { echo "Usage: party $label <user> [--party <name>]" >&2; exit 2; }
}

# Toggle a user's read/write status. $1 = subcommand label (for usage
# and the success message), $2 = server-access flag (-w voice, -r mute),
# $3 = past-participle for the message; the rest is the user's argv.
_set_user_access() {
    label="$1" flag="$2" verb="$3"; shift 3
    _parse_user_party "$label" "$@"
    resolve_authoritative_party "$mod_party" || exit 1
    "$PARTY_TMUX" -S "$RR_SOCKET" server-access "$flag" "$mod_user"
    echo "$verb $mod_user."
}

cmd_voice() { _set_user_access voice -w Voiced "$@"; }
cmd_mute()  { _set_user_access mute  -r Muted  "$@"; }

# Disconnect every client of $user on the resolved party socket and kill
# their guest session if present. Used by both cmd_kick and cmd_detach.
_disconnect_user() {
    user="$1"
    "$PARTY_TMUX" -S "$RR_SOCKET" kill-session -t "__party_guest_$user" 2>/dev/null || true
    "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_name} #{client_user}' 2>/dev/null \
      | while read -r cname cuser; do
            [ "$cuser" = "$user" ] || continue
            "$PARTY_TMUX" -S "$RR_SOCKET" detach-client -t "$cname" 2>/dev/null || true
        done
}

cmd_kick() {
    _parse_user_party kick "$@"
    resolve_authoritative_party "$mod_party" || exit 1
    "$PARTY_TMUX" -S "$RR_SOCKET" server-access -d "$mod_user" 2>/dev/null || true
    _disconnect_user "$mod_user"
    echo "Kicked $mod_user."
}

cmd_detach() {
    _parse_user_party detach "$@"
    resolve_authoritative_party "$mod_party" || exit 1
    _disconnect_user "$mod_user"
    echo "Detached $mod_user (still on allowlist)."
}

cmd_list() {
    found=0
    for rec in $(roster_list); do
        roster_read "$rec" || continue
        state=$(party_conn_state "$RR_SOCKET")
        [ "$state" = dead ] && continue
        # Output hygiene, NOT a confidentiality control. If inherited ACLs
        # widened the per-party dir past its 0750 / group=RR_GROUP mode bits,
        # roster_read above has ALREADY parsed host/name/group into vars,
        # and a determined reader would just cat the roster file directly,
        # never touching `party list`. So this re-check does not close the
        # metadata leak; it only stops `party list` from DISPLAYING parties
        # whose group the caller isn't in. Confidentiality still rides on
        # the FS perimeter alone.
        list_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
        id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$list_group" || continue
        # Show the group only when it differs from the env/default, to
        # keep the common case uncluttered.
        group_tag=
        if [ -n "$RR_GROUP" ] && [ "$RR_GROUP" != "$TMUX_PARTY_GROUP" ]; then
            group_tag=" [group=$RR_GROUP]"
        fi
        if [ "$state" = ok ]; then
            attendees=$("$PARTY_TMUX" -S "$RR_SOCKET" list-clients 2>/dev/null | wc -l | tr -d ' ')
            printf '%-12s %-30s %s attendee(s)%s\n' \
                "$RR_HOST_USER" "$RR_PARTY_NAME" "$attendees" "$group_tag"
        else
            # Live server, connection refused: we can't count attendees
            # (list-clients is behind the auth gate: its empty stdout
            # used to render here as a bogus "0 attendee(s)"), but the
            # party is real and the caller can ask for an invite.
            printf '%-12s %-30s invite-only (ask %s)%s\n' \
                "$RR_HOST_USER" "$RR_PARTY_NAME" "$RR_HOST_USER" "$group_tag"
        fi
        found=$((found+1))
    done

    if [ "$found" -eq 0 ]; then
        echo "no parties found on this host."
    fi
}

cmd_who() {
    short=0 partyname='' sockarg=''
    while [ $# -gt 0 ]; do
        case "$1" in
            --short)  short=1; shift ;;
            --party)
                [ $# -ge 2 ] || { echo "party who: --party needs an argument" >&2; exit 2; }
                partyname="$2"; shift 2 ;;
            --socket)
                [ $# -ge 2 ] || { echo "party who: --socket needs an argument" >&2; exit 2; }
                sockarg="$2"; shift 2 ;;
            -h|--help)
                echo "Usage: party who [--short] [--party name|--socket path]"
                exit 0 ;;
            -*) echo "party who: unknown flag '$1'" >&2; exit 2 ;;
            *)  partyname="$1"; shift ;;
        esac
    done

    if [ -n "$sockarg" ]; then
        # Status-line widget path: derive host/name from the per-party
        # dir basename ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/sock.
        sock="$sockarg"
        parent="${sock%/*}"; parent="${parent##*/}"
        case "$parent" in
            party-*:*.d) ;;
            *) echo "party who: '$sockarg' is not a party socket" >&2; exit 2 ;;
        esac
        stripped="${parent#party-}"; stripped="${stripped%.d}"
        # Seed RR_HOST_USER / RR_PARTY_NAME from the basename before
        # roster_read so the non-short output path never hits an unbound
        # var under set -u when the roster file is missing or unreadable.
        # roster_read overwrites these with the same values on success.
        RR_HOST_USER="${stripped%%:*}"
        RR_PARTY_NAME="${stripped#*:}"
        rec=$(roster_record_path "$RR_HOST_USER" "$RR_PARTY_NAME")
        roster_read "$rec" 2>/dev/null || true
    elif [ -n "$partyname" ]; then
        find_party_by_name "$partyname" || exit 1
    else
        resolve_authoritative_party "" || exit 1
    fi

    sock="${sock:-$RR_SOCKET}"
    [ -S "$sock" ] || { echo "party who: socket gone" >&2; exit 1; }

    invited=$("$PARTY_TMUX" -S "$sock" server-access -l 2>/dev/null \
        | awk '{print $1}' | sort -u)
    attached=$("$PARTY_TMUX" -S "$sock" list-clients -F '#{client_user}' 2>/dev/null \
        | sort -u)

    if [ "$short" = 1 ]; then
        # shellcheck disable=SC2086  # word-split intentional
        set -- $attached
        nicks=$(printf '%s,' "$@" | sed 's/,$//')
        printf 'party: %d (%s)\n' "$#" "$nicks"
        return
    fi

    echo "Party '$RR_PARTY_NAME' (host: $RR_HOST_USER)"
    echo "Invited:"
    for u in $invited; do printf '  %s\n' "$u"; done
    echo "Attached:"
    if [ -z "$attached" ]; then echo "  (none)"
    else for u in $attached; do printf '  %s\n' "$u"; done; fi
}

cmd_status() {
    case "${1:-}" in
        -h|--help) echo "Usage: party status"; exit 0 ;;
    esac

    hosting='' joined=''
    for rec in $(roster_list); do
        roster_read "$rec" || continue
        state=$(party_conn_state "$RR_SOCKET")
        [ "$state" = dead ] && continue
        if [ "$RR_HOST_USER" = "$USER" ]; then
            hosting="$hosting $RR_PARTY_NAME"
        fi
        # The joined checks below trust has-session, which lies (rc 0
        # for any target) when the server refused our connection.
        [ "$state" = ok ] || continue
        # Joined covers two cases. Passive: we have a client attached to
        # the host's session, list-clients sees it. Active: we have our
        # own __party_guest_$USER session, which survives a client detach
        # (party leave will still find and clean it up). OR the two so a
        # detached active guest still reports as joined.
        if "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' \
              2>/dev/null | grep -qx "$USER" \
           || "$PARTY_TMUX" -S "$RR_SOCKET" has-session \
                  -t "__party_guest_$USER" 2>/dev/null
        then
            joined="$joined $RR_PARTY_NAME"
        fi
    done
    [ -n "$hosting$joined" ] || { echo "not in any party."; return 0; }
    if [ -n "$hosting" ]; then echo "hosting:$hosting"; fi
    if [ -n "$joined" ];  then echo "joined: $joined";  fi
}

# Locate the party socket where the calling user has at least one
# attached client. Sets RR_* on success. Used by cmd_role.
cmd_role() {
    sub="${1:-}"
    case "$sub" in
        -h|--help)
            cat <<'EOF'
Usage: party role [active|passive|switch]
  (no arg)  Print each of your client sessions and its role.
  active    Move all your clients to your own guest session.
  passive   Move all your clients to the host's session (mirrored view).
  switch    Per-client toggle between active and passive.

Note: role changes flip the session your client is attached to, but
do not change its read-only state. The watcher boundary is set at
attach time by `party join --passive` (or by `party invite -r`); it
can't be toggled later via `party role`.
EOF
            exit 0 ;;
        ''|active|passive|switch) ;;
        *) echo "party role: unknown role '$sub'" >&2; exit 2 ;;
    esac

    # Find the party we should target. When $TMUX is set the caller is
    # currently inside a tmux session, its first comma-separated field
    # is the active socket path, which uniquely identifies the party.
    # Without that, fall back to "any party where the caller has a
    # client" (correct when the caller is attached to exactly one).
    sock=''
    host_sess=''
    if [ -n "${TMUX:-}" ]; then
        tmux_sock="${TMUX%%,*}"
        for rec in $(roster_list); do
            roster_read "$rec" || continue
            [ "$(party_conn_state "$RR_SOCKET")" != dead ] || continue
            [ "$RR_SOCKET" = "$tmux_sock" ] || continue
            sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break
        done
    fi
    if [ -z "$sock" ]; then
        for rec in $(roster_list); do
            roster_read "$rec" || continue
            [ "$(party_conn_state "$RR_SOCKET")" != dead ] || continue
            "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \
                | grep -qx "$USER" || continue
            sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break
        done
    fi
    [ -n "$sock" ] || { echo "party role: not attached to any party." >&2; exit 1; }
    guest="__party_guest_$USER"

    # active and switch may switch a client INTO the guest session; create
    # it on demand. passive and the no-arg print path don't need it.
    case "$sub" in
        active|switch)
            "$PARTY_TMUX" -S "$sock" has-session -t "$guest" 2>/dev/null \
                || "$PARTY_TMUX" -S "$sock" new-session -d -t "$host_sess" -s "$guest"
            ;;
    esac

    "$PARTY_TMUX" -S "$sock" list-clients \
            -F '#{client_name} #{client_user} #{client_session}' 2>/dev/null \
      | while read -r cname cuser csess; do
            [ "$cuser" = "$USER" ] || continue
            case "$sub" in
                '')
                    case "$csess" in
                        "$guest")     printf '%s: active\n'  "$cname" ;;
                        "$host_sess") printf '%s: passive\n' "$cname" ;;
                        *)            printf '%s: %s\n' "$cname" "$csess" ;;
                    esac ;;
                active)  target="$guest" ;;
                passive) target="$host_sess" ;;
                switch)
                    if [ "$csess" = "$host_sess" ]; then target="$guest"
                    else target="$host_sess"; fi ;;
            esac
            if [ -n "$sub" ]; then
                "$PARTY_TMUX" -S "$sock" switch-client -c "$cname" -t "$target" 2>/dev/null || true
            fi
        done
}

dispatch() {
    [ $# -eq 0 ] && { usage >&2; exit 2; }
    sub="$1"; shift
    case "$sub" in
        -h|--help)    usage; exit 0 ;;
        --version)    echo "party $PARTY_VERSION"; exit 0 ;;
        host)         cmd_host    "$@" ;;
        close)        cmd_close   "$@" ;;
        join)         cmd_join    "$@" ;;
        leave)        cmd_leave   "$@" ;;
        invite)       cmd_invite  "$@" ;;
        voice|rw)     cmd_voice   "$@" ;;
        mute|ro)      cmd_mute    "$@" ;;
        kick)         cmd_kick    "$@" ;;
        detach)       cmd_detach  "$@" ;;
        role)         cmd_role    "$@" ;;
        list)         cmd_list    "$@" ;;
        who)          cmd_who     "$@" ;;
        status)       cmd_status  "$@" ;;
        *)
            echo "party: unknown subcommand '$sub'" >&2
            usage >&2
            exit 2 ;;
    esac
}

# Entrypoint
# ==========

if [ "${__PARTY_LIB_ONLY:-0}" != "1" ]; then
    dispatch "$@"
fi
