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:
parent
a011e4b03c
commit
aea7ef58b6
5 changed files with 229 additions and 7 deletions
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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
109
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 <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
31
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 ,
|
||||
|
|
|
|||
87
tests/87-log.bats
Normal file
87
tests/87-log.bats
Normal 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"* ]]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue