// Canonical reader + liveness classifier for the agent session ledger.
// Contract and thresholds: overdeck docs/agent-session-ledger.md.
// Plain ESM, zero dependencies: it is consumed both by the deployed CLI in ~/.claude/bin
// and by the Overdeck collector, which must not need a build step to read it.

import { execFile, execFileSync } from "node:child_process";
import {
  closeSync,
  existsSync,
  fstatSync,
  mkdirSync,
  openSync,
  readdirSync,
  readFileSync,
  readlinkSync,
  readSync,
  renameSync,
  statSync,
  unlinkSync,
  writeFileSync,
} from "node:fs";
import { homedir, hostname } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

/** Two /proc/<pid>/stat samples this far apart; any tick growth counts as working. */
export const CPU_SAMPLE_MS = 1200;
/** No CPU growth and no transcript growth for this long => idle. */
export const IDLE_MS = 600_000;
/** A FINISHED entry (finishedAt set) older than this is pruned. Matches this box's other
 * state-directory retention convention (remote-build-gc.mjs gc_repo_retention_days). */
export const FINISHED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;

const GIT_BIN = "/usr/bin/git";

export function ledgerDir() {
  return process.env.AGENT_SESSIONS_DIR ?? join(homedir(), ".local", "state", "agent-sessions");
}

export function sessionsDir(root = ledgerDir()) {
  return join(root, "sessions");
}

const warned = new Set();
function warnOnce(path, error) {
  if (warned.has(path)) return;
  warned.add(path);
  process.emitWarning(`[agent-session-reader] ignored ${path}: ${error?.message ?? error}`);
}

export function readEntries({ root = ledgerDir() } = {}) {
  const dir = sessionsDir(root);
  let names;
  try {
    names = readdirSync(dir).filter((n) => n.endsWith(".json") && !n.startsWith("."));
  } catch (error) {
    if (error?.code === "ENOENT") return [];
    throw error;
  }
  const entries = [];
  for (const name of names) {
    const path = join(dir, name);
    try {
      const parsed = JSON.parse(readFileSync(path, "utf8"));
      if (parsed?.schemaVersion !== 1 || typeof parsed.ledgerId !== "string") {
        throw new Error("unsupported schemaVersion or missing ledgerId");
      }
      entries.push(parsed);
    } catch (error) {
      warnOnce(path, error);
    }
  }
  return entries;
}

/** Merge fields into an entry on disk, preserving unknown keys. tmp + rename. */
export function updateEntry(ledgerId, patch, { root = ledgerDir() } = {}) {
  const path = join(sessionsDir(root), `${ledgerId}.json`);
  let current;
  try {
    current = JSON.parse(readFileSync(path, "utf8"));
  } catch {
    return false;
  }
  const next = { ...current, ...patch };
  const tmp = `${path}.${process.pid}.tmp`;
  writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
  renameSync(tmp, path);
  return true;
}

/**
 * Deletes FINISHED entries (finishedAt set) older than retentionMs. Never touches an entry
 * with finishedAt null (live/active/orphaned) or one it fails to parse. Returns the count
 * removed. Best-effort: a single unreadable or already-gone file is skipped, not fatal.
 */
export function pruneFinished({ root = ledgerDir(), now = Date.now(), retentionMs = FINISHED_RETENTION_MS } = {}) {
  const dir = sessionsDir(root);
  let names;
  try {
    names = readdirSync(dir).filter((n) => n.endsWith(".json") && !n.startsWith("."));
  } catch (error) {
    if (error?.code === "ENOENT") return 0;
    throw error;
  }
  let pruned = 0;
  for (const name of names) {
    const path = join(dir, name);
    let parsed;
    try {
      parsed = JSON.parse(readFileSync(path, "utf8"));
    } catch {
      continue;
    }
    if (!parsed?.finishedAt) continue;
    const finishedMs = Date.parse(parsed.finishedAt);
    if (!Number.isFinite(finishedMs) || now - finishedMs < retentionMs) continue;
    try {
      unlinkSync(path);
      pruned += 1;
    } catch {
      // already gone or unreadable; not fatal
    }
  }
  return pruned;
}

function procStat(pid) {
  try {
    const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
    // comm may contain spaces and parentheses; fields are counted after the last ')'.
    const rest = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
    // rest[0] is field 3 (state), so field N is rest[N - 3]:
    // utime = 14, stime = 15, starttime = 22.
    return {
      utime: Number(rest[11]),
      stime: Number(rest[12]),
      starttime: Number(rest[19]),
    };
  } catch {
    return null;
  }
}

function realReadlink(path) {
  try {
    return readlinkSync(path);
  } catch {
    return null;
  }
}

function ownPidDirs() {
  return readdirSync("/proc").filter((n) => /^\d+$/.test(n));
}

/**
 * Map ledgerId -> pid by scanning /proc/*&#47;environ for AGENT_LEDGER_ID. Immune to PID reuse:
 * a recycled PID carries a different environment.
 */
export function resolvePids(wantedIds, { runtimeById = null } = {}) {
  const wanted = new Set(wantedIds);
  const found = new Map();
  const candidates = new Map();
  if (wanted.size === 0) return found;
  for (const pid of ownPidDirs()) {
    let env;
    try {
      env = readFileSync(`/proc/${pid}/environ`, "utf8");
    } catch {
      continue;
    }
    // Must start a variable, or PARENT_AGENT_LEDGER_ID= would resolve a foreign pid.
    let at = env.indexOf("AGENT_LEDGER_ID=");
    while (at > 0 && env[at - 1] !== "\0") at = env.indexOf("AGENT_LEDGER_ID=", at + 1);
    if (at === -1) continue;
    const end = env.indexOf("\0", at);
    const id = env.slice(at + "AGENT_LEDGER_ID=".length, end === -1 ? undefined : end);
    if (!wanted.has(id)) continue;
    const name = baseName(procArgv(Number(pid))[0] ?? "");
    candidates.set(id, [...(candidates.get(id) ?? []), { pid: Number(pid), name }]);
  }
  // The id reaches every descendant, so several processes carry it — the launcher, its shell,
  // the runtime, every tool the runtime spawns. Only a process running the record's own runtime
  // can be that session; among those the outermost one is the session and the rest are its
  // children. Where the runtime is unknown the carrier tree is all there is to go on.
  for (const [id, carriers] of candidates) {
    const runtime = runtimeById?.get(id);
    const eligible = runtime ? carriers.filter((c) => c.name === runtime) : carriers;
    const pool = new Set(eligible.map((c) => c.pid));
    const outermost = eligible.find((c) => {
      for (let cursor = parentPid(c.pid), hops = 0; cursor !== null && cursor > 1 && hops < ANCESTOR_HOPS; hops += 1) {
        if (pool.has(cursor)) return false;
        cursor = parentPid(cursor);
      }
      return true;
    });
    if (outermost !== undefined) found.set(id, outermost.pid);
  }
  return found;
}

/** starttime (field 22) of a live pid, or null. pid+starttime names a process across reuse. */
export function pidStartTicks(pid) {
  return procStat(pid)?.starttime ?? null;
}

/**
 * Liveness for a session enrolled without AGENT_LEDGER_ID in its environment: env cannot be
 * injected into a process that is already running, so the entry names its pid instead and
 * starttime settles whether that pid is still the same process.
 */
export function resolveRecordedPid(entry) {
  const pid = entry?.pid;
  if (!Number.isInteger(pid) || pid <= 0) return null;
  const stat = procStat(pid);
  if (!stat) return null;
  return Number(entry.pidStartTicks) === stat.starttime ? pid : null;
}

/** A dtach client is a dtach process holding the socket with a real terminal on stdin. */
function socketHasAttachedClient(socket) {
  for (const pid of ownPidDirs()) {
    let cmdline;
    try {
      cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
    } catch {
      continue;
    }
    if (!cmdline.startsWith("dtach\0") && !cmdline.includes("\0dtach\0")) continue;
    if (!cmdline.split("\0").includes(socket)) continue;
    const stdin = realReadlink(`/proc/${pid}/fd/0`);
    if (stdin && stdin.startsWith("/dev/pts/")) return true;
  }
  return false;
}

function tmuxHasAttachedClient(socket, target) {
  if (!socket || !target) return false;
  try {
    const attached = execFileSync(
      "tmux",
      ["-S", socket, "display-message", "-p", "-t", `=${target}`, "#{session_attached}"],
      { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] },
    );
    return Number.parseInt(attached.trim(), 10) > 0;
  } catch {
    return false;
  }
}

function transcriptProgress(path) {
  if (!path) return null;
  try {
    const st = statSync(path);
    return { mtimeMs: st.mtimeMs, size: st.size };
  } catch {
    return null;
  }
}

function gitDirtyCount(cwd, execFileImpl) {
  return new Promise((resolve) => {
    if (!cwd || !existsSync(cwd) || !existsSync(GIT_BIN)) return resolve(null);
    execFileImpl(
      GIT_BIN,
      ["--no-optional-locks", "-C", cwd, "status", "--porcelain"],
      { timeout: 5000, maxBuffer: 8 * 1024 * 1024 },
      (error, stdout) => {
        if (error && !stdout) return resolve(null);
        const lines = String(stdout).split("\n").filter(Boolean);
        resolve(lines.length);
      },
    );
  });
}

function git(cwd, args, { env = {} } = {}) {
  try {
    return execFileSync(GIT_BIN, ["--no-optional-locks", "-C", cwd, ...args], {
      encoding: "utf8",
      timeout: 30_000,
      maxBuffer: 8 * 1024 * 1024,
      stdio: ["ignore", "pipe", "ignore"],
      env: { ...process.env, ...env },
    }).trim();
  } catch {
    return null;
  }
}

export function rescueRefName(ledgerId) {
  return `refs/rescued/${ledgerId}`;
}

export function rescueRestoreCommand(entry) {
  return `git -C ${entry.cwd} cherry-pick -n ${entry.rescueRef ?? rescueRefName(entry.ledgerId)}`;
}

/**
 * Snapshots a dead session's uncommitted work into a commit parked on refs/rescued/<ledgerId>.
 * The working tree, the index and HEAD are never touched: the snapshot is built through a
 * throwaway index, so a resumed session still sees exactly the files it left, and no commit
 * hook runs. Returns the ledger patch to record, or null when there is nothing to rescue.
 */
export function rescueUncommitted(entry, { root = ledgerDir(), now = () => Date.now() } = {}) {
  // No recorded pid is not evidence of death — a row enrolled before its process was observed
  // looks identical to a dead one, and rescuing it would burn rescuedAt on a live session.
  if (!Number.isInteger(entry.pid) || entry.pid <= 0) return null;
  if (entry.rescuedAt || !entry.cwd || !existsSync(entry.cwd) || !existsSync(GIT_BIN)) return null;
  const status = git(entry.cwd, ["status", "--porcelain"]);
  if (!status) return null;

  const indexFile = join(root, `rescue-index.${process.pid}.${entry.ledgerId}`);
  let tree = null;
  try {
    if (git(entry.cwd, ["add", "-A"], { env: { GIT_INDEX_FILE: indexFile } }) === null) return null;
    tree = git(entry.cwd, ["write-tree"], { env: { GIT_INDEX_FILE: indexFile } });
  } finally {
    try {
      unlinkSync(indexFile);
    } catch {
      // absent when `git add` never created it
    }
  }
  if (!tree) return null;

  const head = git(entry.cwd, ["rev-parse", "HEAD"]);
  const message =
    `rescued uncommitted work from ${entry.runtime} session ${entry.ledgerId}\n\n` +
    `The session's process was gone and ${status.split("\n").length} path(s) were uncommitted.\n` +
    `directory: ${entry.cwd}\n` +
    `branch: ${entry.branch ?? "unknown"}\n` +
    (entry.worktree ? "" : "This is the repo's main checkout, which other sessions share, so the\n" +
      "snapshot may also contain work that was not this session's.\n");
  const commit = git(entry.cwd, [
    "-c",
    "user.name=agent-session-ledger",
    "-c",
    "user.email=agent-session-ledger@localhost",
    "commit-tree",
    tree,
    ...(head ? ["-p", head] : []),
    "-m",
    message,
  ]);
  if (!commit) return null;

  const ref = rescueRefName(entry.ledgerId);
  // Empty old-value means "must not exist yet", so two overlapping sweeps cannot clobber.
  if (git(entry.cwd, ["update-ref", ref, commit, ""]) === null) return null;
  return {
    rescuedAt: new Date(now()).toISOString(),
    rescueRef: ref,
    rescueCommit: commit,
    rescuedPaths: status.split("\n").length,
  };
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/**
 * Classify every entry from evidence on this machine, never from the entry's own claims.
 * Returns entries decorated with state/attached/lastProgressAt/idleMs/dirtyCount/evidence.
 */
export async function classify(
  entries,
  {
    now = () => Date.now(),
    sampleMs = CPU_SAMPLE_MS,
    idleMs = IDLE_MS,
    includeDirty = true,
    execFileImpl = execFile,
    sleepImpl = sleep,
  } = {},
) {
  const live = entries.filter((e) => !e.finishedAt);
  const pids = resolvePids(live.map((e) => e.ledgerId), {
    runtimeById: new Map(live.map((e) => [e.ledgerId, e.runtime])),
  });
  for (const entry of live) {
    if (pids.has(entry.ledgerId)) continue;
    const recorded = resolveRecordedPid(entry);
    if (recorded !== null) pids.set(entry.ledgerId, recorded);
  }

  const first = new Map();
  for (const [id, pid] of pids) first.set(id, procStat(pid));
  const beforeProgress = new Map();
  for (const entry of live) beforeProgress.set(entry.ledgerId, transcriptProgress(entry.transcriptPath));

  if (pids.size > 0 || live.length > 0) await sleepImpl(sampleMs);

  const out = [];
  for (const entry of entries) {
    const pid = pids.get(entry.ledgerId) ?? null;
    const evidence = [];
    let state;
    let attached = false;
    let cpuGrew = false;

    const before = first.get(entry.ledgerId);
    const after = pid === null ? null : procStat(pid);
    if (before && after) {
      if (before.starttime !== after.starttime) {
        evidence.push("pid recycled mid-sample");
      } else if (after.utime + after.stime > before.utime + before.stime) {
        cpuGrew = true;
      }
    }

    const progressBefore = beforeProgress.get(entry.ledgerId);
    const progressAfter = transcriptProgress(entry.transcriptPath);
    const stamps = [];
    if (progressAfter) {
      stamps.push(progressAfter.mtimeMs);
      if (progressBefore && progressAfter.size > progressBefore.size) {
        evidence.push("transcript growing");
      }
    }
    // A runtime with no transcript (codex, cursor-agent) reports activity only through the
    // sweep's CPU-tick stamp, so the newest of every honest signal wins. Where a transcript
    // exists it is the exact record, and a tick stamp would only round idle time down to zero.
    const stampsToConsider = progressAfter
      ? [entry.lastHeartbeatAt]
      : [entry.lastActiveAt, entry.lastHeartbeatAt];
    for (const stamp of stampsToConsider) {
      const ms = stamp ? Date.parse(stamp) : Number.NaN;
      if (Number.isFinite(ms)) stamps.push(ms);
    }
    if (stamps.length === 0 && entry.startedAt) {
      const ms = Date.parse(entry.startedAt);
      if (Number.isFinite(ms)) stamps.push(ms);
    }
    const lastProgressMs = stamps.length === 0 ? null : Math.max(...stamps);
    const sinceProgress = lastProgressMs === null ? null : now() - lastProgressMs;

    const socket = entry.tmuxSocket ?? entry.mux?.socket ?? null;
    const tmuxTarget = entry.tmuxSession ?? (entry.mux?.kind === "tmux" ? entry.mux.target : null);
    const wrapped = (entry.mux?.kind === "dtach" || entry.mux?.kind === "tmux") && socket !== null;

    if (entry.finishedAt) {
      state = "FINISHED";
      evidence.push(entry.finishReason ? `ended: ${entry.finishReason}` : "ended cleanly");
    } else if (pid === null) {
      state = "ORPHANED";
      evidence.push("no process carries this session id");
      if (entry.tty && !existsSync(entry.tty)) evidence.push("its terminal is gone too");
    } else {
      attached = entry.mux?.kind === "dtach"
        ? socketHasAttachedClient(socket)
        : entry.mux?.kind === "tmux"
          ? tmuxHasAttachedClient(socket, tmuxTarget)
          : Boolean(entry.tty && existsSync(entry.tty));
      if (wrapped && !attached && existsSync(socket)) {
        state = "DETACHED-ALIVE";
        evidence.push("running with no window attached");
      } else if ((cpuGrew && !progressAfter) || (sinceProgress !== null && sinceProgress < idleMs)) {
        state = "ALIVE-WORKING";
        if (cpuGrew && !progressAfter) evidence.push("cpu ticks accumulating");
      } else {
        state = "ALIVE-IDLE";
        evidence.push(`no progress for ${Math.round((sinceProgress ?? 0) / 60000)}m`);
      }
    }

    const dirtyCount =
      includeDirty && state !== "FINISHED" ? await gitDirtyCount(entry.cwd, execFileImpl) : null;

    const narrative = describeTranscript(entry.transcriptPath);
    const lastProgressAt = lastProgressMs === null ? null : new Date(lastProgressMs).toISOString();

    out.push({
      ...entry,
      pid,
      processKey: processKey({ ...entry, pid, pidStartTicks: before?.starttime ?? entry.pidStartTicks }),
      state,
      attached,
      lastProgressAt,
      lastActiveAt: lastProgressAt,
      idleMs: sinceProgress,
      dirtyCount,
      title: narrative.title,
      activity: narrative.activity,
      evidence: evidence.join("; "),
    });
  }

  out.sort((a, b) => (Date.parse(b.lastProgressAt ?? 0) || 0) - (Date.parse(a.lastProgressAt ?? 0) || 0));
  return out;
}

const SHIM_BODY = "_tmpjail-shim.sh";

/**
 * The AI CLIs this ledger covers: every name symlinked to the tmpjail shim, which is the
 * same set the shim enrols under. argv[0]'s basename must equal one of these exactly.
 * A literal list would silently orphan every session of a CLI added to the shim later.
 */
function shimmedRuntimes() {
  const dir = fileURLToPath(new URL("../bin/", import.meta.url));
  let names;
  try {
    names = readdirSync(dir);
  } catch (error) {
    throw new Error(`agent session ledger: cannot read the shim directory ${dir}: ${error.message}`);
  }
  const runtimes = names.filter((name) => {
    try {
      return baseName(readlinkSync(join(dir, name))) === SHIM_BODY;
    } catch {
      return false;
    }
  });
  if (runtimes.length === 0) {
    throw new Error(`agent session ledger: no ${SHIM_BODY} symlink in ${dir}`);
  }
  return runtimes.sort();
}

export const RUNTIMES = shimmedRuntimes();
const RUNTIME_SET = new Set(RUNTIMES);
/** /proc reports times in USER_HZ, which Linux fixes at 100 regardless of CONFIG_HZ. */
const CLOCK_TICKS_PER_SEC = 100;
const ANCESTOR_HOPS = 40;
const TERMINAL_PROCESSES = new Set([
  "gnome-terminal-server",
  "konsole",
  "xterm",
  "alacritty",
  "kitty",
  "tmux",
  "tmux: server",
  "screen",
  "sshd",
  "login",
]);
/**
 * Subcommands that run a service or a maintenance job rather than a conversation. The binary
 * is named the same, so without this a `claude daemon run` renders as a session nobody started.
 */
const NON_SESSION_SUBCOMMANDS = new Set([
  "daemon", "mcp", "install", "update", "doctor", "config", "migrate-installer", "setup-token",
  "login", "logout", "completion",
]);
/** Exported by the factory entrypoint before it execs into its ADW, which is not named "factory". */
const FACTORY_MARKER = "FACTORY_ADW";
/** Runtimes with an end-of-session hook. Every other runtime's exit is only visible as a dead pid. */
const HOOKED_RUNTIMES = new Set(["claude"]);
/** Transcripts reach 145 MB. Only these many bytes are ever read, from each end. */
const TRANSCRIPT_WINDOW_BYTES = 64 * 1024;
const NARRATIVE_CHARS = 140;

function procText(pid, name) {
  try {
    return readFileSync(`/proc/${pid}/${name}`, "utf8");
  } catch {
    return null;
  }
}

function procArgv(pid) {
  const raw = procText(pid, "cmdline");
  if (!raw) return [];
  const parts = raw.split("\0");
  while (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
  return parts;
}

function procEnv(pid) {
  const raw = procText(pid, "environ");
  if (raw === null) return null;
  const env = new Map();
  for (const pair of raw.split("\0")) {
    const at = pair.indexOf("=");
    if (at > 0) env.set(pair.slice(0, at), pair.slice(at + 1));
  }
  return env;
}

function baseName(path) {
  return path.split("/").pop() ?? "";
}

function parentPid(pid) {
  const match = /^PPid:\s*(\d+)$/m.exec(procText(pid, "status") ?? "");
  return match ? Number(match[1]) : null;
}

let bootTimeSecCache;
function bootTimeSec() {
  if (bootTimeSecCache !== undefined) return bootTimeSecCache;
  const match = /^btime\s+(\d+)$/m.exec(readFileSync("/proc/stat", "utf8"));
  bootTimeSecCache = match ? Number(match[1]) : null;
  return bootTimeSecCache;
}

/**
 * Wall-clock UTC birth of a process from its /proc starttime. Never `now`: a session found
 * hours after it started must report its real age, and toISOString is UTC by construction.
 */
export function processStartedAt(startTicks) {
  const boot = bootTimeSec();
  if (boot === null || !Number.isFinite(startTicks)) return null;
  return new Date(Math.round((boot + startTicks / CLOCK_TICKS_PER_SEC) * 1000)).toISOString();
}

/**
 * /proc/<pid>/environ and /proc/<pid>/cwd are ptrace-gated: a process inside a bwrap user
 * namespace cannot read them for a sibling namespace, so a sweep from there would see no
 * existing enrolment and enrol every session a second time.
 */
export function inHostUserNamespace() {
  try {
    return readFileSync("/proc/self/uid_map", "utf8").trim().split(/\s+/).join(" ") === "0 0 4294967295";
  } catch {
    return false;
  }
}

/** Every live AI CLI process on this box, whatever launched it. */
export function runtimeProcesses() {
  const found = [];
  for (const name of ownPidDirs()) {
    const pid = Number(name);
    const argv = procArgv(pid);
    if (argv.length === 0) continue;
    const runtime = baseName(argv[0]);
    if (!RUNTIME_SET.has(runtime)) continue;
    if (NON_SESSION_SUBCOMMANDS.has(argv[1])) continue;
    const stat = procStat(pid);
    if (!stat) continue;
    found.push({ pid, runtime, startTicks: stat.starttime, argv });
  }
  return found;
}

function inInteractiveScope(pid) {
  const cgroup = procText(pid, "cgroup") ?? "";
  return cgroup.includes("vte-spawn") || cgroup.includes("Terminal") || cgroup.includes("human.slice");
}

/**
 * Who started this session, from the nearest ancestor that answers it. A runtime ancestor
 * means another agent dispatched it; anything else is only claimed when the evidence names
 * it, because a wrong answer here is worse than an absent one.
 */
export function launchAttribution(pid) {
  const env = procEnv(pid);
  const user = { launchedBy: "user", parentPid: null, parentRuntime: null };
  if (env?.get(FACTORY_MARKER) === "1") {
    return { launchedBy: "factory", parentPid: null, parentRuntime: null };
  }
  let cursor = parentPid(pid);
  for (let hops = 0; cursor !== null && cursor > 1 && hops < ANCESTOR_HOPS; hops += 1) {
    const argv = procArgv(cursor);
    const name = argv.length > 0 ? baseName(argv[0]) : (procText(cursor, "comm") ?? "").trim();
    if (RUNTIME_SET.has(name)) return { launchedBy: "agent", parentPid: cursor, parentRuntime: name };
    // The factory execs into python, so a terminal ancestor only proves "user" once the
    // environment has been read and does NOT carry the factory marker.
    if (TERMINAL_PROCESSES.has(name)) return env === null ? null : user;
    cursor = parentPid(cursor);
  }
  if (env !== null && inInteractiveScope(pid)) return user;
  return null;
}

/** The account slug a non-default seat runs under, or null when it runs on the default. */
export function accountOf(pid) {
  const env = procEnv(pid);
  if (env === null) return null;
  for (const key of ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "HOME"]) {
    const match = /\/accounts\/([^/]+)\//.exec(env.get(key) ?? "");
    if (match) return match[1];
  }
  return null;
}

/** repoRoot / project / branch / worktree without spawning git, which a PATH shim intercepts. */
export function repoFacts(cwd) {
  const facts = { repoRoot: null, project: null, branch: null, worktree: false };
  let dir = cwd;
  let headFile = null;
  while (dir && dir !== "/") {
    const gitPath = join(dir, ".git");
    let stat;
    try {
      stat = statSync(gitPath);
    } catch {
      dir = dir.slice(0, dir.lastIndexOf("/"));
      continue;
    }
    facts.repoRoot = dir;
    if (stat.isDirectory()) {
      headFile = join(gitPath, "HEAD");
    } else {
      facts.worktree = true;
      const pointer = readFileSync(gitPath, "utf8").trim().replace(/^gitdir:\s*/, "");
      headFile = join(pointer.startsWith("/") ? pointer : join(dir, pointer), "HEAD");
    }
    break;
  }
  if (facts.repoRoot === null) return facts;
  facts.project = baseName(facts.repoRoot);
  try {
    const head = readFileSync(headFile, "utf8").trim();
    facts.branch = head.startsWith("ref: refs/heads/")
      ? head.slice("ref: refs/heads/".length)
      : head
        ? `detached:${head.slice(0, 12)}`
        : null;
  } catch {
    facts.branch = null;
  }
  return facts;
}

function readWindow(fd, position, length) {
  const buffer = Buffer.allocUnsafe(length);
  const read = readSync(fd, buffer, 0, length, position);
  return buffer.subarray(0, read).toString("utf8");
}

function textOf(content) {
  if (typeof content === "string") return content;
  if (!Array.isArray(content)) return null;
  const text = content.find((part) => part?.type === "text" && typeof part.text === "string");
  return text ? text.text : null;
}

function condense(text) {
  const flat = text.replace(/\s+/g, " ").trim();
  if (flat.length === 0) return null;
  return flat.length > NARRATIVE_CHARS ? `${flat.slice(0, NARRATIVE_CHARS - 1)}…` : flat;
}

function titleFrom(head) {
  for (const line of head.split("\n")) {
    let event;
    try {
      event = JSON.parse(line);
    } catch {
      continue;
    }
    if (event?.type !== "user" || event.isMeta === true) continue;
    const text = textOf(event.message?.content);
    // Harness-injected blocks (caveats, command wrappers, system reminders) open with a tag
    // and say nothing about what the user asked for.
    if (!text || text.trimStart().startsWith("<")) continue;
    const title = condense(text);
    if (title) return title;
  }
  return null;
}

/** Claude Code writes its own session title into the transcript; prefer it over a raw prompt. */
function aiTitleFrom(tail) {
  const lines = tail.split("\n");
  for (let at = lines.length - 1; at >= 1; at -= 1) {
    let event;
    try {
      event = JSON.parse(lines[at]);
    } catch {
      continue;
    }
    if (event?.type === "ai-title" && typeof event.aiTitle === "string") return condense(event.aiTitle);
  }
  return null;
}

function activityFrom(tail) {
  const lines = tail.split("\n");
  for (let at = lines.length - 1; at >= 1; at -= 1) {
    let event;
    try {
      event = JSON.parse(lines[at]);
    } catch {
      continue;
    }
    const content = event?.message?.content;
    if (event?.type === "assistant" && Array.isArray(content)) {
      const tool = content.find((part) => part?.type === "tool_use" && typeof part.name === "string");
      if (tool) return `using ${tool.name}`;
    }
    const text = textOf(content);
    if (event?.type === "assistant" && text) return condense(text);
  }
  return null;
}

/**
 * A session's own words: the request that opened it and the last thing it did. Read from
 * both ends of the transcript in bounded windows — these files reach 145 MB.
 */
export function describeTranscript(path) {
  const none = { title: null, activity: null };
  if (!path) return none;
  let fd;
  try {
    fd = openSync(path, "r");
  } catch {
    return none;
  }
  try {
    const size = fstatSync(fd).size;
    const head = readWindow(fd, 0, Math.min(size, TRANSCRIPT_WINDOW_BYTES));
    const tail =
      size <= TRANSCRIPT_WINDOW_BYTES
        ? head
        : readWindow(fd, size - TRANSCRIPT_WINDOW_BYTES, TRANSCRIPT_WINDOW_BYTES);
    return { title: aiTitleFrom(tail) ?? titleFrom(head), activity: activityFrom(tail) };
  } catch {
    return none;
  } finally {
    closeSync(fd);
  }
}

function transcriptFor(cwd, sessionId) {
  // Claude Code slugs a project directory by replacing both "/" and "." with "-".
  const projects = join(homedir(), ".claude", "projects");
  const path = join(projects, cwd.replace(/[/.]/g, "-"), `${sessionId}.jsonl`);
  return existsSync(path) ? path : null;
}

/**
 * A session that moved, or was started from another directory, files its transcript under the
 * project it opened in. This scans every project directory, so it runs only for an entry that
 * has no transcript recorded yet, never on the per-sweep hot path.
 */
function transcriptAnywhere(cwd, sessionId) {
  const direct = transcriptFor(cwd, sessionId);
  if (direct) return direct;
  const projects = join(homedir(), ".claude", "projects");
  let dirs;
  try {
    dirs = readdirSync(projects);
  } catch {
    return null;
  }
  for (const dir of dirs) {
    const candidate = join(projects, dir, `${sessionId}.jsonl`);
    if (existsSync(candidate)) return candidate;
  }
  return null;
}

function resumeTargetOf(proc) {
  if (proc.runtime !== "claude") return null;
  const at = proc.argv.indexOf("--resume");
  const value = at === -1 ? null : proc.argv[at + 1];
  return value && /^[0-9a-f-]{36}$/.test(value) ? value : null;
}

function writeEntry(root, entry) {
  const dir = sessionsDir(root);
  mkdirSync(dir, { recursive: true, mode: 0o700 });
  const path = join(dir, `${entry.ledgerId}.json`);
  const tmp = `${path}.${process.pid}.tmp`;
  writeFileSync(tmp, `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 });
  renameSync(tmp, path);
}

function ledgerIdFor(runtime, startedAt) {
  const stamp = (startedAt ?? new Date().toISOString()).replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
  return `${runtime}-${stamp}-${String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0")}`;
}

function bootId() {
  try {
    return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || null;
  } catch {
    return null;
  }
}

function ttyOf(pid) {
  const fd0 = realReadlink(`/proc/${pid}/fd/0`);
  return fd0 && fd0.startsWith("/dev/") ? fd0 : null;
}

function parentLabel(attribution, entries) {
  if (!attribution?.parentPid) return { parentLedgerId: null, parent: null };
  const ticks = pidStartTicks(attribution.parentPid);
  const owner = entries.find(
    (e) => e.pid === attribution.parentPid && Number(e.pidStartTicks) === ticks,
  );
  if (owner) return { parentLedgerId: owner.ledgerId, parent: owner.ledgerId };
  return { parentLedgerId: null, parent: `${attribution.parentRuntime} (pid ${attribution.parentPid})` };
}

/**
 * AGENT_LEDGER_ID is exported into the whole process tree, so a daemon or a nested runtime
 * inherits the id of the session that spawned it. Adopting it blindly overwrites that
 * session's record with the child's facts. The id is only this process's own when the shim
 * wrote the record for a launch that has not yet been bound to a process, and that launcher
 * is this process or one of its ancestors.
 */
function adoptsCarriedId(carried, entries, pid) {
  const existing = entries.find((e) => e.ledgerId === carried);
  if (!existing) return false;
  if (existing.finishedAt || existing.pid !== null) return false;
  for (let cursor = pid, hops = 0; cursor !== null && cursor > 1 && hops < ANCESTOR_HOPS; hops += 1) {
    if (cursor === existing.launcherPid) return true;
    cursor = parentPid(cursor);
  }
  return false;
}

const DTACH_CREATE_MODES = new Set(["-c", "-A", "-n", "-N"]);

/**
 * A dead hooked session is normally kept open, because ORPHANED is how unfinished work is found.
 * A row that never held a conversation id or a transcript points at nothing recoverable, so
 * leaving it open would grow one permanent LOST row per short-lived headless run. Only applies
 * to a row that once carried a pid — a row still waiting for its process looks identical.
 */
function nothingToRecover(entry) {
  return !entry.sessionId && !entry.transcriptPath;
}

function flagValue(argv, flag) {
  const at = argv.indexOf(flag);
  return at >= 0 && at + 1 < argv.length ? argv[at + 1] : null;
}

/**
 * The multiplexer a running session sits inside, read from its ancestors rather than from
 * whatever the launcher remembered. A session enrolled by discovery never told anyone it was
 * wrapped, and an unwrapped session must keep reporting nothing to attach to.
 */
export function muxOf(pid, { execFile = execFileSync } = {}) {
  const none = { kind: null, socket: null, target: null };
  const chain = [];
  for (let cursor = pid, hops = 0; cursor !== null && cursor > 1 && hops < ANCESTOR_HOPS; hops += 1) {
    chain.push(cursor);
    const argv = procArgv(cursor);
    const name = argv.length > 0 ? baseName(argv[0]) : "";
    if (name === "dtach") {
      // dtach's own mode is always argv[1] and its socket argv[2]; anything later belongs to the
      // wrapped command. `-a` is a client attaching, not the session this process lives in.
      const socket = DTACH_CREATE_MODES.has(argv[1]) ? (argv[2] ?? null) : null;
      if (socket) return { kind: "dtach", socket, target: socket };
    }
    if ((procText(cursor, "comm") ?? "").trim() === "tmux: server") {
      const socket = flagValue(argv, "-S");
      if (socket) return tmuxSessionOf(socket, chain, execFile) ?? none;
    }
    cursor = parentPid(cursor);
  }
  return none;
}

function tmuxSessionOf(socket, chain, execFile) {
  let listing;
  try {
    listing = execFile("tmux", ["-S", socket, "list-panes", "-a", "-F", "#{pane_pid} #{session_name}"], {
      encoding: "utf8",
      timeout: 5000,
      stdio: ["ignore", "pipe", "ignore"],
    });
  } catch {
    return null;
  }
  const members = new Set(chain);
  for (const line of listing.split("\n")) {
    const [panePid, ...rest] = line.trim().split(" ");
    if (rest.length > 0 && members.has(Number(panePid))) {
      return { kind: "tmux", socket, target: rest.join(" ") };
    }
  }
  return null;
}

/** Identity of the process behind a record: unique per boot, immune to PID reuse. */
export function processKey(entry) {
  if (!Number.isInteger(entry.pid) || entry.pidStartTicks === null) return null;
  return `${entry.bootId ?? bootId()}:${entry.pid}:${entry.pidStartTicks}`;
}

/** Everything about a running session that /proc alone can prove. */
export function describeProcess(proc, entries) {
  const cwd = realReadlink(`/proc/${proc.pid}/cwd`);
  const facts = cwd === null
    ? { repoRoot: null, project: null, branch: null, worktree: false }
    : repoFacts(cwd);
  const attribution = launchAttribution(proc.pid);
  const { parentLedgerId, parent } = parentLabel(attribution, entries);
  const sessionId = resumeTargetOf(proc);
  const mux = muxOf(proc.pid);
  return {
    startedAt: processStartedAt(proc.startTicks),
    cwd,
    ...facts,
    tty: ttyOf(proc.pid),
    launchedBy: attribution?.launchedBy ?? null,
    parent,
    parentLedgerId,
    account: accountOf(proc.pid),
    mux,
    tmuxSession: mux.kind === "tmux" ? mux.target : null,
    tmuxSocket: mux.kind === "tmux" ? mux.socket : null,
    sessionId,
    transcriptPath: sessionId && cwd ? transcriptFor(cwd, sessionId) : null,
  };
}

function discoveredEntry(proc, entries) {
  const described = describeProcess(proc, entries);
  return {
    schemaVersion: 1,
    ledgerId: ledgerIdFor(proc.runtime, described.startedAt),
    runtime: proc.runtime,
    startedAt: described.startedAt,
    host: hostname(),
    bootId: bootId(),
    cwd: described.cwd,
    repoRoot: described.repoRoot,
    project: described.project,
    branch: described.branch,
    worktree: described.worktree,
    tty: described.tty,
    launcherPid: null,
    parentLedgerId: described.parentLedgerId,
    parent: described.parent,
    launchedBy: described.launchedBy,
    account: described.account,
    enrolledBy: "discovery",
    mux: described.mux,
    tmuxSession: described.tmuxSession,
    tmuxSocket: described.tmuxSocket,
    pid: proc.pid,
    pidStartTicks: proc.startTicks,
    cpuTicks: null,
    sessionId: described.sessionId,
    transcriptPath: described.transcriptPath,
    lastActiveAt: null,
    lastHeartbeatAt: null,
    finishedAt: null,
    finishReason: null,
  };
}

/**
 * Enrols one known runtime process. Used by a runtime's own hook, which can read its
 * ancestors even from inside a jail that hides every sibling session from a full sweep.
 */
export function enrollProcess(pid, runtime, { root = ledgerDir() } = {}) {
  const stat = procStat(pid);
  if (!stat) return null;
  const entry = discoveredEntry(
    { pid, runtime, startTicks: stat.starttime, argv: procArgv(pid) },
    readEntries({ root }),
  );
  entry.enrolledBy = "hook";
  writeEntry(root, entry);
  return entry.ledgerId;
}

/**
 * Enrols every running AI CLI the ledger does not already name, and refreshes what a live
 * process can still tell us about the ones it does. This is the enrolment point no launcher
 * can bypass: it asks the kernel, not the launcher.
 */
export function sweep({ root = ledgerDir(), now = () => Date.now() } = {}) {
  const entries = readEntries({ root });
  const live = entries.filter((e) => !e.finishedAt);
  // Scanning every /proc/*/environ costs about as much as the rest of the sweep together, and
  // an entry whose recorded pid still checks out does not need it. Only the rest do.
  const unresolved = live.filter((e) => resolveRecordedPid(e) === null).map((e) => e.ledgerId);
  const byEnv = resolvePids(unresolved, {
    runtimeById: new Map(live.map((e) => [e.ledgerId, e.runtime])),
  });

  const claimed = new Set();
  for (const pid of byEnv.values()) {
    const stat = procStat(pid);
    if (stat) claimed.add(`${pid}:${stat.starttime}`);
  }
  for (const entry of live) {
    if (Number.isInteger(entry.pid)) claimed.add(`${entry.pid}:${entry.pidStartTicks}`);
  }

  const enrolled = [];
  const unverifiable = [];
  const rescued = [];
  for (const proc of runtimeProcesses()) {
    if (claimed.has(`${proc.pid}:${proc.startTicks}`)) continue;
    // /proc/<pid>/environ is ptrace-gated across bwrap user namespaces. Unreadable means we
    // cannot prove this process is not already enrolled, and a duplicate row is a lie.
    const env = procEnv(proc.pid);
    if (env === null) {
      // Gone between the scan and this read is a race, not a coverage gap.
      if (procStat(proc.pid)) unverifiable.push(proc.pid);
      continue;
    }
    const entry = discoveredEntry(proc, entries);
    const carried = env.get("AGENT_LEDGER_ID");
    if (carried && adoptsCarriedId(carried, entries, proc.pid)) entry.ledgerId = carried;
    writeEntry(root, entry);
    enrolled.push(entry.ledgerId);
  }

  const refreshed = [];
  for (const entry of live) {
    const pid = byEnv.get(entry.ledgerId) ?? resolveRecordedPid(entry);
    const stat = pid === null || pid === undefined ? null : procStat(pid);
    if (!stat) {
      let salvage = null;
      try {
        salvage = rescueUncommitted(entry, { root, now });
      } catch {
        // best-effort; a repo that refuses a snapshot must not stall the sweep
      }
      if (salvage) rescued.push({ ledgerId: entry.ledgerId, ...salvage });
      const hooked = HOOKED_RUNTIMES.has(entry.runtime);
      const phantom = hooked && Number.isInteger(entry.pid) && nothingToRecover(entry);
      if (hooked && !phantom) {
        if (salvage && updateEntry(entry.ledgerId, salvage, { root })) refreshed.push(entry.ledgerId);
        continue;
      }
      const closed = updateEntry(
        entry.ledgerId,
        {
          ...salvage,
          finishedAt: new Date(now()).toISOString(),
          finishReason: phantom ? "process exited, nothing was recorded to recover" : "process exited",
        },
        { root },
      );
      if (closed) refreshed.push(entry.ledgerId);
      continue;
    }
    const patch = {};
    if (entry.pid !== pid || Number(entry.pidStartTicks) !== stat.starttime) {
      patch.pid = pid;
      patch.pidStartTicks = stat.starttime;
    }
    const ticks = stat.utime + stat.stime;
    // A runtime sitting at its prompt still burns ticks on its own UI, so tick growth is only
    // an activity signal where nothing better exists. A transcript records the real thing.
    if (!entry.transcriptPath && Number.isFinite(entry.cpuTicks) && ticks > entry.cpuTicks) {
      patch.lastActiveAt = new Date(now()).toISOString();
    }
    if (entry.cpuTicks !== ticks) patch.cpuTicks = ticks;
    const proc = { pid, runtime: entry.runtime, startTicks: stat.starttime, argv: procArgv(pid) };
    const described = describeProcess(proc, entries);
    for (const [key, value] of Object.entries(described)) {
      if (key === "mux") continue;
      if (value !== null && (entry[key] === null || entry[key] === undefined)) patch[key] = value;
    }
    // A session the launcher never reported as wrapped can still be sitting in a multiplexer.
    if (described.mux.kind !== null && entry.mux?.kind == null) patch.mux = described.mux;
    const sessionId = entry.sessionId ?? patch.sessionId ?? null;
    if (!entry.transcriptPath && sessionId) {
      const transcript = transcriptAnywhere(entry.cwd ?? homedir(), sessionId);
      if (transcript) patch.transcriptPath = transcript;
    }
    if (Object.keys(patch).length > 0 && updateEntry(entry.ledgerId, patch, { root })) {
      refreshed.push(entry.ledgerId);
    }
  }
  // Pruning was otherwise reachable only from the claude SessionEnd hook, so a stretch with no
  // claude session ending — a box running only codex, or a row this sweep closes itself — kept
  // every expired row.
  let pruned = 0;
  try {
    pruned = pruneFinished({ root, now: now() });
  } catch {
    // best-effort; a failed prune must not discard the enrolments this sweep just made
  }
  return { enrolled, refreshed, unverifiable, pruned, rescued };
}
