#!/usr/bin/env bash
# Agent session ledger: durability, liveness classification, uncommitted-work detection,
# notification dedupe, and survival of terminal death.
#
# Every process this test signals is one it started itself. It never touches a real agent
# session, never runs a resource-exhaustion workload, and never writes to a repo it did
# not create.
set -uo pipefail

REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)
MODULE="$REPO_ROOT/modules/workstation/claude"
PASS=0
FAIL=0

ok() {
  PASS=$((PASS + 1))
  printf 'ok   %s\n' "$1"
}
no() {
  FAIL=$((FAIL + 1))
  printf 'FAIL %s\n' "$1"
}
check() { if [[ $2 == "$3" ]]; then ok "$1"; else no "$1 (want '$3', got '$2')"; fi; }

if [[ -n "${TMPJAIL_ACTIVE:-}" ]]; then
  printf 'refuse: inside the /tmp jail the user manager'"'"'s tmux panes are unreadable.\n' >&2
  printf 'run it outside:\n  systemd-run --user --pipe --wait --collect --same-dir --setenv=TERM=%s -- bash %s\n' \
    "${TERM:-xterm-256color}" "$0" >&2
  exit 1
fi

# tmux refuses to attach a client whose terminfo entry lacks `clear`, so a shell with no TERM
# (a CI job, a systemd unit) would report the tmux host as a regression.
if ! tput -T "${TERM:-dumb}" clear >/dev/null 2>&1; then
  for _t in xterm-256color xterm vt100; do
    if tput -T "$_t" clear >/dev/null 2>&1; then
      export TERM="$_t"
      break
    fi
  done
fi

TESTROOT=$(mktemp -d -t agent-ledger-test.XXXXXX)
STARTED_PIDS=()
cleanup() {
  local pid
  for pid in "${STARTED_PIDS[@]:-}"; do
    [[ -n $pid ]] && kill -9 "$pid" 2>/dev/null
  done
  local socket
  for socket in "$AGENT_SESSIONS_DIR"/sock/*; do
    [[ -S "$socket" ]] && tmux -S "$socket" kill-server 2>/dev/null
  done
  [[ -S $TESTROOT/tmux.sock ]] && tmux -S "$TESTROOT/tmux.sock" kill-server 2>/dev/null
  rm -rf "$TESTROOT"
}
trap cleanup EXIT

export AGENT_SESSIONS_DIR="$TESTROOT/ledger"
# Most cases exercise the ledger rather than its tmux transport. Keep them independent of
# the host's user manager; the dedicated integration case below opts hosting back in.
export AGENT_LEDGER_MUX=0
FAKE_HOME="$TESTROOT/home"
mkdir -p "$FAKE_HOME/.claude"
ln -s "$MODULE/lib" "$FAKE_HOME/.claude/lib"

# A launch chain that mirrors the real one minus the cgroup confinement, which is covered
# by its own test below against the real _agent-build-scope.
SHIMBIN="$TESTROOT/shimbin"
REALBIN="$TESTROOT/realbin"
mkdir -p "$SHIMBIN" "$REALBIN" "$TESTROOT/lib"
# The reader resolves the shim set through <lib>/../bin; mirror the installed layout.
ln -s "$SHIMBIN" "$TESTROOT/bin"
cp "$MODULE/bin/_tmpjail-shim.sh" "$SHIMBIN/_tmpjail-shim.sh"
cp "$MODULE/bin/_agent-session-tmux" "$SHIMBIN/_agent-session-tmux"
python3 - "$SHIMBIN/_agent-session-tmux" "$SHIMBIN/seat-authority" <<'PY'
import sys
path, authority = sys.argv[1:]
text = open(path, encoding="utf-8").read()
text = text.replace("/usr/local/bin/overdeck-seat-scope-entry", authority)
open(path, "w", encoding="utf-8").write(text)
PY
cp "$MODULE/lib/shim-guard.sh" "$TESTROOT/lib/shim-guard.sh"
cp "$MODULE/lib/human-session.tmux.conf" "$TESTROOT/lib/human-session.tmux.conf"
cp "$MODULE/lib/agent-session-reader.mjs" "$TESTROOT/lib/agent-session-reader.mjs"
ln -s "$SHIMBIN/_tmpjail-shim.sh" "$SHIMBIN/stubagent"
printf '#!/bin/sh\nshift\nexec "$@"\n' >"$SHIMBIN/_agent-build-scope"
# Session admission is covered by agent-session-cap.test.sh; here it only has to keep the
# launch chain's argv shape intact.
printf '#!/bin/sh\nexec "$(dirname "$0")/_agent-build-scope" "$@"\n' >"$SHIMBIN/_agent-session-admission"
chmod +x "$SHIMBIN/_agent-session-admission"
# A copy of sleep, not a shell script: a script runs under argv[0] "/bin/sh", so the stub would
# never carry its own name the way a real runtime binary does.
cp /bin/sleep "$REALBIN/stubagent"
chmod +x "$SHIMBIN/_agent-build-scope" "$SHIMBIN/_agent-session-tmux" "$REALBIN/stubagent"
cat >"$SHIMBIN/seat-authority" <<'SH'
#!/usr/bin/env bash
set -uo pipefail
attach=0
socket=""
reap_identity=""
while (( $# )); do
  case "$1" in
    --local-session) shift ;;
    --seat-id) shift 2 ;;
    --socket) socket="$2"; shift 2 ;;
    --cwd) cwd="$2"; shift 2 ;;
    --reap-identity) reap_identity="$2"; shift 2 ;;
    --attach) attach=1; shift ;;
    --) shift; break ;;
    *) exit 64 ;;
  esac
done
[[ -n "$socket" && -n "${cwd:-}" && -n "$reap_identity" && $# -gt 0 ]] || exit 64
mkdir -p "$(dirname "$socket")"
tmux -S "$socket" -f /dev/null new-session -d -s main -c "$cwd" -- "$@" \; \
  set-option -g remain-on-exit on \; set-option -g exit-empty off \; \
  set-option -t main @agent_reap_identity "$reap_identity" || exit 1
if (( attach )); then tmux -S "$socket" attach-session -t main; fi
while :; do
  pane=$(tmux -S "$socket" list-panes -t main -F '#{pane_dead} #{pane_dead_status}' 2>/dev/null) || exit 1
  [[ "$pane" == "1 "* ]] && exit "${pane#1 }"
  sleep 0.05
done
SH
cat >"$SHIMBIN/sudo" <<'SH'
#!/usr/bin/env bash
[[ "$1" == -n ]] || exit 64
shift
exec "$@"
SH
chmod +x "$SHIMBIN/seat-authority" "$SHIMBIN/sudo"

reader() { AGENT_SESSION_LEDGER_HOOK_TEST=1 node --input-type=module -e "$1"; }

# --- 1. entry is on disk before the process can die, and survives SIGKILL ---------------
HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" env -u TMPJAIL_ACTIVE -u DTACH setsid "$SHIMBIN/stubagent" 300 </dev/null >/dev/null 2>&1 &
launch_pid=$!
STARTED_PIDS+=("$launch_pid")
for _ in $(seq 1 50); do
  [[ -n $(ls "$AGENT_SESSIONS_DIR/sessions" 2>/dev/null) ]] && break
  sleep 0.1
done
entry_file=$(ls "$AGENT_SESSIONS_DIR/sessions"/*.json 2>/dev/null | head -1)
[[ -n $entry_file ]] && ok "shim writes a ledger entry at launch" || no "shim writes a ledger entry at launch"
ledger_id=$(basename "${entry_file:-none.json}" .json)

started=$(sed -n 's/.*"startedAt": "\([^"]*\)".*/\1/p' "$entry_file")
skew=$(( $(date +%s) - $(date -d "$started" +%s 2>/dev/null || echo 0) ))
if (( skew > -120 && skew < 120 )); then
  ok "startedAt is UTC, not the local clock labelled Z"
else
  no "startedAt is UTC, not the local clock labelled Z (got '$started', ${skew}s off)"
fi

# The agent process is a grandchild; find it by the exported id, exactly as the reader does.
agent_pid=""
for _ in $(seq 1 50); do
  for p in $(pgrep -u "$USER" stubagent 2>/dev/null); do
    if tr '\0' '\n' <"/proc/$p/environ" 2>/dev/null | grep -qx "AGENT_LEDGER_ID=$ledger_id"; then
      agent_pid=$p
      break
    fi
  done
  [[ -n $agent_pid ]] && break
  sleep 0.1
done
[[ -n $agent_pid ]] && ok "AGENT_LEDGER_ID reaches the launched process" ||
  no "AGENT_LEDGER_ID reaches the launched process"
STARTED_PIDS+=("$agent_pid")

# --- 2. a live session is not misclassified as orphaned --------------------------------
state=$(reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 100, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$ledger_id')?.state ?? 'MISSING');
")
if [[ $state == ALIVE-* ]]; then ok "live session classified $state"; else no "live session classified $state"; fi

kill -9 "$agent_pid" 2>/dev/null
kill -9 "$launch_pid" 2>/dev/null
wait "$launch_pid" 2>/dev/null || :
sleep 0.3

[[ -s $entry_file ]] && ok "entry survives SIGKILL of the session process" ||
  no "entry survives SIGKILL of the session process"

# --- 3. a killed session is finished from kernel evidence, not its stale JSON flag ------
state=$(reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 50, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$ledger_id')?.state ?? 'MISSING');
")
check "killed session classified FINISHED from evidence" "$state" "FINISHED"

# --- 4. a cleanly finished session is FINISHED, not ORPHANED ---------------------------
state=$(reader "
import { classify, readEntries, updateEntry } from '$MODULE/lib/agent-session-reader.mjs';
updateEntry('$ledger_id', { finishedAt: new Date().toISOString(), finishReason: 'test' });
const s = await classify(readEntries(), { sampleMs: 50, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$ledger_id')?.state ?? 'MISSING');
")
check "cleanly finished session classified FINISHED" "$state" "FINISHED"

# --- 5. uncommitted-work detection ------------------------------------------------------
WORKREPO="$TESTROOT/workrepo"
mkdir -p "$WORKREPO"
git -C "$WORKREPO" init -q -b main
git -C "$WORKREPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base
dirty=$(reader "
import { classify } from '$MODULE/lib/agent-session-reader.mjs';
const base = { schemaVersion: 1, ledgerId: 'x', runtime: 'claude', startedAt: new Date().toISOString(), cwd: '$WORKREPO', mux: { kind: null, socket: null, target: null } };
const clean = await classify([base], { sampleMs: 10 });
process.stdout.write(String(clean[0].dirtyCount));
")
check "clean worktree reports 0 uncommitted files" "$dirty" "0"

printf 'scratch\n' >"$WORKREPO/untracked.txt"
printf 'more\n' >"$WORKREPO/second.txt"
dirty=$(reader "
import { classify } from '$MODULE/lib/agent-session-reader.mjs';
const base = { schemaVersion: 1, ledgerId: 'x', runtime: 'claude', startedAt: new Date().toISOString(), cwd: '$WORKREPO', mux: { kind: null, socket: null, target: null } };
const s = await classify([base], { sampleMs: 10 });
process.stdout.write(String(s[0].dirtyCount));
")
check "dirty worktree reports 2 uncommitted files" "$dirty" "2"

# --- 6. notification dedupe -------------------------------------------------------------
dedupe=$(reader "
import { selectUnnotified } from '$MODULE/hooks/agent-session-ledger.mjs';
const s = [{ ledgerId: 'a', state: 'ORPHANED', dirtyCount: 3, runtime: 'claude', cwd: '/tmp', idleMs: 60000 }];
const store = {};
const first = selectUnnotified(s, store, '/tmp').length;
const second = selectUnnotified(s, store, '/tmp').length;
const otherFolder = selectUnnotified(s, {}, '/somewhere-else').length;
const noCwd = selectUnnotified(s, {}, null).length;
process.stdout.write(first + ',' + second + ',' + otherFolder + ',' + noCwd);
")
check "orphan announced once, same folder only" "$dedupe" "1,0,0,0"

quiet=$(reader "
import { selectUnnotified } from '$MODULE/hooks/agent-session-ledger.mjs';
const s = [{ ledgerId: 'b', state: 'ORPHANED', dirtyCount: 0, cwd: '/tmp' }, { ledgerId: 'c', state: 'ALIVE-IDLE', dirtyCount: 9, cwd: '/tmp' }];
process.stdout.write(String(selectUnnotified(s, {}, '/tmp').length));
")
check "no notification without abandoned uncommitted work" "$quiet" "0"

# --- 7. terminal death detaches instead of killing the tmux-hosted session ---------------
if command -v tmux >/dev/null 2>&1 && command -v script >/dev/null 2>&1 &&
   tput -T "${TERM:-dumb}" clear >/dev/null 2>&1 &&
   systemctl --user show-environment >/dev/null 2>&1; then
  rm -f "$AGENT_SESSIONS_DIR"/sessions/*.json
  HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" AGENT_LEDGER_MUX=1 \
    env -u TMPJAIL_ACTIVE -u TMUX setsid script -qfec "$SHIMBIN/stubagent 300" /dev/null \
    >"$TESTROOT/interactive.log" 2>&1 &
  term_pid=$!
  STARTED_PIDS+=("$term_pid")
  wrapped_id=""
  for _ in $(seq 1 60); do
    f=$(ls "$AGENT_SESSIONS_DIR/sessions"/*.json 2>/dev/null | head -1)
    if [[ -n $f ]] && grep -q '"kind": "tmux"' "$f"; then
      wrapped_id=$(basename "$f" .json)
      break
    fi
    sleep 0.1
  done
  if [[ -n $wrapped_id ]]; then
    ok "interactive launch is hosted in tmux"
    socket=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["tmuxSocket"])' \
      "$AGENT_SESSIONS_DIR/sessions/$wrapped_id.json")
    reap_id=""
    for _ in $(seq 1 60); do
      reap_id="$(tmux -S "$socket" show-options -v -t main @agent_reap_identity 2>/dev/null || :)"
      [[ "$reap_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] && break
      sleep 0.1
    done
    if [[ "$reap_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]]; then
      ok "tmux-hosted session has a launch-time reap identity"
    else
      no "tmux-hosted session has a launch-time reap identity (value='$reap_id'; $(tr '\n' ' ' <"$TESTROOT/interactive.log"))"
    fi
    ledger_reap_id=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("tmuxReapIdentity", ""))' \
      "$AGENT_SESSIONS_DIR/sessions/$wrapped_id.json")
    check "ledger binds the launch-time reap identity" "$ledger_reap_id" "$reap_id"
    wrapped_agent=""
    for _ in $(seq 1 60); do
      for p in $(pgrep -u "$USER" stubagent 2>/dev/null); do
        if tr '\0' '\n' <"/proc/$p/environ" 2>/dev/null | grep -qx "AGENT_LEDGER_ID=$wrapped_id"; then
          wrapped_agent=$p
          break
        fi
      done
      [[ -n $wrapped_agent ]] && break
      sleep 0.1
    done
    STARTED_PIDS+=("$wrapped_agent")

    # Kill the terminal, not the session -- this is the crash the user keeps hitting.
    kill -9 "$term_pid" 2>/dev/null
    wait "$term_pid" 2>/dev/null || :
    sleep 0.5
    if [[ -n $wrapped_agent ]] && kill -0 "$wrapped_agent" 2>/dev/null; then
      ok "session survives the death of its terminal"
    else
      no "session survives the death of its terminal"
    fi
    [[ -S $socket ]] && ok "tmux socket still resolves after terminal death" ||
      no "session socket still resolves after terminal death"

    state=$(reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 100, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$wrapped_id')?.state ?? 'MISSING');
")
    check "orphaned-terminal session classified DETACHED-ALIVE" "$state" "DETACHED-ALIVE"

    reopen=$(HOME="$FAKE_HOME" node "$MODULE/bin/agent-sessions" 2>/dev/null |
      grep -F "agent-sessions attach $wrapped_id" | head -1)
    [[ -n $reopen ]] && ok "recovery command prints a reopen command for it" ||
      no "recovery command prints a reopen command for it"
    [[ -n $wrapped_agent ]] && kill -9 "$wrapped_agent" 2>/dev/null
  else
    no "interactive launch is hosted in tmux"
  fi
elif ! tput -T "${TERM:-dumb}" clear >/dev/null 2>&1; then
  printf 'skip no terminfo entry tmux can drive on this host\n'
else
  printf 'skip tmux/script/user systemd not available\n'
fi

# --- 8. cgroup confinement survives the wrap -------------------------------------------
if command -v tmux >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1 &&
   [[ -x "$HOME/.claude/bin/_agent-build-scope" ]]; then
  cat >"$SHIMBIN/_agent-session-admission" <<'SH'
#!/usr/bin/env bash
exec "$HOME/.claude/bin/_agent-build-scope" "$@"
SH
  chmod +x "$SHIMBIN/_agent-session-admission"
  cg_id="cgroup-$$"
  cg_socket_key="$(printf '%s' "$cg_id" | sha256sum | cut -c1-32)"
  cg_sock="$AGENT_SESSIONS_DIR/sock/$cg_socket_key/tmux.sock"
  mkdir -p "$TESTROOT/cgroup-slots" "$AGENT_SESSIONS_DIR/sessions"
  printf '{"schemaVersion":1,"ledgerId":"%s"}\n' "$cg_id" \
    >"$AGENT_SESSIONS_DIR/sessions/$cg_id.json"
  AGENT_LEDGER_ID="$cg_id" AGENT_LEDGER_MUX_SOCKET="$cg_sock" AGENT_LEDGER_MUX_TARGET=main \
    AGENT_LEDGER_MUX_JAILED=1 AGENT_SESSION_SLOT_DIR="$TESTROOT/cgroup-slots" \
    PATH="$SHIMBIN:$PATH" setsid script -qfec "$SHIMBIN/_agent-session-tmux /bin/sleep 60" /dev/null \
    >"$TESTROOT/cgroup.log" 2>&1 &
  cg_client=$!
  STARTED_PIDS+=("$cg_client")
  cg_pid=""
  for _ in $(seq 1 200); do
    cg_pid=$(pgrep -u "$USER" -f '^/bin/sleep 60$' | head -1)
    [[ -n $cg_pid ]] && break
    sleep 0.1
  done
  if [[ -n $cg_pid ]]; then
    STARTED_PIDS+=("$cg_pid")
    cg=$(cat "/proc/$cg_pid/cgroup" 2>/dev/null)
    if [[ $cg == *"agent.slice"* ]]; then
      ok "tmux-hosted session still lands in agent.slice ($(basename "$cg"))"
    else
      no "tmux-hosted session still lands in agent.slice (got '$cg')"
    fi
    kill -9 "$cg_pid" 2>/dev/null
  else
    no "tmux-hosted session still lands in agent.slice (process never appeared: $(tr '\n' ' ' <"$TESTROOT/cgroup.log"))"
  fi
else
  printf 'skip tmux, user systemd or _agent-build-scope not available\n'
fi
printf '#!/bin/sh\nexec "$(dirname "$0")/_agent-build-scope" "$@"\n' >"$SHIMBIN/_agent-session-admission"
chmod +x "$SHIMBIN/_agent-session-admission"

# --- 9. a broken ledger never blocks a launch ------------------------------------------
# The shim is the entry point for every agent on the machine: a ledger failure must cost
# the entry, never the session.
printf '#!/bin/sh\necho LAUNCHED\n' >"$REALBIN/probeagent"
chmod +x "$REALBIN/probeagent"
ln -s "$SHIMBIN/_tmpjail-shim.sh" "$SHIMBIN/probeagent"

out=$(HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" AGENT_SESSIONS_DIR=/proc/nonexistent/ledger \
  env -u TMPJAIL_ACTIVE -u DTACH "$SHIMBIN/probeagent" </dev/null 2>/dev/null)
check "launch survives an unwritable ledger directory" "$out" "LAUNCHED"

BROKEN_HOME="$TESTROOT/brokenhome"
mkdir -p "$BROKEN_HOME/.claude/lib"
printf 'if [[ \n' >"$BROKEN_HOME/.claude/lib/agent-session-ledger.sh"
out=$(HOME="$BROKEN_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" \
  env -u TMPJAIL_ACTIVE -u DTACH "$SHIMBIN/probeagent" </dev/null 2>/dev/null)
check "launch survives a corrupt ledger library" "$out" "LAUNCHED"

# --- 10. a nested launch records the session that spawned it ---------------------------
NESTED_DIR="$TESTROOT/nested"
HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" AGENT_SESSIONS_DIR="$NESTED_DIR" \
  AGENT_LEDGER_ID=parent-test-id env -u TMPJAIL_ACTIVE -u DTACH \
  "$SHIMBIN/probeagent" </dev/null >/dev/null 2>&1
nested_entry=$(ls "$NESTED_DIR/sessions"/*.json 2>/dev/null | head -1)
parent=$(reader "
import { readFileSync } from 'node:fs';
const e = JSON.parse(readFileSync('${nested_entry:-/dev/null}','utf8'));
process.stdout.write([e.parentLedgerId, e.parent, e.launchedBy].join(' '));
" 2>/dev/null)
check "nested launch records its parent session before the tmux handoff" \
  "$parent" "parent-test-id parent-test-id agent"

# --- 11. a control byte in a ref name still yields a parseable entry -------------------
CTRL_DIR="$TESTROOT/ctrlrepo"
CTRL_LEDGER="$TESTROOT/ctrl"
mkdir -p "$CTRL_DIR/.git"
printf 'ref: refs/heads/a\001b\n' >"$CTRL_DIR/.git/HEAD"
(
  cd "$CTRL_DIR" &&
    HOME="$FAKE_HOME" PATH="$SHIMBIN:$REALBIN:$PATH" AGENT_SESSIONS_DIR="$CTRL_LEDGER" \
      env -u TMPJAIL_ACTIVE -u DTACH "$SHIMBIN/probeagent" </dev/null >/dev/null 2>&1
)
ctrl_entry=$(ls "$CTRL_LEDGER/sessions"/*.json 2>/dev/null | head -1)
parsed=$(reader "
import { readFileSync } from 'node:fs';
try { JSON.parse(readFileSync('${ctrl_entry:-/dev/null}','utf8')); process.stdout.write('ok'); }
catch { process.stdout.write('unparseable'); }
" 2>/dev/null)
check "control byte in a ref name still yields parseable JSON" "$parsed" "ok"

# --- 12. a lookalike environment variable never resolves a session ---------------------
LOOK_DIR="$TESTROOT/lookalike"
mkdir -p "$LOOK_DIR/sessions"
look_id="claude-lookalike-000000"
printf '{"schemaVersion":1,"ledgerId":"%s","runtime":"claude","pid":1,"cwd":"%s","startedAt":"%s","finishedAt":null,"mux":{"kind":null}}\n' \
  "$look_id" "$TESTROOT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$LOOK_DIR/sessions/$look_id.json"
PARENT_AGENT_LEDGER_ID="$look_id" setsid sleep 30 </dev/null >/dev/null 2>&1 &
look_pid=$!
STARTED_PIDS+=("$look_pid")
sleep 0.3
state=$(AGENT_SESSIONS_DIR="$LOOK_DIR" reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const s = await classify(readEntries(), { sampleMs: 50, includeDirty: false });
process.stdout.write(s.find((x) => x.ledgerId === '$look_id')?.state ?? 'MISSING');
")
check "lookalike env var does not resurrect a dead session" "$state" "FINISHED"
kill -9 "$look_pid" 2>/dev/null
wait "$look_pid" 2>/dev/null || :

# --- 13. finished ledger evidence is retained; ledger files are never deleted -----------
RETAIN_DIR="$TESTROOT/retain"
mkdir -p "$RETAIN_DIR/sessions"
old_iso="2020-01-01T00:00:00.000Z"
recent_iso=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%S.000Z)
write_entry() { # $1=ledgerId $2=finishedAt-or-empty
  local fin="null"
  [[ -n $2 ]] && fin="\"$2\""
  printf '{"schemaVersion":1,"ledgerId":"%s","runtime":"claude","cwd":"/tmp","startedAt":"%s","finishedAt":%s,"mux":{"kind":null}}\n' \
    "$1" "$old_iso" "$fin" >"$RETAIN_DIR/sessions/$1.json"
}
write_entry "old-finished" "$old_iso"
write_entry "recent-finished" "$recent_iso"
write_entry "still-live" ""
[[ -e "$RETAIN_DIR/sessions/old-finished.json" ]] && ok "stale FINISHED entry is retained" ||
  no "stale FINISHED entry is retained"
[[ -e "$RETAIN_DIR/sessions/recent-finished.json" ]] && ok "recent FINISHED entry survives" ||
  no "recent FINISHED entry survives"
[[ -e "$RETAIN_DIR/sessions/still-live.json" ]] && ok "live entry remains until evidence closes it" ||
  no "live entry remains until evidence closes it"

# --- 14. readers tolerate a session file vanishing mid-read (no crash, no fabricated entry)
VANISH_DIR="$TESTROOT/vanish"
mkdir -p "$VANISH_DIR/sessions"
printf '{"schemaVersion":1,"ledgerId":"stays","runtime":"claude","cwd":"/tmp","startedAt":"%s","finishedAt":null,"mux":{"kind":null}}\n' \
  "$old_iso" >"$VANISH_DIR/sessions/stays.json"
# A dangling symlink deterministically reproduces the readdir-then-read race: the dirent
# exists (readdirSync lists it, as it would for a file the pruner just unlinked), but the
# read that follows throws ENOENT exactly as it would on the real race.
ln -s "$VANISH_DIR/sessions/does-not-exist.json" "$VANISH_DIR/sessions/vanishes.json"
out=$(AGENT_SESSIONS_DIR="$VANISH_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(readEntries().map((e) => e.ledgerId).join(','));
" 2>/dev/null)
check "a file removed before it's read is skipped, not fabricated or fatal" "$out" "stays"

# --- 15. a launch that never crossed the PATH shim is enrolled by the hook --------------
# The regression this exists to prevent: the shim is one launcher among several, so any
# other one used to produce no record at all.
ADOPT_DIR="$TESTROOT/adopt"
FAKEBIN="$TESTROOT/fakebin"
mkdir -p "$ADOPT_DIR/sessions" "$FAKEBIN"
# The hook finds its session by walking up to the process named for the runtime, so the
# fixture has to really be named claude and really be the hook's ancestor.
cp /bin/bash "$FAKEBIN/claude"
HOOK="$MODULE/hooks/agent-session-ledger.mjs"

payload() { printf '{"hook_event_name":"%s","session_id":"%s","transcript_path":"%s"}' "$1" "$2" "$3"; }

cat >"$TESTROOT/adopt-session.sh" <<'FIXTURE'
node "$HOOK" <<<"$START_ONE" >/dev/null 2>&1
node "$HOOK" <<<"$START_TWO" >/dev/null 2>&1
: >"$ADOPT_DIR/.started"
for _ in $(seq 1 200); do [[ -e $ADOPT_DIR/.go ]] && break; sleep 0.1; done
node "$HOOK" <<<"$END_ONE" >/dev/null 2>&1
: >"$ADOPT_DIR/.ended"
sleep 5
FIXTURE

env -u AGENT_LEDGER_ID -u TMPJAIL_ACTIVE \
  AGENT_SESSIONS_DIR="$ADOPT_DIR" HOOK="$HOOK" ADOPT_DIR="$ADOPT_DIR" \
  START_ONE="$(payload SessionStart sess-one "$TESTROOT/one.jsonl")" \
  START_TWO="$(payload SessionStart sess-two "$TESTROOT/two.jsonl")" \
  END_ONE="$(payload SessionEnd sess-one "$TESTROOT/one.jsonl")" \
  "$FAKEBIN/claude" "$TESTROOT/adopt-session.sh" </dev/null >/dev/null 2>&1 &
adopt_pid=$!
STARTED_PIDS+=("$adopt_pid")

for _ in $(seq 1 200); do [[ -e "$ADOPT_DIR/.started" ]] && break; sleep 0.1; done
shopt -s nullglob
adopt_files=("$ADOPT_DIR"/sessions/*.json)
shopt -u nullglob
check "a shim-bypassing launch produces exactly one ledger entry" "${#adopt_files[@]}" "1"
adopt_entry="${adopt_files[0]:-/dev/null}"

read_field() { sed -n "s/.*\"$1\": \"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}$/\1/p" "$adopt_entry" | head -1; }
check "the entry names the runtime process it was adopted from" "$(read_field pid)" "$adopt_pid"
check "the first session id wins, so --resume reaches the conversation" "$(read_field sessionId)" "sess-one"

# The load-bearing assertion: an adopted entry carries no AGENT_LEDGER_ID in any process
# environment, so it is only ever classified alive by the pid it recorded.
adopt_state=$(AGENT_SESSIONS_DIR="$ADOPT_DIR" reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const [s] = await classify(readEntries(), { sampleMs: 50, includeDirty: false });
process.stdout.write(s.state);
")
if [[ $adopt_state == ALIVE-* ]]; then
  ok "an adopted session with a live process classifies alive, not lost"
else
  no "an adopted session with a live process classifies alive, not lost (got '$adopt_state')"
fi

# --- 16. SessionEnd closes an adopted entry, which has no AGENT_LEDGER_ID to key on -----
: >"$ADOPT_DIR/.go"
for _ in $(seq 1 200); do [[ -e "$ADOPT_DIR/.ended" ]] && break; sleep 0.1; done
grep -q '"finishedAt": "' "$adopt_entry" &&
  ok "SessionEnd marks an adopted session finished, so retention can reclaim it" ||
  no "SessionEnd marks an adopted session finished, so retention can reclaim it"

# --- 17. a session the shim already recorded is never adopted a second time ------------
SHIM_DIR="$TESTROOT/shimborn"
mkdir -p "$SHIM_DIR/sessions"
printf '{"schemaVersion":1,"ledgerId":"shim-born","runtime":"claude","cwd":"/tmp","startedAt":"%s","pid":null,"pidStartTicks":null,"sessionId":null,"finishedAt":null,"mux":{"kind":null}}\n' \
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$SHIM_DIR/sessions/shim-born.json"
AGENT_SESSIONS_DIR="$SHIM_DIR" AGENT_LEDGER_ID=shim-born node "$HOOK" \
  <<<"$(payload SessionStart shim-session /nonexistent.jsonl)" >/dev/null 2>&1
shopt -s nullglob
shim_files=("$SHIM_DIR"/sessions/*.json)
shopt -u nullglob
check "an enrolled session is updated, not duplicated" "${#shim_files[@]}" "1"
grep -q '"sessionId": "shim-session"' "$SHIM_DIR/sessions/shim-born.json" &&
  ok "the shim-born entry still receives its session id" ||
  no "the shim-born entry still receives its session id"

# --- 18. a recorded pid that has been recycled is a dead session, not a live one --------
recycled=$(reader "
import { resolveRecordedPid } from '$MODULE/lib/agent-session-reader.mjs';
const live = { pid: process.pid, pidStartTicks: 1 };
process.stdout.write(String(resolveRecordedPid(live)));
")
check "a pid whose start time disagrees never resolves as live" "$recycled" "null"

# --- 19. discovery enrols a runtime no launcher ever reported --------------------------
# The gap this closes: only the PATH shim wrote entries, so `cld`, a bare `claude`, a
# headless dispatch, codex and cursor-agent produced no record at all.
SWEEP_DIR="$TESTROOT/sweep"
SWEEPBIN="$TESTROOT/sweepbin"
mkdir -p "$SWEEP_DIR/sessions" "$SWEEPBIN"
# argv[0]'s basename is the whole test, and a shebang would replace it with the interpreter,
# so the fixture has to be a real binary that is really named codex.
cp /bin/sh "$SWEEPBIN/codex"
setsid "$SWEEPBIN/codex" -c 'sleep 60' </dev/null >/dev/null 2>&1 &
sweep_pid=$!
STARTED_PIDS+=("$sweep_pid")
sleep 1

sweep_run() {
  AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { sweep } from '$MODULE/lib/agent-session-reader.mjs';
const r = sweep();
process.stdout.write(JSON.stringify(r));
"
}
sweep_one=$(sweep_run)
case ",$(printf '%s' "$sweep_one" | sed -n 's/.*"unverifiable":\[\([^]]*\)\].*/\1/p')," in
*",$sweep_pid,"*) no "a session this shell can inspect is never left unenrolled" ;;
*) ok "a session this shell can inspect is never left unenrolled" ;;
esac

discovered=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.pid === $sweep_pid);
process.stdout.write(e ? [e.runtime, e.enrolledBy, e.startedAt].join('|') : 'MISSING');
")
check "a launcher that never touched the shim is still enrolled, as itself" \
  "${discovered%%|*}" "codex"
case "$discovered" in
*"|discovery|"*) ok "the entry says it came from discovery, not from a launcher" ;;
*) no "the entry says it came from discovery, not from a launcher (got '$discovered')" ;;
esac

# --- 20. a discovered startedAt is the process's real UTC birth ------------------------
# Two defects at once: local time labelled Z rendered negative ages, and stamping `now`
# made an hours-old session look brand new.
age=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.pid === $sweep_pid);
const ms = Date.parse(e.startedAt);
process.stdout.write(String(Math.round((Date.now() - ms) / 1000)));
")
if [[ -n $age && $age -ge 0 && $age -le 120 ]]; then
  ok "startedAt is real UTC: the age is positive and matches the process"
else
  no "startedAt is real UTC: the age is positive and matches the process (got ${age}s)"
fi

# --- 21. sweeping again never doubles a session ----------------------------------------
sweep_run >/dev/null
dupes=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(String(readEntries().filter((x) => x.pid === $sweep_pid).length));
")
check "a second sweep recognises the session it already enrolled" "$dupes" "1"

# --- 22. one schema, two writers -------------------------------------------------------
# The shim writes entries in bash and discovery writes them in JS; a field only one of them
# emits is a field consumers cannot rely on.
cat >"$TESTROOT/shim-birth.sh" <<FIXTURE
. "$MODULE/lib/agent-session-ledger.sh"
agent_ledger_birth claude
FIXTURE
shim_id=$(cd "$TESTROOT" && AGENT_SESSIONS_DIR="$TESTROOT/schema" bash "$TESTROOT/shim-birth.sh")
shim_keys=$(reader "
import { readFileSync } from 'node:fs';
const e = JSON.parse(readFileSync('$TESTROOT/schema/sessions/$shim_id.json', 'utf8'));
process.stdout.write(Object.keys(e).sort().join(','));
")
js_keys=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.pid === $sweep_pid);
process.stdout.write(Object.keys(e).sort().join(','));
")
check "both writers emit the same fields" "$js_keys" "$shim_keys"

prebound_socket="$TESTROOT/human.sock"
prebound_reap="$(</proc/sys/kernel/random/uuid)"
prebound_id=$(cd "$TESTROOT" && AGENT_SESSIONS_DIR="$TESTROOT/prebound" bash -c \
  '. "$1"; agent_ledger_birth claude "$2" "$3"' _ "$MODULE/lib/agent-session-ledger.sh" "$prebound_socket" "$prebound_reap")
prebound_target=$(reader "
import { readFileSync } from 'node:fs';
const e = JSON.parse(readFileSync('$TESTROOT/prebound/sessions/$prebound_id.json', 'utf8'));
process.stdout.write([e.tmuxSession, e.tmuxSocket, e.tmuxReapIdentity].join('|'));
")
check "a human ledger birth records its tmux target atomically" \
  "$prebound_target" "$prebound_id|$prebound_socket|$prebound_reap"

collision_dir="$TESTROOT/collision"
collision_script="$TESTROOT/collision-birth.sh"
cat >"$collision_script" <<'FIXTURE'
. "$1"
first_nonce=11111111111141118111111111111111
second_nonce=22222222222242228222222222222222
_agent_ledger_nonce() { printf '%s' "$first_nonce"; }
first_id=$(agent_ledger_birth claude) || exit 1
first_path="$AGENT_SESSIONS_DIR/sessions/$first_id.json"
printf '%s' "$(<"$first_path")" >"$AGENT_SESSIONS_DIR/first-entry"
_agent_ledger_nonce() {
  if [[ -e "$AGENT_SESSIONS_DIR/collision-seen" ]]; then
    printf '%s' "$second_nonce"
  else
    : >"$AGENT_SESSIONS_DIR/collision-seen"
    printf '%s' "$first_nonce"
  fi
}
second_id=$(agent_ledger_birth claude) || exit 1
[[ "$first_id" != "$second_id" ]] || exit 1
[[ "$(<"$first_path")" == "$(<"$AGENT_SESSIONS_DIR/first-entry")" ]] || exit 1
printf '%s|%s' "$first_id" "$second_id"
FIXTURE
collision_ids=$(AGENT_SESSIONS_DIR="$collision_dir" bash "$collision_script" \
  "$MODULE/lib/agent-session-ledger.sh")
collision_count=$(reader "
import { readdirSync, readFileSync } from 'node:fs';
const dir = '$collision_dir/sessions';
const files = readdirSync(dir).filter((name) => name.endsWith('.json'));
for (const file of files) JSON.parse(readFileSync(dir + '/' + file, 'utf8'));
process.stdout.write(String(files.length));
")
check "ledger ID collisions retry without overwriting either complete entry" \
  "$collision_count|${collision_ids%%|*}|${collision_ids##*|}" \
  "2|${collision_ids%%|*}|${collision_ids##*|}"

# --- 23. a transcript is read from both ends, never whole -------------------------------
BIGLOG="$TESTROOT/big.jsonl"
{
  printf '{"type":"user","isMeta":true,"message":{"content":"session bookkeeping line"}}\n'
  printf '{"type":"user","message":{"content":"<local-command-caveat>harness preamble</local-command-caveat>"}}\n'
  printf '{"type":"user","message":{"content":"Fix the session ledger enrolment gap"}}\n'
  for _ in $(seq 1 20000); do
    printf '{"type":"user","message":{"content":[{"type":"tool_result","content":"padding padding padding padding"}]}}\n'
  done
  printf '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit"}]}}\n'
} >"$BIGLOG"
narrative=$(reader "
import { describeTranscript } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(String(describeTranscript('$BIGLOG').title));
")
check "with no ai-title, the opening request is the title" \
  "$narrative" "Fix the session ledger enrolment gap"

printf '{"type":"ai-title","aiTitle":"Close the session-ledger enrolment gap"}\n' >>"$BIGLOG"
narrative=$(reader "
import { describeTranscript } from '$MODULE/lib/agent-session-reader.mjs';
const d = describeTranscript('$BIGLOG');
process.stdout.write([d.title, d.activity].join('|'));
")
check "the runtime's own session title wins over the raw opening prompt" \
  "${narrative%%|*}" "Close the session-ledger enrolment gap"
check "the activity is the last thing the session did" "${narrative##*|}" "using Edit"

# --- 24. a factory launch is attributed even though nothing is named "factory" ----------
# The entrypoint execs into `uv run python`, so the ancestor walk would otherwise reach the
# terminal and report the run as the user's own.
cp /bin/sh "$SWEEPBIN/cursor-agent"
FACTORY_ADW=1 setsid "$SWEEPBIN/cursor-agent" -c 'sleep 60' </dev/null >/dev/null 2>&1 &
factory_pid=$!
STARTED_PIDS+=("$factory_pid")
sleep 1
attributed=$(reader "
import { launchAttribution } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(String(launchAttribution($factory_pid)?.launchedBy));
")
check "a factory run is not reported as the user's own launch" "$attributed" "factory"

# --- 25. a runtime with no end-of-session hook does not stay orphaned forever -----------
kill "$sweep_pid" 2>/dev/null
wait "$sweep_pid" 2>/dev/null
sweep_run >/dev/null
closed=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.pid === $sweep_pid);
process.stdout.write(e?.finishReason ?? 'still open');
")
check "a codex session whose process is gone is closed, not orphaned forever" \
  "$closed" "process exited"

# --- 26. a claude session that died hard is closed even without a SessionEnd hook --------
cat >"$SWEEP_DIR/sessions/claude-dead.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-dead","runtime":"claude","pid":$sweep_pid,
 "pidStartTicks":1,"sessionId":"dead-session","transcriptPath":"$SWEEP_DIR/dead.jsonl",
 "finishedAt":null,"finishReason":null}
JSON
sweep_run >/dev/null
closed_claude=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.ledgerId === 'claude-dead');
process.stdout.write(e?.finishReason ?? 'still open');
")
check "a dead claude session is closed from process evidence" "$closed_claude" "process exited"

# --- 26b. a dead claude row with nothing behind it is closed, not a permanent LOST row ----
# Short headless runs enrol by discovery and exit before a conversation id is known; kept open
# they would accumulate one phantom lost-work row per dispatch.
cat >"$SWEEP_DIR/sessions/claude-phantom.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-phantom","runtime":"claude","pid":$sweep_pid,
 "pidStartTicks":1,"sessionId":null,"transcriptPath":null,"finishedAt":null,"finishReason":null}
JSON
sweep_run >/dev/null
phantom=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.ledgerId === 'claude-phantom');
process.stdout.write(e?.finishReason ?? 'still open');
")
check "a dead claude row with no transcript and no session id is closed" \
  "$phantom" "process exited"

# --- 26c. a row enrolled before its process is visible is not mistaken for a phantom -----
# The launcher writes the row, then execs; between the two the row carries no pid, no session
# id and no transcript, which is exactly the phantom shape. Closing it there loses a starting
# session and re-enrols it under a second ledger id on the next tick.
cat >"$SWEEP_DIR/sessions/claude-starting.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-starting","runtime":"claude","pid":null,
 "pidStartTicks":null,"sessionId":null,"transcriptPath":null,"finishedAt":null,"finishReason":null}
JSON
sweep_run >/dev/null
starting=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.ledgerId === 'claude-starting');
process.stdout.write(e?.finishedAt ? 'closed' : 'still open');
")
check "a pid-less claude row is left open, not closed as a phantom" "$starting" "still open"

# --- 27. a service subcommand is not a session -----------------------------------------
# `claude daemon run` is the same binary under the same name; enrolling it puts a row in the
# panel for a session no one started.
cp /bin/sh "$SWEEPBIN/claude"
setsid "$SWEEPBIN/claude" daemon run </dev/null >/dev/null 2>&1 &
daemon_pid=$!
STARTED_PIDS+=("$daemon_pid")
sleep 1
seen=$(reader "
import { runtimeProcesses } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(String(runtimeProcesses().some((p) => p.pid === $daemon_pid)));
")
check "a daemon subcommand is never enrolled as a session" "$seen" "false"

# --- 28. an inherited ledger id never overwrites the session that exported it ------------
# AGENT_LEDGER_ID reaches the whole tree, so the launcher, its shell and every child runtime
# carry it. The record's recorded pid is deliberately unverifiable here, which is the only path
# that consults the carriers at all — a stale start time is how a real record loses its process.
cat >"$SWEEP_DIR/sessions/claude-parent.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-parent","runtime":"claude","pid":$$,
 "pidStartTicks":1,"launcherPid":$$,"sessionId":"parent-session",
 "finishedAt":null,"finishReason":null}
JSON
AGENT_LEDGER_ID=claude-parent setsid "$SWEEPBIN/cursor-agent" -c 'sleep 60' </dev/null >/dev/null 2>&1 &
child_pid=$!
STARTED_PIDS+=("$child_pid")
sleep 1
sweep_run >/dev/null
stolen=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries, resolvePids } from '$MODULE/lib/agent-session-reader.mjs';
const carrier = resolvePids(['claude-parent'], { runtimeById: new Map([['claude-parent', 'claude']]) });
const e = readEntries().find((x) => x.ledgerId === 'claude-parent');
process.stdout.write([carrier.get('claude-parent') ?? 'none', e?.pid].join(' '));
")
check "no carrier of another runtime, nor a wrapper around it, is taken for the session" \
  "$stolen" "none $$"

# --- 29. process identity separates a resume from a duplicate, and never guesses ---------
# Two rows sharing a sessionId are a session and its resume: different processes, so different
# keys. A row with no process behind it has no identity at all rather than a plausible one.
identity=$(reader "
import { processKey } from '$MODULE/lib/agent-session-reader.mjs';
const one = { pid: 4242, pidStartTicks: 111, bootId: 'b' };
const resumed = { pid: 4243, pidStartTicks: 222, bootId: 'b' };
const reused = { pid: 4242, pidStartTicks: 999, bootId: 'b' };
const pending = { pid: null, pidStartTicks: null, bootId: 'b' };
process.stdout.write([
  processKey(one) === processKey(resumed) ? 'collide' : 'distinct',
  processKey(one) === processKey(reused) ? 'collide' : 'distinct',
  String(processKey(pending)),
].join(' '));
")
check "a resume, a recycled pid and a pid-less row are never one identity" \
  "$identity" "distinct distinct null"

# --- 30. a dtach-wrapped session is discovered as reattachable from /proc alone ----------
# The launcher may never have recorded the wrap; membership is a fact about the ancestors.
if ! command -v dtach >/dev/null 2>&1; then
  printf 'skip a dtach-wrapped session reports its socket (dtach not installed here)\n'
else
  dtach_sock="$TESTROOT/dtach.sock"
  dtach -n "$dtach_sock" "$SWEEPBIN/claude" -c "echo \$\$ >'$TESTROOT/dtach.pid'; exec sleep 60" \
    </dev/null >/dev/null 2>&1
  for _ in $(seq 1 50); do [[ -s "$TESTROOT/dtach.pid" ]] && break; sleep 0.1; done
  dtach_pid=$(cat "$TESTROOT/dtach.pid" 2>/dev/null || echo 0)
  STARTED_PIDS+=("$dtach_pid")
  dtach_mux=$(reader "
import { muxOf } from '$MODULE/lib/agent-session-reader.mjs';
const m = muxOf($dtach_pid);
process.stdout.write([m.kind, m.socket].join(' '));
")
  check "a dtach-wrapped session reports its socket" "$dtach_mux" "dtach $dtach_sock"
fi

# --- 31. a shim-bypassing tmux session is discovered, enrolled and watchable ------------
# Its own server on its own socket: the user's human-session server is never touched. This
# launch deliberately calls the runtime binary directly, so no PATH shim can create its row.
tmux_sock="$TESTROOT/tmux.sock"
if tmux -S "$tmux_sock" new-session -d -s ledgertest \
  "$SWEEPBIN/claude -c \"echo \\\$\\\$ >'$TESTROOT/tmux.pid'; sleep 60 & wait\"" >/dev/null 2>&1; then
for _ in $(seq 1 50); do [[ -s "$TESTROOT/tmux.pid" ]] && break; sleep 0.1; done
tmux_pid=$(cat "$TESTROOT/tmux.pid" 2>/dev/null || echo 0)
tmux_mux=$(reader "
import { muxOf } from '$MODULE/lib/agent-session-reader.mjs';
const m = muxOf($tmux_pid);
process.stdout.write([m.kind, m.target].join(' '));
")
check "a tmux-wrapped session reports its pane's session name" "$tmux_mux" "tmux ledgertest"
sweep_run >/dev/null
tmux_enrolled=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.pid === $tmux_pid);
process.stdout.write(e ? [e.enrolledBy, e.mux?.kind, e.tmuxSession, e.tmuxSocket].join(' ') : 'MISSING');
")
check "a tmux session that bypasses the shim is enrolled with its watch target" \
  "$tmux_enrolled" "discovery tmux ledgertest $tmux_sock"
tmux_ledger_id=$(AGENT_SESSIONS_DIR="$SWEEP_DIR" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(readEntries().find((x) => x.pid === $tmux_pid)?.ledgerId ?? 'MISSING');
")
tmux_reopen=$(timeout 60 env AGENT_SESSIONS_DIR="$SWEEP_DIR" node "$MODULE/bin/agent-sessions" 2>/dev/null |
  grep -F "agent-sessions attach $tmux_ledger_id" | head -1)
[[ -n "$tmux_reopen" ]] && ok "the recovery CLI can reopen a shim-bypassing tmux session" ||
  no "the recovery CLI can reopen a shim-bypassing tmux session"
mediated_generation="11111111-1111-4111-8111-111111111111"
AGENT_SESSIONS_DIR="$SWEEP_DIR" node --input-type=module - "$tmux_ledger_id" "$mediated_generation" "$MODULE" <<'JS'
const [ledgerId, generation, moduleRoot] = process.argv.slice(2);
const { updateEntry } = await import(`${moduleRoot}/lib/agent-session-reader.mjs`);
updateEntry(ledgerId, { sessionAuthority: { kind: "seat", seatId: "seat-mediated", generation } });
JS
mkdir -p "$TESTROOT/mediated-bin"
cat >"$TESTROOT/mediated-bin/tmux" <<'SH'
#!/bin/sh
exit 97
SH
cat >"$TESTROOT/mediated-bin/sudo" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"$MEDIATED_CALLS"
exit 0
SH
chmod +x "$TESTROOT/mediated-bin/tmux" "$TESTROOT/mediated-bin/sudo"
export MEDIATED_CALLS="$TESTROOT/mediated-calls"
mediated_reopen=$(timeout 60 env PATH="$TESTROOT/mediated-bin:$PATH" AGENT_SESSIONS_DIR="$SWEEP_DIR" \
  node "$MODULE/bin/agent-sessions" 2>/dev/null | grep -F "agent-sessions attach $tmux_ledger_id" | head -1)
[[ -n "$mediated_reopen" ]] && ok "authority session visibility uses the generation-bound mediator" ||
  no "authority session visibility uses the generation-bound mediator"
timeout 60 env PATH="$TESTROOT/mediated-bin:$PATH" AGENT_SESSIONS_DIR="$SWEEP_DIR" \
  node "$MODULE/bin/agent-sessions" attach "$tmux_ledger_id" >/dev/null 2>&1
expected_mediated="-n /usr/local/bin/overdeck-seat-tmux-mediator --seat-id seat-mediated --socket $tmux_sock --generation $mediated_generation"
grep -F -- "$expected_mediated has-session -t ledgertest" "$MEDIATED_CALLS" >/dev/null &&
  grep -F -- "$expected_mediated attach-session -t ledgertest" "$MEDIATED_CALLS" >/dev/null &&
  ok "authority attach is generation-bound with no raw tmux fallback" ||
  no "authority attach is generation-bound with no raw tmux fallback"
tmux -S "$tmux_sock" kill-server >/dev/null 2>&1
else
  printf 'skip shim-bypassing tmux discovery (tmux server unavailable on this host)\n'
fi

# --- 32. an unwrapped session reports nothing to attach to ------------------------------
setsid "$SWEEPBIN/claude" -c 'exec sleep 60' </dev/null >/dev/null 2>&1 &
bare_pid=$!
STARTED_PIDS+=("$bare_pid")
sleep 1
bare_mux=$(reader "
import { muxOf } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(String(muxOf($bare_pid).kind));
")
check "an unwrapped session claims no multiplexer" "$bare_mux" "null"

# --- 33. idle comes from the transcript, not from the process burning ticks --------------
# A session sitting at its prompt still consumes CPU; counting that as activity reported every
# open session as working and idle time as zero.
idle_dir="$TESTROOT/idle"
mkdir -p "$idle_dir/sessions"
printf '{"type":"user"}\n' >"$idle_dir/old.jsonl"
touch -d '40 minutes ago' "$idle_dir/old.jsonl"
cat >"$idle_dir/sessions/claude-idle.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-idle","runtime":"claude","pid":$$,
 "pidStartTicks":$(awk '{print $22}' "/proc/$$/stat"),
 "cwd":"$TESTROOT","transcriptPath":"$idle_dir/old.jsonl","sessionId":"idle-session",
 "lastActiveAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","finishedAt":null,"finishReason":null}
JSON
idle_state=$(AGENT_SESSIONS_DIR="$idle_dir" reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const [s] = await classify(readEntries(), { includeDirty: false });
process.stdout.write([s.state, Math.round(s.idleMs / 60000)].join(' '));
")
check "idle time is read from the transcript, not the CPU counter" "$idle_state" "ALIVE-IDLE 40"

# --- 34. a transcript filed under another project is still found ------------------------
# The runtime files a transcript under the directory it opened in, which is not always the cwd
# the ledger recorded; without this the session falls back to the CPU counter for idle time.
moved_dir="$TESTROOT/moved"
mkdir -p "$moved_dir/sessions" "$FAKE_HOME/.claude/projects/-somewhere-else"
printf '{"type":"user"}\n' >"$FAKE_HOME/.claude/projects/-somewhere-else/moved-session.jsonl"
touch -d '25 minutes ago' "$FAKE_HOME/.claude/projects/-somewhere-else/moved-session.jsonl"
cat >"$moved_dir/sessions/claude-moved.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-moved","runtime":"claude","pid":$$,
 "pidStartTicks":$(awk '{print $22}' "/proc/$$/stat"),"cwd":"$TESTROOT",
 "sessionId":"moved-session","transcriptPath":null,"cpuTicks":0,
 "finishedAt":null,"finishReason":null}
JSON
moved=$(HOME="$FAKE_HOME" AGENT_SESSIONS_DIR="$moved_dir" reader "
import { sweep, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
sweep();
const e = readEntries().find((x) => x.ledgerId === 'claude-moved');
process.stdout.write(String(e?.transcriptPath));
")
check "a transcript filed under another project directory is still resolved" \
  "$moved" "$FAKE_HOME/.claude/projects/-somewhere-else/moved-session.jsonl"

# --- 35. a transcript-bearing session that writes nothing reads as idle while it works ----
# Named consequence of 33: a long single tool call appends nothing to the transcript, so a busy
# session crosses the idle threshold. Burning CPU never overrides the transcript.
busy_dir="$TESTROOT/busy"
mkdir -p "$busy_dir/sessions"
printf '{"type":"user"}\n' >"$busy_dir/old.jsonl"
touch -d '40 minutes ago' "$busy_dir/old.jsonl"
timeout 20 bash -c 'while :; do :; done' &
busy_pid=$!
STARTED_PIDS+=("$busy_pid")
cat >"$busy_dir/sessions/claude-busy.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-busy","runtime":"claude","pid":$busy_pid,
 "pidStartTicks":$(awk '{print $22}' "/proc/$busy_pid/stat"),
 "cwd":"$TESTROOT","transcriptPath":"$busy_dir/old.jsonl","sessionId":"busy-session",
 "finishedAt":null,"finishReason":null}
JSON
busy_state=$(AGENT_SESSIONS_DIR="$busy_dir" reader "
import { classify, readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const [s] = await classify(readEntries(), { includeDirty: false });
process.stdout.write([s.state, s.evidence].join(' '));
")
kill "$busy_pid" 2>/dev/null || true
check "a busy session with a silent transcript reads as idle, without a CPU claim" \
  "$busy_state" "ALIVE-IDLE no progress for 40m"

# --- 36. sweeps retain finished evidence instead of deleting ledger files -----------------
prune_dir="$TESTROOT/prune"
mkdir -p "$prune_dir/sessions"
for triple in "codex-ancient codex 90" "cursor-agent-recent cursor-agent 1"; do
  set -- $triple
  cat >"$prune_dir/sessions/$1.json" <<JSON
{"schemaVersion":1,"ledgerId":"$1","runtime":"$2","pid":null,"pidStartTicks":null,
 "sessionId":null,"transcriptPath":null,
 "finishedAt":"$(date -u -d "$3 days ago" +%Y-%m-%dT%H:%M:%SZ)","finishReason":"process exited"}
JSON
done
AGENT_SESSIONS_DIR="$prune_dir" reader "
import { sweep } from '$MODULE/lib/agent-session-reader.mjs';
sweep();
" >/dev/null 2>&1
kept=$(AGENT_SESSIONS_DIR="$prune_dir" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const ids = readEntries().map((e) => e.ledgerId);
process.stdout.write([ids.includes('codex-ancient'), ids.includes('cursor-agent-recent')].join(' '));
")
check "a sweep retains finished rows regardless of age" "$kept" "true true"

# --- 37. SessionEnd records a reason only when the runtime actually stated one -------------
# "other" is Claude Code's documented catch-all and no other payload field narrows it, so
# storing the literal token would put a word that means nothing on the panel.
reason_dir="$TESTROOT/reason"
mkdir -p "$reason_dir/sessions"
reason_for() {
  local id="claude-reason-$2"
  cat >"$reason_dir/sessions/$id.json" <<JSON
{"schemaVersion":1,"ledgerId":"$id","runtime":"claude","pid":null,"pidStartTicks":null,
 "sessionId":null,"transcriptPath":null,"finishedAt":null,"finishReason":null}
JSON
  printf '{"hook_event_name":"SessionEnd","session_id":"s","reason":%s}' "$1" |
    env AGENT_SESSIONS_DIR="$reason_dir" AGENT_LEDGER_ID="$id" \
      node "$MODULE/hooks/agent-session-ledger.mjs" >/dev/null 2>&1
  AGENT_SESSIONS_DIR="$reason_dir" reader "
  import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
  const e = readEntries().find((x) => x.ledgerId === '$id');
  process.stdout.write(String(e.finishReason) + '|' + (e.finishedAt === null ? 'open' : 'closed'));
  "
}
check "a documented reason is stored in the ledger's own prose" \
  "$(reason_for '"prompt_input_exit"' prompt)" "exited at the prompt|closed"
check "the catch-all reason leaves the field absent, and still closes the row" \
  "$(reason_for '"other"' other)" "null|closed"
check "a reason the runtime adds later is passed through, never dropped" \
  "$(reason_for '"future_token"' future)" "future_token|closed"
check "an ambiguous documented reason is passed through, never glossed" \
  "$(reason_for '"resume"' resume)" "resume|closed"
check "a payload with no reason invents none" \
  "$(reason_for 'null' none)" "null|closed"

# --- 38. the covered runtimes are the shim's symlink set, never a literal list ------------
# A hardcoded list orphans every session of a CLI added to the shim later: its carrier is
# rejected by name, so a live session reads as dead.
shim_names=$(cd "$MODULE/bin" && for f in *; do
  [[ -L $f && $(readlink "$f") == _tmpjail-shim.sh ]] && printf '%s\n' "$f"
done | sort | tr '\n' ' ')
reader_names=$(reader "
import { RUNTIMES } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(RUNTIMES.join(' ') + ' ');
")
check "every tmpjail shim name is a covered runtime, and no other" "$reader_names" "$shim_names"
for required in agy opencode grok kiro-cli; do
  case " $reader_names " in
    *" $required "*) ok "$required is a covered runtime" ;;
    *) no "$required is a covered runtime" ;;
  esac
done

# --- 39. a dead session's uncommitted work is snapshotted, and the tree is left untouched ---
# The snapshot is built through a throwaway index: a resumed session must find its files
# exactly as it left them, so `git status` has to be byte-identical across the rescue.
rescue_repo="$TESTROOT/rescue-repo"
mkdir -p "$rescue_repo"
git init -q "$rescue_repo"
printf 'base\n' >"$rescue_repo/tracked.txt"
printf '*.log\n' >"$rescue_repo/.gitignore"
git -C "$rescue_repo" add -A
git -C "$rescue_repo" -c user.email=t@t -c user.name=t commit -qm base
printf 'edited\n' >>"$rescue_repo/tracked.txt"
printf 'new\n' >"$rescue_repo/untracked.txt"
printf 'noise\n' >"$rescue_repo/ignored.log"
rescue_dir="$TESTROOT/rescue-ledger"
mkdir -p "$rescue_dir/sessions"
sleep 0 & dead_pid=$!
wait "$dead_pid" 2>/dev/null || true
cat >"$rescue_dir/sessions/cursor-agent-dead.json" <<JSON
{"schemaVersion":1,"ledgerId":"cursor-agent-dead","runtime":"cursor-agent","pid":$dead_pid,
 "pidStartTicks":1,"cwd":"$rescue_repo","branch":"wt/dead","worktree":true,
 "sessionId":null,"transcriptPath":null,"finishedAt":null,"finishReason":null}
JSON
status_before=$(git -C "$rescue_repo" status --porcelain)
rescue_out=$(AGENT_SESSIONS_DIR="$rescue_dir" reader "
import { sweep } from '$MODULE/lib/agent-session-reader.mjs';
const { rescued } = sweep();
process.stdout.write(rescued.map((r) => [r.ledgerId, r.rescueRef, r.rescuedPaths].join(' ')).join(','));
")
status_after=$(git -C "$rescue_repo" status --porcelain)
check "a dead session's uncommitted work is snapshotted onto its own ref" \
  "$rescue_out" "cursor-agent-dead refs/rescued/cursor-agent-dead 2"
check "the rescue leaves the working tree exactly as the dead session left it" \
  "$status_after" "$status_before"
check "the snapshot carries the modified and untracked files, and no ignored one" \
  "$(git -C "$rescue_repo" ls-tree -r --name-only refs/rescued/cursor-agent-dead | sort | tr '\n' ' ')" \
  ".gitignore tracked.txt untracked.txt "
check "the ledger records where the rescued work went" \
  "$(AGENT_SESSIONS_DIR="$rescue_dir" reader "
import { readEntries } from '$MODULE/lib/agent-session-reader.mjs';
const e = readEntries().find((x) => x.ledgerId === 'cursor-agent-dead');
process.stdout.write([e.rescueRef, e.rescuedPaths, Boolean(e.rescuedAt)].join(' '));
")" "refs/rescued/cursor-agent-dead 2 true"

# --- 40. the rescue is edge-triggered: no session is snapshotted twice --------------------
first_commit=$(git -C "$rescue_repo" rev-parse refs/rescued/cursor-agent-dead)
printf 'more\n' >>"$rescue_repo/tracked.txt"
second_out=$(AGENT_SESSIONS_DIR="$rescue_dir" reader "
import { sweep } from '$MODULE/lib/agent-session-reader.mjs';
const { rescued } = sweep();
process.stdout.write(String(rescued.filter((entry) => entry.ledgerId === 'cursor-agent-dead').length));
")
check "a second sweep re-snapshots nothing" "$second_out" "0"
check "the first snapshot is left alone" \
  "$(git -C "$rescue_repo" rev-parse refs/rescued/cursor-agent-dead)" "$first_commit"

# --- 41. the snapshot restores the work into a cleaned tree ------------------------------
git -C "$rescue_repo" checkout -q .
rm -f "$rescue_repo/untracked.txt"
git -C "$rescue_repo" cherry-pick -n refs/rescued/cursor-agent-dead >/dev/null 2>&1
check "restoring the snapshot brings back the modified and the untracked file" \
  "$(git -C "$rescue_repo" status --porcelain | sort | tr '\n' ' ')" \
  "A  untracked.txt M  tracked.txt "

# --- 42. a live session is never rescued -------------------------------------------------
timeout 20 sleep 15 &
live_pid=$!
STARTED_PIDS+=("$live_pid")
live_rescue_dir="$TESTROOT/rescue-live"
mkdir -p "$live_rescue_dir/sessions"
cat >"$live_rescue_dir/sessions/claude-live.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-live","runtime":"claude","pid":$live_pid,
 "pidStartTicks":$(awk '{print $22}' "/proc/$live_pid/stat"),
 "cwd":"$rescue_repo","branch":"wt/live","worktree":true,
 "sessionId":null,"transcriptPath":null,"finishedAt":null,"finishReason":null}
JSON
live_out=$(AGENT_SESSIONS_DIR="$live_rescue_dir" reader "
import { sweep } from '$MODULE/lib/agent-session-reader.mjs';
process.stdout.write(String(sweep().rescued.length));
")
check "a session whose process is still alive is never rescued" "$live_out" "0"

# --- 43. a row that never recorded a pid is not evidence of death -------------------------
nopid_dir="$TESTROOT/rescue-nopid"
mkdir -p "$nopid_dir/sessions"
cat >"$nopid_dir/sessions/claude-nopid.json" <<JSON
{"schemaVersion":1,"ledgerId":"claude-nopid","runtime":"claude","pid":null,"pidStartTicks":null,
 "cwd":"$rescue_repo","branch":"wt/nopid","worktree":true,
 "sessionId":"abc","transcriptPath":null,"finishedAt":null,"finishReason":null}
JSON
nopid_out=$(AGENT_SESSIONS_DIR="$nopid_dir" reader "
import { readEntries, sweep } from '$MODULE/lib/agent-session-reader.mjs';
sweep();
const row = readEntries().find((x) => x.ledgerId === 'claude-nopid');
process.stdout.write(String(row.rescuedAt ?? 'null'));
")
check "a session with no recorded pid is never rescued" "$nopid_out" "null"

# --- 44. a linked worktree's rescue outlives the worktree ---------------------------------
lw_repo="$TESTROOT/rescue-lw"
git -C "$rescue_repo" worktree add -q "$lw_repo" -b wt/lw
printf 'lost\n' >"$lw_repo/only-here.txt"
lw_dir="$TESTROOT/rescue-lw-ledger"
mkdir -p "$lw_dir/sessions"
sleep 0 & lw_pid=$!
wait "$lw_pid" 2>/dev/null || true
cat >"$lw_dir/sessions/cursor-agent-lw.json" <<JSON
{"schemaVersion":1,"ledgerId":"cursor-agent-lw","runtime":"cursor-agent","pid":$lw_pid,
 "pidStartTicks":1,"cwd":"$lw_repo","branch":"wt/lw","worktree":true,
 "sessionId":null,"transcriptPath":null,"finishedAt":null,"finishReason":null}
JSON
AGENT_SESSIONS_DIR="$lw_dir" reader "
import { sweep } from '$MODULE/lib/agent-session-reader.mjs';
sweep();
" >/dev/null
git -C "$rescue_repo" worktree remove --force "$lw_repo"
check "the rescue ref survives removal of the worktree it came from" \
  "$(git -C "$rescue_repo" ls-tree -r --name-only refs/rescued/cursor-agent-lw | grep -c only-here.txt)" "1"


printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
