From aea7ef58b625b09fdac3dd6800592594a17e5035 Mon Sep 17 00:00:00 2001 From: veg Date: Sat, 11 Jul 2026 12:48:29 +0000 Subject: [PATCH] feat: party log records a group-readable transcript tmux pipe-pane on every pane of the party session, appending into /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. --- README.md | 2 + ROADMAP.md | 7 --- party | 109 ++++++++++++++++++++++++++++++++++++++++++++++ party.1 | 31 +++++++++++++ tests/87-log.bats | 87 ++++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 tests/87-log.bats diff --git a/README.md b/README.md index c512486..7a58b8b 100644 --- a/README.md +++ b/README.md @@ -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 ` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). | diff --git a/ROADMAP.md b/ROADMAP.md index 5088ca6..77696cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/party b/party index cf80530..ada8c15 100755 --- a/party +++ b/party @@ -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 ] + 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 ] + Record the party (host only) into /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 "$@" ;; diff --git a/party.1 b/party.1 index e0d6c61..61b3afd 100644 --- a/party.1 +++ b/party.1 @@ -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 , diff --git a/tests/87-log.bats b/tests/87-log.bats new file mode 100644 index 0000000..558b836 --- /dev/null +++ b/tests/87-log.bats @@ -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"* ]] +}