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. - 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. - 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. - 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 ## 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 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 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 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 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 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). | | `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 encouraging people to work together in a terminal, not by adding
machinery. 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 ## Later
- **Matrix:** live validation on NetBSD and DragonFly; one - **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 close [name] Tear down the party you host (name it when
you host several). you host several).
clean Remove your own dead party dirs (crash leftovers). clean Remove your own dead party dirs (crash leftovers).
log [--stop] [--party <name>]
Record the party into its private dir (host only).
Joining: Joining:
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt. 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 # PARTY_SOCKET_DIR is caller-controlled env, not a privilege
# boundary, so we don't canonicalize it. # boundary, so we don't canonicalize it.
"$PARTY_TMUX" -S "$expected_sock" kill-server 2>/dev/null || true "$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" rm -rf "$expected_dir"
echo "Party '$name' closed." echo "Party '$name' closed."
} }
@ -984,6 +1011,87 @@ EOF
done done
[ "$removed" -gt 0 ] || echo "nothing to clean." [ "$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() { cmd_join() {
passive=0 passive=0
name= name=
@ -1586,6 +1694,7 @@ dispatch() {
host) cmd_host "$@" ;; host) cmd_host "$@" ;;
close) cmd_close "$@" ;; close) cmd_close "$@" ;;
clean) cmd_clean "$@" ;; clean) cmd_clean "$@" ;;
log) cmd_log "$@" ;;
join) cmd_join "$@" ;; join) cmd_join "$@" ;;
leave) cmd_leave "$@" ;; leave) cmd_leave "$@" ;;
knock) cmd_knock "$@" ;; knock) cmd_knock "$@" ;;

31
party.1
View file

@ -16,6 +16,10 @@
.Nm .Nm
.Cm clean .Cm clean
.Nm .Nm
.Cm log
.Op Fl -stop
.Op Fl -party Ar name
.Nm
.Cm join .Cm join
.Op Ar name .Op Ar name
.Op Fl -passive .Op Fl -passive
@ -142,6 +146,23 @@ Host-only.
Remove the caller's own dead party directories: crash leftovers whose Remove the caller's own dead party directories: crash leftovers whose
tmux server no longer answers. tmux server no longer answers.
Never touches live parties, other users' directories, or symlinks. 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 .It Cm join Op Ar name Op Fl -passive
Join a party. Join a party.
With no With no
@ -314,6 +335,11 @@ every read, not trusted from the file.
Auto-generated helper that fans Auto-generated helper that fans
.Cm display-message .Cm display-message
out to every attached client on join/leave. 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 .El
.Sh EXIT STATUS .Sh EXIT STATUS
.Bl -tag -width Ds .Bl -tag -width Ds
@ -493,6 +519,11 @@ has no equivalent of
The watcher boundary is set at The watcher boundary is set at
.Cm join Fl -passive .Cm join Fl -passive
time and is fixed for the life of that client. 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 .Sh SEE ALSO
.Xr tmux 1 , .Xr tmux 1 ,
.Xr screen 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"* ]]
}