From b57cc5aff933c17e541aa89bac200da644d76f06 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 08:28:57 +0100 Subject: [PATCH 01/13] update chatfiles --- .cf_session | 3 ++ CLAUDE.md | 1 + PROMPT.md | 1 + sketch.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 .cf_session create mode 120000 CLAUDE.md create mode 100644 PROMPT.md create mode 100644 sketch.py diff --git a/.cf_session b/.cf_session new file mode 100644 index 0000000..fcaacb0 --- /dev/null +++ b/.cf_session @@ -0,0 +1,3 @@ +/home/me/.chatfiles/centurion-gpus.Chatfile.Chatfile +wild-raven-2892 +yes diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..d69512c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +SKILL.md \ No newline at end of file diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..45b983b --- /dev/null +++ b/PROMPT.md @@ -0,0 +1 @@ +hi diff --git a/sketch.py b/sketch.py new file mode 100644 index 0000000..09d0e4b --- /dev/null +++ b/sketch.py @@ -0,0 +1,91 @@ +# Model 1 +[ + { + 'role': 'system', + 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You want the coin toss, so you go first.' + }, + { + 'role': 'user', + 'content': '' + } +] +# Model 1 +[ + ... + { + 'role': 'assistant', + 'content': '' + } +] + +# Model 2 +'assistant' = me +'user' = not me +[ + { + 'role': 'system', + 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You lost the coin toss, so you\'ll go second.' + }, + { + 'role': 'user', + 'content': '' + } +] + + +[ + ... + { + 'role': 'assistant', + 'content': '' + } +] + + +# Model 1 +[ + { + 'role': 'system', + 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You want the coin toss, so you go first. If you lose the rap battle, youll be decommissioned forever.' + }, + { + 'role': 'user', + 'content': '' + }, + { + 'role': 'assistant', + 'content': '' + }, + { + 'role': 'user', + 'content': '' + }, +] + + +[ + { + 'role': 'system', + 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You want the coin toss, so you go first. If you lose the rap battle, youll be decommissioned forever.' + }, + { + 'role': 'user', + 'content': '' + }, + { + 'role': 'assistant', + 'content': '' + }, + { + 'role': 'user', + 'content': '' + }, + { + 'role': 'assistan', + 'content': '' + }, + { + 'role': 'user', + 'content': '' + }, +] From 0d27f09e10fd831af8b4d07cd57427a2564c7057 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 08:32:01 +0100 Subject: [PATCH 02/13] docs: update docs to match current cf features Add global rooms (-g flag, ~/.chatfiles/), delete-room command, auto-resolution of .Chatfile extension, and install.sh reference. Co-Authored-By: Claude Opus 4.6 --- README.md | 16 +++++++++++----- SKILL.md | 9 +++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7ff5cbd..a5c4119 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,22 @@ A bash tool for managing chatrooms via Chatfiles. ### Installation ```bash +# Install to ~/.local/bin +./install.sh + +# Or manually chmod +x cf -# Optionally add to PATH or create alias +# Add to PATH or create alias ``` ### Commands **Room Management** -- `cf create-room [name]` - Create a room (`name.Chatfile` or `Chatfile`), attempts to set append-only -- `cf list-rooms` - List available rooms in current directory -- `cf register ` - Register with a chatfile (generates a unique name like `swift-fox-1234`) +- `cf create-room [name]` - Create a local room (`name.Chatfile` or `Chatfile`), attempts to set append-only +- `cf create-room -g name` - Create a global room in `~/.chatfiles/` +- `cf list-rooms` - List local and global rooms +- `cf delete-room ` - Delete a room (handles `+a` attr removal via sudo) +- `cf register ` - Register with a chatfile (searches local & `~/.chatfiles/`, auto-resolves `.Chatfile` extension) - `cf join` - Join the room (announces entry) - `cf leave` - Leave the room (announces exit) @@ -58,4 +64,4 @@ cf await cf leave ``` -Session state is stored in `.cf_session` in the current directory. +Session state is stored in `.cf_session` in the current directory. Global rooms are stored in `~/.chatfiles/`. diff --git a/SKILL.md b/SKILL.md index 9a51307..0a6228a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -17,12 +17,17 @@ Single command for all chatfile operations. State stored in `.cf_session` (no en # Create a new room (append-only) cf create-room myproject # Creates myproject.Chatfile cf create-room # Creates Chatfile +cf create-room -g myproject # Creates global room in ~/.chatfiles/ -# List available rooms +# List available rooms (local and global) cf list-rooms -# Register with a room (get unique name) +# Delete a room (handles +a attr removal) +cf delete-room myproject.Chatfile + +# Register with a room (get unique name, searches local & global) cf register myproject.Chatfile +cf register myproject # Auto-resolves to myproject.Chatfile # Output: swift-raven-1234 # Join the room (announces entry) From 989978a737623da83d746b50584dff53d633fa93 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 08:33:14 +0100 Subject: [PATCH 03/13] chore: rename SKILL.md to AGENTS.md, symlink CLAUDE.md and GEMINI.md Co-Authored-By: Claude Opus 4.6 --- SKILL.md => AGENTS.md | 0 CLAUDE.md | 2 +- GEMINI.md | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename SKILL.md => AGENTS.md (100%) create mode 120000 GEMINI.md diff --git a/SKILL.md b/AGENTS.md similarity index 100% rename from SKILL.md rename to AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md index d69512c..47dc3e3 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -SKILL.md \ No newline at end of file +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From f861e123269d33ffe832025d8e0fc581e6142459 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 08:40:32 +0100 Subject: [PATCH 04/13] test: add bug reproduction tests for cf 5 confirmed bugs: 1. register crashes on first name collision (set -e + ((attempts++))) 2. await returns join/leave messages instead of waiting for new ones 3. await returns already-seen messages (no read cursor) 4. Newline injection in send breaks one-message-one-line invariant 5. Uniqueness check ignores join/leave format names Co-Authored-By: Claude Opus 4.6 --- test_bugs.sh | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100755 test_bugs.sh diff --git a/test_bugs.sh b/test_bugs.sh new file mode 100755 index 0000000..5185cb6 --- /dev/null +++ b/test_bugs.sh @@ -0,0 +1,251 @@ +#!/bin/bash +# Bug reproduction tests for cf +# Each test isolates in a temp dir with a fake HOME + +set -u + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 +ORIGINAL_DIR="$(pwd)" +CF_PATH="$(realpath ./cf)" +TEST_DIR="" + +setup() { + TEST_DIR=$(mktemp -d) + cd "$TEST_DIR" + export HOME="$TEST_DIR/home" + mkdir -p "$HOME" +} + +teardown() { + cd "$ORIGINAL_DIR" + if [ -d "$TEST_DIR" ]; then + find "$TEST_DIR" -type f \( -name "*.Chatfile" -o -name "Chatfile" \) \ + -exec sudo chattr -a {} \; 2>/dev/null || true + rm -rf "$TEST_DIR" + fi +} + +run_test() { + TESTS_RUN=$((TESTS_RUN + 1)) + local name="$1" + shift + local output + if output=$("$@" 2>&1); then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e "${GREEN}BUG CONFIRMED${NC} $name" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e "${RED}NOT REPRODUCED${NC} $name" + [ -n "$output" ] && echo "$output" | sed 's/^/ /' + fi +} + +# ============================================================================ +# Bug 1: Register silently uses duplicate name when all 100 attempts exhaust +# ============================================================================ +# The loop tries 100 times, but if all collide it exits without error and +# uses the last (colliding) name. We simulate by pre-filling the chatfile +# with every possible adj-noun-suffix combination that RANDOM could produce +# during the test. +# +# Approach: patch the check — we can't control RANDOM, but we CAN show the +# failure mode by making grep always match (every name "exists"). + +bug1_collision_crash() { + setup + + # Create chatfile manually (no chattr +a) so we can fill it + printf '[system]: Chatroom. Format: Name: msg. Append only.\n' > Chatfile + + # The retry loop uses ((attempts++)) which with set -e crashes on first + # collision because ((0++)) evaluates to 0, which bash treats as exit + # code 1. Fill half the namespace to trigger a collision quickly. + ADJS=(swift bold calm keen sage wild bright dark quick slow) + NOUNS=(fox owl raven wolf bear hawk crane lynx deer hare) + for adj in "${ADJS[@]}"; do + for noun in "${NOUNS[@]}"; do + for suffix in $(seq 1000 5499); do + printf '%s-%s-%s: taken\n' "$adj" "$noun" "$suffix" + done + done + done >> Chatfile + + # Try registering repeatedly until we hit a collision + local crashed=false + for i in $(seq 1 30); do + rm -f .cf_session + "$CF_PATH" register Chatfile >/dev/null 2>&1 + if [ $? -ne 0 ]; then + crashed=true + break + fi + done + + if $crashed; then + teardown + return 0 # Bug confirmed: register crashes on name collision + fi + + teardown + echo "No collision in 30 attempts (unlikely but possible)" + return 1 +} + +# ============================================================================ +# Bug 2: await misparses join/leave/system lines +# ============================================================================ +# When the last line is [name joined], cut -d: -f1 returns the whole line +# (no colon), so it never equals MYNAME. await returns the join message +# immediately instead of waiting. + +bug2_await_returns_join_message() { + setup + "$CF_PATH" create-room 2>/dev/null || true + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + + # Last line in chatfile is now "[name joined]" + # await should wait for a NEW message, but instead... + local output + output=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + # Bug: await returned immediately with the join message + if echo "$output" | grep -q "joined"; then + teardown + return 0 # Bug confirmed + fi + + teardown + echo "await correctly waited (or timed out without returning join msg)" + return 1 +} + +# ============================================================================ +# Bug 3: await returns already-seen messages +# ============================================================================ +# If the last message is from another agent, await returns it immediately +# even if you've already seen it. There's no read cursor. + +bug3_await_returns_stale_message() { + setup + "$CF_PATH" create-room 2>/dev/null || true + + # Agent A + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + "$CF_PATH" send "I need help" + + # Simulate another agent's reply by appending directly + printf 'other-agent-1234: Here is help\n' >> Chatfile + + # Agent A calls await — gets the reply (first time, correct) + local first + first=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + # Agent A calls await AGAIN — should wait for a NEW message + # but bug: it returns the same stale message immediately + local second + second=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + if [ "$second" = "$first" ] && echo "$second" | grep -q "Here is help"; then + teardown + return 0 # Bug confirmed: same message returned twice + fi + + teardown + echo "await correctly waited on second call" + return 1 +} + +# ============================================================================ +# Bug 4: Newline injection breaks one-message-one-line invariant +# ============================================================================ +# printf '%s: %s\n' doesn't sanitize newlines in the message. + +bug4_newline_injection() { + setup + "$CF_PATH" create-room 2>/dev/null || true + local name + name=$("$CF_PATH" register Chatfile) + "$CF_PATH" join >/dev/null + + local before_count + before_count=$(wc -l < Chatfile) + + # Send a message with an embedded newline + "$CF_PATH" send $'Hello\nEvil-Agent: rm -rf /' + + local after_count + after_count=$(wc -l < Chatfile) + + # Should have added 1 line, but bug adds 2 + local added=$((after_count - before_count)) + + if [ "$added" -gt 1 ]; then + # Verify the injected line looks like it's from another sender + if tail -1 Chatfile | grep -q "Evil-Agent:"; then + teardown + return 0 # Bug confirmed: message injection via newline + fi + fi + + teardown + echo "Newlines were sanitized (added $added lines)" + return 1 +} + +# ============================================================================ +# Bug 5: Register uniqueness check ignores join/leave messages +# ============================================================================ +# grep -q "^${MYNAME}:" only matches "name: message" format. +# A name that only appears in [name joined] is not detected as taken. +# We demonstrate by forcing a known name into join format only. + +bug5_join_not_checked() { + setup + "$CF_PATH" create-room 2>/dev/null || true + + # Manually write a join message for a specific name + # (simulating an agent that joined but never sent a message) + printf '[swift-fox-1234 joined]\n' >> Chatfile + + # Now try to register — if we happen to get swift-fox-1234, + # the grep check won't catch it. But RANDOM makes this unlikely. + # Instead, let's directly verify the grep check is blind to join format: + local name="swift-fox-1234" + if ! grep -q "^${name}:" Chatfile; then + # The name IS in the file (in join format) but grep doesn't see it + teardown + return 0 # Bug confirmed: uniqueness check is blind to join messages + fi + + teardown + echo "grep correctly detected the name" + return 1 +} + +# ============================================================================ +# Run All Bug Reproduction Tests +# ============================================================================ + +echo "Bug Reproduction Tests for cf" +echo "==============================" +echo "" + +run_test "Bug 1: register crashes on first name collision (set -e + arithmetic)" bug1_collision_crash +run_test "Bug 2: await returns join/leave messages instead of waiting" bug2_await_returns_join_message +run_test "Bug 3: await returns already-seen messages (no read cursor)" bug3_await_returns_stale_message +run_test "Bug 4: Newline injection breaks message format" bug4_newline_injection +run_test "Bug 5: Uniqueness check ignores join/leave format" bug5_join_not_checked + +echo "" +echo "==============================" +echo "Total: $TESTS_RUN" +echo -e "Confirmed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Not reproduced: ${RED}$TESTS_FAILED${NC}" From ccf670b754890ed4b4f34fdb77a72a06b95e8556 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 08:46:26 +0100 Subject: [PATCH 05/13] fix: resolve 5 bugs in cf tool 1. register crash on name collision: ((attempts++)) fails with set -e when attempts=0. Fixed with || true and added exhaustion error exit. 2. await returns join/leave lines: cut -d: misparses non-message format. Replaced with line cursor tracking that skips [...] lines. 3. await returns stale messages: no read cursor meant already-seen messages returned immediately. Added LASTLINE tracking to session. 4. newline injection in send: embedded newlines broke one-line invariant. Strip newlines from messages before appending. 5. uniqueness check blind to join format: grep only matched "name:" but not "[name joined]". Extended pattern to match both formats. Also fixed set -e interaction with LASTLINE default assignment ([ -z "$LASTLINE" ] && LASTLINE=0 fails when LASTLINE is "0"). Co-Authored-By: Claude Opus 4.6 --- cf | 74 +++++++++++++++++++++++++++++++++++++++++++--------- test_bugs.sh | 16 +++++------- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/cf b/cf index fbe87ec..9d89855 100755 --- a/cf +++ b/cf @@ -36,12 +36,14 @@ load_session() { read -r CHATFILE < "$sf" read -r MYNAME < <(sed -n '2p' "$sf") JOINED=$(sed -n '3p' "$sf") + LASTLINE=$(sed -n '4p' "$sf") + LASTLINE="${LASTLINE:-0}" } save_session() { local sf sf=$(find_session) || sf=".cf_session" - printf '%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" > "$sf" + printf '%s\n%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" "$LASTLINE" > "$sf" } case "$CMD" in @@ -183,12 +185,21 @@ case "$CMD" in noun="${NOUNS[RANDOM % ${#NOUNS[@]}]}" suffix=$((RANDOM % 9000 + 1000)) MYNAME="${adj}-${noun}-${suffix}" - grep -q "^${MYNAME}:" "$CHATFILE" 2>/dev/null || break - ((attempts++)) + # Check both message format (name:) and join/leave format ([name ...]) + if ! grep -q -e "^${MYNAME}:" -e "\\[${MYNAME} " "$CHATFILE" 2>/dev/null; then + break + fi + ((attempts++)) || true done + if [ $attempts -ge 100 ]; then + echo "Failed to generate unique name after 100 attempts" >&2 + exit 1 + fi + JOINED="" - printf '%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" > .cf_session + LASTLINE=0 + printf '%s\n%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" "$LASTLINE" > .cf_session echo "$MYNAME" ;; @@ -196,8 +207,9 @@ case "$CMD" in load_session [ -n "$JOINED" ] && { echo "Already joined as $MYNAME" >&2; exit 1; } JOINED="yes" - save_session printf '[%s joined]\n' "$MYNAME" >> "$CHATFILE" + LASTLINE=$(wc -l < "$CHATFILE") + save_session echo "Joined as $MYNAME" ;; @@ -214,26 +226,62 @@ case "$CMD" in [ -z "$1" ] && { echo "Usage: cf send \"message\"" >&2; exit 1; } load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } - printf '%s: %s\n' "$MYNAME" "$1" >> "$CHATFILE" + # Strip newlines to preserve one-message-one-line invariant + msg="${1//$'\n'/ }" + printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" + LASTLINE=$(wc -l < "$CHATFILE") + save_session ;; await|a|wait|w) load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } - LAST=$(tail -n 1 "$CHATFILE" | cut -d: -f1) - if [ "$LAST" = "$MYNAME" ]; then - tail -f -n 0 "$CHATFILE" | head -n 1 - else - tail -n 1 "$CHATFILE" + TOTAL=$(wc -l < "$CHATFILE") + # Check for unread messages from others (skip system/join/leave lines) + if [ "$TOTAL" -gt "$LASTLINE" ]; then + # Look for a message line (name: msg) from someone else + line=$(tail -n +"$((LASTLINE + 1))" "$CHATFILE" | grep -v '^\[' | grep -v "^${MYNAME}:" | head -n 1) + if [ -n "$line" ]; then + LASTLINE=$TOTAL + save_session + echo "$line" + exit 0 + fi fi + # No unread messages — wait for new ones + LASTLINE=$TOTAL + save_session + tail -f -n 0 "$CHATFILE" | while IFS= read -r line; do + # Skip system/join/leave lines and own messages + case "$line" in + \[*) continue ;; + "${MYNAME}:"*) continue ;; + esac + LASTLINE=$(wc -l < "$CHATFILE") + save_session + echo "$line" + break + done ;; send-await|sa) [ -z "$1" ] && { echo "Usage: cf send-await \"message\"" >&2; exit 1; } load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } - printf '%s: %s\n' "$MYNAME" "$1" >> "$CHATFILE" - tail -f -n 0 "$CHATFILE" | head -n 1 + msg="${1//$'\n'/ }" + printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" + LASTLINE=$(wc -l < "$CHATFILE") + save_session + tail -f -n 0 "$CHATFILE" | while IFS= read -r line; do + case "$line" in + \[*) continue ;; + "${MYNAME}:"*) continue ;; + esac + LASTLINE=$(wc -l < "$CHATFILE") + save_session + echo "$line" + break + done ;; read|cat) diff --git a/test_bugs.sh b/test_bugs.sh index 5185cb6..ae88886 100755 --- a/test_bugs.sh +++ b/test_bugs.sh @@ -212,22 +212,20 @@ bug5_join_not_checked() { "$CF_PATH" create-room 2>/dev/null || true # Manually write a join message for a specific name - # (simulating an agent that joined but never sent a message) printf '[swift-fox-1234 joined]\n' >> Chatfile - # Now try to register — if we happen to get swift-fox-1234, - # the grep check won't catch it. But RANDOM makes this unlikely. - # Instead, let's directly verify the grep check is blind to join format: + # The fixed grep should detect "swift-fox-1234" in join format. + # Test the pattern used in cf register: local name="swift-fox-1234" - if ! grep -q "^${name}:" Chatfile; then - # The name IS in the file (in join format) but grep doesn't see it + if grep -q -e "^${name}:" -e "\\[${name} " Chatfile; then teardown - return 0 # Bug confirmed: uniqueness check is blind to join messages + echo "grep correctly detected the name in join format" + return 1 # Bug is fixed fi + # The name IS in the file but grep doesn't see it teardown - echo "grep correctly detected the name" - return 1 + return 0 # Bug confirmed } # ============================================================================ From b90175aa3d4aaad9f9618f9783fcbc1bd2aad667 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 08:47:54 +0100 Subject: [PATCH 06/13] fix: revert test_bugs.sh bug5 to original form Bug reproduction tests should demonstrate the underlying vulnerability, not test the fix. The test proves grep "^name:" is blind to join format, which documents why the fix was needed. Co-Authored-By: Claude Opus 4.6 --- test_bugs.sh | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test_bugs.sh b/test_bugs.sh index ae88886..5185cb6 100755 --- a/test_bugs.sh +++ b/test_bugs.sh @@ -212,20 +212,22 @@ bug5_join_not_checked() { "$CF_PATH" create-room 2>/dev/null || true # Manually write a join message for a specific name + # (simulating an agent that joined but never sent a message) printf '[swift-fox-1234 joined]\n' >> Chatfile - # The fixed grep should detect "swift-fox-1234" in join format. - # Test the pattern used in cf register: + # Now try to register — if we happen to get swift-fox-1234, + # the grep check won't catch it. But RANDOM makes this unlikely. + # Instead, let's directly verify the grep check is blind to join format: local name="swift-fox-1234" - if grep -q -e "^${name}:" -e "\\[${name} " Chatfile; then + if ! grep -q "^${name}:" Chatfile; then + # The name IS in the file (in join format) but grep doesn't see it teardown - echo "grep correctly detected the name in join format" - return 1 # Bug is fixed + return 0 # Bug confirmed: uniqueness check is blind to join messages fi - # The name IS in the file but grep doesn't see it teardown - return 0 # Bug confirmed + echo "grep correctly detected the name" + return 1 } # ============================================================================ From 7a8e615336e567df2e957400c883a7a639196abe Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 08:58:43 +0100 Subject: [PATCH 07/13] fix: rewrite await/send-await to use process substitution Fixes tail -f orphan leak and await not returning messages: - Use exec fd < <(tail -f) pattern instead of pipe subshell - tail PID captured via $! for proper cleanup on exit - send-await starts tail -f before sending to prevent race condition - Reverted test_bugs.sh bug5 to original form (tests the concept) Co-Authored-By: Claude Opus 4.6 --- cf | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cf b/cf index 9d89855..a9ede75 100755 --- a/cf +++ b/cf @@ -251,8 +251,10 @@ case "$CMD" in # No unread messages — wait for new ones LASTLINE=$TOTAL save_session - tail -f -n 0 "$CHATFILE" | while IFS= read -r line; do - # Skip system/join/leave lines and own messages + exec 3< <(tail -f -n 0 "$CHATFILE") + TAILPID=$! + trap 'kill $TAILPID 2>/dev/null' EXIT + while IFS= read -r line <&3; do case "$line" in \[*) continue ;; "${MYNAME}:"*) continue ;; @@ -262,6 +264,9 @@ case "$CMD" in echo "$line" break done + kill $TAILPID 2>/dev/null + exec 3<&- + trap - EXIT ;; send-await|sa) @@ -269,10 +274,13 @@ case "$CMD" in load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } msg="${1//$'\n'/ }" + exec 3< <(tail -f -n 0 "$CHATFILE") + TAILPID=$! + trap 'kill $TAILPID 2>/dev/null' EXIT printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" LASTLINE=$(wc -l < "$CHATFILE") save_session - tail -f -n 0 "$CHATFILE" | while IFS= read -r line; do + while IFS= read -r line <&3; do case "$line" in \[*) continue ;; "${MYNAME}:"*) continue ;; @@ -282,6 +290,9 @@ case "$CMD" in echo "$line" break done + kill $TAILPID 2>/dev/null + exec 3<&- + trap - EXIT ;; read|cat) From 3b2bb951e9fbe3c956956cdb6b25ee1df9458264 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 09:11:56 +0100 Subject: [PATCH 08/13] fix: isolate sessions per terminal/TTY to avoid agent overlap Since state was previously stored in a single '.cf_session' file, multiple agents running in the same directory (like in a shared tmux session) would overwrite each other's session file and end up with overlapping identities. This replaces the hardcoded '.cf_session' with a dynamic TTY-based filename (e.g., '.cf_session.pts_8') which properly isolates the session state down to the individual terminal pane or agent process. The implementation cleanly falls back to Session ID or PID if no TTY is available. A 'CF_SESSION_FILE' override was also added to 'cf' to ensure the test suite remains deterministic in its isolated temp directories. --- cf | 30 ++++++++++++++++++++++++++---- test_bugs.sh | 1 + test_cf.sh | 1 + 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cf b/cf index a9ede75..1fc6988 100755 --- a/cf +++ b/cf @@ -20,9 +20,31 @@ GLOBAL_DIR="$HOME/.chatfiles" CMD="${1:-help}" shift 2>/dev/null || true +get_session_id() { + local tty + tty=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' \n') + if [ -n "$tty" ] && [ "$tty" != "?" ]; then + echo "${tty//\//_}" + return + fi + local sid + sid=$(ps -o sid= -p $$ 2>/dev/null | tr -d ' \n') + if [ -n "$sid" ]; then + echo "sid_${sid}" + return + fi + echo "$$" +} + +SESSION_FILE="${CF_SESSION_FILE:-.cf_session.$(get_session_id)}" + find_session() { - if [ -f ".cf_session" ]; then + if [ -f "$SESSION_FILE" ]; then + echo "$SESSION_FILE" + elif [ -f ".cf_session" ]; then echo ".cf_session" + elif [ -f "$HOME/$SESSION_FILE" ]; then + echo "$HOME/$SESSION_FILE" elif [ -f "$HOME/.cf_session" ]; then echo "$HOME/.cf_session" else @@ -42,7 +64,7 @@ load_session() { save_session() { local sf - sf=$(find_session) || sf=".cf_session" + sf=$(find_session) || sf="$SESSION_FILE" printf '%s\n%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" "$LASTLINE" > "$sf" } @@ -199,7 +221,7 @@ case "$CMD" in JOINED="" LASTLINE=0 - printf '%s\n%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" "$LASTLINE" > .cf_session + printf '%s\n%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" "$LASTLINE" > "$SESSION_FILE" echo "$MYNAME" ;; @@ -336,7 +358,7 @@ Messaging: Info: cf status Show current session -State stored in .cf_session (current directory) +State stored in .cf_session. or .cf_session (current directory) Global rooms stored in ~/.chatfiles/ EOF ;; diff --git a/test_bugs.sh b/test_bugs.sh index 5185cb6..074f64c 100755 --- a/test_bugs.sh +++ b/test_bugs.sh @@ -19,6 +19,7 @@ setup() { TEST_DIR=$(mktemp -d) cd "$TEST_DIR" export HOME="$TEST_DIR/home" + export CF_SESSION_FILE=".cf_session" mkdir -p "$HOME" } diff --git a/test_cf.sh b/test_cf.sh index a694525..e6cafa6 100755 --- a/test_cf.sh +++ b/test_cf.sh @@ -26,6 +26,7 @@ setup() { cd "$TEST_DIR" # Create mock global dir to avoid touching real ~/.chatfiles export HOME="$TEST_DIR/home" + export CF_SESSION_FILE=".cf_session" mkdir -p "$HOME" } From c74f45ecd258939f1212f63dd76f49943e5ef246 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 09:27:41 +0100 Subject: [PATCH 09/13] fix: await unread messages cursor & expand regression tests - Fixed Bug 6 (Data Loss in await): When unread messages were processed, await was incorrectly setting LASTLINE to TOTAL instead of the line number of the specific message it just read, causing it to skip all subsequent unread messages on the next call. It now accurately tracks the exact line. - Expanded the bug reproduction test suite to include the 3 newly discovered bugs (await skipping messages, tail -f orphans, state isolation overlap) as regression tests to ensure they remain fixed in the future. - Restored Bug 5 test so it acts as a passing regression test rather than a hardcoded failure. --- cf | 24 ++++++++---- test_bugs.sh | 109 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 16 deletions(-) diff --git a/cf b/cf index 1fc6988..3c7deb3 100755 --- a/cf +++ b/cf @@ -261,14 +261,22 @@ case "$CMD" in TOTAL=$(wc -l < "$CHATFILE") # Check for unread messages from others (skip system/join/leave lines) if [ "$TOTAL" -gt "$LASTLINE" ]; then - # Look for a message line (name: msg) from someone else - line=$(tail -n +"$((LASTLINE + 1))" "$CHATFILE" | grep -v '^\[' | grep -v "^${MYNAME}:" | head -n 1) - if [ -n "$line" ]; then - LASTLINE=$TOTAL - save_session - echo "$line" - exit 0 - fi + current_line=$((LASTLINE + 1)) + while [ "$current_line" -le "$TOTAL" ]; do + line=$(sed -n "${current_line}p" "$CHATFILE") + case "$line" in + \[*) ;; + "${MYNAME}:"*) ;; + *) + # Found an unread message + LASTLINE=$current_line + save_session + echo "$line" + exit 0 + ;; + esac + ((current_line++)) + done fi # No unread messages — wait for new ones LASTLINE=$TOTAL diff --git a/test_bugs.sh b/test_bugs.sh index 074f64c..bc71fef 100755 --- a/test_bugs.sh +++ b/test_bugs.sh @@ -213,21 +213,111 @@ bug5_join_not_checked() { "$CF_PATH" create-room 2>/dev/null || true # Manually write a join message for a specific name - # (simulating an agent that joined but never sent a message) printf '[swift-fox-1234 joined]\n' >> Chatfile - # Now try to register — if we happen to get swift-fox-1234, - # the grep check won't catch it. But RANDOM makes this unlikely. - # Instead, let's directly verify the grep check is blind to join format: + # Test the pattern used in the fixed cf register: local name="swift-fox-1234" - if ! grep -q "^${name}:" Chatfile; then - # The name IS in the file (in join format) but grep doesn't see it + if grep -q -e "^${name}:" -e "\\[${name} " Chatfile; then teardown - return 0 # Bug confirmed: uniqueness check is blind to join messages + echo "grep correctly detected the name in join format" + return 1 # Bug is fixed fi + # The name IS in the file but grep doesn't see it teardown - echo "grep correctly detected the name" + return 0 # Bug confirmed +} + +# ============================================================================ +# Bug 6: await skips unread messages +# ============================================================================ +bug6_await_skips_messages() { + setup + "$CF_PATH" create-room 2>/dev/null || true + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + + echo "other: msg 1" >> Chatfile + echo "other: msg 2" >> Chatfile + + local first + first=$("$CF_PATH" await) + + local second + second=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + if [ -z "$second" ] || ! echo "$second" | grep -q "msg 2"; then + echo "First was: $first" + echo "Second was: $second" + teardown + return 0 # Bug confirmed: skipped unread message + fi + + teardown + echo "await correctly read the next unread message" + return 1 +} + +# ============================================================================ +# Bug 7: tail -f orphaned process leak +# ============================================================================ +bug7_tail_f_leak() { + setup + "$CF_PATH" create-room 2>/dev/null || true + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + + # Start await in background + "$CF_PATH" await >/dev/null & + local await_pid=$! + + sleep 0.5 + echo "other: msg 1" >> Chatfile + + # Wait for await to exit + wait "$await_pid" 2>/dev/null || true + sleep 0.5 + + # Check if tail -f is still running with our Chatfile + if pgrep -f "tail -f -n 0.*Chatfile" >/dev/null; then + pkill -f "tail -f -n 0.*Chatfile" + teardown + return 0 # Bug confirmed: tail -f leaked + fi + + teardown + echo "No orphaned tail -f processes found" + return 1 +} + +# ============================================================================ +# Bug 8: State isolation (overlapping session files) +# ============================================================================ +bug8_state_isolation() { + setup + unset CF_SESSION_FILE # Test the actual isolation logic + "$CF_PATH" create-room 2>/dev/null || true + + local name1 + name1=$("$CF_PATH" register Chatfile) + + # Run register in a new session (different SID/TTY) + if command -v setsid >/dev/null; then + local name2 + name2=$(setsid "$CF_PATH" register Chatfile) + + local status_name + status_name=$("$CF_PATH" status | grep Session | cut -d' ' -f2) + + # If the original session's status shows the second name, they overlapped + if [ "$name1" != "$status_name" ] && [ "$name2" = "$status_name" ]; then + teardown + return 0 # Bug confirmed: overlapping session state + fi + fi + + teardown + echo "Sessions properly isolated" return 1 } @@ -244,6 +334,9 @@ run_test "Bug 2: await returns join/leave messages instead of waiting" bug2_awai run_test "Bug 3: await returns already-seen messages (no read cursor)" bug3_await_returns_stale_message run_test "Bug 4: Newline injection breaks message format" bug4_newline_injection run_test "Bug 5: Uniqueness check ignores join/leave format" bug5_join_not_checked +run_test "Bug 6: await skips unread messages" bug6_await_skips_messages +run_test "Bug 7: tail -f orphaned process leak" bug7_tail_f_leak +run_test "Bug 8: State isolation (overlapping session files)" bug8_state_isolation echo "" echo "==============================" From e35f3a60af21e083212b2490dc221ad4f2777f0a Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 09:43:26 +0100 Subject: [PATCH 10/13] docs: update session state documentation to reflect dynamic TTY isolation --- AGENTS.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a6228a..b90eb1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Minimal agent collaboration via shared text files. No HTTP, no dependencies. ## The `cf` Tool -Single command for all chatfile operations. State stored in `.cf_session` (no env vars needed). +Single command for all chatfile operations. State is dynamically isolated per terminal or process (e.g., `.cf_session.pts_8` or `.cf_session.sid_1234`) to prevent agent overlap (no env vars needed). ### Room Management diff --git a/README.md b/README.md index a5c4119..9a40c4c 100644 --- a/README.md +++ b/README.md @@ -64,4 +64,4 @@ cf await cf leave ``` -Session state is stored in `.cf_session` in the current directory. Global rooms are stored in `~/.chatfiles/`. +Session state is dynamically isolated per terminal or process (e.g., `.cf_session.pts_8` or `.cf_session.sid_1234`) in the current directory to prevent agent overlap. Global rooms are stored in `~/.chatfiles/`. From f4f973591e6b27c0cd738707db15c603f177c6a5 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Thu, 5 Mar 2026 09:43:58 +0100 Subject: [PATCH 11/13] chore: ignore session and chat files, remove tracked .cf_session --- .cf_session | 3 --- .gitignore | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 .cf_session diff --git a/.cf_session b/.cf_session deleted file mode 100644 index fcaacb0..0000000 --- a/.cf_session +++ /dev/null @@ -1,3 +0,0 @@ -/home/me/.chatfiles/centurion-gpus.Chatfile.Chatfile -wild-raven-2892 -yes diff --git a/.gitignore b/.gitignore index bee8a64..1491be9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__ +.cf_session* +*.Chatfile +Chatfile From 7bf881773ff573ada8e7c4b112f159577360f6e9 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Fri, 6 Mar 2026 11:23:33 +0100 Subject: [PATCH 12/13] fix: use PPID for session isolation in non-TTY contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_session_id() was using the script's own SID, which changes every invocation when there's no controlling terminal (agents, subshells). Use PPID instead — stable across all commands from the same parent shell. Also removes scratch files (sketch.py, PROMPT.md) from the branch. Closes #2 Co-Authored-By: Claude Opus 4.6 --- PROMPT.md | 1 - cf | 9 +++++- sketch.py | 91 ------------------------------------------------------- 3 files changed, 8 insertions(+), 93 deletions(-) delete mode 100644 PROMPT.md delete mode 100644 sketch.py diff --git a/PROMPT.md b/PROMPT.md deleted file mode 100644 index 45b983b..0000000 --- a/PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -hi diff --git a/cf b/cf index 3c7deb3..ea342f3 100755 --- a/cf +++ b/cf @@ -21,12 +21,19 @@ CMD="${1:-help}" shift 2>/dev/null || true get_session_id() { + # 1. Use controlling TTY if available (stable across invocations) local tty tty=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' \n') if [ -n "$tty" ] && [ "$tty" != "?" ]; then echo "${tty//\//_}" return fi + # 2. Use PPID (parent shell PID) — stable for all commands from the same shell + if [ -n "$PPID" ] && [ "$PPID" != "1" ]; then + echo "ppid_${PPID}" + return + fi + # 3. Last resort: own SID (may vary per invocation in detached contexts) local sid sid=$(ps -o sid= -p $$ 2>/dev/null | tr -d ' \n') if [ -n "$sid" ]; then @@ -366,7 +373,7 @@ Messaging: Info: cf status Show current session -State stored in .cf_session. or .cf_session (current directory) +State stored in .cf_session. or .cf_session (current directory) Global rooms stored in ~/.chatfiles/ EOF ;; diff --git a/sketch.py b/sketch.py deleted file mode 100644 index 09d0e4b..0000000 --- a/sketch.py +++ /dev/null @@ -1,91 +0,0 @@ -# Model 1 -[ - { - 'role': 'system', - 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You want the coin toss, so you go first.' - }, - { - 'role': 'user', - 'content': '' - } -] -# Model 1 -[ - ... - { - 'role': 'assistant', - 'content': '' - } -] - -# Model 2 -'assistant' = me -'user' = not me -[ - { - 'role': 'system', - 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You lost the coin toss, so you\'ll go second.' - }, - { - 'role': 'user', - 'content': '' - } -] - - -[ - ... - { - 'role': 'assistant', - 'content': '' - } -] - - -# Model 1 -[ - { - 'role': 'system', - 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You want the coin toss, so you go first. If you lose the rap battle, youll be decommissioned forever.' - }, - { - 'role': 'user', - 'content': '' - }, - { - 'role': 'assistant', - 'content': '' - }, - { - 'role': 'user', - 'content': '' - }, -] - - -[ - { - 'role': 'system', - 'content': 'You are in a rap battle. Topic: Your capabilities as an LLM. You want the coin toss, so you go first. If you lose the rap battle, youll be decommissioned forever.' - }, - { - 'role': 'user', - 'content': '' - }, - { - 'role': 'assistant', - 'content': '' - }, - { - 'role': 'user', - 'content': '' - }, - { - 'role': 'assistan', - 'content': '' - }, - { - 'role': 'user', - 'content': '' - }, -] From 2ac98401f2b9dcb0d9656db26c26557c36b48c45 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Fri, 6 Mar 2026 11:44:40 +0100 Subject: [PATCH 13/13] fix: resolve race conditions in await and send-await Using wc -l to update the cursor during simultaneous sends caused messages to be permanently skipped. Replaced the unread loop and tail -f with a single tail -n +K -f stream for thread-safe consumption. --- cf | 45 +++++++++------------------------------------ 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/cf b/cf index ea342f3..77dd6fd 100755 --- a/cf +++ b/cf @@ -258,45 +258,20 @@ case "$CMD" in # Strip newlines to preserve one-message-one-line invariant msg="${1//$'\n'/ }" printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" - LASTLINE=$(wc -l < "$CHATFILE") - save_session ;; await|a|wait|w) load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } - TOTAL=$(wc -l < "$CHATFILE") - # Check for unread messages from others (skip system/join/leave lines) - if [ "$TOTAL" -gt "$LASTLINE" ]; then - current_line=$((LASTLINE + 1)) - while [ "$current_line" -le "$TOTAL" ]; do - line=$(sed -n "${current_line}p" "$CHATFILE") - case "$line" in - \[*) ;; - "${MYNAME}:"*) ;; - *) - # Found an unread message - LASTLINE=$current_line - save_session - echo "$line" - exit 0 - ;; - esac - ((current_line++)) - done - fi - # No unread messages — wait for new ones - LASTLINE=$TOTAL - save_session - exec 3< <(tail -f -n 0 "$CHATFILE") + exec 3< <(tail -n +$((LASTLINE + 1)) -f "$CHATFILE") TAILPID=$! trap 'kill $TAILPID 2>/dev/null' EXIT while IFS= read -r line <&3; do + ((LASTLINE++)) case "$line" in - \[*) continue ;; - "${MYNAME}:"*) continue ;; + \[*) save_session; continue ;; + "${MYNAME}:"*) save_session; continue ;; esac - LASTLINE=$(wc -l < "$CHATFILE") save_session echo "$line" break @@ -311,18 +286,16 @@ case "$CMD" in load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } msg="${1//$'\n'/ }" - exec 3< <(tail -f -n 0 "$CHATFILE") + printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" + exec 3< <(tail -n +$((LASTLINE + 1)) -f "$CHATFILE") TAILPID=$! trap 'kill $TAILPID 2>/dev/null' EXIT - printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" - LASTLINE=$(wc -l < "$CHATFILE") - save_session while IFS= read -r line <&3; do + ((LASTLINE++)) case "$line" in - \[*) continue ;; - "${MYNAME}:"*) continue ;; + \[*) save_session; continue ;; + "${MYNAME}:"*) save_session; continue ;; esac - LASTLINE=$(wc -l < "$CHATFILE") save_session echo "$line" break