#!/usr/bin/env node
// Recovery surface for agent sessions that outlived their terminal.
// It never commits, stashes or cleans; `reap` is the only path that signals a process.
//
//   agent-sessions              list every session that is not cleanly finished, plus every
//                                box-resident session found on a reachable buildbox
//   agent-sessions --all        include cleanly finished sessions
//   agent-sessions --json       machine-readable ({ local, boxResident })
//   agent-sessions --no-remote  skip the buildbox probe (local ledger only)
//   agent-sessions attach ID    reopen a session that is still running
//   agent-sessions reap         stop idle detached sessions and orphan tmux scopes (--dry-run previews)
//
// Contract: overdeck docs/agent-session-ledger.md.

import { spawn, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import {
  REAP_IDLE_MS,
  classify,
  closeSessionTmux,
  ledgerDir,
  readEntries,
  reapCandidates,
  rescueRestoreCommand,
  sweep,
  updateEntry,
} from "../lib/agent-session-reader.mjs";
import { loadRegistry, resolveAccess } from "../lib/buildbox-registry.mjs";
import { sshOpts, sshTarget } from "../lib/remote-ssh.mjs";

const REAP_HELPER = fileURLToPath(new URL("./_agent-session-reap-close.py", import.meta.url));
const RESIDENT_PROBE_TIMEOUT_MS = 5000;

// Box-resident sessions (docs/plans/2026-08-15-laptop-as-terminal.md S2/S4) live entirely on a
// buildbox — no ledger entry here, because nothing local ever ran. Best-effort and read-only: an
// unreachable or slow host is skipped, never blocks the local listing it's appended to.
function remoteResidentSessions() {
  let registry;
  try { registry = loadRegistry(); } catch { return []; }
  const found = [];
  for (const host of registry.hosts ?? []) {
    if (host.state !== "reachable") continue;
    let access;
    try { access = resolveAccess(registry, host.name, "tailscale_ip"); } catch { continue; }
    const cfg = { host: access.host, port: access.port, ssh_user: access.user, identity_file: access.identity_file, connect_timeout_sec: 4 };
    const probe = spawnSync(
      "ssh",
      [...sshOpts(cfg, { mux: false }), sshTarget(cfg),
        "tmux list-sessions -F '#{session_name}' 2>/dev/null | grep '^resident-' || true; " +
          "podman ps -a --filter 'name=harness-resident-' --format '{{.Names}}\\t{{.Status}}' 2>/dev/null || true"],
      { encoding: "utf8", timeout: RESIDENT_PROBE_TIMEOUT_MS },
    );
    if (probe.status !== 0 && probe.status !== null) continue;
    const lines = String(probe.stdout || "").split("\n").map((l) => l.trim()).filter(Boolean);
    const sessions = lines.filter((l) => l.startsWith("resident-") && !l.includes("\t"));
    const containers = new Map(
      lines.filter((l) => l.includes("\t")).map((l) => { const [name, ...rest] = l.split("\t"); return [name, rest.join("\t")]; }),
    );
    for (const session of sessions) {
      const slug = session.replace(/^resident-/, "");
      found.push({ host: host.name, sshAlias: host.ssh_alias || host.name, tmuxSession: session, slug, containerStatus: containers.get(`harness-resident-${slug}`) ?? "unknown" });
    }
  }
  return found;
}

const SEAT_TMUX_MEDIATOR = "/usr/local/bin/overdeck-seat-tmux-mediator";
const SEAT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const GENERATION_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

const STATE_LABEL = {
  "ALIVE-WORKING": "working",
  "ALIVE-IDLE": "idle",
  "DETACHED-ALIVE": "open, no window",
  ORPHANED: "LOST",
  FINISHED: "finished",
};

function humanAge(ms) {
  if (ms === null || Number.isNaN(ms)) return "unknown";
  const minutes = Math.round(ms / 60_000);
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 48) return `${hours}h ago`;
  return `${Math.round(hours / 24)}d ago`;
}

function dtachTarget(session) {
  return session.mux?.kind === "dtach" && session.mux.socket && existsSync(session.mux.socket)
    ? session.mux.socket
    : null;
}

function authorityRouting(session) {
  if (session.sessionAuthority === undefined) return null;
  const routing = session.sessionAuthority;
  if (
    routing?.kind !== "seat" || !SEAT_ID_RE.test(routing.seatId ?? "")
    || !GENERATION_RE.test(routing.generation ?? "")
  ) return false;
  return routing;
}

function tmuxTarget(session) {
  const socket = session.tmuxSocket ?? (session.mux?.kind === "tmux" ? session.mux.socket : null);
  const name = session.tmuxSession ?? (session.mux?.kind === "tmux" ? session.mux.target : null);
  if (!name || !socket || !existsSync(socket)) return null;
  const routing = authorityRouting(session);
  if (routing === false) return null;
  const command = routing === null ? "tmux" : "sudo";
  const args = routing === null
    ? ["-S", socket, "has-session", "-t", name]
    : ["-n", SEAT_TMUX_MEDIATOR, "--seat-id", routing.seatId, "--socket", socket,
      "--generation", routing.generation, "has-session", "-t", name];
  const probe = spawnSync(command, args, { stdio: "ignore" });
  return probe.status === 0 ? { socket, name, routing } : null;
}

function attachable(session) {
  return dtachTarget(session) !== null || tmuxTarget(session) !== null;
}

async function cmdList(argv) {
  const all = argv.includes("--all");
  const asJson = argv.includes("--json");
  const noRemote = argv.includes("--no-remote");
  const sessions = await classify(readEntries());
  const shown = all ? sessions : sessions.filter((s) => s.state !== "FINISHED");
  const resident = noRemote ? [] : remoteResidentSessions();

  if (asJson) {
    process.stdout.write(`${JSON.stringify({ local: shown, boxResident: resident }, null, 2)}\n`);
    return 0;
  }

  if (resident.length > 0) {
    process.stdout.write("\nbox-resident sessions\n");
    for (const r of resident) {
      process.stdout.write(`\nresident  ${r.host}  slug=${r.slug}\n`);
      process.stdout.write(`  container      ${r.containerStatus}\n`);
      process.stdout.write(`  attach it      ssh ${r.sshAlias} -t 'tmux attach -t ${r.tmuxSession}'\n`);
    }
    process.stdout.write("\n");
  }

  if (shown.length === 0) {
    if (resident.length === 0) process.stdout.write("No open or abandoned agent sessions.\n");
    return 0;
  }

  for (const s of shown) {
    const dirty =
      s.dirtyCount === null
        ? "uncommitted work unknown"
        : s.dirtyCount === 0
          ? "no uncommitted work"
          : `${s.dirtyCount} uncommitted file${s.dirtyCount === 1 ? "" : "s"}`;
    const where = s.project ? `${s.project}${s.branch ? ` @ ${s.branch}` : ""}` : s.cwd;

    process.stdout.write(`\n${STATE_LABEL[s.state] ?? s.state}  ${s.runtime}  ${where}\n`);
    process.stdout.write(`  last activity  ${humanAge(s.idleMs)}\n`);
    process.stdout.write(`  directory      ${s.cwd}\n`);
    process.stdout.write(`  ${dirty}\n`);
    if (s.rescueRef) {
      process.stdout.write(`  rescued        ${s.rescuedPaths ?? "?"} path(s) into ${s.rescueRef}\n`);
      process.stdout.write(`  restore it     ${rescueRestoreCommand(s)}\n`);
    }
    if (s.transcriptPath) process.stdout.write(`  transcript     ${s.transcriptPath}\n`);
    if (s.evidence) process.stdout.write(`  why            ${s.evidence}\n`);
    if (attachable(s) && s.state !== "FINISHED") {
      process.stdout.write(`  reopen it      agent-sessions attach ${s.ledgerId}\n`);
    } else if (s.state !== "FINISHED" && s.runtime === "claude" && s.sessionId) {
      process.stdout.write(`  resume it      cd ${s.cwd} && claude --resume ${s.sessionId}\n`);
    }
  }
  process.stdout.write("\n");
  return 0;
}

async function cmdAttach(ledgerId) {
  if (!ledgerId) {
    process.stderr.write("agent-sessions attach: session id required\n");
    return 2;
  }
  const sessions = readEntries();
  const entry = sessions.find((s) => s.ledgerId === ledgerId);
  if (!entry) {
    process.stderr.write(`agent-sessions: unknown session ${ledgerId}\n`);
    return 1;
  }
  const tmux = tmuxTarget(entry);
  const socket = entry.mux?.socket ?? join(ledgerDir(), "sock", ledgerId);
  const dtach = entry.mux?.kind === "dtach" && existsSync(socket) ? socket : null;
  if (!tmux && !dtach) {
    // A session launched outside the PATH shim holds no multiplexer socket, so there is no
    // terminal to reattach — but the conversation is still resumable by its own session id.
    process.stderr.write(
      !entry.finishedAt && entry.runtime === "claude" && entry.sessionId
        ? `agent-sessions: ${ledgerId} has no terminal to reattach — resume it with:\n` +
            `  cd ${entry.cwd} && claude --resume ${entry.sessionId}\n`
        : `agent-sessions: ${ledgerId} cannot be reopened — it is not running any more.\n` +
            (entry.transcriptPath ? `Its transcript is at ${entry.transcriptPath}\n` : ""),
    );
    return 1;
  }
  const [command, args] = tmux
    ? tmux.routing === null
      ? ["tmux", ["-S", tmux.socket, "attach-session", "-t", tmux.name]]
      : ["sudo", ["-n", SEAT_TMUX_MEDIATOR, "--seat-id", tmux.routing.seatId,
        "--socket", tmux.socket, "--generation", tmux.routing.generation,
        "attach-session", "-t", tmux.name]]
    : ["dtach", ["-a", dtach, "-E", "-z", "-r", "winch"]];
  return await new Promise((resolve) => {
    const child = spawn(command, args, { stdio: "inherit" });
    child.on("error", (error) => {
      process.stderr.write(`agent-sessions: could not reopen: ${error.message}\n`);
      resolve(1);
    });
    child.on("exit", (code) => resolve(code ?? 0));
  });
}

async function stopSession(session) {
  const preparedAt = new Date().toISOString();
  try {
    if (!updateEntry(session.ledgerId, { reapPreparedAt: preparedAt })) {
      return { closed: false, recorded: false, persistenceFailed: true };
    }
  } catch {
    return { closed: false, recorded: false, persistenceFailed: true };
  }

  if (
    !closeSessionTmux(session, session.tmuxIdentity, {
      pid: session.pid,
      startTicks: session.reapProcessStartTicks,
    })
  ) {
    try {
      const recorded = updateEntry(session.ledgerId, { reapPreparedAt: null });
      return { closed: false, recorded, persistenceFailed: !recorded };
    } catch {
      return { closed: false, recorded: false, persistenceFailed: true };
    }
  }

  try {
    const recorded = updateEntry(session.ledgerId, {
      finishedAt: new Date().toISOString(),
      finishReason: "reaped: idle and detached",
      reapPreparedAt: null,
    });
    return { closed: true, recorded, persistenceFailed: !recorded };
  } catch {
    return { closed: true, recorded: false, persistenceFailed: true };
  }
}

async function reapOrphanScopes(dryRun) {
  const args = ["--orphan-scopes", ...(dryRun ? ["--dry-run"] : [])];
  const result = spawnSync("python3", [REAP_HELPER, ...args], { encoding: "utf8" });
  if (result.stdout) process.stdout.write(result.stdout);
  if (result.stderr) process.stderr.write(result.stderr);
  if (result.error) {
    process.stderr.write(`agent-sessions: orphan scope cleanup failed: ${result.error.message}\n`);
    return false;
  }
  return result.status === 0;
}

async function cmdReap(argv) {
  const dryRun = argv.includes("--dry-run");
  const sessions = await classify(readEntries());
  const candidates = reapCandidates(sessions);
  const idleMinutes = Math.round(REAP_IDLE_MS / 60_000);
  const orphanScopesOk = await reapOrphanScopes(dryRun);

  let reaped = 0;
  let persistenceFailed = false;
  for (const s of candidates) {
    const where = s.project ?? s.cwd;
    const line = `${s.ledgerId}  ${s.runtime}  ${where}  idle ${humanAge(s.idleMs)}`;
    if (dryRun) {
      process.stdout.write(`would reap ${line}\n`);
      continue;
    }
    const result = await stopSession(s);
    if (!result.closed) {
      if (result.persistenceFailed) {
        persistenceFailed = true;
        process.stderr.write(`agent-sessions: kept ${s.ledgerId}: ledger is not writable\n`);
      }
      process.stdout.write(
        `kept ${line} (${result.persistenceFailed ? "ledger unavailable" : "attachment or identity changed"})\n`,
      );
      continue;
    }
    reaped += 1;
    process.stdout.write(`reaped ${line}\n`);
    if (!result.recorded) {
      persistenceFailed = true;
      process.stderr.write(`agent-sessions: reaped ${s.ledgerId}, but could not mark its ledger entry finished\n`);
    }
    if (s.runtime === "claude" && s.sessionId) {
      process.stdout.write(`  resume it      cd ${s.cwd} && claude --resume ${s.sessionId}\n`);
    }
  }
  if (candidates.length > 0) {
    process.stdout.write(
      dryRun
        ? `would reap ${candidates.length} session(s) detached and idle over ${idleMinutes}m\n`
        : `reaped ${reaped} of ${candidates.length} candidate session(s) detached and idle over ${idleMinutes}m\n`,
    );
  }
  return persistenceFailed || !orphanScopesOk ? 1 : 0;
}

function cmdSweep() {
  const result = sweep();
  // Runs every 20s from a timer, and refreshing a CPU counter is not news.
  if (result.enrolled.length > 0) {
    process.stdout.write(
      `enrolled ${result.enrolled.length}, refreshed ${result.refreshed.length}\n` +
        result.enrolled.map((id) => `  + ${id}\n`).join(""),
    );
  }
  for (const r of result.rescued) {
    process.stdout.write(
      `rescued ${r.rescuedPaths} uncommitted path(s) from ${r.ledgerId} into ${r.rescueRef}\n`,
    );
  }
  if (result.unverifiable.length > 0) {
    process.stderr.write(
      `agent-sessions: ${result.unverifiable.length} running session(s) left unenrolled — ` +
        "their environment is unreadable from here; run this from a shell outside the agent jail\n",
    );
    return 1;
  }
  return 0;
}

const [, , sub, ...rest] = process.argv;
const exit =
  sub === "attach"
    ? await cmdAttach(rest[0])
    : sub === "sweep"
      ? cmdSweep()
      : sub === "reap"
        ? await cmdReap(rest)
        : sub === "--help" || sub === "-h"
        ? (process.stdout.write(
            "usage: agent-sessions [--all] [--json] | agent-sessions attach <session-id>\n" +
              "       agent-sessions sweep   enrol every running AI CLI, whatever launched it\n" +
              "       agent-sessions reap [--dry-run]   stop detached idle sessions and orphan\n" +
              "                              tmux-spawn scopes; session threshold is\n" +
              "                              AGENT_SESSION_REAP_IDLE_MIN (default 60) minutes\n",
          ),
          0)
        : await cmdList(process.argv.slice(2));
process.exit(exit);
