Skip to content

Commit 97bf966

Browse files
hoomanclaude
andcommitted
refactor(watcher): switch to launchd WatchPaths one-shot architecture
Instead of a long-running fswatch daemon (which had EX_CONFIG issues under launchd), use launchd's native WatchPaths to trigger the script on each CLAUDE_SESSION_LOG.md change. Script now runs once, checks for new Chat IR entries, invokes claude if needed, and exits. launchd restarts it on the next file change. Benefits: - No fswatch dependency (launchd uses native kqueue) - No RunAtLoad / KeepAlive / WorkingDirectory plist constraints (eliminates source of exit-78 crashes at session startup) - Simpler script: one-shot logic, no infinite loop - ThrottleInterval=5 prevents rapid re-triggering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c53eb46 commit 97bf966

2 files changed

Lines changed: 70 additions & 123 deletions

File tree

scripts/install_watcher.sh

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ fi
3232
chmod +x "$WATCH_SCRIPT"
3333

3434
# Build PATH for launchd (it doesn't inherit the user's shell PATH)
35-
FSWATCH_BIN=$(command -v fswatch)
36-
CLAUDE_DIR=$(dirname "$CLAUDE_BIN")
37-
LAUNCHD_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$CLAUDE_DIR"
35+
LAUNCHD_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
3836

3937
# --- Write plist ---
38+
# Uses WatchPaths so launchd triggers the script on each file change
39+
# (native kqueue — no fswatch needed). The script runs once, processes
40+
# any new Integration Requests, then exits. launchd restarts it on the
41+
# next file change. No RunAtLoad, no KeepAlive, no WorkingDirectory.
4042
cat > "$PLIST_PATH" <<EOF
4143
<?xml version="1.0" encoding="UTF-8"?>
4244
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
@@ -60,26 +62,15 @@ cat > "$PLIST_PATH" <<EOF
6062
<string>$HOME</string>
6163
</dict>
6264
63-
<key>WorkingDirectory</key>
64-
<string>$REPO_ROOT</string>
65-
66-
<!-- Start immediately when loaded and after each reboot -->
67-
<key>RunAtLoad</key>
68-
<true/>
69-
70-
<!-- Restart automatically if the watcher process dies -->
71-
<key>KeepAlive</key>
72-
<true/>
65+
<!-- Trigger on session log file changes (native kqueue, no fswatch needed) -->
66+
<key>WatchPaths</key>
67+
<array>
68+
<string>$REPO_ROOT/CLAUDE_SESSION_LOG.md</string>
69+
</array>
7370
74-
<!-- Minimum seconds between restarts (avoids tight crash loops) -->
71+
<!-- Prevent hammering if something loops: min 5s between runs -->
7572
<key>ThrottleInterval</key>
76-
<integer>30</integer>
77-
78-
<!-- Route stdout/stderr to watcher.log (the script also appends here) -->
79-
<key>StandardOutPath</key>
80-
<string>$REPO_ROOT/.claude/watcher.log</string>
81-
<key>StandardErrorPath</key>
82-
<string>$REPO_ROOT/.claude/watcher.log</string>
73+
<integer>5</integer>
8374
</dict>
8475
</plist>
8576
EOF

scripts/watch_session_log.sh

Lines changed: 58 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,46 @@
11
#!/usr/bin/env bash
22
# watch_session_log.sh
33
#
4-
# Watches CLAUDE_SESSION_LOG.md for new Chat Integration Requests and
5-
# invokes Claude Code CLI non-interactively to process them.
4+
# One-shot processor for CLAUDE_SESSION_LOG.md. Triggered by launchd
5+
# WatchPaths when the log file changes. Checks whether new Chat Integration
6+
# Requests have appeared; if so, invokes Claude Code CLI non-interactively.
7+
# Exits after processing (launchd restarts it on the next file change).
68
#
7-
# Loop prevention: only triggers when the count of "Chat — Integration Request"
8-
# entries increases. Code's own writes (Integration Complete) don't change
9-
# that count, so no feedback loop.
9+
# Loop prevention: compares current Chat IR count against stored state.
10+
# Code's own writes (Integration Complete) don't increase the Chat IR count,
11+
# so they don't trigger a Claude invocation.
1012
#
1113
# Log rotation: delegates to rotate_session_log.py when file exceeds
12-
# ROTATION_THRESHOLD lines. Rotation commits automatically.
14+
# ROTATION_THRESHOLD lines.
1315
#
14-
# Dependencies: fswatch (brew install fswatch), claude CLI
16+
# Dependencies: claude CLI (resolved dynamically from ~/Library/...)
1517
# State: .claude/watcher_state (last known IR count), .claude/watcher.lock
1618
# Log: .claude/watcher.log
1719
#
18-
# Run directly for foreground monitoring, or via launchd (install_watcher.sh).
20+
# Install: bash scripts/install_watcher.sh
21+
# Run manually: bash scripts/watch_session_log.sh
1922

2023
set -euo pipefail
2124

2225
REPO_ROOT="/Volumes/SanDiskSSD/Developer/Repositories/framework"
2326
LOG_FILE="$REPO_ROOT/CLAUDE_SESSION_LOG.md"
2427
STATE_DIR="$REPO_ROOT/.claude"
2528
STATE_FILE="$STATE_DIR/watcher_state"
29+
LOCK_FILE="$STATE_DIR/watcher.lock"
30+
ROTATION_THRESHOLD=400
31+
32+
TRIGGER_PATTERN="— Chat — Integration Request"
2633

27-
# Redirect all output (including shell errors) to the watcher log early,
28-
# so even pre-startup crashes are captured when running under launchd.
34+
# Route all output to watcher.log early (captures shell-level errors too)
2935
mkdir -p "$STATE_DIR"
3036
exec >> "$STATE_DIR/watcher.log" 2>&1
3137

32-
# Trap to log the exit code whenever the script exits (aids launchd debugging)
33-
trap 'echo "[$(date +%Y-%m-%d\ %H:%M:%S)] WATCHER EXIT: status=$?" >> "$STATE_DIR/watcher.log"' EXIT
34-
LOCK_FILE="$STATE_DIR/watcher.lock"
35-
ROTATION_THRESHOLD=400 # lines before rotation is triggered
36-
37-
TRIGGER_PATTERN="— Chat — Integration Request"
38+
trap 'echo "[$(date "+%Y-%m-%d %H:%M:%S")] EXIT: status=$?"' EXIT
3839

3940
# Find claude binary — resolve latest installed version dynamically
4041
CLAUDE_BIN=$(ls -1dt "$HOME/Library/Application Support/Claude/claude-code/"*/claude 2>/dev/null | head -1 || true)
4142
if [[ -z "$CLAUDE_BIN" ]]; then
42-
echo "ERROR: claude CLI not found" >&2
43+
echo "[$(date "+%Y-%m-%d %H:%M:%S")] ERROR: claude CLI not found"
4344
exit 1
4445
fi
4546

@@ -51,98 +52,53 @@ per the Staging Directory Protocol in CLAUDE_CODE_INSTRUCTIONS.md. \
5152
Commit all changes atomically and append an Integration Complete entry \
5253
to the session log."
5354

54-
# --- Helpers ---
55-
log() {
56-
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
57-
}
55+
log() { echo "[$(date "+%Y-%m-%d %H:%M:%S")] $*"; }
5856

5957
count_requests() {
6058
grep -c "$TRIGGER_PATTERN" "$LOG_FILE" 2>/dev/null || echo "0"
6159
}
6260

63-
# --- Log rotation ---
64-
rotate_log() {
65-
local line_count
66-
line_count=$(wc -l < "$LOG_FILE" | tr -d ' ')
67-
log "Rotation triggered ($line_count lines > $ROTATION_THRESHOLD threshold)"
68-
python3 "$REPO_ROOT/scripts/rotate_session_log.py" "$LOG_FILE" \
69-
&& log "Rotation complete" \
70-
|| log "WARNING: rotation failed — skipping (log continues to grow)"
71-
}
61+
# --- Check for new requests ---
62+
current_count=$(count_requests)
63+
last_count=0
64+
[[ -f "$STATE_FILE" ]] && last_count=$(cat "$STATE_FILE")
7265

73-
# --- Claude invocation ---
74-
process_requests() {
75-
# Lockfile prevents concurrent runs
76-
if [[ -f "$LOCK_FILE" ]]; then
77-
log "Claude already running (lockfile present) — skipping this event"
78-
return
79-
fi
80-
81-
log "Triggering Claude Code (non-interactive)"
82-
touch "$LOCK_FILE"
83-
84-
cd "$REPO_ROOT"
85-
# Unset CLAUDECODE so the CLI doesn't refuse to start inside an existing session
86-
# stdout/stderr already go to watcher.log via exec redirect at top of script
87-
env -u CLAUDECODE \
88-
"$CLAUDE_BIN" -p \
89-
--permission-mode bypassPermissions \
90-
--model sonnet \
91-
"$CLAUDE_PROMPT" \
92-
&& log "Claude Code run complete" \
93-
|| log "ERROR: Claude Code exited with error (exit $?)"
94-
95-
rm -f "$LOCK_FILE"
96-
97-
# Update state after run (IR count may have changed if Chat wrote again during run)
98-
count_requests > "$STATE_FILE"
99-
}
66+
# Always update state (so Code's own writes don't trigger on next run)
67+
echo "$current_count" > "$STATE_FILE"
10068

101-
# --- Check file and maybe trigger ---
102-
check_and_trigger() {
103-
local current_count
104-
current_count=$(count_requests)
105-
local last_count=0
106-
[[ -f "$STATE_FILE" ]] && last_count=$(cat "$STATE_FILE")
107-
108-
if (( current_count > last_count )); then
109-
log "Integration Request count: $last_count$current_count"
110-
process_requests
111-
fi
112-
113-
# Check rotation threshold after processing
114-
local line_count
115-
line_count=$(wc -l < "$LOG_FILE" | tr -d ' ')
116-
if (( line_count > ROTATION_THRESHOLD )); then
117-
rotate_log
118-
# Update state after rotation (line numbers shifted)
119-
count_requests > "$STATE_FILE"
120-
fi
121-
}
69+
if (( current_count <= last_count )); then
70+
# No new Chat IR entries — nothing to do (Code's write or no change)
71+
exit 0
72+
fi
12273

123-
# --- Init ---
124-
if [[ ! -f "$STATE_FILE" ]]; then
125-
count_requests > "$STATE_FILE"
126-
log "Initialized: $(cat "$STATE_FILE") existing Integration Request(s)"
74+
log "Integration Request count: $last_count$current_count"
75+
76+
# --- Lockfile: prevent concurrent invocations ---
77+
if [[ -f "$LOCK_FILE" ]]; then
78+
log "Claude already running (lockfile present) — skipping"
79+
exit 0
12780
fi
12881

129-
log "Watcher started — monitoring $LOG_FILE"
130-
log "Claude binary: $CLAUDE_BIN"
131-
132-
# Check once at startup (in case requests arrived while watcher was down)
133-
check_and_trigger
134-
135-
# Watch for changes — outer loop restarts fswatch if it exits (e.g. EX_CONFIG
136-
# on first launch under launchd before FSEvents is fully initialised).
137-
# -o aggregates rapid events into one; -l 2 lets writes settle.
138-
# pipefail is disabled for this pipeline so fswatch exits don't kill the script.
139-
while true; do
140-
set +o pipefail
141-
fswatch -o -l 2 "$LOG_FILE" | while read -r _; do
142-
check_and_trigger
143-
done
144-
EXIT_CODE=$?
145-
set -o pipefail
146-
log "fswatch exited (code $EXIT_CODE) — restarting in 10 seconds"
147-
sleep 10
148-
done
82+
log "Triggering Claude Code (non-interactive)"
83+
touch "$LOCK_FILE"
84+
85+
cd "$REPO_ROOT"
86+
# env -u CLAUDECODE: strip nested-session guard so CLI starts outside a session
87+
env -u CLAUDECODE \
88+
"$CLAUDE_BIN" -p \
89+
--permission-mode bypassPermissions \
90+
--model sonnet \
91+
"$CLAUDE_PROMPT" \
92+
&& log "Claude Code run complete" \
93+
|| log "ERROR: Claude Code exited with error (exit $?)"
94+
95+
rm -f "$LOCK_FILE"
96+
97+
# --- Log rotation ---
98+
line_count=$(wc -l < "$LOG_FILE" | tr -d ' ')
99+
if (( line_count > ROTATION_THRESHOLD )); then
100+
log "Rotation triggered ($line_count lines)"
101+
python3 "$REPO_ROOT/scripts/rotate_session_log.py" "$LOG_FILE" \
102+
&& log "Rotation complete" \
103+
|| log "WARNING: rotation failed — log continues to grow"
104+
fi

0 commit comments

Comments
 (0)