← Files NightshiftARCHIVED FILE

lib/ownership.sh

38.3 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

#!/usr/bin/env bash
# Session, lease, and shift-ownership fencing shared by Nightshift hooks.

# Cross-session mutex over one .nightshift/ — mkdir is the one atomic primitive every platform
# here ships (macOS has no flock). The holder writes its pid inside; a lock whose holder is
# provably dead is broken on sight, a mid-claim lock (no pid yet) is waited on, never stolen.
# The wait is bounded because a Stop hook must never hang a session over bookkeeping: on
# timeout the caller proceeds without the lock, and the race window is merely what it was
# before locks existed.
ns_lock() { # $1 = the .nightshift dir; bounded ~2s wait
  local dir holder _
  ns_layout_set dir "$1" lock
  mkdir -p "${dir%/*}" 2>/dev/null || :
  for _ in 1 2 3 4 5 6 7 8 9 10; do
    if mkdir "$dir" 2>/dev/null; then
      printf '%s' "$$" >"$dir/pid" 2>/dev/null || true
      return 0
    fi
    holder="$(cat "$dir/pid" 2>/dev/null)"
    case "$holder" in
      '' | *[!0-9]*) ;;
      *) kill -0 "$holder" 2>/dev/null || { rm -rf "$dir" 2>/dev/null; continue; } ;;
    esac
    sleep 0.2
  done
  return 1
}
ns_unlock() { rm -rf "$(ns_layout_path "$1" lock)" 2>/dev/null; }

# One active shift may keep one conversation identity across several host processes. The
# conversation record preserves continuity; this lease fences the process that currently owns
# that conversation after a watchman revival. Its six lines are:
#   original session scope · host · generation · revival nonce · process pid · process start time
# The nonce is empty for the original interactive process. A watchman writes a new nonce and
# generation before every spawn, so an older process carrying the same session id is fenced.
ns_lease_lock() { # $1 = the .nightshift dir; bounded ~2s wait
  local dir holder _
  ns_layout_set dir "$1" lease-lock
  mkdir -p "${dir%/*}" 2>/dev/null || :
  for _ in 1 2 3 4 5 6 7 8 9 10; do
    if mkdir "$dir" 2>/dev/null; then
      printf '%s' "$$" >"$dir/pid" 2>/dev/null || true
      return 0
    fi
    holder="$(cat "$dir/pid" 2>/dev/null)"
    case "$holder" in
      '' | *[!0-9]*) ;;
      *) kill -0 "$holder" 2>/dev/null || { rm -rf "$dir" 2>/dev/null; continue; } ;;
    esac
    sleep 0.2
  done
  return 1
}
ns_lease_unlock() { rm -rf "$(ns_layout_path "$1" lease-lock)" 2>/dev/null; }

ns_lease_safe_line() {
  case "$1" in
    *$'\n'* | *$'\r'*) return 1 ;;
  esac
  return 0
}

ns_session_write() { # <ns> <sid> <transcript> <pid> <start> <host> <tmp>; validates and writes tmp
  local ns="$1" sid="$2" transcript="$3" pid="$4" start="$5" host="$6" tmp="$7"
  [ -d "$ns" ] && [ -n "$sid" ] || return 1
  ns_lease_safe_line "$sid" && ns_lease_safe_line "$transcript" \
    && ns_lease_safe_line "$pid" && ns_lease_safe_line "$start" || return 1
  case "$pid" in *[!0-9]*) return 1 ;; esac
  case "$host" in claude | codex | cursor) ;; *) return 1 ;; esac
  (umask 077; printf '%s\n%s\n%s\n%s\n%s\n' \
    "$sid" "$transcript" "$pid" "$start" "$host" >"$tmp") || {
    rm -f "$tmp"
    return 1
  }
}

ns_session_claim() { # <ns> <sid> <transcript> <pid> <start> <host>; complete file appears atomically
  local ns="$1" rec tmp rc
  ns_layout_set rec "$ns" session
  mkdir -p "${rec%/*}" 2>/dev/null || :
  tmp="$rec.tmp.$$.$RANDOM"
  ns_session_write "$ns" "$2" "$3" "$4" "$5" "$6" "$tmp" || return 1
  [ -L "$rec" ] && rm -f "$rec"
  ln "$tmp" "$rec" 2>/dev/null
  rc=$?
  rm -f "$tmp"
  return "$rc"
}

# Replace an existing conversation record. Claim creates; this updates the bound session
# after a revival or interactive reclaim without losing the race to a second first-writer.
ns_session_replace() { # <ns> <sid> <transcript> <pid> <start> <host>
  local ns="$1" rec tmp
  ns_layout_set rec "$ns" session
  tmp="$rec.tmp.$$.$RANDOM"
  ns_session_write "$ns" "$2" "$3" "$4" "$5" "$6" "$tmp" || return 1
  mv -f "$tmp" "$rec"
}

# Prefer the recorded host pid when it is still the same process; otherwise walk ancestry.
# Sets NS_CURRENT_PID and NS_CURRENT_START for the caller.
# shellcheck disable=SC2034
ns_host_process() { # <host> <ns> <fallback-pid>
  local rec_pid rec_start live
  NS_CURRENT_PID=""
  NS_CURRENT_START=""
  rec_pid="$(ns_session_line "$2" 3 | tr -d '[:space:]')"
  rec_start="$(ns_session_line "$2" 4)"
  case "$rec_pid" in
    '' | *[!0-9]*) ;;
    *)
      if [ "$rec_pid" -gt 1 ] && kill -0 "$rec_pid" 2>/dev/null; then
        live="$(ns_process_start "$rec_pid" 2>/dev/null || true)"
        if [ -n "$live" ] && [ "$live" = "$rec_start" ]; then
          NS_CURRENT_PID="$rec_pid"
          NS_CURRENT_START="$rec_start"
          return 0
        fi
      fi
      ;;
  esac
  NS_CURRENT_PID="$(ns_ancestor_pid "$1" "$3" 2>/dev/null || true)"
  [ -z "$NS_CURRENT_PID" ] || NS_CURRENT_START="$(ns_process_start "$NS_CURRENT_PID" 2>/dev/null || true)"
}

ns_lease_load() { # $1 = the .nightshift dir; one descriptor gives one coherent snapshot
  local f _
  ns_layout_set f "$1" lease
  NS_LEASE_SID=""
  NS_LEASE_HOST=""
  NS_LEASE_GENERATION=""
  NS_LEASE_NONCE=""
  NS_LEASE_PID=""
  NS_LEASE_START=""
  [ -f "$f" ] && [ ! -L "$f" ] || return 1
  {
    IFS= read -r NS_LEASE_SID &&
      IFS= read -r NS_LEASE_HOST &&
      IFS= read -r NS_LEASE_GENERATION &&
      IFS= read -r NS_LEASE_NONCE &&
      IFS= read -r NS_LEASE_PID &&
      IFS= read -r NS_LEASE_START || return 1
    if IFS= read -r _; then return 1; fi
  } <"$f"
  # Native Windows writers use CRLF; read -r keeps the CR and the line checks refuse it.
  NS_LEASE_SID="${NS_LEASE_SID%$'\r'}"
  NS_LEASE_HOST="${NS_LEASE_HOST%$'\r'}"
  NS_LEASE_GENERATION="${NS_LEASE_GENERATION%$'\r'}"
  NS_LEASE_NONCE="${NS_LEASE_NONCE%$'\r'}"
  NS_LEASE_PID="${NS_LEASE_PID%$'\r'}"
  NS_LEASE_START="${NS_LEASE_START%$'\r'}"
  ns_lease_safe_line "$NS_LEASE_SID" && ns_lease_safe_line "$NS_LEASE_HOST" \
    && ns_lease_safe_line "$NS_LEASE_GENERATION" && ns_lease_safe_line "$NS_LEASE_NONCE" \
    && ns_lease_safe_line "$NS_LEASE_PID" && ns_lease_safe_line "$NS_LEASE_START" || return 1
  case "$NS_LEASE_HOST" in claude | codex | cursor) ;; *) return 1 ;; esac
  case "$NS_LEASE_GENERATION" in '' | *[!0-9]*) return 1 ;; esac
  [ "$NS_LEASE_GENERATION" -gt 0 ] 2>/dev/null || return 1
  case "$NS_LEASE_NONCE" in *[!A-Za-z0-9._-]*) return 1 ;; esac
  case "$NS_LEASE_PID" in *[!0-9]*) return 1 ;; esac
  [ -n "$NS_LEASE_PID" ] || [ -z "$NS_LEASE_START" ] || return 1
  [ -n "$NS_LEASE_SID" ] || [ -n "$NS_LEASE_NONCE" ] || return 1
  return 0
}
ns_lease_valid() { ns_lease_load "$1"; }

ns_lease_write_unlocked() { # <ns> <sid> <host> <generation> <nonce> <pid> <start>
  local ns="$1" sid="$2" host="$3" generation="$4" nonce="$5" pid="$6" start="$7" lease tmp
  ns_layout_set lease "$ns" lease
  ns_lease_safe_line "$sid" && ns_lease_safe_line "$start" || return 1
  case "$host" in claude | codex | cursor) ;; *) return 1 ;; esac
  case "$generation" in '' | *[!0-9]*) return 1 ;; esac
  [ "$generation" -gt 0 ] 2>/dev/null || return 1
  case "$nonce" in *[!A-Za-z0-9._-]*) return 1 ;; esac
  case "$pid" in *[!0-9]*) return 1 ;; esac
  [ -n "$sid" ] || [ -n "$nonce" ] || return 1
  [ -n "$pid" ] || [ -z "$start" ] || return 1
  tmp="$lease.tmp.$$.$RANDOM"
  (umask 077; printf '%s\n%s\n%s\n%s\n%s\n%s\n' \
    "$sid" "$host" "$generation" "$nonce" "$pid" "$start" >"$tmp") || {
    rm -f "$tmp"
    return 1
  }
  mv -f "$tmp" "$lease" || {
    rm -f "$tmp"
    return 1
  }
}

ns_lease_claim_initial() { # <ns> <sid> <host> <pid> <start>
  local ns="$1" sid="$2" host="$3" pid="$4" start="$5" rc lease
  [ -n "$sid" ] || return 1
  ns_layout_set lease "$ns" lease
  ns_lease_lock "$ns" || return 2
  if [ -e "$lease" ] || [ -L "$lease" ]; then
    ns_lease_valid "$ns"
    rc=$?
    ns_lease_unlock "$ns"
    return "$rc"
  fi
  ns_lease_write_unlocked "$ns" "$sid" "$host" 1 "" "$pid" "$start"
  rc=$?
  ns_lease_unlock "$ns"
  return "$rc"
}

ns_lease_takeover() { # <ns> <possibly-empty-sid> <host>; prints: generation nonce
  local ns="$1" sid="$2" host="$3" generation=0 nonce rc existing_sid lease
  ns_layout_set lease "$ns" lease
  ns_lease_lock "$ns" || return 2
  if [ -e "$lease" ] || [ -L "$lease" ]; then
    if ! ns_lease_valid "$ns"; then
      ns_lease_unlock "$ns"
      return 1
    fi
    existing_sid="$NS_LEASE_SID"
    [ -z "$existing_sid" ] || sid="$existing_sid"
    generation="$NS_LEASE_GENERATION"
  fi
  generation=$((generation + 1))
  nonce="$host.$generation.$$.$RANDOM.$RANDOM"
  ns_lease_write_unlocked "$ns" "$sid" "$host" "$generation" "$nonce" "" ""
  rc=$?
  ns_lease_unlock "$ns"
  [ "$rc" -eq 0 ] || return "$rc"
  printf '%s %s' "$generation" "$nonce"
}

ns_lease_nonce_matches() { # <ns> <host> <nonce> <generation>; ignores sid for fresh fallback
  local ns="$1" host="$2" nonce="$3" generation="$4"
  [ -n "$nonce" ] && [ -n "$generation" ] || return 1
  ns_lease_load "$ns" || return 1
  [ "$NS_LEASE_HOST" = "$host" ] || return 1
  [ "$NS_LEASE_GENERATION" = "$generation" ] || return 1
  [ "$NS_LEASE_NONCE" = "$nonce" ]
}

ns_lease_rebind_session() { # <ns> <sid> <host> <nonce> <generation>; fills an empty scope
  local ns="$1" sid="$2" host="$3" nonce="$4" generation="$5" scope pid start rc
  [ -n "$sid" ] || return 1
  ns_lease_lock "$ns" || return 2
  if ! ns_lease_nonce_matches "$ns" "$host" "$nonce" "$generation"; then
    ns_lease_unlock "$ns"
    return 1
  fi
  scope="$NS_LEASE_SID"
  [ -n "$scope" ] || scope="$sid"
  pid="$NS_LEASE_PID"
  start="$NS_LEASE_START"
  ns_lease_write_unlocked "$ns" "$scope" "$host" "$generation" "$nonce" "$pid" "$start"
  rc=$?
  ns_lease_unlock "$ns"
  return "$rc"
}

ns_lease_attach_process() { # <ns> <host> <nonce> <generation> <pid> <start>
  local ns="$1" host="$2" nonce="$3" generation="$4" pid="$5" start="$6" sid rc
  ns_lease_lock "$ns" || return 2
  if ! ns_lease_nonce_matches "$ns" "$host" "$nonce" "$generation"; then
    ns_lease_unlock "$ns"
    return 1
  fi
  sid="$NS_LEASE_SID"
  ns_lease_write_unlocked "$ns" "$sid" "$host" "$generation" "$nonce" "$pid" "$start"
  rc=$?
  ns_lease_unlock "$ns"
  return "$rc"
}

ns_lease_reclaim_interactive() { # <ns> <sid> <host> <old-generation> <pid> <start>
  local ns="$1" sid="$2" host="$3" old_generation="$4" pid="$5" start="$6"
  local lease_sid lease_host generation nonce old_pid old_start rc
  [ -n "$pid" ] || return 1
  ns_lease_lock "$ns" || return 2
  if ! ns_lease_valid "$ns"; then
    ns_lease_unlock "$ns"
    return 1
  fi
  lease_sid="$NS_LEASE_SID"
  lease_host="$NS_LEASE_HOST"
  generation="$NS_LEASE_GENERATION"
  nonce="$NS_LEASE_NONCE"
  old_pid="$NS_LEASE_PID"
  old_start="$NS_LEASE_START"
  if [ "$lease_sid" != "$sid" ] || [ "$lease_host" != "$host" ] \
    || [ "$generation" != "$old_generation" ] || [ -n "$nonce" ]; then
    ns_lease_unlock "$ns"
    return 1
  fi
  ns_recorded_process "$old_pid" "$old_start"
  rc=$?
  if [ "$rc" -ne 1 ]; then
    ns_lease_unlock "$ns"
    return 1
  fi
  generation=$((generation + 1))
  ns_lease_write_unlocked "$ns" "$sid" "$host" "$generation" "" "$pid" "$start"
  rc=$?
  ns_lease_unlock "$ns"
  return "$rc"
}

# Take back a lease whose recovery holder is gone. A revival carries a nonce and the pid of
# the process it fenced the site for; when that process is provably dead the site has no owner,
# and the conversation the record names may resume it at the next generation with an empty
# nonce and its own process. Provable death only, under the lease lock: an unreadable pid, an
# empty one, or a process the host cannot classify all leave the lease alone.
ns_lease_reclaim_recorded() { # <ns> <sid> <host> <pid> <start>
  local ns="$1" sid="$2" host="$3" pid="$4" start="$5" generation reclaimed rc
  [ -n "$sid" ] || return 1
  ns_lease_lock "$ns" || return 1
  if ! ns_lease_valid "$ns"; then
    ns_lease_unlock "$ns"
    return 1
  fi
  if [ "$NS_LEASE_HOST" != "$host" ] || [ -z "$NS_LEASE_NONCE" ] || [ -z "$NS_LEASE_PID" ]; then
    ns_lease_unlock "$ns"
    return 1
  fi
  ns_recorded_process "$NS_LEASE_PID" "$NS_LEASE_START"
  rc=$?
  if [ "$rc" -ne 1 ]; then
    ns_lease_unlock "$ns"
    return 1
  fi
  generation="$NS_LEASE_GENERATION"
  reclaimed=$((generation + 1))
  ns_lease_write_unlocked "$ns" "$sid" "$host" "$reclaimed" "" "$pid" "$start"
  rc=$?
  ns_lease_unlock "$ns"
  [ "$rc" -eq 0 ] || return 1
  ns_shift_log "$ns" "lease reclaimed by the recorded conversation after a dead recovery attempt (generation $generation → $reclaimed)"
  return 0
}

ns_lease_allows() { # <ns> <sid> <host> <pid> <start> <nonce> <generation>
  local ns="$1" sid="$2" host="$3" pid="$4" start="$5" nonce="$6" generation="$7"
  local lease_sid lease_host lease_generation lease_nonce lease_pid lease_start rc
  ns_lease_load "$ns" || return 2
  lease_sid="$NS_LEASE_SID"
  lease_host="$NS_LEASE_HOST"
  lease_generation="$NS_LEASE_GENERATION"
  lease_nonce="$NS_LEASE_NONCE"
  lease_pid="$NS_LEASE_PID"
  lease_start="$NS_LEASE_START"
  [ "$lease_host" = "$host" ] || return 1
  if [ -n "$lease_nonce" ]; then
    if [ "$nonce" = "$lease_nonce" ] && [ "$generation" = "$lease_generation" ]; then
      return 0
    fi
    return 1
  fi
  [ "$lease_sid" = "$sid" ] || return 1
  [ -z "$nonce" ] && [ -z "$generation" ] || return 1
  [ -n "$lease_pid" ] || return 0 # Codex cannot vouch for an interactive process pid.
  if [ -n "$pid" ] && [ "$pid" = "$lease_pid" ]; then
    ns_recorded_process "$lease_pid" "$lease_start"
    return
  fi
  [ -n "$pid" ] || return 1
  ns_recorded_process "$lease_pid" "$lease_start"
  rc=$?
  [ "$rc" -eq 1 ] || return 1
  ns_lease_reclaim_interactive "$ns" "$sid" "$host" "$lease_generation" "$pid" "$start"
}

ns_lease_release() { # $1 = the .nightshift dir
  local ns="$1" rc
  ns_lease_lock "$ns" || return 1
  rm -f "$(ns_layout_path "$ns" lease)"
  rc=$?
  ns_lease_unlock "$ns"
  return "$rc"
}

ns_lease_release_retry() { # $1 = the .nightshift dir
  ns_lease_release "$1" && return 0
  sleep 0.2
  ns_lease_release "$1"
}

# One ownership protocol for every host hook. Wrappers claim the first session and emit
# the host-specific deny/pass. Unbound runs before that claim; rebind runs after it;
# authorize runs after Start's binding probe so a losing Start cannot pass as a helper.
# Uses NS, SID, TPATH, LEASE_NONCE, LEASE_GENERATION, NIGHTSHIFT_REVIVAL.
# Sets NS_SHIFT_REC and NS_SHIFT_FAIL.
# Returns 0 = continue as owner, 1 = pass through, 2 = fail closed.
# shellcheck disable=SC2034
ns_shift_unbound() { # <host> <mode:hardhat|gate> [binding-probe:0|1]
  local host="$1" mode="$2" probe="${3:-0}" bound scope=""
  : "${LEASE_NONCE:=}" "${LEASE_GENERATION:=}"
  NS_SHIFT_FAIL=""
  bound="$(ns_session_line "$NS" 1)"
  [ -z "$bound" ] || return 0
  if ns_lease_load "$NS"; then
    if [ -n "$NS_LEASE_NONCE" ]; then
      if [ "${NIGHTSHIFT_REVIVAL:-}" != "1" ] \
        || ! ns_lease_nonce_matches "$NS" "$host" "$LEASE_NONCE" "$LEASE_GENERATION"; then
        if [ "$mode" = hardhat ]; then
          NS_SHIFT_FAIL="BLOCKED: this shift is being recovered before its new conversation is bound. Reopen the recorded conversation and retry after recovery."
          return 2
        fi
        return 1
      fi
      return 0
    fi
    scope="$NS_LEASE_SID"
  fi
  # No conversation is recorded. Start's binding probe writes that record right after arming, so a
  # Stop or a tool call from any other conversation is not the shift's: a Start interrupted before
  # its probe, or a stop-work order that dropped the record, leaves the site to no one. The
  # conversation the lease still names may record itself again, and rebind judges a revival child.
  # A payload that names no conversation cannot be told apart, so rebind and authorize judge it.
  [ -n "${SID:-}" ] || return 0
  [ "$probe" = 1 ] && return 0
  [ "${NIGHTSHIFT_REVIVAL:-}" = "1" ] && return 0
  [ -n "$scope" ] && [ "$scope" = "$SID" ] && return 0
  return 1
}

# shellcheck disable=SC2034
ns_shift_rebind() { # <host> <pid> <start> <mode:hardhat|gate>
  local host="$1" pid="$2" start="$3" mode="$4"
  local rec session_pid transcript
  : "${LEASE_NONCE:=}" "${LEASE_GENERATION:=}"
  NS_SHIFT_REC=""
  NS_SHIFT_FAIL=""

  rec="$(ns_session_line "$NS" 1)"
  if [ "${NIGHTSHIFT_REVIVAL:-}" = "1" ]; then
    if ! ns_lease_nonce_matches "$NS" "$host" "$LEASE_NONCE" "$LEASE_GENERATION"; then
      if [ "$mode" = hardhat ]; then
        NS_SHIFT_FAIL="BLOCKED: this recovered worker no longer owns the shift. Reopen the recorded conversation instead of continuing an older process."
        return 2
      fi
      return 1
    fi
    if [ -n "${SID:-}" ]; then
      if [ -z "$NS_LEASE_SID" ]; then
        if ! ns_lease_rebind_session "$NS" "$SID" "$host" "$LEASE_NONCE" "$LEASE_GENERATION"; then
          if [ "$mode" = hardhat ]; then
            NS_SHIFT_FAIL="BLOCKED: the shift process lease could not bind the recovered conversation. Issue STOP from another session, then run Start again."
            return 2
          fi
          return 1
        fi
      fi
      session_pid="$(ns_session_line "$NS" 3 | tr -d '[:space:]')"
      if [ "$rec" != "$SID" ] || { [ -n "$pid" ] && [ "$session_pid" != "$pid" ]; }; then
        transcript="${TPATH:-$(ns_session_line "$NS" 2)}"
        if ! ns_session_replace "$NS" "$SID" "$transcript" "$pid" "$start" "$host"; then
          if [ "$mode" = hardhat ]; then
            NS_SHIFT_FAIL="BLOCKED: the recovered conversation could not update .shift-session. Issue STOP from another session, then run Start again."
            return 2
          fi
          return 1
        fi
      fi
      if [ -n "$pid" ]; then
        if ! ns_lease_load "$NS"; then
          if [ "$mode" = hardhat ]; then
            NS_SHIFT_FAIL="BLOCKED: the recovered process lease became unreadable. Issue STOP from another session, then run Start again."
            return 2
          fi
          return 1
        fi
        if [ "$NS_LEASE_PID" != "$pid" ]; then
          if ! ns_lease_attach_process "$NS" "$host" "$LEASE_NONCE" "$LEASE_GENERATION" "$pid" "$start"; then
            if [ "$mode" = hardhat ]; then
              NS_SHIFT_FAIL="BLOCKED: the recovered process could not refresh its shift lease. Reopen the recorded conversation."
              return 2
            fi
            return 1
          fi
        fi
      fi
      rec="$SID"
    fi
  fi

  NS_SHIFT_REC="$rec"
  return 0
}

# shellcheck disable=SC2034
ns_shift_authorize() { # <host> <pid> <start> <mode:hardhat|gate>
  local host="$1" pid="$2" start="$3" mode="$4"
  local rec session_pid lease_scope check_sid lease_rc transcript worker lease_file
  : "${LEASE_NONCE:=}" "${LEASE_GENERATION:=}"
  NS_SHIFT_FAIL=""
  rec="${NS_SHIFT_REC:-$(ns_session_line "$NS" 1)}"

  lease_scope=""
  if ns_lease_valid "$NS"; then lease_scope="$NS_LEASE_SID"; fi
  worker=""
  if [ "$host" = cursor ]; then
    worker="$(ns_cursor_worker_id "$NS")"
  fi
  if [ -n "$rec" ] && [ -n "${SID:-}" ] && [ "$SID" != "$rec" ] \
    && [ "$SID" != "$lease_scope" ] && [ "$SID" != "$worker" ] \
    && [ "${NIGHTSHIFT_REVIVAL:-}" != "1" ]; then
    return 1
  fi

  if [ -z "$rec" ]; then
    return 0
  fi

  check_sid="${SID:-$rec}"
  ns_layout_set lease_file "$NS" lease
  if [ ! -e "$lease_file" ] && [ ! -L "$lease_file" ]; then
    if ! ns_lease_claim_initial "$NS" "$rec" "$host" "$pid" "$start"; then
      if [ "$mode" = hardhat ]; then
        NS_SHIFT_FAIL="BLOCKED: the shift process lease could not be created. Issue STOP from another session, then run Start again."
      else
        NS_SHIFT_FAIL="DO NOT STOP — the shift process lease is unreadable. Issue STOP from another session, then run Start again."
      fi
      return 2
    fi
  elif [ "$host" = cursor ] && ns_lease_valid "$NS" \
    && [ "$NS_LEASE_HOST" = claude ] && [ "$NS_LEASE_SID" = "$rec" ] \
    && [ -z "$NS_LEASE_NONCE" ] && [ -z "${LEASE_NONCE:-}" ]; then
    # Cursor IDE also ran Claude marketplace hooks, which leased as host claude while
    # .shift-session already names cursor. Reclaim so the Cursor hardhat can own the shift.
    ns_lease_lock "$NS" || {
      if [ "$mode" = hardhat ]; then
        NS_SHIFT_FAIL="BLOCKED: the shift process lease could not be reclaimed for Cursor. Issue STOP from another session, then run Start again."
      else
        NS_SHIFT_FAIL="DO NOT STOP — the shift process lease could not be reclaimed for Cursor. Issue STOP from another session, then run Start again."
      fi
      return 2
    }
    if ! ns_lease_write_unlocked "$NS" "$rec" cursor 1 "" "$pid" "$start"; then
      ns_lease_unlock "$NS"
      if [ "$mode" = hardhat ]; then
        NS_SHIFT_FAIL="BLOCKED: the shift process lease could not be reclaimed for Cursor. Issue STOP from another session, then run Start again."
      else
        NS_SHIFT_FAIL="DO NOT STOP — the shift process lease could not be reclaimed for Cursor. Issue STOP from another session, then run Start again."
      fi
      return 2
    fi
    ns_lease_unlock "$NS"
  fi

  ns_lease_allows "$NS" "$check_sid" "$host" "$pid" "$start" \
    "$LEASE_NONCE" "$LEASE_GENERATION"
  lease_rc=$?
  # A recovery attempt that died holds nothing. The conversation the record names takes the
  # lease back here rather than waiting for a worker that will never report; a live holder,
  # a scope the record no longer names, and a process the host cannot classify keep the fence.
  if [ "$lease_rc" -eq 1 ] && [ -n "${SID:-}" ] && [ "$SID" = "$rec" ] \
    && [ -z "$LEASE_NONCE" ] \
    && ns_lease_reclaim_recorded "$NS" "$rec" "$host" "$pid" "$start"; then
    lease_rc=0
  fi
  if [ "$lease_rc" -eq 1 ]; then
    if [ "$mode" = hardhat ]; then
      if ns_lease_load "$NS" && [ -n "$NS_LEASE_NONCE" ] \
        && [ -n "$NS_LEASE_PID" ] && ns_recorded_process "$NS_LEASE_PID" "$NS_LEASE_START"; then
        NS_SHIFT_FAIL="BLOCKED: this shift is being recovered in another process. Wait or issue STOP from a separate session; reopening the recorded conversation stays blocked while that worker holds the lease."
      else
        NS_SHIFT_FAIL="BLOCKED: this shift continued in a recovered process. Reopen the recorded conversation before using tools here."
      fi
      return 2
    fi
    return 1
  fi
  if [ "$lease_rc" -ne 0 ]; then
    if [ "$mode" = hardhat ]; then
      NS_SHIFT_FAIL="BLOCKED: this shift continued in a recovered process. Reopen the recorded conversation before using tools here."
    else
      NS_SHIFT_FAIL="DO NOT STOP — the shift process lease is unreadable. Issue STOP from another session, then run Start again."
    fi
    return 2
  fi

  if [ -n "${SID:-}" ] && [ -n "$pid" ] && [ -z "$LEASE_NONCE" ]; then
    if ! ns_lease_load "$NS"; then
      if [ "$mode" = hardhat ]; then
        NS_SHIFT_FAIL="BLOCKED: the shift process lease became unreadable. Issue STOP from another session, then run Start again."
      else
        NS_SHIFT_FAIL="DO NOT STOP — the shift process lease became unreadable. Issue STOP from another session, then run Start again."
      fi
      return 2
    fi
    session_pid="$(ns_session_line "$NS" 3 | tr -d '[:space:]')"
    if [ "$NS_LEASE_PID" = "$pid" ] && [ "$session_pid" != "$pid" ]; then
      transcript="${TPATH:-$(ns_session_line "$NS" 2)}"
      if ! ns_session_replace "$NS" "$SID" "$transcript" "$pid" "$start" "$host"; then
        if [ "$mode" = hardhat ]; then
          NS_SHIFT_FAIL="BLOCKED: the reclaimed interactive process could not refresh .shift-session. Issue STOP from another session, then run Start again."
        else
          NS_SHIFT_FAIL="DO NOT STOP — the reclaimed process could not refresh .shift-session. Issue STOP from another session, then run Start again."
        fi
        return 2
      fi
    fi
  fi

  NS_SHIFT_REC="$rec"
  return 0
}

# Gate wrappers have no Start probe between rebind and authorize.
ns_shift_ownership() { # <host> <pid> <start> <mode:hardhat|gate>
  ns_shift_rebind "$1" "$2" "$3" "$4" || return "$?"
  ns_shift_authorize "$1" "$2" "$3" "$4"
}

# Fence a recovery child: take the lease, export the capability, attach the pid, wait.
# Remaining arguments are the command line. Returns 3 when takeover fails.
ns_watchman_run_child() { # <ns> <host> <sid> <work_target> <project_env> <project> <cmd...>
  local ns="$1" host="$2" sid="$3" work="$4" env_name="$5" project="$6"
  local lease generation nonce child start rc
  shift 6
  lease="$(ns_lease_takeover "$ns" "$sid" "$host")" || return 3
  generation="${lease%% *}"
  nonce="${lease#* }"
  (
    cd "$work" || exit 1
    env "${env_name}=${project}" \
      NIGHTSHIFT_REVIVAL=1 \
      NIGHTSHIFT_LEASE_GENERATION="$generation" \
      NIGHTSHIFT_LEASE_NONCE="$nonce" \
      "$@" >/dev/null 2>&1
  ) &
  child=$!
  start="$(ns_process_start "$child" 2>/dev/null || true)"
  ns_lease_attach_process "$ns" "$host" "$nonce" "$generation" "$child" "$start" || true
  wait "$child"
  rc=$?
  return "$rc"
}

# Exit 0 from a revival child is not proof it worked — a denied claude -p still quits clean.
# 0 = a box moved, the pulse is fresh, or the lease holder is still alive.
ns_watchman_revival_proved() { # <ns> <sentinel> <interval_min> <open_before>
  local ns="$1" interval="${3:-0}" before="${4:-}"
  local now_open ended
  ns_layout_set ended "$ns" ended
  if [ -f "$ended" ] && [ ! -L "$ended" ]; then
    return 0
  fi
  now_open="$(ns_open_boxes "$(ns_layout_path "$ns" punch-list)" 2>/dev/null)" || now_open=""
  case "$before" in
    '' | *[!0-9]*) ;;
    *)
      case "$now_open" in
        '' | *[!0-9]*) ;;
        *) [ "$now_open" -lt "$before" ] && return 0 ;;
      esac
      ;;
  esac
  if ns_pulse_fresh "$ns" "$interval"; then
    return 0
  fi
  ns_lease_pid_live "$ns"
}

# After a clock-out spawn: 0 = the shift ended, 1 = still armed (sentinel refreshed,
# recovery nonce restored to interactive when the child is proven dead). Callers stand
# down on 1 — production must not retry terminal clock-out. Extra args are ignored.
ns_watchman_clockout_pending() { # <ns> <sentinel> [ignored-max-wakes] [ignored-wake]
  local ended
  ns_layout_set ended "$1" ended
  if [ -f "$ended" ] && [ ! -L "$ended" ]; then
    ns_lease_release "$1" || true
    return 0
  fi
  : >"$2" 2>/dev/null || true
  ns_lease_restore_interactive "$1" || true
  return 1
}

# Convert a dead recovery nonce back to the recorded interactive session. Proves the
# lease-holder pid is dead (or never attached) under the lease lock. Empty sid with a
# nonce releases instead of writing an unowned interactive lease.
ns_lease_restore_interactive() { # <ns>
  local ns="$1" sid host generation rc
  ns_lease_lock "$ns" || return 1
  if ! ns_lease_valid "$ns"; then
    ns_lease_unlock "$ns"
    return 1
  fi
  if [ -z "$NS_LEASE_NONCE" ]; then
    ns_lease_unlock "$ns"
    return 0
  fi
  if [ -n "$NS_LEASE_PID" ]; then
    ns_recorded_process "$NS_LEASE_PID" "$NS_LEASE_START"
    rc=$?
    if [ "$rc" -ne 1 ]; then
      ns_lease_unlock "$ns"
      return 1
    fi
  fi
  sid="$NS_LEASE_SID"
  host="$NS_LEASE_HOST"
  generation=$((NS_LEASE_GENERATION + 1))
  if [ -z "$sid" ]; then
    rm -f "$(ns_layout_path "$ns" lease)"
    rc=$?
    ns_lease_unlock "$ns"
    return "$rc"
  fi
  # Empty pid: the recorded session id may reclaim. Copying a still-live
  # recorded pid would fence that conversation's next tool process.
  ns_lease_write_unlocked "$ns" "$sid" "$host" "$generation" "" "" ""
  rc=$?
  ns_lease_unlock "$ns"
  return "$rc"
}

ns_lease_reset_stale() { # $1 = .nightshift; caller has proved no process or watchman owns it
  local ns="$1" rc lease
  ns_layout_set lease "$ns" lease
  rm -rf "$(ns_layout_path "$ns" lease-lock)" 2>/dev/null
  ns_lease_lock "$ns" || return 1
  rm -f "$lease" "$lease".tmp.*
  rc=$?
  ns_lease_unlock "$ns"
  return "$rc"
}

# Every tool that speaks Claude Code's plugin interface executes these same hooks, so a hook
# cannot assume Claude is the host it runs in. The transcript path names the writer: Cursor
# keeps its conversations under ~/.cursor, and a record claimed from one belongs to Cursor —
# Claude's watchman must stand down from it rather than fire `claude --resume` at a
# conversation it can never reach. Paths without a known foreign marker stay claude.
ns_claude_session_host() { # <transcript-path>
  case "$1" in
    */.cursor/*) printf 'cursor' ;;
    *) printf 'claude' ;;
  esac
}

# Cursor IDE loads Claude marketplace plugins and runs their hooks in the same Agent tab.
# Those Claude-entry hooks must not claim or fence a Cursor-owned shift. True when the
# transcript lives under ~/.cursor, or .shift-session already records host cursor.
ns_claude_foreign_cursor_surface() { # <ns-dir> <transcript-path>
  case "$2" in
    */.cursor/*) return 0 ;;
  esac
  [ "$(ns_session_host "$1")" = "cursor" ]
}

# True when .shift-session is a real file, not a planted symlink.
ns_session_present() { # <ns-dir>
  local rec
  ns_layout_set rec "$1" session
  [ -f "$rec" ] && [ ! -L "$rec" ]
}

# Prints one line of a real .shift-session file. Empty when the path is missing or a symlink.
ns_session_line() { # <ns-dir> <line>
  ns_session_present "$1" || return 0
  sed -n "$2p" "$(ns_layout_path "$1" session)" 2>/dev/null
}

# Which host owns this shift. Absent means a record written before hosts were distinguished,
# and every such record is Claude's — nothing else could have written one.
ns_session_host() {
  local h
  h="$(ns_session_line "$1" 5 | tr -d '[:space:]')"
  printf '%s' "${h:-claude}"
}

# Codex `exec resume` accepts a session/thread id, not a rollout path or ChatGPT scratch handle.
# Fail closed on anything that is not a known resumable shape so recovery never claims it
# resumed a thread it did not.
# Prints: missing | resumable | malformed | unsupported
# Return 0 only for resumable.
ns_codex_identity_kind() {
  local id="$1"
  if [ -z "$id" ]; then
    printf 'missing'
    return 1
  fi
  if printf '%s' "$id" | grep -qE '[[:space:]/\\\$`;|&<>*]'; then
    printf 'malformed'
    return 1
  fi
  case "$id" in
    thread_*|conv_*|chatgpt-*|rollout-*|task_*|scratch_*|local|unknown)
      printf 'unsupported'
      return 1
      ;;
  esac
  if printf '%s' "$id" | grep -Eq '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; then
    printf 'resumable'
    return 0
  fi
  if printf '%s' "$id" | grep -Eq '^[0-9a-fA-F]{32,}$'; then
    printf 'resumable'
    return 0
  fi
  printf 'unsupported'
  return 1
}

# ns_codex_turn_error <rollout> — how the thread's last turn ended, when it ended on an error.
#
# Codex records a failed model request on the turn's closing event: `task_complete` carries an
# `error` object with a `codex_error_info` kind (`usage_limit_exceeded`, `other`, …) and the
# session stays open and quiet. Prints that kind, `error` when the object names none, and nothing
# when the last turn boundary is a start or a clean completion, or the rollout cannot be read. The
# tail is bounded, since one rollout event can be large.
ns_codex_turn_error() {
  local rollout="$1" last kind
  [ -n "$rollout" ] && [ -f "$rollout" ] && [ ! -L "$rollout" ] || return 0
  last="$(tail -c 1048576 "$rollout" 2>/dev/null |
    grep -E '"payload":\{"type":"task_(started|complete)"' | tail -n 1)"
  case "$last" in *'"payload":{"type":"task_complete"'*) ;; *) return 0 ;; esac
  printf '%s' "$last" | grep -qE '[^\\]"error":\{' || return 0
  kind="$(printf '%s' "$last" | grep -oE '"codex_error_info":"[a-z_]+"' | tail -n 1 | cut -d'"' -f4)"
  printf '%s' "${kind:-error}"
}

# ns_codex_turn_error_at <rollout> — the epoch that errored turn completed, or nothing.
ns_codex_turn_error_at() {
  [ -n "$(ns_codex_turn_error "$1")" ] || return 0
  tail -c 1048576 "$1" 2>/dev/null | grep -E '"payload":\{"type":"task_complete"' | tail -n 1 |
    grep -oE '"completed_at":[0-9]+' | cut -d: -f2
}

# ns_codex_limit_reset <rollout> — the epoch Codex last reported the usage window resets, or
# nothing when the rollout names none.
ns_codex_limit_reset() {
  local rollout="$1"
  [ -n "$rollout" ] && [ -f "$rollout" ] && [ ! -L "$rollout" ] || return 0
  tail -c 1048576 "$rollout" 2>/dev/null | grep -oE '"resets_at":[0-9]+' | tail -n 1 | cut -d: -f2
}

# Cursor CLI worker — the resumable id in ~/.cursor/chats. The origin IDE conversation
# stays on .shift-session; this file is the id agent --resume may legally receive.
ns_cursor_worker_present() { # <ns>
  local rec
  ns_layout_set rec "$1" worker
  [ -f "$rec" ] && [ ! -L "$rec" ]
}

ns_cursor_worker_id() { # <ns>
  ns_cursor_worker_present "$1" || return 0
  sed -n 1p "$(ns_layout_path "$1" worker)" 2>/dev/null
}

ns_cursor_worker_write() { # <ns> <cli_id>
  local ns="$1" id="$2" rec tmp
  [ -d "$ns" ] && [ -n "$id" ] || return 1
  ns_lease_safe_line "$id" || return 1
  case "$id" in
    *[[:space:]/\\\$\`\;\|\&\<\>\*]*) return 1 ;;
  esac
  ns_layout_set rec "$ns" worker
  mkdir -p "${rec%/*}" 2>/dev/null || :
  tmp="$rec.tmp.$$.$RANDOM"
  (umask 077; printf '%s\n' "$id" >"$tmp") || { rm -f "$tmp"; return 1; }
  [ -L "$rec" ] && rm -f "$rec"
  mv -f "$tmp" "$rec"
}

ns_cursor_store_kind() { # <transcript-path>
  case "$1" in
    */agent-transcripts/*) printf 'ide' ;;
    */.cursor/chats/* | */.cursor/chats) printf 'cli' ;;
    *) printf 'unknown' ;;
  esac
}

# Origin IDE tab while a different CLI worker holds the shift.
ns_cursor_stale_origin() { # <ns> <sid>
  local rec worker
  rec="$(ns_session_line "$1" 1)"
  worker="$(ns_cursor_worker_id "$1")"
  [ -n "$rec" ] && [ -n "$worker" ] && [ -n "${2:-}" ] \
    && [ "$2" = "$rec" ] && [ "$2" != "$worker" ]
}

ns_cursor_resume_command() { # <ns> <workspace>
  local worker workspace
  worker="$(ns_cursor_worker_id "$1")"
  workspace="$2"
  [ -n "$worker" ] && [ -n "$workspace" ] || return 1
  printf 'agent --resume="%s" --workspace "%s"' "$worker" "$workspace"
}

ns_cursor_pointer_message() { # <ns> <workspace>
  local cmd
  cmd="$(ns_cursor_resume_command "$1" "$2")" || return 1
  printf 'BLOCKED: this shift session died and continued in a recovered CLI session. To see it, run this in a terminal: %s. To stop it from this chat, ask Nightshift to stop.' "$cmd"
}

# Owner asking this tab to end the shift — not ordinary work on the dead thread.
ns_cursor_stop_request() { # <prompt>
  printf '%s' "${1:-}" | grep -qiE \
    'ask[[:space:]]+nightshift[[:space:]]+to[[:space:]]+stop|[[:space:]/]nightshift[[:space:]]+stop|/nightshift:stop|stop[[:space:]]+the[[:space:]]+shift'
}


# .shift-pulse — overwrite-only liveness: "epoch session-id" (unix seconds).
# Fresh: epoch within 2 * watchMinutes. Stale: older than that, or never written
# and at least two wake intervals have passed since arm (or the supplied clock).
ns_pulse_epoch() { # <ns>
  local f line epoch
  ns_layout_set f "$1" pulse
  [ -f "$f" ] && [ ! -L "$f" ] || return 0
  IFS= read -r line <"$f" || true
  epoch="${line%% *}"
  case "$epoch" in '' | *[!0-9]*) return 0 ;; esac
  printf '%s' "$epoch"
}

ns_pulse_fresh() { # <ns> <interval_min>
  local epoch now window interval="${2:-0}"
  epoch="$(ns_pulse_epoch "$1")"
  [ -n "$epoch" ] || return 1
  case "$interval" in '' | *[!0-9]*) interval=0 ;; esac
  window=$((interval * 120))
  now="$(date +%s)"
  [ $((now - epoch)) -lt "$window" ]
}

ns_pulse_stale() { # <ns> <interval_min> [clock_epoch]
  local ns="$1" interval="${2:-0}" clock="${3:-}" now epoch window armed
  case "$interval" in '' | *[!0-9]*) interval=0 ;; esac
  window=$((interval * 120))
  now="$(date +%s)"
  epoch="$(ns_pulse_epoch "$ns")"
  if [ -n "$epoch" ]; then
    [ $((now - epoch)) -ge "$window" ]
    return
  fi
  ns_layout_set armed "$ns" armed
  if [ -f "$armed" ] && [ ! -L "$armed" ]; then
    clock="$(ns_mtime "$armed")"
  fi
  case "$clock" in '' | *[!0-9]*) clock="$now" ;; esac
  [ $((now - clock)) -ge "$window" ]
}

# Live lease holder pid. Empty pid is not live and does not decide death.
ns_lease_pid_live() { # <ns>
  ns_lease_valid "$1" || return 1
  [ -n "$NS_LEASE_PID" ] || return 1
  ns_recorded_process "$NS_LEASE_PID" "$NS_LEASE_START"
}

ns_fence_print() { # <action> <duplicate> <fenced> <active> <takeover>
  local action="$1" duplicate="$2" fenced="$3" active="$4" takeover="$5"
  local json_true=true json_false=false
  printf '{\n'
  printf '  "action": "%s",\n' "$action"
  printf '  "duplicateWorkerRejected": %s,\n' "$([ "$duplicate" -eq 1 ] && printf '%s' "$json_true" || printf '%s' "$json_false")"
  printf '  "kind": "handoff-fence",\n'
  printf '  "priorOwnerFenced": %s,\n' "$([ "$fenced" -eq 1 ] && printf '%s' "$json_true" || printf '%s' "$json_false")"
  printf '  "priorWorkerActive": %s,\n' "$([ "$active" -eq 1 ] && printf '%s' "$json_true" || printf '%s' "$json_false")"
  printf '  "schemaVersion": 1,\n'
  printf '  "takeoverAllowed": %s,\n' "$([ "$takeover" -eq 1 ] && printf '%s' "$json_true" || printf '%s' "$json_false")"
  printf '  "twoActiveWorkersAllowed": false\n'
  printf '}\n'
}

# Worker fence from on-disk lease / session / pid. Model-authored flags are not consulted.
# Prints a handoff-fence object. 0 proceed · 1 refuse · 2 unavailable
ns_fence_check() { # <ns>
  local ns="$1"
  local prior_fenced=0 prior_active=0 duplicate=0 takeover=0 action=refuse
  local session_exists=0 session_pid="" session_start="" session_host="" session_sid=""
  local lease_rc sess_rc rec

  if [ -z "$ns" ] || [ ! -d "$ns" ] || [ -L "$ns" ]; then
    ns_fence_print refuse 0 0 0 0
    return 2
  fi

  if ! ns_lease_load "$ns"; then
    ns_fence_print refuse 0 0 0 0
    return 2
  fi

  ns_layout_set rec "$ns" session
  if [ -e "$rec" ] || [ -L "$rec" ]; then
    if ! ns_session_present "$ns"; then
      ns_fence_print refuse 0 0 0 0
      return 2
    fi
    session_exists=1
    session_sid="$(ns_session_line "$ns" 1)"
    session_pid="$(ns_session_line "$ns" 3 | tr -d '[:space:]')"
    session_start="$(ns_session_line "$ns" 4)"
    session_host="$(ns_session_line "$ns" 5 | tr -d '[:space:]')"
    [ -n "$session_host" ] || session_host=claude
    case "$session_host" in
      claude | codex | cursor) ;;
      *)
        ns_fence_print refuse 0 0 0 0
        return 2
        ;;
    esac
    [ -n "$session_sid" ] || {
      ns_fence_print refuse 0 0 0 0
      return 2
    }
  fi

  if [ -z "$NS_LEASE_PID" ]; then
    prior_fenced=1
  else
    ns_recorded_process "$NS_LEASE_PID" "$NS_LEASE_START"
    lease_rc=$?
    case "$lease_rc" in
      0) prior_active=1 ;;
      1) prior_fenced=1 ;;
      *)
        ns_fence_print refuse 0 0 0 0
        return 2
        ;;
    esac
  fi

  if [ "$session_exists" -eq 1 ] && [ -n "$session_pid" ]; then
    ns_recorded_process "$session_pid" "$session_start"
    sess_rc=$?
    case "$sess_rc" in
      0)
        if [ -n "$NS_LEASE_PID" ] && [ "$session_pid" != "$NS_LEASE_PID" ]; then
          duplicate=1
        fi
        prior_active=1
        prior_fenced=0
        ;;
      1) ;;
      *)
        ns_fence_print refuse 0 0 0 0
        return 2
        ;;
    esac
  fi

  if [ "$prior_active" -eq 0 ] && [ "$duplicate" -eq 0 ] && [ "$prior_fenced" -eq 1 ]; then
    takeover=1
    action=proceed
    ns_fence_print "$action" "$duplicate" "$prior_fenced" "$prior_active" "$takeover"
    return 0
  fi
  ns_fence_print refuse "$duplicate" "$prior_fenced" "$prior_active" 0
  return 1
}

SHA-256: 9036c6cbe28e2ccd74ac662159d95ff092500d5c8b08fc8bd0d5ce2f0654fd8f