tmux-party/party
veg 6be0ac1877 Initial pre-release
tmux-party: share a tmux session with people on the same UNIX host.
Single-file POSIX shell (party) with a filesystem + tmux server-access
trust model. See README.md.
2026-06-01 15:31:54 +00:00

1324 lines
53 KiB
Bash

#!/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)}"
: "${TMUX_PARTY_GROUP:=party}"
: "${PARTY_SOCKET_DIR:=/tmp}"
: "${PARTY_TMUX:=tmux}"
# Usage
# =====
usage() {
cat <<'EOF'
Usage: party <subcommand> [args]
Hosting:
host [name] [--group <g>]
Start a party on a dedicated tmux server.
close Tear down the party you host.
Joining:
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
--passive lands in the host's session (mirrored view).
leave Detach from the party you joined.
role [active|passive|switch]
Flip your clients between guest session and host session.
Moderation (host only):
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] Show invited and attached users for the active 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}. Reject empty,
# paths, shell metas, and overlong names: the name lands in a file
# path component, a tmux session name, and shell command lines.
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 /tmp/party-${USER}-${name} 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
*[!A-Za-z0-9._-]* )
echo "party: name '$name' has invalid characters" >&2
echo " allowed: letters, digits, '.', '_', '-'" >&2
return 2 ;;
esac
# Reject ".", "..", and any leading-dot variant we don't want.
case "$name" in
.|.. )
echo "party: name '$name' is reserved" >&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 the named user in TMUX_PARTY_GROUP?
user_in_party_group() {
user="$1"
id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP"
}
# Soft assertion: caller is in TMUX_PARTY_GROUP. Fails loudly with
# remediation instructions when not. Used by every subcommand that
# touches the roster or socket.
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.
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 '.' (basename party-USER:..d) or 'a b'
# match the glob but validate_party_name rejects them. Run the
# canonical validator so the invariant cmd_host enforces on write
# is also checked on read.
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
# --------
# Liveness check: the socket is a live tmux server we can speak the tmux
# protocol to. `tmux -S list-clients` exits 0 only against a real tmux
# server bound to that exact socket path, which simultaneously rules out
# stale roster pointers (server gone), AF_UNIX impostors (a same-group
# user planting a hand-rolled listener via `nc -lU`), and PID-reuse
# leftovers, none of those answer the tmux handshake. Stronger than any
# PID check, and it works regardless of who owns the server process.
#
# Earlier revisions also gated on `kill -0 $pid` as a cheap 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 is the outlier that returns 0 for "exists, even if not
# signalable," and Linux ABI environments (LX-branded zones) inherit
# that behavior, which is why the bug hid in the test matrix until a
# native illumos run surfaced it. The PID arg is kept in the function
# signature for caller compatibility and as a numeric-validation guard,
# but is no longer probed. Local var names are prefixed to avoid
# clobbering caller-side `pid` / `sock` (POSIX sh has no real locals).
is_party_alive() {
_pa_pid="$1"
_pa_sock="$2"
case "$_pa_pid" in
'' | *[!0-9]* ) return 1 ;;
esac
"$PARTY_TMUX" -S "$_pa_sock" list-clients >/dev/null 2>&1
}
# 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"
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"
}
# 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"
cat > "$notify_script" <<'EOF'
#!/bin/sh
# 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_TMUX" -S "$sock" set-option -g status-right \
"party: #(party 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
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || 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
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || 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
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 > default 'party'.
Pick any existing group (e.g. 'staff', 'users', 'wheel');
no install-time setup needed.
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 > default
# 'party' (set by the env-defaults block at the top of the file).
# 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
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 is_party_alive "${RR_SERVER_PID:-}" "${RR_SOCKET:-}"; then
echo "party: '$name' is already running (pid $RR_SERVER_PID)" >&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`).
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.
if ! "$PARTY_TMUX" -S "$RR_SOCKET" server-access -l 2>/dev/null \
| awk '{print $1}' | grep -qx "$USER"; then
echo "party: '$RR_PARTY_NAME', ask $RR_HOST_USER to invite you." >&2
exit 13
fi
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.
attach_cmd="$PARTY_TMUX -S $RR_SOCKET 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"
attach_cmd="$PARTY_TMUX -S $RR_SOCKET attach-session -t $guest"
fi
if [ "${PARTY_DRY_RUN:-0}" = 1 ]; then
echo "$attach_cmd"
return 0
fi
# shellcheck disable=SC2086
exec $attach_cmd
}
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
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || 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
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || 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'."
}
# 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, $4 = $user.
_set_user_access() {
label="$1" flag="$2" verb="$3" user="$4"
[ -n "$user" ] || { echo "Usage: party $label <user>" >&2; exit 2; }
resolve_authoritative_party "" || exit 1
"$PARTY_TMUX" -S "$RR_SOCKET" server-access "$flag" "$user"
echo "$verb $user."
}
cmd_voice() { _set_user_access voice -w Voiced "${1:-}"; }
cmd_mute() { _set_user_access mute -r Muted "${1:-}"; }
# 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() {
user="${1:-}"
[ -n "$user" ] || { echo "Usage: party kick <user>" >&2; exit 2; }
resolve_authoritative_party "" || exit 1
"$PARTY_TMUX" -S "$RR_SOCKET" server-access -d "$user" 2>/dev/null || true
_disconnect_user "$user"
echo "Kicked $user."
}
cmd_detach() {
user="${1:-}"
[ -n "$user" ] || { echo "Usage: party detach <user>" >&2; exit 2; }
resolve_authoritative_party "" || exit 1
_disconnect_user "$user"
echo "Detached $user (still on allowlist)."
}
cmd_list() {
found=0
for rec in $(roster_list); do
roster_read "$rec" || continue
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || 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
# Stale records (dead PID, dead socket, planted daemon) are
# filtered out by is_party_alive. They're not auto-cleaned here:
# cmd_host's mkdir-as-lock refuses to take over leftover dirs
# (auto-cleanup races with concurrent in-flight hosts). Recovery
# for a genuine crash leftover is a one-shot
# manual `rm -rf` per the host-side error message.
attendees=$("$PARTY_TMUX" -S "$RR_SOCKET" list-clients 2>/dev/null | wc -l | tr -d ' ')
# 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
printf '%-12s %-30s %s attendee(s)%s\n' \
"$RR_HOST_USER" "$RR_PARTY_NAME" "$attendees" "$group_tag"
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
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
if [ "$RR_HOST_USER" = "$USER" ]; then
hosting="$hosting $RR_PARTY_NAME"
fi
# 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
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || 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
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || 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