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

View file

@ -100,6 +100,7 @@ Three honest caveats, with the full detail in `man party`:
- On ACL-enabled filesystems (ZFS, HFS+/APFS), inherited ACLs can override the mode bits, so the FS gate is best-effort. The auth gate still holds.
- A party's *existence* is not hidden the way *attaching* is. That confidentiality rides on the FS gate.
- Active guests share one tmux server, where any write-capable invitee is trusted by design. Invite read-only (`-r`) if you do not trust that far.
- `party log`'s transcript is group-readable, not invitee-readable: any member of the party's group can read it, including group members who were never invited.
## Status
@ -120,6 +121,7 @@ Three honest caveats, with the full detail in `man party`:
| `party status` | Show the caller's own state: hosting, attached, or idle. |
| `party close` | Tear down the party server and its roster entry. Host-only. |
| `party clean` | Remove the caller's own dead party dirs: crash leftovers whose tmux server is gone. |
| `party log [--stop]` | Record the party into its private dir via `tmux pipe-pane` (host only), group-readable. `--stop` ends it. |
| `party join [name] [--passive]` | Join a party. Auto-attaches when one is running; picker otherwise. `--passive` attaches read-only to the host's view (watcher mode). Read-only invitees always join passive. |
| `party leave` | Detach and clean up the per-guest session. |
| `party knock <name>` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). |

View file

@ -10,13 +10,6 @@ Direction, not promises. Two rules govern everything below (README
encouraging people to work together in a terminal, not by adding
machinery.
## Next: small, high value
- **`party log`:** per-party transcript via `tmux pipe-pane` into the
per-party dir (group-readable so every attendee can grab a copy);
`party log --stop` ends it. "What did we do last night?" for
collectives, and the audit trail human+AI co-work needs.
## Later
- **Matrix:** live validation on NetBSD and DragonFly; one

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 "$@" ;;

31
party.1
View file

@ -16,6 +16,10 @@
.Nm
.Cm clean
.Nm
.Cm log
.Op Fl -stop
.Op Fl -party Ar name
.Nm
.Cm join
.Op Ar name
.Op Fl -passive
@ -142,6 +146,23 @@ Host-only.
Remove the caller's own dead party directories: crash leftovers whose
tmux server no longer answers.
Never touches live parties, other users' directories, or symlinks.
.It Cm log Op Fl -stop Op Fl -party Ar name
Record the party into
.Pa log
inside the per-party directory (host only), via
.Xr tmux 1
.Ic pipe-pane .
The file is group-readable so any attendee can grab a copy.
Starting and stopping is announced to everyone attached.
Panes opened after logging starts are not captured until
.Cm log
is re-run; output is raw (ANSI escapes included, read with
.Ql less -R ) .
.Fl -stop
ends recording.
.Cm close
rescues a non-empty transcript to the host's home directory before
removing the party directory.
.It Cm join Op Ar name Op Fl -passive
Join a party.
With no
@ -314,6 +335,11 @@ every read, not trusted from the file.
Auto-generated helper that fans
.Cm display-message
out to every attached client on join/leave.
.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/log
Transcript written by
.Cm log ;
removed with the party directory unless rescued by
.Cm close .
.El
.Sh EXIT STATUS
.Bl -tag -width Ds
@ -493,6 +519,11 @@ has no equivalent of
The watcher boundary is set at
.Cm join Fl -passive
time and is fixed for the life of that client.
.Pp
Every member of the party's group can read a
.Cm log
transcript, since the per-party directory is the perimeter, including
group members who were never invited.
.Sh SEE ALSO
.Xr tmux 1 ,
.Xr screen 1 ,

87
tests/87-log.bats Normal file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env bats
#
# party log: pipe-pane transcript into the per-party dir. Needs a real
# tmux server (pipe-pane semantics can't be stubbed honestly), so most
# tests gate on require_party_group like the other e2e suites.
load 'helpers'
setup() {
setup_party_sandbox
export TMUX_PARTY_GROUP="$(id -gn)"
}
teardown() { teardown_party_sandbox; }
logfile() { printf '%s/party-%s:%s.d/log\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
sock_of() { printf '%s/party-%s:%s.d/sock\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
# Wait up to ~10s for $2 to appear in file $1. Integer sleeps only:
# fractional sleep is not portable across the matrix.
poll_for() {
for _ in 1 2 3 4 5 6 7 8 9 10; do
grep -q "$2" "$1" 2>/dev/null && return 0
sleep 1
done
return 1
}
@test "log without a hosted party fails" {
run "$PARTY_BIN" log
[ "$status" -eq 1 ]
[[ "$output" == *"do not host"* ]]
}
@test "log captures pane output; --stop ends capture" {
"$PARTY_BIN" host scribe
run "$PARTY_BIN" log
[ "$status" -eq 0 ]
[[ "$output" == *"Logging to"* ]]
tmux -S "$(sock_of scribe)" send-keys -t scribe 'echo marker-ONE' Enter
poll_for "$(logfile scribe)" marker-ONE
run "$PARTY_BIN" log --stop
[ "$status" -eq 0 ]
tmux -S "$(sock_of scribe)" send-keys -t scribe 'echo marker-TWO' Enter
sleep 2
! grep -q marker-TWO "$(logfile scribe)"
}
@test "log file is 0640 so group members can copy it" {
"$PARTY_BIN" host perms
"$PARTY_BIN" log
[ -n "$(find "$(logfile perms)" -prune -perm 0640 2>/dev/null)" ]
}
@test "re-running log attaches panes created after the first run" {
"$PARTY_BIN" host panes
"$PARTY_BIN" log
tmux -S "$(sock_of panes)" split-window -t panes
"$PARTY_BIN" log
tmux -S "$(sock_of panes)" send-keys -t panes.1 'echo marker-PANE2' Enter
poll_for "$(logfile panes)" marker-PANE2
}
@test "close rescues a non-empty transcript to HOME" {
export HOME="$PARTY_TMP/home"
mkdir -p "$HOME"
"$PARTY_BIN" host rescue
"$PARTY_BIN" log
tmux -S "$(sock_of rescue)" send-keys -t rescue 'echo marker-KEEP' Enter
poll_for "$(logfile rescue)" marker-KEEP
run "$PARTY_BIN" close rescue
[ "$status" -eq 0 ]
[[ "$output" == *"transcript saved to"* ]]
saved=$(ls "$HOME"/party-rescue-*.log)
grep -q marker-KEEP "$saved"
}
@test "close without a transcript stays silent about rescue" {
export HOME="$PARTY_TMP/home"
mkdir -p "$HOME"
"$PARTY_BIN" host quiet
run "$PARTY_BIN" close quiet
[ "$status" -eq 0 ]
[[ "$output" != *"transcript"* ]]
}