Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 79 additions & 199 deletions .claude/hooks/preflight.sh
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
#!/usr/bin/env bash
# =============================================================================
# Desktop Command Center — CLI Agent Pre-flight Check
# DifferentWire Standard Preflight Hook
# =============================================================================
# Runs on SessionStart. Blocks the session (exit 2) if required coordination
# services are unavailable. Auto-starts the Dolt SSH tunnel if it's down.
# services are unavailable. ALL checks are hard blocks.
#
# Required services:
# 1. Dolt SSH tunnel (localhost → Hetzner:3307) — for Beads task tracking
# 2. MCP Agent Mail (getunfocused.app/mcp/) — for multi-agent coordination
#
# Zombie port detection:
# If a previous session's SSH tunnel died without closing its socket,
# the port stays LISTENING with a dead PID. This hook detects that and
# falls back to alternate ports (3308, 3309).
# 1. Citadel API — task management (HARD BLOCK)
# 2. Agent Mail — multi-agent coordination (HARD BLOCK)
#
# Exit codes:
# 0 = all checks passed, session may proceed
# 2 = BLOCKING — required service unavailable, session must not start
# 0 = all checks passed
# 2 = BLOCKING — required service unavailable
#
# Projects copy this to .claude/hooks/preflight.sh and customize PROJECT_DIR.
# =============================================================================

set -euo pipefail

HETZNER_HOST="unfocused@46.224.181.82"
DOLT_PORTS=(3307 3308 3309) # Cascade: try each until one works
# ─── Project-specific overrides (edit these) ──────────────────────────────
PROJECT_DIR="/c/Dev/Desk_Command_Center"
# ──────────────────────────────────────────────────────────────────────────

CITADEL_HEALTH="https://getunfocused.app/citadel/health"
AGENT_MAIL_HEALTH="https://getunfocused.app/health/liveness"
PID_FILE="$HOME/.dcc-tunnel.pid"
HETZNER_HOST="unfocused@46.224.181.82"

PASS="✓"
FAIL="✗"
Expand All @@ -37,215 +37,106 @@ fail() { log "$FAIL $1"; errors=$((errors + 1)); }
warn() { log "$WARN $1"; }

log ""
log "═══ Desktop Command Center Pre-flight Check ═══"
log "═══ Preflight Check ═══"
log ""

# ─── Helper: check if a PID is a live process ─────────────────────────────
pid_alive() {
local pid=$1
# tasklist on Windows — returns 0 if process exists
if tasklist //FI "PID eq $pid" 2>/dev/null | grep -q "$pid"; then
return 0
fi
return 1
}

# ─── Helper: check if port is listening AND the PID is alive ──────────────
port_healthy() {
local port=$1
local pid
pid=$(netstat -ano 2>/dev/null | grep "127.0.0.1:${port}" | grep -i "listen" | awk '{print $NF}' | head -1)

if [ -z "$pid" ]; then
return 1 # Port not listening at all
fi

if pid_alive "$pid"; then
return 0 # Port listening with a live process — healthy
else
warn "Port $port held by zombie PID $pid (process dead, socket orphaned)"
return 1
fi
}

# ─── Helper: kill old tunnel from PID file ────────────────────────────────
cleanup_old_tunnel() {
if [ -f "$PID_FILE" ]; then
local old_pid
old_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "$old_pid" ] && pid_alive "$old_pid"; then
log " Killing previous tunnel (PID $old_pid)..."
taskkill //F //PID "$old_pid" 2>/dev/null || kill "$old_pid" 2>/dev/null || true
sleep 1
fi
rm -f "$PID_FILE"
fi
}

# ─── 1. Dolt SSH Tunnel ─────────────────────────────────────────────────────
# Beads connects to Dolt via localhost. This requires an SSH tunnel
# forwarding to the Hetzner server where Dolt runs.

ACTIVE_PORT=""

# First, try to clean up any previous tunnel
cleanup_old_tunnel

# Try each port in the cascade
for port in "${DOLT_PORTS[@]}"; do
if port_healthy "$port"; then
ACTIVE_PORT=$port
pass "Dolt SSH tunnel (port $port — existing healthy tunnel)"
break
fi

# Port not healthy (either not listening or zombie). Try to start tunnel.
# -o ExitOnForwardFailure=yes = fail immediately if port is already bound
if ssh -o ExitOnForwardFailure=yes -fNL "${port}:127.0.0.1:3307" "$HETZNER_HOST" 2>/dev/null; then
sleep 2
if port_healthy "$port"; then
# Save the PID for cleanup by next session
local_pid=$(netstat -ano 2>/dev/null | grep "127.0.0.1:${port}" | grep -i "listen" | awk '{print $NF}' | head -1)
echo "$local_pid" > "$PID_FILE"
ACTIVE_PORT=$port
pass "Dolt SSH tunnel (auto-started on port $port)"
break
fi
fi

# This port didn't work (zombie or bind failure), try next
if [ "$port" != "${DOLT_PORTS[-1]}" ]; then
warn "Port $port unavailable, trying next..."
fi
done

if [ -z "$ACTIVE_PORT" ]; then
fail "Dolt SSH tunnel — all ports (${DOLT_PORTS[*]}) failed"
log " Zombie ports may require system reboot to release."
log " Manual fix: ssh -fNL 3307:127.0.0.1:3307 ${HETZNER_HOST}"
fi

# ─── 2. Beads Connectivity ──────────────────────────────────────────────────
# Verify Beads can actually talk to Dolt through the tunnel.

if [ -n "$ACTIVE_PORT" ]; then
export PATH="/c/Dev/flutter/bin:$PATH"
export BD_DSN="root:@tcp(127.0.0.1:${ACTIVE_PORT})/Desk_Command_Center"

if bd list --quiet 2>/dev/null | head -1 >/dev/null 2>&1; then
pass "Beads (bd list via port $ACTIVE_PORT)"

# ─── 2b. Pull latest Beads data from Dolt remote ─────────────
# Prevents agents from starting with stale task state.
# Warning only — stale data is better than a blocked session.
#
# Auto-commit metadata first: `dolt push` writes tracking data to
# the metadata table outside SQL transactions, leaving dirty state
# that blocks all future pulls. Commit it silently before pulling.
ssh -o ConnectTimeout=5 "$HETZNER_HOST" \
"docker exec dolt-beads dolt --data-dir=/var/lib/dolt sql -q \
\"USE Desk_Command_Center; CALL dolt_add('metadata'); CALL dolt_commit('-m', 'chore: auto-commit metadata before pull', '--allow-empty');\"" \
2>/dev/null || true

if bd dolt pull 2>/dev/null; then
pass "Beads data synced (bd dolt pull)"
else
warn "bd dolt pull failed — session may have stale Beads data"
log " This is a warning, not a blocker. Continuing."
fi
else
# Tunnel is up but Beads can't connect — maybe Dolt server isn't running
fail "Beads cannot reach Dolt (tunnel up but Dolt may not be running)"
log " Check Dolt on server: ssh ${HETZNER_HOST} \"docker ps | grep dolt\""
fi
# ─── 1. Citadel API (HARD BLOCK) ────────────────────────────────────────
# Validate response body contains "citadel", not just HTTP 200.
# The Flutter catch-all returns 200 with HTML for any path — checking
# status code alone produces false positives.
CITADEL_BODY=$(curl -s --max-time 5 "$CITADEL_HEALTH" 2>/dev/null || echo "")
if echo "$CITADEL_BODY" | grep -q '"citadel"'; then
pass "Citadel API"
else
fail "Citadel API unreachable or returning wrong content"
log " Response: ${CITADEL_BODY:0:80}"
log " No task management = no work. Fix before proceeding."
log " Check: ssh ${HETZNER_HOST} \"docker ps | grep api\""
fi

# ─── 3. MCP Agent Mail ──────────────────────────────────────────────────────
# Agent Mail runs on Hetzner, accessed via HTTPS through Cloudflare/nginx.

# ─── 2. Agent Mail (HARD BLOCK) ─────────────────────────────────────────
if curl -s --connect-timeout 5 "$AGENT_MAIL_HEALTH" 2>/dev/null | grep -q "alive"; then
pass "Agent Mail (${AGENT_MAIL_HEALTH})"
pass "Agent Mail"
else
fail "Agent Mail is unreachable"
fail "Agent Mail unreachable"
log " No coordination layer = no multi-agent safety. Fix before proceeding."
log " Check: ssh ${HETZNER_HOST} \"cd /opt/mcp_agent_mail && docker compose -f docker-compose.prod.yml logs --tail 20\""
log " Restart: ssh ${HETZNER_HOST} \"cd /opt/mcp_agent_mail && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d\""
fi

# ─── 4. Git Hooks Path ─────────────────────────────────────────────────────
# Ensure core.hookspath points to .claude/hooks/ (tracked, works in worktrees).
# .beads/hooks/ is gitignored and doesn't exist in worktrees → no hooks run.

HOOKS_PATH=$(cd /c/Dev/Desk_Command_Center && git config core.hookspath 2>/dev/null || echo "")
# ─── 3. Git Hooks Path ──────────────────────────────────────────────────
HOOKS_PATH=$(cd "$PROJECT_DIR" && git config core.hookspath 2>/dev/null || echo "")
if [ "$HOOKS_PATH" = ".claude/hooks" ]; then
pass "Git hooks path (.claude/hooks — tracked, worktree-safe)"
pass "Git hooks path (.claude/hooks)"
else
warn "Git hooks path is '${HOOKS_PATH:-<unset>}' — fixing to .claude/hooks"
cd /c/Dev/Desk_Command_Center && git config core.hookspath .claude/hooks
pass "Git hooks path fixed to .claude/hooks"
cd "$PROJECT_DIR" && git config core.hookspath .claude/hooks
pass "Git hooks path fixed"
fi

# ─── 5. Abandoned Worktree Detection ──────────────────────────────────────
# Scan for worktrees that might be leftovers from crashed agent sessions.
# Warning only — don't auto-delete (might have uncommitted work).

# ─── 4. Abandoned Worktree Detection ────────────────────────────────────
ABANDONED=()
while IFS= read -r wt_line; do
wt_path=$(echo "$wt_line" | awk '{print $1}')
wt_branch=$(echo "$wt_line" | awk '{print $2}' | tr -d '[]')

# Skip bare repo and main worktree
if [ "$wt_path" = "/c/Dev/Desk_Command_Center" ] || [ "$wt_path" = "/c/Dev/Desk_Command_Center-main" ]; then
continue
fi

# Skip if it looks like a known pattern but check age
if [ "$wt_path" = "$PROJECT_DIR" ]; then continue; fi
if [ -d "$wt_path" ]; then
# Check last commit time in the worktree
LAST_COMMIT=$(cd "$wt_path" 2>/dev/null && git log -1 --format=%ct 2>/dev/null || echo "0")
NOW=$(date +%s)
AGE_HOURS=$(( (NOW - LAST_COMMIT) / 3600 ))

if [ "$AGE_HOURS" -gt 24 ]; then
ABANDONED+=("$wt_path (branch: $wt_branch, last commit: ${AGE_HOURS}h ago)")
ABANDONED+=("$wt_path (branch: $wt_branch, ${AGE_HOURS}h ago)")
fi
fi
done < <(cd /c/Dev/Desk_Command_Center && git worktree list 2>/dev/null | grep -v "^$")
done < <(cd "$PROJECT_DIR" && git worktree list 2>/dev/null | grep -v "^$")

if [ ${#ABANDONED[@]} -gt 0 ]; then
warn "Found ${#ABANDONED[@]} potentially abandoned worktree(s):"
for wt in "${ABANDONED[@]}"; do
log " $wt"
done
log " To clean up: git worktree remove <path> && git branch -d <branch>"
log " These may contain uncommitted work from crashed agent sessions."
fi

# ─── 6. Claude Heartbeat to Bridge ──────────────────────────────────────────
# DCC-specific: send heartbeat to the Pi5 bridge for status display.

BRIDGE_URL="http://192.168.50.24:8080"
AGENT_NAME=""
if [ -f "${CLAUDE_PROJECT_DIR:-.}/.agent-identity" ]; then
AGENT_NAME=$(cat "${CLAUDE_PROJECT_DIR:-.}/.agent-identity" 2>/dev/null | tr -d '[:space:]')
fi
if [ -z "$AGENT_NAME" ]; then
AGENT_NAME="Claude-$$"
# ─── 5. Canonical Slash Commands Sync ───────────────────────────────────
# Sync command files from standards/.claude/commands/ into this project's
# .claude/commands/. Convention: any file in standards/.claude/commands/
# is canonical — meant to be available in every DW project. Updates
# propagate to all projects at next SessionStart, OR when /refresh-context
# is invoked in a long-running session (since /refresh-context invokes this
# preflight). Project-local-only commands (like /work) live in
# <project>/.claude/commands/ but have no canonical counterpart, so the
# sync never touches them.

CANONICAL_CMDS="/c/Dev/DifferentWire/standards/.claude/commands"
PROJECT_CMDS="$PROJECT_DIR/.claude/commands"

if [ -d "$CANONICAL_CMDS" ]; then
mkdir -p "$PROJECT_CMDS"
synced=0
installed=0
# Guard against `set -e` aborting on the cmp/cp non-zero returns
for canonical in "$CANONICAL_CMDS"/*.md; do
[ -f "$canonical" ] || continue
name=$(basename "$canonical")
local="$PROJECT_CMDS/$name"
if [ ! -f "$local" ]; then
cp "$canonical" "$local" && installed=$((installed + 1)) || true
elif ! cmp -s "$canonical" "$local"; then
cp "$canonical" "$local" && synced=$((synced + 1)) || true
fi
done
if [ "$installed" -gt 0 ] || [ "$synced" -gt 0 ]; then
pass "Canonical commands: $installed installed, $synced updated"
else
pass "Canonical commands: up to date"
fi
else
warn "Canonical commands dir not found at $CANONICAL_CMDS — standards repo not at canonical path?"
fi

curl -sf --max-time 3 -X POST "$BRIDGE_URL/api/claude/heartbeat" \
-H "Content-Type: application/json" \
-d "{\"agent\": \"$AGENT_NAME\", \"task\": \"Session starting\", \"program\": \"claude-code\"}" \
>/dev/null 2>&1 && pass "Claude heartbeat sent ($AGENT_NAME)" || warn "Claude heartbeat failed (non-blocking)"

# ─── 7. Session State (Compaction Recovery) ──────────────────────────────────
# If an agent session is active, output its state. This output becomes a
# system reminder that survives context compaction, allowing the agent to
# recover its working context (current epic, task, branch, budget).

# Resolve script path relative to this file (works in any worktree)
# ─── 6. Session State Recovery ───────────────────────────────────────────
PREFLIGHT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SESSION_STATE_SCRIPT="$PREFLIGHT_DIR/session-state.sh"
SESSION_STATE_FILE="/c/Dev/Desk_Command_Center/.claude/agent-session.json"
SESSION_STATE_FILE="$PROJECT_DIR/.claude/agent-session.json"

if [ -f "$SESSION_STATE_FILE" ] && [ -f "$SESSION_STATE_SCRIPT" ]; then
SESSION_OUTPUT=$(bash "$SESSION_STATE_SCRIPT" read 2>/dev/null || true)
Expand All @@ -257,29 +148,18 @@ if [ -f "$SESSION_STATE_FILE" ] && [ -f "$SESSION_STATE_SCRIPT" ]; then
log " $line"
done <<< "$SESSION_OUTPUT"
log ""
log " This session state was recovered from disk."
log " If /work was invoked, resume your current task."
log " If this is a new interactive session, ignore this — run"
log " 'bash .claude/hooks/session-state.sh reset' to clear stale state."
log ""
fi
fi

# ─── Result ──────────────────────────────────────────────────────────────────

# ─── Result ──────────────────────────────────────────────────────────────
log ""
if [ "$errors" -gt 0 ]; then
log "═══ BLOCKED: $errors check(s) failed ═══"
log "Fix the issues above, then retry. Agents must not operate without"
log "Beads (task coordination) and Agent Mail (file reservation)."
log "Agents must not operate without Citadel AND Agent Mail."
log ""
exit 2
fi

if [ -n "$ACTIVE_PORT" ]; then
log " Export: BD_DSN=root:@tcp(127.0.0.1:${ACTIVE_PORT})/Desk_Command_Center"
fi

log "═══ All checks passed — session ready ═══"
log ""
exit 0
Loading