#!/bin/bash
[ "${PAI_WORKER:-}" = "1" ] && exit 0  # disposable worker: no per-session bookkeeping
# PAI Knowledge OS — session-stop hook
#
# Called by Claude Code when a session ends.
# Updates session status to 'completed', sets closed_at, and syncs Obsidian.
#
# NEVER exits non-zero — this must not interrupt Claude Code.

PAI_OS="pai"

# Bail gracefully if pai is not installed
command -v "$PAI_OS" &>/dev/null || exit 0
command -v sqlite3 &>/dev/null || exit 0

# ---------------------------------------------------------------------------
# Read the Claude session UUID from the hook payload
# ---------------------------------------------------------------------------
# Claude Code delivers the Stop event as JSON on stdin, carrying `session_id`.
# That UUID is the only stable handle on this session: the steps below rename
# (`session slug --apply`) and renumber (`session cleanup --execute`) the
# session note, so anything derived from its filename has already changed by
# the time the handover runs. Passing the UUID through is what lets the
# handover recognise a checkpoint `pai pause` wrote minutes earlier instead of
# mistaking it for a stale one and overwriting it.
#
# Read non-blocking: when no payload is piped in, carry on without it.

CLAUDE_SESSION_UUID=""
if [ ! -t 0 ]; then
  HOOK_INPUT=$(timeout 2 cat 2>/dev/null || true)
  if [ -n "$HOOK_INPUT" ]; then
    if command -v jq &>/dev/null; then
      CLAUDE_SESSION_UUID=$(printf '%s' "$HOOK_INPUT" | jq -r '.session_id // empty' 2>/dev/null || true)
    else
      CLAUDE_SESSION_UUID=$(printf '%s' "$HOOK_INPUT" | python3 -c \
        "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null || true)
    fi
  fi
fi

# PAI_HOME (~/.claude/pai by default — see src/config/pai-home.ts), falling
# back to the pre-2026-09-19 ~/.pai/registry.db.
REGISTRY_DB="${PAI_HOME:-$HOME/.claude/pai}/registry.db"
[ -f "$REGISTRY_DB" ] || REGISTRY_DB="$HOME/.pai/registry.db"
[ -f "$REGISTRY_DB" ] || exit 0

# ---------------------------------------------------------------------------
# Everything below runs DETACHED. Nothing here is on the critical path.
# ---------------------------------------------------------------------------
# Claude Code blocks the next turn until its Stop hooks return, and this hook
# makes four `pai` calls, each of which talks to the daemon. Measured on this
# machine, the same call costs 47 ms with the daemon idle and 5560 ms while it
# is mid embed-pass — a 100x swing the user sees as "running stop hooks... 1m 3s".
#
# None of this work needs to finish before the next turn starts: marking a
# session completed, renaming its note, checkpointing and writing a handover are
# all bookkeeping. Detaching them makes the hook's cost independent of daemon
# load, which is the only way this stays fixed when the daemon is busy again.
#
# Single-flight, because turns arrive faster than this tail completes: a second
# copy would race the first over the same note and TODO.md. mkdir is the atomic
# primitive available in POSIX sh. If the lock is held, skip entirely — the next
# turn runs it, and every step here is idempotent and derives from current state
# rather than accumulating.
#
# Location: PAI_HOME (~/.claude/pai by default — see src/config/pai-home.ts).
# A shell script can't import that resolver, so this mirrors its two-path
# logic inline: new location, else the pre-2026-09-19 ~/.config/pai one if a
# fresh lock is already sitting there (an in-flight hook from before this
# script updated) — never both, that would defeat the mutex.
PAI_HOME_DIR="${PAI_HOME:-$HOME/.claude/pai}"
LOCK_DIR="$PAI_HOME_DIR/.session-stop.lock"
OLD_LOCK_DIR="$HOME/.config/pai/.session-stop.lock"
mkdir -p "$(dirname "$LOCK_DIR")" 2>/dev/null || true

if [ -d "$OLD_LOCK_DIR" ] && [ ! -d "$LOCK_DIR" ]; then
  OLD_LOCK_AGE=$(( $(date +%s) - $(stat -f %m "$OLD_LOCK_DIR" 2>/dev/null || echo 0) ))
  if [ "$OLD_LOCK_AGE" -le 600 ]; then
    echo "pai: honoring in-flight lock at old location $OLD_LOCK_DIR" >&2
    LOCK_DIR="$OLD_LOCK_DIR"
  fi
fi

# A crashed run must not wedge this forever: treat a lock older than 10 minutes
# as abandoned. Longer than any observed tail, shorter than a working session.
if [ -d "$LOCK_DIR" ]; then
  LOCK_AGE=$(( $(date +%s) - $(stat -f %m "$LOCK_DIR" 2>/dev/null || echo 0) ))
  [ "$LOCK_AGE" -gt 600 ] && rmdir "$LOCK_DIR" 2>/dev/null
fi

mkdir "$LOCK_DIR" 2>/dev/null || exit 0

{
  trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT

# ---------------------------------------------------------------------------
# Detect current project
# ---------------------------------------------------------------------------

DETECT_JSON=$("$PAI_OS" project detect --json 2>/dev/null) || exit 0
[ -z "$DETECT_JSON" ] && exit 0

# Parse slug — try jq first, fall back to python3
if command -v jq &>/dev/null; then
  PROJECT_SLUG=$(echo "$DETECT_JSON" | jq -r '.slug // empty' 2>/dev/null)
else
  PROJECT_SLUG=$(echo "$DETECT_JSON" | python3 -c \
    "import sys,json; d=json.load(sys.stdin); print(d.get('slug',''))" 2>/dev/null) || true
fi

[ -z "$PROJECT_SLUG" ] && exit 0

# ---------------------------------------------------------------------------
# Look up project and latest open/compacted session
# ---------------------------------------------------------------------------

PROJECT_ID=$(sqlite3 "$REGISTRY_DB" \
  "SELECT id FROM projects WHERE slug = '$PROJECT_SLUG' LIMIT 1" 2>/dev/null) || exit 0
[ -z "$PROJECT_ID" ] && exit 0

SESSION_ID=$(sqlite3 "$REGISTRY_DB" \
  "SELECT id FROM sessions WHERE project_id = $PROJECT_ID AND status IN ('open','compacted') ORDER BY created_at DESC LIMIT 1" \
  2>/dev/null) || true

# ---------------------------------------------------------------------------
# Mark session completed and set closed_at
# ---------------------------------------------------------------------------

if [ -n "$SESSION_ID" ]; then
  TS=$(date +%s)000
  sqlite3 "$REGISTRY_DB" \
    "UPDATE sessions SET status = 'completed', closed_at = $TS WHERE id = $SESSION_ID" \
    2>/dev/null || true
fi

# ---------------------------------------------------------------------------
# Auto-generate slug from transcript and rename session note
# ---------------------------------------------------------------------------

"$PAI_OS" session slug "$PROJECT_SLUG" latest --apply 2>/dev/null || true

# ---------------------------------------------------------------------------
# Periodic housekeeping — NOT every turn
# ---------------------------------------------------------------------------
# The `Stop` event does not mean "the session ended". Claude Code fires it at the
# end of EVERY assistant turn, so everything in this file runs after every single
# message. Measured 2026-08-04, on this machine:
#
#     session cleanup --execute   17705 ms      <- machine-wide sweep
#     obsidian sync                2813 ms      <- walks the vault
#     detect + slug + checkpoint + handover   ~300 ms total
#     stop-hook.mjs (separate hook)            4324 ms
#
# So roughly 25 SECONDS of work between one message and the next, and the owner
# could see it: "in the middle of the session I keep seeing running stop hooks".
# Two of those calls are whole-machine passes over 112 project directories and
# ~2900 transcripts. Doing that per turn is not a slow implementation, it is the
# wrong trigger.
#
# The cheap calls stay per-turn: a checkpoint and a handover after every turn are
# genuine crash insurance, and together they cost under a fifth of a second.
#
# The expensive two are debounced rather than moved to SessionEnd, deliberately —
# SessionEnd does not fire when a session is killed, and these are exactly the
# jobs that matter after an unclean exit. A stamp file gets them run regularly
# without paying for them every turn. Override with PAI_HOUSEKEEPING_INTERVAL
# (seconds, 0 disables the debounce and restores per-turn behaviour).

HOUSEKEEP_INTERVAL="${PAI_HOUSEKEEPING_INTERVAL:-1800}"
# Same PAI_HOME two-path logic as LOCK_DIR above.
HOUSEKEEP_STAMP="$PAI_HOME_DIR/.last-housekeeping"
OLD_HOUSEKEEP_STAMP="$HOME/.config/pai/.last-housekeeping"
mkdir -p "$(dirname "$HOUSEKEEP_STAMP")" 2>/dev/null || true
if [ ! -f "$HOUSEKEEP_STAMP" ] && [ -f "$OLD_HOUSEKEEP_STAMP" ]; then
  echo "pai: $OLD_HOUSEKEEP_STAMP is at an old location — run \`pai config migrate\` to move it to $HOUSEKEEP_STAMP" >&2
  HOUSEKEEP_STAMP="$OLD_HOUSEKEEP_STAMP"
fi

housekeeping_due() {
  [ "$HOUSEKEEP_INTERVAL" = "0" ] && return 0
  [ -f "$HOUSEKEEP_STAMP" ] || return 0
  local last now
  # Read the FIRST line and keep only digits.
  #
  # This was `cat` piped into a numeric check that reset to 0 on anything
  # non-numeric — and a stamp file with a stray second value makes the whole
  # read non-numeric, so `last` became 0, every turn looked overdue, and the
  # debounce never once took effect. The guard meant to make this cheap was
  # itself the reason it stayed expensive.
  #
  # Concurrent turns can both write here, so a malformed file is a state to
  # tolerate rather than an impossibility. Taking the first line and stripping
  # to digits cannot be defeated by extra values or trailing whitespace.
  last=$(head -n 1 "$HOUSEKEEP_STAMP" 2>/dev/null | tr -dc '0-9')
  [ -z "$last" ] && last=0
  now=$(date +%s)
  [ $((now - last)) -ge "$HOUSEKEEP_INTERVAL" ]
}

if housekeeping_due; then
  # Stamp BEFORE running, not after: these take ~20s, and two turns finishing
  # close together would otherwise both pass the check and run concurrently.
  date +%s > "$HOUSEKEEP_STAMP" 2>/dev/null || true

  "$PAI_OS" obsidian sync 2>/dev/null || true
  "$PAI_OS" session cleanup --execute 2>/dev/null || true
fi

# ---------------------------------------------------------------------------
# Auto-checkpoint before stop (captures final state)
# ---------------------------------------------------------------------------

"$PAI_OS" session checkpoint "Session ending — auto-checkpoint" 2>/dev/null || true

# ---------------------------------------------------------------------------
# Generate handover brief for next session
# ---------------------------------------------------------------------------
# Write a "## Continue" section to project's Notes/TODO.md with key items
# from this session so the next session can pick up immediately.
# This command will extract insights from the session transcript and append
# a handover section. If the command doesn't exist yet, fail gracefully.
#

if [ -n "$CLAUDE_SESSION_UUID" ]; then
  "$PAI_OS" session handover "$PROJECT_SLUG" latest \
    --session-id "$CLAUDE_SESSION_UUID" 2>/dev/null || true
else
  "$PAI_OS" session handover "$PROJECT_SLUG" latest 2>/dev/null || true
fi

# Set tab color to completed state when session ends
TAB_COLOR="${ADAPTER_DIR:-${PAI_DIR:-$HOME/.claude}}/tab-color-command.sh"
[[ -x "$TAB_COLOR" ]] && "$TAB_COLOR" completed

} >/dev/null 2>&1 &

# Detach so Claude Code does not wait on the child. Both parts matter: the
# redirect above closes the inherited stdout/stderr the parent would otherwise
# block reading, and disown drops the job from this shell's table so exiting
# cannot signal it.
disown 2>/dev/null || true

exit 0
