feat: party log records a group-readable transcript

tmux pipe-pane on every pane of the party session, appending into
<party dir>/log (0640, party group) so any attendee can grab a copy.
Start and stop are announced to everyone attached; close rescues a
non-empty transcript to the host's home before removing the dir.
Panes opened later are picked up by re-running party log (a
pane_pipe check keeps already-piped panes single-piped, since tmux's
pipe-pane -o toggles an existing pipe closed rather than skipping it).
Raw output, escapes included. Close polls the transcript for size
stability before rescuing it, since a cross-filesystem mv is
copy+unlink and could otherwise drop bytes still draining from the
pipe-pane writers.
This commit is contained in:
veg 2026-07-11 12:48:29 +00:00
parent a011e4b03c
commit aea7ef58b6
5 changed files with 229 additions and 7 deletions

109
party
View file

@ -50,6 +50,8 @@ Hosting:
close [name] Tear down the party you host (name it when
you host several).
clean Remove your own dead party dirs (crash leftovers).
log [--stop] [--party <name>]
Record the party into its private dir (host only).
Joining:
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
@ -944,6 +946,31 @@ cmd_close() {
# 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
# Rescue a transcript before the dir vanishes: losing the night's
# log to a routine close is exactly the accident class the threat
# model says to prevent. After kill-server the pipe-pane `cat`
# children are exiting but may still be draining their last bytes,
# so poll for two equal size samples (bounded, integer sleeps only)
# before moving it. A same-filesystem mv is a rename and would keep
# catching late writes regardless, but $HOME and PARTY_SOCKET_DIR
# are commonly different filesystems (tmpfs /tmp vs a home
# partition), where mv is copy+unlink and tail bytes written after
# the copy started would be lost from the copy.
if [ -s "$expected_dir/log" ]; then
_cl_prev=-1
for _cl_i in 1 2 3; do
_cl_size=$(wc -c < "$expected_dir/log")
[ "$_cl_size" -eq "$_cl_prev" ] && break
_cl_prev="$_cl_size"
sleep 1
done
_cl_dest="$HOME/party-$name-$(date -u '+%Y%m%d-%H%M%S').log"
if mv "$expected_dir/log" "$_cl_dest" 2>/dev/null; then
echo "party close: transcript saved to $_cl_dest"
fi
fi
rm -rf "$expected_dir"
echo "Party '$name' closed."
}
@ -984,6 +1011,87 @@ EOF
done
[ "$removed" -gt 0 ] || echo "nothing to clean."
}
# Tell everyone in the room when recording starts or stops: a
# transcript nobody knew about is the kind of surprise the social
# design exists to avoid. Same fan-out shape as the .party-notify
# helper, run inline because only the host (who is authorized by
# definition) calls it.
log_notify() {
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_name}' 2>/dev/null \
| while read -r _ln_c; do
"$PARTY_TMUX" -S "$RR_SOCKET" display-message -c "$_ln_c" \
"$USER $1 logging this party" 2>/dev/null || :
done
}
cmd_log() {
stop=0 partyname=''
while [ $# -gt 0 ]; do
case "$1" in
--stop) stop=1; shift ;;
--party)
[ $# -ge 2 ] || { echo "party log: --party needs an argument" >&2; exit 2; }
partyname="$2"; shift 2 ;;
-h|--help)
cat <<'EOF'
Usage: party log [--stop] [--party <name>]
Record the party (host only) into <party dir>/log, readable by every
group member. Raw pane output, ANSI escapes included (read with
less -R). Panes opened after logging starts are not captured; re-run
`party log` to pick them up. Starting and stopping is announced to
everyone attached. `party close` rescues a non-empty transcript to
your home directory before removing the party dir.
EOF
exit 0 ;;
-*) echo "party log: unknown flag '$1'" >&2; exit 2 ;;
*)
if [ -z "$partyname" ]; then partyname="$1"; shift
else echo "party log: unexpected arg '$1'" >&2; exit 2; fi
;;
esac
done
resolve_authoritative_party "$partyname" || exit 1
logfile="${RR_SOCKET%/sock}/log"
if [ "$stop" = 1 ]; then
# pipe-pane with no command closes a pane's pipe; harmless on
# panes that were never piped.
"$PARTY_TMUX" -S "$RR_SOCKET" list-panes -s -t "$RR_PARTY_NAME" -F '#{pane_id}' \
| while read -r _lg_p; do
"$PARTY_TMUX" -S "$RR_SOCKET" pipe-pane -t "$_lg_p"
done
log_notify stopped
echo "Logging stopped. Transcript: $logfile"
return 0
fi
# Create the file ahead of pipe-pane with the roster's perms
# recipe (0640 + party group): every group member can grab a copy,
# only the host writes. Append so resuming mid-party keeps earlier
# segments.
: >> "$logfile"
chmod 0640 "$logfile"
chgrp "${RR_GROUP:-$TMUX_PARTY_GROUP}" "$logfile"
# Filter to unpiped panes ourselves rather than trusting `-o` to
# skip them: on tmux 3.5a, `-o` toggles an existing pipe closed
# instead of leaving it alone, which would silently stop logging
# every previously-piped pane on a second `party log` call. Reading
# #{pane_pipe} first keeps re-running `party log` idempotent for
# already-piped panes while still attaching new ones. The path is
# single-quoted for the server-side sh: a quote in PARTY_SOCKET_DIR
# is accepted breakage, same as the status-right widget.
"$PARTY_TMUX" -S "$RR_SOCKET" list-panes -s -t "$RR_PARTY_NAME" -F '#{pane_pipe} #{pane_id}' \
| while read -r _lg_piped _lg_p; do
[ "$_lg_piped" = 1 ] && continue
"$PARTY_TMUX" -S "$RR_SOCKET" pipe-pane -t "$_lg_p" "cat >> '$logfile'"
done
log_notify started
echo "Logging to $logfile"
}
cmd_join() {
passive=0
name=
@ -1586,6 +1694,7 @@ dispatch() {
host) cmd_host "$@" ;;
close) cmd_close "$@" ;;
clean) cmd_clean "$@" ;;
log) cmd_log "$@" ;;
join) cmd_join "$@" ;;
leave) cmd_leave "$@" ;;
knock) cmd_knock "$@" ;;