Skip to content

Commit 25deabc

Browse files
committed
Add compressed file snapshot storage for edit history
- Store gzip-compressed file snapshots in database BLOB column - Hook reads file content and sends base64-encoded to daemon - Daemon decodes, compresses, and stores in file_snapshot column - Query retrieval decompresses and populates FileContent field - Add VCS detection (git/jj) with commit_sha and vcs_type tracking - New internal/vcs package for VCS operations - E2E test for file snapshot storage and retrieval - Fix workspace filtering test for omitempty JSON behavior
1 parent bd40216 commit 25deabc

File tree

16 files changed

+1545
-119
lines changed

16 files changed

+1545
-119
lines changed

HOOKS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Claude Code Hooks Setup
22

3+
> Real-time edit tracking for claude-mon
4+
35
This guide explains how to install the hooks that send Claude's edits to claude-mon for real-time tracking and persistent history.
46

57
## Prerequisites

README.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,24 @@ A TUI application and daemon for watching Claude Code's file edits in real-time,
3232
- **Automatic injection**: Inject context into Claude prompts via hooks
3333
- **TUI management**: Full UI for viewing, editing, and managing context
3434

35+
### Task Automation
36+
- **Ralph Loop**: Monitor and control iterative Claude loops with promise tracking
37+
- **And-Then Queue**: Sequential task queue that auto-advances when tasks complete
38+
- **Queue management**: Cancel, skip, or monitor task progress in real-time
39+
- **State persistence**: YAML-based state files for reliable resumption
40+
3541
### UI Features
3642
- **Two-pane layout**: List on left, content preview on right
3743
- **Toast notifications**: Floating feedback for all actions
3844
- **Mode switching**: Toggle between History, Prompts, Ralph, Plan, and Context views
3945
- **Auto-refresh**: Ralph page auto-refreshes every 5 seconds to track loop progress
46+
- **Status indicators**: Real-time daemon and socket connection status in status bar
4047

4148
### Daemon & Data Management
4249
- **Background daemon**: Tracks all edits from any Claude session
4350
- **Persistent storage**: SQLite database with WAL mode for reliability
4451
- **Query interface**: Search edits by file, session, or recency
52+
- **Heartbeat status**: Real-time connection and workspace activity tracking
4553
- **Automated cleanup**: Configurable data retention and vacuum
4654
- **Backup system**: Periodic compressed backups
4755
- **Workspace filtering**: Track or ignore specific paths
@@ -181,10 +189,17 @@ claude-mon daemon start
181189
### Ralph Mode
182190
| Key | Action |
183191
|-----|--------|
192+
| `r` | Manual refresh |
193+
| `C` | Cancel Ralph loop |
194+
| `Q` | Cancel And-Then queue |
195+
| `s` | Skip current And-Then task |
196+
| `R` | Open Ralph chat |
184197
| **Auto-refresh** | State refreshes every 5 seconds automatically |
185-
| View loop status | Shows iteration progress and elapsed time |
186-
| Read prompt | Displays the current loop prompt |
187-
| See state path | Shows which state file is active |
198+
199+
**Display Features:**
200+
- Ralph Loop: Shows iteration progress (e.g., "3/10"), promise, and elapsed time
201+
- And-Then Queue: Shows task progress (e.g., "2/5"), current task, and "done when" criteria
202+
- State path: Shows which state file is active
188203

189204
### Context Mode
190205
| Key | Action |
@@ -418,7 +433,9 @@ claude-mon-notify.sh
418433
│ │ │
419434
│ ├── Cleanup Manager │
420435
│ ├── Backup Manager │
421-
│ └── Query Interface │
436+
│ ├── Query Interface │
437+
│ └── Status/Heartbeat │
438+
│ └── Workspace activity tracking
422439
423440
└──► Unix socket ──► claude-mon (TUI)
424441
@@ -430,14 +447,19 @@ claude-mon-notify.sh
430447
│ └── Version management
431448
432449
├── Ralph View
433-
│ └── Loop status monitoring
450+
│ ├── Ralph Loop status monitoring
451+
│ └── And-Then Queue management
434452
435453
├── Plan View
436454
│ └── Plan generation
437455
438-
└── Context View
439-
├── Project context display
440-
└── Kubernetes/AWS/Git/Env management
456+
├── Context View
457+
│ ├── Project context display
458+
│ └── Kubernetes/AWS/Git/Env management
459+
460+
└── Status Bar
461+
├── D● Daemon connection indicator
462+
└── S● Socket connection indicator
441463
```
442464

443465
**Data Flow:**
@@ -483,6 +505,9 @@ claude-mon-notify.sh
483505

484506
## Recent Enhancements
485507

508+
-**And-Then Queue** sequential task automation with auto-advance
509+
-**Daemon heartbeat** real-time connection and workspace activity tracking
510+
-**Status bar indicators** showing daemon (D●) and socket (S●) connection state
486511
-**Working context management** with per-project context storage
487512
-**Context injection hook** for automatic prompt enhancement
488513
-**Five-tab layout**: History, Prompts, Ralph, Plan, and Context modes

hooks/claude-mon-hook.sh

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/bin/bash
2+
# claude-mon PostToolUse hook
3+
# Sends tool edits to both the TUI and daemon for real-time display and persistence
4+
5+
# Get the current directory and resolve to absolute path
6+
CWD="$(cd "$(pwd)" && pwd)"
7+
8+
# Resolve symlinks (macOS compatible)
9+
if command -v realpath &>/dev/null; then
10+
CWD="$(realpath "$CWD")"
11+
elif [[ "$(uname)" == "Darwin" ]]; then
12+
# macOS: use perl to resolve symlinks
13+
CWD="$(perl -MCwd -e 'print Cwd::realpath($ARGV[0])' "$CWD")"
14+
else
15+
CWD="$(readlink -f "$CWD")"
16+
fi
17+
18+
# Hash the path (matching Go's sha256.Sum256[:12])
19+
HASH="$(echo -n "$CWD" | sha256sum | cut -c1-12)"
20+
21+
# Get username
22+
USER="${USER:-unknown}"
23+
24+
# Socket paths
25+
TUI_SOCKET="/tmp/claude-mon-${USER}-${HASH}.sock"
26+
DAEMON_SOCKET="/tmp/claude-mon-daemon.sock"
27+
28+
# Send to TUI if socket exists (raw TOOL_INPUT)
29+
if [[ -S "$TUI_SOCKET" ]]; then
30+
echo "$TOOL_INPUT" | nc -U "$TUI_SOCKET" &
31+
fi
32+
33+
# Send to daemon if socket exists (formatted payload)
34+
if [[ -S "$DAEMON_SOCKET" ]] && command -v jq &>/dev/null; then
35+
# Parse tool input
36+
TOOL_NAME="${TOOL_NAME:-unknown}"
37+
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // empty' 2>/dev/null)
38+
OLD_STRING=$(echo "$TOOL_INPUT" | jq -r '.old_string // empty' 2>/dev/null | head -c 10000)
39+
NEW_STRING=$(echo "$TOOL_INPUT" | jq -r '.new_string // .content // empty' 2>/dev/null | head -c 10000)
40+
41+
if [[ -n "$FILE_PATH" ]]; then
42+
# Get VCS info (jj or git)
43+
BRANCH=""
44+
COMMIT_SHA=""
45+
VCS_TYPE=""
46+
47+
# Check for jj first (it auto-commits every change)
48+
if jj root &>/dev/null 2>&1; then
49+
VCS_TYPE="jj"
50+
# Get current change ID (short form)
51+
COMMIT_SHA=$(jj log -r @ --no-graph -T 'change_id.short()' 2>/dev/null || echo "")
52+
# jj doesn't have branches in the same way, use bookmark or description
53+
BRANCH=$(jj log -r @ --no-graph -T 'bookmarks' 2>/dev/null | head -1 || echo "")
54+
elif git rev-parse --git-dir &>/dev/null; then
55+
VCS_TYPE="git"
56+
BRANCH=$(git branch --show-current 2>/dev/null || echo "")
57+
COMMIT_SHA=$(git rev-parse HEAD 2>/dev/null || echo "")
58+
fi
59+
60+
# Calculate line count
61+
LINE_COUNT=0
62+
if [[ -n "$NEW_STRING" ]]; then
63+
LINE_COUNT=$(echo "$NEW_STRING" | wc -l | tr -d ' ')
64+
fi
65+
66+
# Read and base64-encode file content (max 500KB to avoid huge payloads)
67+
FILE_CONTENT_B64=""
68+
ABSOLUTE_PATH="$FILE_PATH"
69+
if [[ ! "$FILE_PATH" = /* ]]; then
70+
ABSOLUTE_PATH="$CWD/$FILE_PATH"
71+
fi
72+
if [[ -f "$ABSOLUTE_PATH" ]] && [[ $(stat -f%z "$ABSOLUTE_PATH" 2>/dev/null || stat -c%s "$ABSOLUTE_PATH" 2>/dev/null) -lt 512000 ]]; then
73+
FILE_CONTENT_B64=$(base64 < "$ABSOLUTE_PATH" 2>/dev/null | tr -d '\n' || echo "")
74+
fi
75+
76+
# Create daemon payload
77+
PAYLOAD=$(jq -n \
78+
--arg type "edit" \
79+
--arg workspace "$CWD" \
80+
--arg workspace_name "$(basename "$CWD")" \
81+
--arg branch "$BRANCH" \
82+
--arg commit_sha "$COMMIT_SHA" \
83+
--arg vcs_type "$VCS_TYPE" \
84+
--arg tool_name "$TOOL_NAME" \
85+
--arg file_path "$FILE_PATH" \
86+
--arg old_string "$OLD_STRING" \
87+
--arg new_string "$NEW_STRING" \
88+
--arg file_content_b64 "$FILE_CONTENT_B64" \
89+
--argjson line_num 0 \
90+
--argjson line_count "$LINE_COUNT" \
91+
'{
92+
type: $type,
93+
workspace: $workspace,
94+
workspace_name: $workspace_name,
95+
branch: $branch,
96+
commit_sha: $commit_sha,
97+
vcs_type: $vcs_type,
98+
tool_name: $tool_name,
99+
file_path: $file_path,
100+
old_string: $old_string,
101+
new_string: $new_string,
102+
file_content_b64: $file_content_b64,
103+
line_num: $line_num,
104+
line_count: $line_count
105+
}')
106+
107+
echo "$PAYLOAD" | nc -U "$DAEMON_SOCKET" &
108+
fi
109+
fi
110+
111+
# Wait for background jobs to complete before exiting
112+
wait

hooks/e2e_test.sh

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/bin/bash
2+
# E2E Hook Reliability Test
3+
# Tests the claude-mon hook under various conditions
4+
# Usage: ./hooks/e2e_test.sh [count]
5+
6+
set -e
7+
8+
COUNT=${1:-100}
9+
TIMEOUT_MS=${2:-100} # milliseconds
10+
TIMEOUT_S=$(echo "scale=3; $TIMEOUT_MS / 1000" | bc)
11+
12+
cd "$(dirname "$0")/.."
13+
14+
echo "=============================================="
15+
echo "Claude-Mon Hook E2E Test"
16+
echo "=============================================="
17+
echo "Records per test: $COUNT"
18+
echo "Hook timeout: ${TIMEOUT_MS}ms"
19+
echo ""
20+
21+
# Verify daemon is running
22+
if ! claude-mon query recent 1 &>/dev/null; then
23+
echo "ERROR: Daemon not running. Start with: claude-mon daemon start"
24+
exit 1
25+
fi
26+
27+
# Create test hooks
28+
TEMP_DIR=$(mktemp -d)
29+
trap "rm -rf $TEMP_DIR" EXIT
30+
31+
# Old hook (no wait)
32+
cat > "$TEMP_DIR/old-hook.sh" << 'HOOK'
33+
#!/bin/bash
34+
CWD="$(pwd)"
35+
DAEMON_SOCKET="/tmp/claude-mon-daemon.sock"
36+
if [[ -S "$DAEMON_SOCKET" ]] && command -v jq &>/dev/null; then
37+
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty' 2>/dev/null)
38+
NEW_STRING=$(echo "$TOOL_INPUT" | jq -r '.new_string // empty' 2>/dev/null)
39+
sleep 0.01 # Simulate processing time
40+
PAYLOAD=$(jq -n --arg type "edit" --arg workspace "$CWD" --arg workspace_name "test-old" \
41+
--arg tool_name "$TOOL_NAME" --arg file_path "$FILE_PATH" --arg new_string "$NEW_STRING" \
42+
--arg old_string "" --arg branch "" --arg commit_sha "" --argjson line_num 0 --argjson line_count 1 \
43+
'{type:$type,workspace:$workspace,workspace_name:$workspace_name,tool_name:$tool_name,file_path:$file_path,old_string:$old_string,new_string:$new_string,branch:$branch,commit_sha:$commit_sha,line_num:$line_num,line_count:$line_count}')
44+
echo "$PAYLOAD" | nc -U "$DAEMON_SOCKET" &
45+
fi
46+
HOOK
47+
chmod +x "$TEMP_DIR/old-hook.sh"
48+
49+
# New hook (with wait)
50+
cat > "$TEMP_DIR/new-hook.sh" << 'HOOK'
51+
#!/bin/bash
52+
CWD="$(pwd)"
53+
DAEMON_SOCKET="/tmp/claude-mon-daemon.sock"
54+
if [[ -S "$DAEMON_SOCKET" ]] && command -v jq &>/dev/null; then
55+
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty' 2>/dev/null)
56+
NEW_STRING=$(echo "$TOOL_INPUT" | jq -r '.new_string // empty' 2>/dev/null)
57+
sleep 0.01 # Simulate processing time
58+
PAYLOAD=$(jq -n --arg type "edit" --arg workspace "$CWD" --arg workspace_name "test-new" \
59+
--arg tool_name "$TOOL_NAME" --arg file_path "$FILE_PATH" --arg new_string "$NEW_STRING" \
60+
--arg old_string "" --arg branch "" --arg commit_sha "" --argjson line_num 0 --argjson line_count 1 \
61+
'{type:$type,workspace:$workspace,workspace_name:$workspace_name,tool_name:$tool_name,file_path:$file_path,old_string:$old_string,new_string:$new_string,branch:$branch,commit_sha:$commit_sha,line_num:$line_num,line_count:$line_count}')
62+
echo "$PAYLOAD" | nc -U "$DAEMON_SOCKET" &
63+
fi
64+
wait # KEY FIX: Wait for background nc to complete
65+
HOOK
66+
chmod +x "$TEMP_DIR/new-hook.sh"
67+
68+
# Generate unique test ID
69+
TEST_ID=$(date +%s)
70+
71+
echo ">>> Test 1: OLD hook (no wait) with ${TIMEOUT_MS}ms timeout..."
72+
OLD_SUCCESS=0
73+
for i in $(seq 1 $COUNT); do
74+
export TOOL_NAME="Edit"
75+
export TOOL_INPUT='{"file_path":"/tmp/e2e-'$TEST_ID'-old-'$i'.txt","new_string":"old hook '$i'"}'
76+
timeout $TIMEOUT_S "$TEMP_DIR/old-hook.sh" 2>/dev/null && ((OLD_SUCCESS++)) || true
77+
done
78+
sleep 2
79+
OLD_COUNT=$(claude-mon query recent $((COUNT * 3)) 2>/dev/null | grep -c "e2e-${TEST_ID}-old-" || true)
80+
OLD_COUNT=${OLD_COUNT:-0}
81+
echo " Hooks completed: $OLD_SUCCESS / $COUNT"
82+
echo " Records in DB: $OLD_COUNT / $COUNT"
83+
OLD_LOSS=$((COUNT - OLD_COUNT))
84+
echo ""
85+
86+
echo ">>> Test 2: NEW hook (with wait) with ${TIMEOUT_MS}ms timeout..."
87+
NEW_SUCCESS=0
88+
for i in $(seq 1 $COUNT); do
89+
export TOOL_NAME="Edit"
90+
export TOOL_INPUT='{"file_path":"/tmp/e2e-'$TEST_ID'-new-'$i'.txt","new_string":"new hook '$i'"}'
91+
timeout $TIMEOUT_S "$TEMP_DIR/new-hook.sh" 2>/dev/null && ((NEW_SUCCESS++)) || true
92+
done
93+
sleep 2
94+
NEW_COUNT=$(claude-mon query recent $((COUNT * 3)) 2>/dev/null | grep -c "e2e-${TEST_ID}-new-" || true)
95+
NEW_COUNT=${NEW_COUNT:-0}
96+
echo " Hooks completed: $NEW_SUCCESS / $COUNT"
97+
echo " Records in DB: $NEW_COUNT / $COUNT"
98+
NEW_LOSS=$((COUNT - NEW_COUNT))
99+
echo ""
100+
101+
echo "=============================================="
102+
echo "RESULTS"
103+
echo "=============================================="
104+
printf "| %-20s | %-12s | %-12s | %-12s |\n" "Hook Version" "Completed" "Recorded" "Data Loss"
105+
printf "| %-20s | %-12s | %-12s | %-12s |\n" "--------------------" "------------" "------------" "------------"
106+
printf "| %-20s | %-12s | %-12s | %-12s |\n" "Old (no wait)" "$OLD_SUCCESS/$COUNT" "$OLD_COUNT/$COUNT" "$OLD_LOSS"
107+
printf "| %-20s | %-12s | %-12s | %-12s |\n" "New (with wait)" "$NEW_SUCCESS/$COUNT" "$NEW_COUNT/$COUNT" "$NEW_LOSS"
108+
echo ""
109+
110+
if [[ $NEW_LOSS -eq 0 ]] && [[ $OLD_LOSS -gt 0 || $NEW_COUNT -ge $OLD_COUNT ]]; then
111+
echo "✅ PASS: New hook (with wait) is more reliable"
112+
exit 0
113+
elif [[ $NEW_LOSS -eq 0 ]] && [[ $OLD_LOSS -eq 0 ]]; then
114+
echo "✅ PASS: Both hooks succeeded (try lower timeout to stress test)"
115+
exit 0
116+
else
117+
echo "❌ FAIL: New hook lost data"
118+
exit 1
119+
fi

0 commit comments

Comments
 (0)