#!/usr/bin/env node
// Keeps the agent session ledger honest for Claude Code sessions and surfaces abandoned
// ones the user would otherwise never come back to.
//
//   SessionStart      bind this session's id + transcript to its ledger entry, then report
//                     abandoned sessions that still hold uncommitted work
//   UserPromptSubmit  heartbeat
//   Stop              heartbeat
//   SessionEnd        mark finished (this is what makes a dead session ORPHANED or not)
//
// Notification is edge-triggered: once per (ledgerId, state), recorded in notified.json.
// Canonical doctrine: ~/.claude/projects/-home-user/memory/feedback_notifications_edge_triggered.md
// Contract: overdeck docs/agent-session-ledger.md.

import { existsSync, mkdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
  classify,
  enrollProcess,
  ledgerDir,
  pidStartTicks,
  pruneFinished,
  readEntries,
  sessionsDir,
  updateEntry,
} from "../lib/agent-session-reader.mjs";

const NOTIFY_STATES = new Set(["ORPHANED"]);
const RUNTIME = "claude";

function readStdin() {
  return new Promise((resolve) => {
    let data = "";
    process.stdin.setEncoding("utf8");
    process.stdin.on("data", (chunk) => {
      data += chunk;
    });
    process.stdin.on("end", () => resolve(data));
    process.stdin.on("error", () => resolve(""));
  });
}

function notifiedPath() {
  return join(ledgerDir(), "notified.json");
}

function readNotified() {
  try {
    const parsed = JSON.parse(readFileSync(notifiedPath(), "utf8"));
    return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
  } catch {
    return {};
  }
}

function writeNotified(store) {
  const path = notifiedPath();
  mkdirSync(ledgerDir(), { recursive: true, mode: 0o700 });
  const tmp = `${path}.${process.pid}.tmp`;
  writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
  renameSync(tmp, path);
}

/** Sessions whose state is newly worth telling the user about. Mutates and persists dedupe. */
export function selectUnnotified(sessions, store) {
  const fresh = [];
  for (const session of sessions) {
    if (!NOTIFY_STATES.has(session.state)) continue;
    if (!session.dirtyCount) continue;
    if (store[session.ledgerId]?.state === session.state) continue;
    store[session.ledgerId] = { state: session.state, at: new Date().toISOString() };
    fresh.push(session);
  }
  return fresh;
}

function describe(session) {
  const where = session.project
    ? `${session.project}${session.branch ? ` (${session.branch})` : ""}`
    : session.cwd;
  const files = session.rescueRef
    ? `${session.rescuedPaths} uncommitted path(s) rescued to ${session.rescueRef}`
    : `${session.dirtyCount} uncommitted file${session.dirtyCount === 1 ? "" : "s"}`;
  const resume =
    session.runtime === "claude" && session.sessionId
      ? `  resume: cd ${session.cwd} && claude --resume ${session.sessionId}`
      : `  directory: ${session.cwd}`;
  return `- ${session.runtime} in ${where} — ${files}, left behind ${
    session.idleMs === null ? "at an unknown time" : `${Math.round(session.idleMs / 60000)}m ago`
  }\n${resume}`;
}

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

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

/** The runtime process this hook was spawned under, found by walking its ancestors. */
function runtimePid() {
  let pid = parentPid(process.pid);
  for (let hops = 0; pid !== null && pid > 1 && hops < 16; hops += 1) {
    const comm = (procFile(pid, "comm") ?? "").trim();
    const argv0 = (procFile(pid, "cmdline") ?? "").split("\0")[0];
    if (comm === RUNTIME || argv0.split("/").pop() === RUNTIME) return pid;
    pid = parentPid(pid);
  }
  return null;
}

/** The runtime's own identity, which is what a birth record must describe. */
function runtimeIdentity() {
  const pid = runtimePid();
  if (pid === null) return null;
  const ticks = pidStartTicks(pid);
  if (ticks === null) return null;
  let cwd = null;
  let tty = null;
  try {
    cwd = readlinkSync(`/proc/${pid}/cwd`);
  } catch {
    cwd = null;
  }
  try {
    const fd0 = readlinkSync(`/proc/${pid}/fd/0`);
    if (fd0.startsWith("/dev/")) tty = fd0;
  } catch {
    tty = null;
  }
  return { pid, ticks, cwd, tty };
}

function entryForProcess(identity) {
  return (
    readEntries().find(
      (e) => !e.finishedAt && e.pid === identity.pid && e.pidStartTicks === identity.ticks,
    ) ?? null
  );
}

/**
 * The ledger id of the session this hook is running inside. AGENT_LEDGER_ID covers launches
 * that crossed the PATH shim; every other launcher reaches the ledger only through the entry
 * this hook adopted, which is keyed by the runtime's pid and start time.
 */
function currentLedgerId() {
  const fromEnv = process.env.AGENT_LEDGER_ID;
  if (fromEnv && existsSync(join(sessionsDir(), `${fromEnv}.json`))) return fromEnv;
  const identity = runtimeIdentity();
  return identity === null ? null : (entryForProcess(identity)?.ledgerId ?? null);
}

/**
 * Enrols a session whose launcher never crossed the PATH shim. Sessions the shim already
 * recorded keep their entry: adoption only runs when no entry names this process.
 */
function adopt() {
  const identity = runtimeIdentity();
  if (identity === null) return null;
  const existing = entryForProcess(identity);
  const ledgerId = existing?.ledgerId ?? enrollProcess(identity.pid, RUNTIME);
  if (!ledgerId) return null;
  return { ledgerId, identity, existing };
}

/** True when this hook is running for a subagent, whose transcript is not the session's. */
function isSidechain(transcriptPath) {
  if (!transcriptPath) return false;
  try {
    const head = readFileSync(transcriptPath, "utf8").slice(0, 8192).split("\n")[0];
    return JSON.parse(head)?.isSidechain === true;
  } catch {
    return false;
  }
}

/**
 * Fills the fields only the runtime can supply, on every event rather than at enrolment
 * alone: a session discovered while already running has no session id until it speaks.
 */
function bindSession(ledgerId, input) {
  const patch = { lastHeartbeatAt: new Date().toISOString() };
  const entry = readEntries().find((e) => e.ledgerId === ledgerId);
  if (input.session_id && !entry?.sessionId && !isSidechain(input.transcript_path)) {
    patch.sessionId = input.session_id;
    patch.transcriptPath = input.transcript_path ?? null;
  }
  updateEntry(ledgerId, patch);
}

async function onSessionStart(input) {
  const ledgerId = currentLedgerId() ?? adopt()?.ledgerId ?? null;
  if (ledgerId) {
    const identity = runtimeIdentity();
    if (identity !== null) {
      updateEntry(ledgerId, { pid: identity.pid, pidStartTicks: identity.ticks, tty: identity.tty });
    }
    bindSession(ledgerId, input);
  }

  const sessions = await classify(readEntries(), { sampleMs: 150 });
  const store = readNotified();
  const fresh = selectUnnotified(sessions, store);
  if (fresh.length === 0) return;
  writeNotified(store);

  const body = [
    `${fresh.length} agent session${fresh.length === 1 ? "" : "s"} died with uncommitted work still on disk.`,
    "Tell the user about these before starting new work on the same repos — this is the double-work they asked to stop.",
    ...fresh.map(describe),
    "Full list: agent-sessions",
  ].join("\n");

  process.stdout.write(
    `${JSON.stringify({
      hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: body },
    })}\n`,
  );
}

function heartbeat(input) {
  const ledgerId = currentLedgerId() ?? adopt()?.ledgerId ?? null;
  if (!ledgerId) return;
  bindSession(ledgerId, input);
}

// Claude Code's documented SessionEnd `reason` vocabulary, in the ledger's own prose. "other" is
// its catch-all for a termination it does not classify, and no other field on the payload narrows
// it, so it states no reason and the field stays absent. A token not listed here is passed through
// verbatim rather than dropped or guessed at.
// `resume` is deliberately absent: its direction is undocumented, so it takes the verbatim path.
const END_REASONS = {
  clear: "cleared by the user",
  logout: "logged out",
  prompt_input_exit: "exited at the prompt",
  bypass_permissions_disabled: "bypass-permissions mode disabled",
  other: null,
};

function endReason(reason) {
  if (typeof reason !== "string" || reason === "") return null;
  return Object.hasOwn(END_REASONS, reason) ? END_REASONS[reason] : reason;
}

function finish(input) {
  const ledgerId = currentLedgerId();
  if (ledgerId) {
    updateEntry(ledgerId, {
      finishedAt: new Date().toISOString(),
      finishReason: endReason(input.reason),
    });
  }
  try {
    pruneFinished();
  } catch {
    // retention is best-effort; never block session end on it
  }
}

async function main() {
  let input = {};
  try {
    input = JSON.parse((await readStdin()) || "{}");
  } catch {
    input = {};
  }
  const event = input.hook_event_name ?? process.argv[2] ?? "";
  if (event === "SessionStart") await onSessionStart(input);
  else if (event === "SessionEnd") finish(input);
  else heartbeat(input);
}

if (process.env.AGENT_SESSION_LEDGER_HOOK_TEST !== "1") {
  main().catch(() => process.exit(0));
}
