// agent-pool.js — warm Haiku worker pool for call_agent one-shot dispatch.
//
// Design (Gate B, WP-B1/B3/B4) — cold path:
//   * Each `claude -p "<prompt>"` is inherently a fresh detached process — the
//     prompt is passed as argv, so an idle pre-spawned process cannot be
//     re-targeted. The cold "warm pool" therefore does two things:
//       1. Resolves + caches the claude binary once (no per-call `--version`
//          probe) and keeps a neutral, project-free cwd hot on disk.
//       2. Optionally fires `poolSize` priming pings at startup so the OS file
//          cache / model session is warm — the FIRST real call then returns in
//          ~4-5s instead of the ~50-90s cold path.
//
// WP-P2 — TRUE persistent re-targetable warm workers:
//   * The `claude` CLI (v2.1.170) supports `-p --input-format stream-json
//     --output-format stream-json`: a single long-lived process that reads
//     newline-delimited user-message envelopes from stdin and emits
//     newline-delimited events (including a `result` per turn) on stdout. The
//     SAME process (same session_id) answers MULTIPLE sequential prompts —
//     proven: 3 prompts on one process, sid stable, 4.4s → 2.0s → 2.8s with NO
//     per-call CLI boot. This is true re-targeting: a pre-spawned idle worker IS
//     re-aimable at a new prompt over stdin.
//   * `WarmWorker` wraps one such process. `AgentPool.dispatch()` prefers a free
//     warm worker (sub-boot dispatch — latency is pure model inference, not the
//     ~10-15s CLI cold-boot) and transparently FALLS BACK to the cold spawn path
//     when warm workers are disabled, all busy, or unavailable. Fully backward
//     compatible: the public dispatch()/job-record contract is unchanged.
//   * Warm workers are health-checked and AUTO-RESTARTED on death; the reaper
//     enforces per-turn deadlines on warm turns too. Model-tiered: each pool is
//     pinned to one model (default Haiku); a Sonnet/Opus pool is a second
//     AgentPool instance.
//
//   * Lean one-shot: neutral cwd + `--setting-sources user` + `--strict-mcp-config`
//     strips the regen-root CLAUDE.md / SessionEnd hooks / lessons gate that
//     otherwise contaminates replies ("Waived." instead of the real answer).
//   * Auth: piggybacks the CLI's own login session. NEVER sets ANTHROPIC_API_KEY.
//   * Reaping: a periodic sweep kills workers that exceed their timeout and
//     frees concurrency slots so the queue self-heals.
//
// This module is intentionally self-contained and daemon-free so its core can
// be unit/integration tested WITHOUT the clauth vault or boot.key.

import { spawn } from "child_process";
import { execSync } from "child_process";
import os from "os";
import path from "path";
import fs from "fs";
import crypto from "crypto";

export const DEFAULT_MODEL = "claude-haiku-4-5";
export const DEFAULT_TIMEOUT_MS = 120000;
export const DEFAULT_POOL_SIZE = 2;
// Warm workers are ON by default. Set CLAUTH_AGENT_WARM=0 to force the cold path.
export const WARM_ENABLED_DEFAULT =
  String(process.env.CLAUTH_AGENT_WARM ?? "1") !== "0";

// ── WP-P7: hop-limited sub-agent delegation defaults ─────────────────────────
// A parent call_agent worker may itself invoke call_agent SUB-agents, bounded by
// a hop budget and routed through a SEPARATE execution lane (DelegationLane) so
// it can NEVER consume the shared warm/interactive pool slots (anti-deadlock).
export const DEFAULT_DELEGATION_MAX_HOPS = 2; // tree depth cap (hop 0 = top call)
export const DEFAULT_DELEGATION_MAX_INFLIGHT = 4; // daemon-wide concurrent sub-agents

const LEAN_SYSTEM_PROMPT =
  "You are a stateless one-shot agent worker invoked via clauth call_agent. " +
  "Answer the user's prompt directly, concisely, and completely. " +
  "Do not editorialize, do not read project files unless the prompt asks, " +
  "and do not emit meta-commentary about hooks, sessions, or governance. " +
  "If a JSON answer is requested, return ONLY valid JSON.";

// ── Tiered call_agent startup (bootstrap) ────────────────────────────────────
// A spawned worker otherwise has NO identity and NO idea what it can reach (no
// clauth creds, no Supabase, no repo). These three tiers fix that, additively:
//
//   * "none"     — today's lean fast path, byte-identical to LEAN_SYSTEM_PROMPT.
//                  For trivial one-shots; warm ~2s. Neutral, project-free cwd.
//   * "identity" — DEFAULT. Prepends a SHORT startup that tells the agent who it
//                  is and HOW to reach access (clauth daemon, Supabase ref,
//                  work_items RPCs, Bash/file tools). Neutral cwd. Low latency.
//   * "full"     — for real repo/DB work: runs in the REPO cwd and points the
//                  agent at .rdc/guides/agent-bootstrap.md (creds, git rules,
//                  completion report) plus the identity/access block.
//
// IMPORTANT: the startup TELLS the agent how to fetch credentials; it NEVER
// embeds a credential and NEVER sets ANTHROPIC_API_KEY.
export const BOOTSTRAP_LEVELS = ["none", "identity", "full"];
export const DEFAULT_BOOTSTRAP = "identity";
export const DEFAULT_AGENT_REPO_ROOT =
  process.env.CLAUTH_AGENT_REPO_ROOT || "C:/Dev/regen-root";

// The clauth credential daemon every Regen agent uses.
const CLAUTH_DAEMON_URL = "http://127.0.0.1:52437";
const SUPABASE_PROJECT_REF = "uvojezuorjgqzmhhgluu";

// Short identity/access block — a dozen lines so latency stays low. This is the
// minimum every default agent must know: who it is + how to get access.
const IDENTITY_SYSTEM_PROMPT =
  "You are a Regen agent running on the operator's machine. " +
  "ACCESS — discover service names through the clauth HTTP daemon at " +
  `${CLAUTH_DAEMON_URL}: run \`curl -s ${CLAUTH_DAEMON_URL}/knowledge\` for ` +
  "sanitized metadata, then fetch exactly one needed credential with " +
  `\`curl -s ${CLAUTH_DAEMON_URL}/v/<service>\`. ` +
  "Do not bulk-fetch secrets. NEVER print a credential to stdout — it is logged. " +
  `The Supabase project ref is ${SUPABASE_PROJECT_REF}. ` +
  "Work items live in Supabase and are accessed ONLY through RPCs " +
  "(get_open_epics, insert_work_item, update_work_item_status) — never raw " +
  "SQL CRUD against work_items. You have Bash plus file read/write tools. " +
  "Answer the user's prompt directly and completely; if JSON is requested, " +
  "return ONLY valid JSON.";

/**
 * Build the "full" startup: identity/access block + a pointer to the project
 * bootstrap guide that the agent must read first. Runs in the repo cwd.
 * @param {string} [repoRoot]
 */
function buildFullSystemPrompt(repoRoot = DEFAULT_AGENT_REPO_ROOT) {
  const root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
  return (
    IDENTITY_SYSTEM_PROMPT +
    " You are running in the repo at " +
    root +
    ". Before doing repo or database work, READ " +
    root +
    "/.rdc/guides/agent-bootstrap.md FIRST — it has the full credential, " +
    "Supabase, git (commit to develop, never main, never force-push), and " +
    "completion-report contract. Then read your role guide under " +
    root +
    "/.rdc/guides/ if one applies."
  );
}

// ── Corpus handoff (Regen Agent corpus, matching @regen/claude-dispatch) ─────
// Every Regen route agent dispatched through @regen/claude-dispatch gets a
// `## Corpus` section assembled into its prompt body from the dispatch
// envelope's `corpus` field. call_agent previously gave only a thin identity
// system prompt and NO corpus. composeCorpus() reproduces the EXACT dispatch
// format (packages/claude-dispatch/src/channels/cli.ts buildPrompt) so a
// call_agent worker starts up with the same handoff a route agent gets.
//
// Shape (DispatchCorpus): { product?: string, design?: string, docs?: CorpusDocument[] }
// CorpusDocument: { path: string, title?: string, content: string }
//
// CRITICAL — warm-reuse correctness: the corpus is assembled into the per-turn
// PROMPT (the stdin/`-p` user message), NOT the worker's persistent
// append-system-prompt. A warm worker's system prompt is fixed at spawn and
// shared across all turns; corpus content is variable per call. Because the
// corpus rides the turn's user message (composeCallAgentPrompt output) and not
// the worker identity, a generic warm worker can serve a corpus call correctly
// — the corpus is scoped to that single turn and never leaks into the next.

// Per-document and total corpus byte caps so an oversized handoff cannot blow
// the agent's context. Each doc/field is truncated to MAX_CORPUS_DOC_CHARS and
// the assembled section to MAX_CORPUS_TOTAL_CHARS; truncations are logged (not
// silently dropped). Overridable via env for ops tuning.
export const MAX_CORPUS_DOC_CHARS = Number.isFinite(Number(process.env.CLAUTH_AGENT_CORPUS_DOC_CHARS))
  ? Number(process.env.CLAUTH_AGENT_CORPUS_DOC_CHARS)
  : 24000;
export const MAX_CORPUS_TOTAL_CHARS = Number.isFinite(Number(process.env.CLAUTH_AGENT_CORPUS_TOTAL_CHARS))
  ? Number(process.env.CLAUTH_AGENT_CORPUS_TOTAL_CHARS)
  : 60000;

const BOOTSTRAP_DOC_REL_PATH = ".rdc/guides/agent-bootstrap.md";

/** Truncate a string to `cap` chars, appending a visible marker + logging. */
function capText(text, cap, label) {
  if (typeof text !== "string") return "";
  if (text.length <= cap) return text;
  const truncated = text.slice(0, cap);
  console.error(
    `[agent-pool] corpus ${label} truncated: ${text.length} → ${cap} chars (cap MAX_CORPUS_DOC_CHARS)`
  );
  return `${truncated}\n…[truncated ${text.length - cap} chars]`;
}

/**
 * Load the project bootstrap guide CONTENT from disk so a "full"-tier agent
 * gets the REAL bootstrap inline as a Supporting Doc — not just a "go read this
 * file" pointer. Returns a CorpusDocument or null if unreadable (never throws).
 * @param {string} [repoRoot]
 * @returns {{ path: string, title: string, content: string } | null}
 */
export function loadBootstrapDoc(repoRoot = DEFAULT_AGENT_REPO_ROOT) {
  const root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
  const abs = path.join(root, BOOTSTRAP_DOC_REL_PATH);
  try {
    const content = fs.readFileSync(abs, "utf8");
    if (!content || !content.trim()) return null;
    return { path: BOOTSTRAP_DOC_REL_PATH, title: "Agent Bootstrap", content };
  } catch (e) {
    console.error(`[agent-pool] bootstrap doc unreadable (${abs}): ${e.message} — skipping`);
    return null;
  }
}

/**
 * Assemble the `## Corpus` section EXACTLY as @regen/claude-dispatch does
 * (buildPrompt). Returns the full section string beginning with "## Corpus\n",
 * or "" when nothing to emit. Order: Product MD, Design MD, Supporting Docs.
 *
 * Format (byte-for-byte with dispatch):
 *   ## Corpus
 *   ### Product MD\n<product>
 *
 *   ### Design MD\n<design>
 *
 *   ### Supporting Docs
 *   - <path> (<title>)\n```\n<content>\n```      ← per doc; title parenthetical
 *                                                  omitted if no title; docs
 *                                                  joined by "\n"
 * (corpusSections joined by "\n\n"; section is "## Corpus" + "\n" + body.)
 *
 * @param {{product?:string, design?:string, docs?:Array<{path:string,title?:string,content:string}>}|null|undefined} corpus
 * @returns {string}
 */
export function composeCorpus(corpus) {
  if (!corpus || typeof corpus !== "object") return "";
  const corpusSections = [];
  if (typeof corpus.product === "string" && corpus.product) {
    corpusSections.push(`### Product MD\n${capText(corpus.product, MAX_CORPUS_DOC_CHARS, "product")}`);
  }
  if (typeof corpus.design === "string" && corpus.design) {
    corpusSections.push(`### Design MD\n${capText(corpus.design, MAX_CORPUS_DOC_CHARS, "design")}`);
  }
  if (Array.isArray(corpus.docs) && corpus.docs.length > 0) {
    const docs = corpus.docs
      .filter((d) => d && typeof d.path === "string" && typeof d.content === "string")
      .map((doc) => {
        const titleLine = doc.title ? ` (${doc.title})` : "";
        const content = capText(doc.content, MAX_CORPUS_DOC_CHARS, `doc ${doc.path}`);
        return `- ${doc.path}${titleLine}\n\`\`\`\n${content}\n\`\`\``;
      })
      .join("\n");
    if (docs) corpusSections.push(`### Supporting Docs\n${docs}`);
  }
  if (corpusSections.length === 0) return "";
  let section = `## Corpus\n${corpusSections.join("\n\n")}`;
  if (section.length > MAX_CORPUS_TOTAL_CHARS) {
    console.error(
      `[agent-pool] corpus total truncated: ${section.length} → ${MAX_CORPUS_TOTAL_CHARS} chars (cap MAX_CORPUS_TOTAL_CHARS)`
    );
    section = `${section.slice(0, MAX_CORPUS_TOTAL_CHARS)}\n…[corpus truncated]`;
  }
  return section;
}

/**
 * Build the effective corpus for a call: the caller's corpus (if any), with the
 * bootstrap doc auto-appended as a Supporting Doc when bootstrap === "full" and
 * the file is readable. Caller docs come first, then the bootstrap doc, matching
 * the merge contract (caller docs + bootstrap doc). Returns the assembled
 * `## Corpus` section string ("" when nothing to emit).
 *
 * @param {object} opts
 * @param {string} opts.bootstrap                 normalized level
 * @param {object} [opts.corpus]                  caller-supplied DispatchCorpus
 * @param {string} [opts.repoRoot]                repo root for the bootstrap doc
 * @param {function} [opts.loadDoc]               injectable loader (tests)
 * @returns {string}
 */
export function buildCorpusSection({ bootstrap, corpus, repoRoot, loadDoc } = {}) {
  const caller = corpus && typeof corpus === "object" ? corpus : null;
  let effective = caller;
  if (bootstrap === "full") {
    const load = typeof loadDoc === "function" ? loadDoc : loadBootstrapDoc;
    const bootDoc = load(repoRoot);
    if (bootDoc) {
      const callerDocs = Array.isArray(caller?.docs) ? caller.docs : [];
      effective = {
        ...(caller || {}),
        docs: [...callerDocs, bootDoc],
      };
    }
  }
  return composeCorpus(effective);
}

/**
 * Normalize a requested bootstrap level to one of BOOTSTRAP_LEVELS.
 * Unknown/absent → DEFAULT_BOOTSTRAP ("identity").
 */
export function normalizeBootstrap(level) {
  if (typeof level !== "string") return DEFAULT_BOOTSTRAP;
  const l = level.trim().toLowerCase();
  return BOOTSTRAP_LEVELS.includes(l) ? l : DEFAULT_BOOTSTRAP;
}

/**
 * Compose the call_agent startup for a bootstrap level. Returns the
 * append-system-prompt text plus the cwd policy for that tier:
 *   - none/identity → neutral cwd (caller passes its neutral dir)
 *   - full          → the repo cwd (so the agent can read the bootstrap guide)
 *
 * @param {string} level                "none" | "identity" | "full"
 * @param {object} [opts]
 * @param {string} [opts.repoRoot]      repo root for the "full" tier
 * @param {string} [opts.extra]         optional caller append_system_prompt,
 *                                      concatenated after the tier text
 * @returns {{ level:string, appendSystemPrompt:string, useRepoCwd:boolean, repoRoot:string|null }}
 */
export function composeBootstrap(level, { repoRoot, extra } = {}) {
  const lvl = normalizeBootstrap(level);
  let base;
  let useRepoCwd = false;
  let root = null;
  if (lvl === "none") {
    base = LEAN_SYSTEM_PROMPT;
  } else if (lvl === "full") {
    root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
    base = buildFullSystemPrompt(root);
    useRepoCwd = true;
  } else {
    base = IDENTITY_SYSTEM_PROMPT; // identity (default)
  }
  const appendSystemPrompt =
    extra && typeof extra === "string" && extra.trim()
      ? `${base}\n\n${extra.trim()}`
      : base;
  return { level: lvl, appendSystemPrompt, useRepoCwd, repoRoot: root };
}

function resolveClaudeBinary(explicit) {
  const candidates = [
    explicit,
    process.env.CLAUDE_BIN,
    path.join(os.homedir(), ".local", "bin", process.platform === "win32" ? "claude.exe" : "claude"),
    path.join(process.env.APPDATA || "", "npm", "claude.cmd"),
    path.join(process.env.APPDATA || "", "npm", "claude"),
    "claude",
  ].filter(Boolean);
  for (const c of candidates) {
    try {
      execSync(`"${c}" --version`, { stdio: "ignore", timeout: 5000 });
      return c;
    } catch {
      /* try next */
    }
  }
  return null;
}

function makeJobId(prefix = "call") {
  return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
}

/**
 * Build the persistent stream-json argv for a warm worker. Same lean isolation
 * as the cold buildArgs() (model, skip-permissions, user-only settings, strict
 * mcp, lean system prompt) but with the persistent stdin/stdout streaming
 * transport instead of a one-shot `-p "<prompt>"`.
 */
function buildWarmArgs({ model, appendSystemPrompt }) {
  return [
    "-p",
    "--input-format",
    "stream-json",
    "--output-format",
    "stream-json",
    "--verbose", // required by the CLI for stream-json output
    "--model",
    model,
    "--dangerously-skip-permissions",
    "--setting-sources",
    "user",
    "--strict-mcp-config",
    "--append-system-prompt",
    appendSystemPrompt || LEAN_SYSTEM_PROMPT,
  ];
}

/**
 * WarmWorker — one long-lived `claude` process in stream-json mode that can be
 * RE-TARGETED with a new prompt per turn over stdin. This is the true persistent
 * re-targetable worker: no per-call CLI boot.
 *
 * Lifecycle: spawned → ready (process is alive and stdin is connected) → busy
 * (a turn is in flight) → idle again (after that turn's `result`). On process
 * death it is marked dead; the pool auto-restarts it. One turn at a time per
 * worker (the CLI session is single-threaded); the pool routes each call to a
 * free idle worker.
 *
 * READINESS NOTE: the `claude` CLI in `--input-format stream-json` mode emits
 * NOTHING (not even `system/init`) until the FIRST stdin user-message arrives —
 * `init` is part of the first turn's event stream, not a standalone boot signal.
 * So a warm worker is "ready" the moment its process is spawned with a live
 * stdin pipe. The first `send()` pays the CLI boot/init cost inside its turn;
 * every subsequent `send()` re-targets the already-booted process (pure
 * inference latency, no boot). That is the warm win: boot is paid ONCE, then the
 * hot process is re-aimed per call.
 */
export class WarmWorker {
  /**
   * @param {object} opts
   * @param {string} opts.binary           resolved claude binary
   * @param {string} opts.model            model pinned for this worker
   * @param {string} opts.cwd              neutral project-free cwd
   * @param {string} [opts.appendSystemPrompt]
   * @param {function} [opts.spawnImpl]    inject a fake spawn for tests
   * @param {function} [opts.onExit]       called when the process dies
   */
  constructor(opts = {}) {
    this.binary = opts.binary;
    this.model = opts.model || DEFAULT_MODEL;
    this.cwd = opts.cwd;
    this.appendSystemPrompt = opts.appendSystemPrompt;
    this._spawn = opts.spawnImpl || spawn;
    this.onExit = typeof opts.onExit === "function" ? opts.onExit : () => {};

    this.id = `warm-${crypto.randomBytes(4).toString("hex")}`;
    this.proc = null;
    this.sessionId = null;
    this.state = "spawning"; // spawning | ready | busy | dead
    this.spawnedAt = Date.now();
    this.turns = 0;

    this._buf = "";
    this._current = null; // { resolve, stdout, deadline, jobId, settled }
    this._readyResolve = null;
    this._readyPromise = new Promise((r) => (this._readyResolve = r));
  }

  /** True iff the worker is alive and free to accept a new turn. */
  isIdle() {
    return this.state === "ready";
  }
  isBusy() {
    return this.state === "busy";
  }
  isDead() {
    return this.state === "dead";
  }

  /** Spawn the persistent process. Resolves once the worker is ready (or dead). */
  async start() {
    let proc;
    try {
      const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
      const args = buildWarmArgs({ model: this.model, appendSystemPrompt: this.appendSystemPrompt });
      const command = isCmdShim ? "cmd" : this.binary;
      const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...args] : args;
      proc = this._spawn(command, spawnArgs, {
        cwd: this.cwd,
        env: process.env, // inherits CLI login session; never sets ANTHROPIC_API_KEY
        stdio: ["pipe", "pipe", "pipe"],
        shell: false,
        windowsHide: true,
      });
    } catch (e) {
      this.state = "dead";
      this.error = `spawn_failed: ${e.message}`;
      this._readyResolve?.(false);
      return false;
    }

    this.proc = proc;
    this.pid = proc.pid;

    proc.stdout?.on("data", (d) => this._onStdout(d));
    proc.stderr?.on("data", (d) => {
      this._stderr = `${this._stderr || ""}${d.toString()}`.slice(-20000);
    });
    proc.on("error", (e) => this._onDeath(`proc_error: ${e.message}`));
    proc.on("close", (code) => this._onDeath(`exit_${code}`));

    // In stream-json mode the CLI is silent until the first stdin message, so a
    // freshly-spawned process with a live stdin pipe IS ready. Mark ready on the
    // next tick (after spawn errors would have fired synchronously). The first
    // send() pays the boot cost inside its turn; later sends are hot re-targets.
    await new Promise((r) => setImmediate(r));
    if (this.state === "spawning" && this.proc && this.proc.stdin && !this.isDead()) {
      this.state = "ready";
      this._readyResolve?.(true);
    }
    return await this._readyPromise;
  }

  _onStdout(d) {
    this._buf += d.toString();
    let idx;
    while ((idx = this._buf.indexOf("\n")) >= 0) {
      const line = this._buf.slice(0, idx).trim();
      this._buf = this._buf.slice(idx + 1);
      if (!line) continue;
      let msg;
      try {
        msg = JSON.parse(line);
      } catch {
        continue; // ignore non-JSON noise
      }
      this._onEvent(msg);
    }
  }

  _onEvent(msg) {
    if (msg.session_id && !this.sessionId) this.sessionId = msg.session_id;
    if (msg.type === "system" && msg.subtype === "init") {
      if (this.state === "spawning") {
        this.state = "ready";
        this._readyResolve?.(true);
      }
      return;
    }
    if (msg.type === "result" && this._current) {
      const cur = this._current;
      if (cur.settled) return;
      cur.settled = true;
      if (cur.timer) clearTimeout(cur.timer); // C2: cancel internal timeout on normal settle
      const text = typeof msg.result === "string" ? msg.result : cur.stdout;
      this._current = null;
      if (this.state === "busy") this.state = "ready";
      cur.resolve({
        ok: !msg.is_error,
        package: (text || "").trim(),
        is_error: !!msg.is_error,
        session_id: this.sessionId,
      });
    }
  }

  _onDeath(reason) {
    if (this.state === "dead") return;
    this.state = "dead";
    this.error = reason;
    // Fail any in-flight turn so the caller (and concurrency slot) is released.
    if (this._current && !this._current.settled) {
      const cur = this._current;
      cur.settled = true;
      if (cur.timer) clearTimeout(cur.timer); // C2: cancel internal timeout on death
      this._current = null;
      cur.resolve({ ok: false, package: "", error: `worker_died: ${reason}`, dead: true });
    }
    // Unblock a start() still waiting on readiness.
    if (this.state === "dead") this._readyResolve?.(false);
    this.onExit(this, reason);
  }

  /**
   * Re-target this warm worker with a new prompt. One turn at a time.
   * Resolves with { ok, package, is_error } when the turn's `result` arrives,
   * or { ok:false, ... } if the deadline elapses (caller/pool kills the worker).
   * @returns {Promise<object>}
   */
  send(prompt, { timeout_ms } = {}) {
    if (this.state !== "ready") {
      return Promise.resolve({ ok: false, error: `worker_not_ready:${this.state}` });
    }
    return new Promise((resolve) => {
      this.state = "busy";
      this.turns++;
      const ms = Number.isFinite(timeout_ms) ? timeout_ms : DEFAULT_TIMEOUT_MS;
      const cur = { resolve, stdout: "", settled: false, startedAt: Date.now() };
      cur.deadline = Date.now() + ms;
      // C2 — own internal timeout. The pool reaper only covers turns that have a
      // job record; the PRIMING turn (ensureWarmWorkers) has none, so a hung
      // prime would otherwise leave `send()`'s promise unsettled forever and
      // `await Promise.all(primes)` would never resolve. This timer settles the
      // turn with a failure AND kills the wedged process independently of the
      // reaper, hardening every send() (priming and normal) against a hung CLI.
      cur.timer = setTimeout(() => {
        if (cur.settled) return;
        cur.settled = true;
        // Detach so a late `result` event can't double-settle.
        if (this._current === cur) this._current = null;
        cur.resolve({ ok: false, package: "", error: "warm_send_timeout", timeout: true });
        // The session is wedged mid-turn — kill the proc so the worker is
        // recycled (onExit → circuit-breaker accounting / auto-restart).
        this.kill("send_timeout");
      }, ms);
      if (cur.timer && cur.timer.unref) cur.timer.unref();
      this._current = cur;
      const envelope = {
        type: "user",
        message: { role: "user", content: [{ type: "text", text: prompt }] },
      };
      try {
        this.proc.stdin.write(JSON.stringify(envelope) + "\n");
      } catch (e) {
        if (cur.timer) clearTimeout(cur.timer);
        cur.settled = true;
        this._current = null;
        this.state = "ready";
        resolve({ ok: false, error: `stdin_write_failed: ${e.message}` });
      }
    });
  }

  /** Deadline of the in-flight turn, or null if idle. Used by the pool reaper. */
  currentDeadline() {
    return this._current && !this._current.settled ? this._current.deadline : null;
  }

  kill(reason = "killed") {
    try {
      this.proc?.stdin?.end();
    } catch {
      /* ignore */
    }
    try {
      this.proc?.kill("SIGKILL");
    } catch {
      /* already gone */
    }
    this._onDeath(reason);
  }
}

/**
 * AgentPool — concurrency-limited, warm, model-targeted one-shot dispatcher.
 */
export class AgentPool {
  /**
   * @param {object} opts
   * @param {string} [opts.binary]       explicit claude binary path
   * @param {number} [opts.poolSize]     max concurrent + prime count (default 2)
   * @param {number} [opts.maxConcurrent] hard concurrency cap (default = poolSize)
   * @param {string} [opts.defaultModel] default model alias
   * @param {number} [opts.defaultTimeoutMs]
   * @param {string} [opts.neutralCwd]   project-free working dir for lean runs
   * @param {number} [opts.reaperIntervalMs] sweep cadence (default 5000)
   * @param {number} [opts.maxJobs]      retained job history (default 200)
   * @param {boolean} [opts.spawnImpl]   inject a fake spawn for tests
   */
  constructor(opts = {}) {
    this.binary = opts.binary || resolveClaudeBinary(opts.binary);
    this.poolSize = Number.isFinite(opts.poolSize) ? Math.max(1, opts.poolSize) : DEFAULT_POOL_SIZE;
    this.maxConcurrent = Number.isFinite(opts.maxConcurrent)
      ? Math.max(1, opts.maxConcurrent)
      : this.poolSize;
    this.defaultModel = opts.defaultModel || DEFAULT_MODEL;
    this.defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs)
      ? opts.defaultTimeoutMs
      : DEFAULT_TIMEOUT_MS;
    this.neutralCwd = opts.neutralCwd || path.join(os.tmpdir(), "clauth-call-agent-neutral");
    this.reaperIntervalMs = Number.isFinite(opts.reaperIntervalMs) ? opts.reaperIntervalMs : 5000;
    this.maxJobs = Number.isFinite(opts.maxJobs) ? opts.maxJobs : 200;
    this._spawn = opts.spawnImpl || spawn;

    // WP-P2: true persistent re-targetable warm workers via stream-json.
    // `warmEnabled` gates the whole warm path; when false the pool is exactly
    // the legacy cold dispatcher. `warmSize` long-lived workers are kept hot.
    this.warmEnabled = opts.warmEnabled != null ? !!opts.warmEnabled : WARM_ENABLED_DEFAULT;
    this.warmSize = Number.isFinite(opts.warmSize) ? Math.max(0, opts.warmSize) : this.poolSize;
    // When false, skip the per-worker priming turn (used by tests to isolate
    // lifecycle behavior from prime-turn timing). Default: prime on (undefined).
    this.warmPrimeTurn = opts.warmPrimeTurn;
    // Warm workers serve the DEFAULT bootstrap tier ("identity"): they are
    // pre-spawned with the identity startup prompt (who-am-I + how-to-get-access)
    // on the neutral cwd. "none" and "full" calls carry a different prompt/cwd and
    // are routed to the cold path in _drain(), so the warm prompt is the identity
    // tier — not the bare LEAN prompt — to match what a default call expects.
    this.appendSystemPrompt = opts.appendSystemPrompt || IDENTITY_SYSTEM_PROMPT;
    this._warmWorkers = []; // WarmWorker[]
    this._warmStarting = false; // H2: reentrancy guard for ensureWarmWorkers()

    // C1 — boot-failure circuit breaker. A deterministic boot failure (bad
    // CLAUTH_AGENT_MODEL, broken login/MCP) makes every warm worker die during
    // its prime turn and respawn forever. We count CONSECUTIVE immediate deaths
    // (lifeMs < IMMEDIATE_DEATH_MS); after `warmCircuitThreshold` of them we trip
    // the breaker: disable warm for the daemon's life and fall back to the cold
    // path permanently. A worker that lives past the immediate window resets the
    // counter (genuine flapping isn't the same as a hard boot failure).
    this.warmCircuitThreshold = Number.isFinite(opts.warmCircuitThreshold)
      ? Math.max(1, opts.warmCircuitThreshold)
      : 5;
    this.immediateDeathMs = Number.isFinite(opts.immediateDeathMs)
      ? opts.immediateDeathMs
      : 2000;
    this._consecutiveImmediateDeaths = 0;
    this._warmCircuitTripped = false;
    this._warmCircuitLogged = false;
    // Base unit for the immediate-death respawn backoff (exponential, capped at
    // 30s). Overridable so tests can exercise the breaker quickly.
    this.warmBackoffBaseMs = Number.isFinite(opts.warmBackoffBaseMs) ? opts.warmBackoffBaseMs : 1000;

    // WP-5: session-level warm worker pinning. A session (e.g. Studio editor)
    // can claim a warm worker and reuse it across turns, preserving conversation
    // context. Pinned workers are excluded from the general _acquireWarmWorker()
    // pool so other callers cannot steal them mid-session.
    this._sessionPins = new Map(); // Map<sessionId, WarmWorker>

    this.jobs = new Map(); // jobId -> job record
    this.active = 0;
    this.queue = []; // pending { resolve, reject, args }
    this.warm = false;
    this._reaper = null;

    try {
      fs.mkdirSync(this.neutralCwd, { recursive: true });
    } catch {
      /* best effort */
    }
  }

  available() {
    return this.binary != null;
  }

  startReaper() {
    if (this._reaper) return;
    this._reaper = setInterval(() => this.reap(), this.reaperIntervalMs);
    // Note: intentionally NOT unref'd. While a job is in flight the reaper is
    // the only thing that can settle a hung worker, so it must keep the loop
    // alive. The daemon owns lifecycle and calls shutdown()/stopReaper().
  }

  stopReaper() {
    if (this._reaper) {
      clearInterval(this._reaper);
      this._reaper = null;
    }
  }

  /**
   * Kill any running job past its deadline, free its slot, drain the queue.
   * Also evicts old terminal jobs beyond maxJobs.
   */
  reap() {
    const now = Date.now();
    let reaped = 0;
    for (const job of this.jobs.values()) {
      if (job.status === "running" && job.deadline && now > job.deadline) {
        this._killJob(job, "timeout");
        reaped++;
      }
    }
    // evict oldest terminal jobs
    if (this.jobs.size > this.maxJobs) {
      const terminal = [...this.jobs.values()]
        .filter((j) => j.status !== "running")
        .sort((a, b) => (a.completed_at || 0) - (b.completed_at || 0));
      let toRemove = this.jobs.size - this.maxJobs;
      for (const j of terminal) {
        if (toRemove-- <= 0) break;
        this.jobs.delete(j.jobId);
      }
    }
    this._drain();
    return reaped;
  }

  _killJob(job, reason) {
    if (job._proc && job.status === "running") {
      try {
        job._proc.kill("SIGKILL");
      } catch {
        /* already gone */
      }
    }
    // Warm job past its deadline: the worker's session is wedged mid-turn. Kill
    // the persistent worker (auto-restart re-tops the pool) so the slot frees.
    if (job._worker && job.status === "running") {
      try {
        job._worker.kill(reason === "timeout" ? "turn_timeout" : reason);
      } catch {
        /* already gone */
      }
    }
    if (job.status === "running") {
      job.status = reason === "timeout" ? "timeout" : "killed";
      job.completed_at = Date.now();
      job.ms = job.completed_at - job.started_at;
      job.error = reason;
      // NOTE: do NOT decrement this.active here. settle() is the single source
      // of truth for the concurrency-slot decrement (it guards with the
      // `settled` flag). Decrementing here too double-counted every reaped /
      // timed-out / shutdown-killed job, dropping active by 2 and letting
      // _drain() launch more than maxConcurrent real workers.
      if (job._settle) job._settle();
    }
  }

  buildArgs({ prompt, model, appendSystemPrompt }) {
    return [
      "-p",
      prompt,
      "--model",
      model || this.defaultModel,
      "--dangerously-skip-permissions",
      // Lean isolation: only the user's auth/settings, no project CLAUDE.md/hooks.
      "--setting-sources",
      "user",
      "--strict-mcp-config",
      "--append-system-prompt",
      appendSystemPrompt || LEAN_SYSTEM_PROMPT,
    ];
  }

  // ── WP-P2 warm worker management ───────────────────────────────────────────

  /** Count of alive (ready or busy) warm workers. */
  _liveWarmCount() {
    return this._warmWorkers.filter((w) => !w.isDead()).length;
  }

  /**
   * First idle warm worker matching `model`, or null. Warm workers are pinned
   * to one model; a call requesting a different model must NOT be silently
   * served by a mismatched warm worker — it falls back to a cold spawn that
   * honors the requested model. Workers pinned to a session are excluded so
   * general dispatch cannot steal a session's dedicated worker.
   */
  _acquireWarmWorker(model) {
    const pinnedSet = new Set(this._sessionPins.values());
    return this._warmWorkers.find((w) =>
      w.isIdle() && (!model || w.model === model) && !pinnedSet.has(w)
    ) || null;
  }

  // ── WP-5: session-level warm worker pinning ─────────────────────────────

  /**
   * Acquire (or re-acquire) a warm worker pinned to `sessionId`. Idempotent:
   * calling with the same sessionId returns the already-pinned worker. A stale
   * pin (dead worker) is transparently replaced. Returns null when no warm
   * worker is available (caller should fall back or queue).
   *
   * @param {string} sessionId
   * @param {object} [opts]
   * @param {string} [opts.model]
   * @returns {WarmWorker|null}
   */
  acquireForSession(sessionId, opts = {}) {
    if (this._sessionPins.has(sessionId)) {
      const worker = this._sessionPins.get(sessionId);
      if (!worker.isDead()) return worker;
      this._sessionPins.delete(sessionId); // stale pin, re-acquire below
    }
    const model = opts.model || this.defaultModel;
    const worker = this._acquireWarmWorker(model);
    if (!worker) return null;
    this._sessionPins.set(sessionId, worker);
    return worker;
  }

  /**
   * Release a session's pin. The worker returns to the general pool (it is
   * just no longer reserved). Returns the formerly-pinned worker, or null.
   * @param {string} sessionId
   * @returns {WarmWorker|null}
   */
  releaseSession(sessionId) {
    const worker = this._sessionPins.get(sessionId);
    this._sessionPins.delete(sessionId);
    return worker || null;
  }

  /**
   * Dispatch a prompt to the worker pinned for `sessionId`. Unlike general
   * dispatch, this always targets the pinned worker (preserving conversation
   * context). Returns a rejected-shape result if no pin exists or the worker
   * is dead. If the worker is busy (prior turn still in flight), waits for it.
   *
   * @param {string} sessionId
   * @param {object} [args]  { prompt, timeout_ms, ... }
   * @returns {Promise<object>}
   */
  dispatchToSession(sessionId, args = {}) {
    const worker = this._sessionPins.get(sessionId);
    if (!worker || worker.isDead()) {
      return Promise.resolve({ ok: false, error: "no_pinned_worker", sessionId });
    }
    const prompt = args.prompt;
    if (!prompt || typeof prompt !== "string") {
      return Promise.resolve({ ok: false, error: "prompt_required" });
    }
    return new Promise((resolve, reject) => {
      const item = { args, resolve, reject };
      const tryRun = () => {
        if (worker.isDead()) {
          return resolve({ ok: false, error: "pinned_worker_died", sessionId });
        }
        if (worker.isIdle()) {
          this._runWarm(item, worker);
        } else {
          setTimeout(tryRun, 100);
        }
      };
      tryRun();
    });
  }

  /**
   * Query the pin state for a session. Returns null if no pin exists.
   * @param {string} sessionId
   * @returns {object|null}
   */
  getSessionInfo(sessionId) {
    const worker = this._sessionPins.get(sessionId);
    if (!worker) return null;
    return {
      sessionId,
      pinned: true,
      alive: !worker.isDead(),
      idle: worker.isIdle(),
      model: worker.model,
      turns: worker.turns,
    };
  }

  /** Drop dead workers from the roster. */
  _pruneDeadWarm() {
    this._warmWorkers = this._warmWorkers.filter((w) => !w.isDead());
  }

  /**
   * Boot `warmSize` persistent workers (idempotent — only tops up to target).
   * Auto-restart is wired via each worker's onExit, which respawns a fresh
   * worker if the pool still wants warm capacity and isn't shutting down.
   */
  async ensureWarmWorkers() {
    if (!this.warmEnabled || !this.available() || this._shuttingDown) return 0;
    if (this._warmCircuitTripped) return 0; // C1: breaker open — warm is permanently off
    // H2 — reentrancy guard. Two overlapping ensureWarmWorkers() calls (e.g. a
    // boot prime racing a manual top-up) would each independently compute the
    // deficit BEFORE the other's workers register, and both spawn — overshooting
    // warmSize. Honor `_warmStarting`: a second concurrent entrant returns 0 and
    // lets the in-flight call do the provisioning.
    if (this._warmStarting) return 0;
    this._warmStarting = true;
    try {
      this._pruneDeadWarm();
      const deficit = this.warmSize - this._liveWarmCount();
      if (deficit <= 0) return 0;
      const starts = [];
      for (let i = 0; i < deficit; i++) {
        const w = new WarmWorker({
          binary: this.binary,
          model: this.defaultModel,
          cwd: this.neutralCwd,
          appendSystemPrompt: this.appendSystemPrompt,
          spawnImpl: this._spawn,
          onExit: (worker, reason) => this._onWarmWorkerExit(worker, reason),
        });
        this._warmWorkers.push(w);
        starts.push(w.start().catch(() => false));
      }
      const results = await Promise.all(starts);
      this._pruneDeadWarm();
      // Fire ONE priming turn per freshly-started worker so the CLI boot/init cost
      // is paid during warm-up, not on the user's first real call. After this, the
      // worker's session is hot and every dispatch is a pure-inference re-target.
      // C2: send() now has its own internal timeout, so a hung prime can no longer
      // wedge this Promise.all forever.
      if (this.warmPrimeTurn !== false) {
        const primes = [];
        for (const w of this._warmWorkers) {
          if (w.isIdle() && w.turns === 0) {
            primes.push(w.send("Reply with exactly: OK", { timeout_ms: 60000 }).catch(() => null));
          }
        }
        await Promise.all(primes);
        this._pruneDeadWarm();
      }
      this.warm = this._warmWorkers.some((w) => !w.isDead());
      return results.filter(Boolean).length;
    } finally {
      this._warmStarting = false;
    }
  }

  /**
   * Auto-restart on death: replace a dead worker if still under target. A short
   * backoff prevents a tight respawn loop when a worker dies the instant it
   * starts (e.g. a session that crashes immediately) — without it, each death
   * would synchronously schedule another start that dies and reschedules,
   * spinning CPU and spawning processes in a hot loop.
   */
  _onWarmWorkerExit(worker, reason) {
    this._pruneDeadWarm();
    this.warm = this._warmWorkers.some((w) => !w.isDead());

    // Once shutting down, a dying worker is expected teardown — do not count it
    // toward the boot-failure breaker and do not respawn. (Also prevents a
    // late-firing backoff respawn's death from tripping the breaker post-shutdown.)
    if (this._shuttingDown) return;

    // C1/H1 — death classification. Measure how long THIS worker lived. An
    // immediate death (lifeMs < immediateDeathMs) is the signature of a hard boot
    // failure (bad model, login/MCP error) — the worker died during boot/prime.
    // A worker that lived past the window died for a transient reason (a normal
    // turn, an OOM, a kill) and must NOT clear protection a different flapping
    // worker raised: we only reset the consecutive-immediate-death counter when a
    // worker proves the boot path actually works by surviving the window.
    const lifeMs = worker && worker.spawnedAt ? Date.now() - worker.spawnedAt : Infinity;
    const immediate = lifeMs < this.immediateDeathMs;

    if (immediate) {
      this._consecutiveImmediateDeaths++;
    } else {
      // A healthy lifetime proves boot works → safe to forgive accumulated
      // immediate-death backoff for the lineage.
      this._consecutiveImmediateDeaths = 0;
    }

    // C1 — trip the breaker after N consecutive immediate deaths. Disable warm
    // for the daemon's life, log ONCE, and stop respawning. dispatch() already
    // falls back to the cold path whenever no warm worker is free, so flipping
    // warmEnabled=false makes the cold fallback permanent. This protects the live
    // daemon from endless `claude` churn on a misconfigured CLAUTH_AGENT_MODEL.
    if (!this._warmCircuitTripped && this._consecutiveImmediateDeaths >= this.warmCircuitThreshold) {
      this._warmCircuitTripped = true;
      this.warmEnabled = false;
      if (!this._warmCircuitLogged) {
        this._warmCircuitLogged = true;
        console.error(
          `[agent-pool] WARM CIRCUIT BREAKER TRIPPED: ${this._consecutiveImmediateDeaths} ` +
            `consecutive warm-worker boot failures (last: ${reason || worker?.error || "unknown"}). ` +
            `Disabling warm workers for the rest of this process; call_agent now uses the cold ` +
            `path exclusively. Check CLAUTH_AGENT_MODEL / CLI login / MCP config.`
        );
      }
      return; // do not respawn — breaker is open
    }

    if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
    if (this._liveWarmCount() >= this.warmSize) return;

    // H1 — backoff keyed to the CONSECUTIVE immediate-death count (lineage
    // protection), not a shared mutable counter that any healthy exit zeroes.
    // Each successive immediate death backs off longer (capped at 30s); a
    // worker that lived past the window respawns immediately.
    const delay = immediate
      ? Math.min(30000, this.warmBackoffBaseMs * Math.pow(2, Math.max(0, this._consecutiveImmediateDeaths - 1)))
      : 0;

    const spawnReplacement = () => {
      if (this._warmCircuitTripped) return;
      if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
      if (this._liveWarmCount() >= this.warmSize) return;
      const replacement = new WarmWorker({
        binary: this.binary,
        model: this.defaultModel,
        cwd: this.neutralCwd,
        appendSystemPrompt: this.appendSystemPrompt,
        spawnImpl: this._spawn,
        onExit: (w, r) => this._onWarmWorkerExit(w, r),
      });
      this._warmWorkers.push(replacement);
      replacement
        .start()
        .then(() => {
          this.warm = this._warmWorkers.some((w) => !w.isDead());
        })
        .catch(() => {});
    };
    if (delay > 0) {
      const t = setTimeout(spawnReplacement, delay);
      if (t.unref) t.unref();
    } else {
      spawnReplacement();
    }
  }

  /**
   * Pre-warm. With warm workers enabled (default), boots `warmSize` persistent
   * re-targetable stream-json workers. Otherwise (legacy/cold), fires poolSize
   * trivial one-shot pings so the OS file cache / model session is warm.
   * Returns when warming settles (or immediately if unavailable).
   */
  async prime() {
    if (!this.available()) return { warmed: 0, available: false, mode: "unavailable" };
    if (this.warmEnabled) {
      const warmed = await this.ensureWarmWorkers();
      return { warmed, available: true, mode: "warm-workers", workers: this._liveWarmCount() };
    }
    const n = this.poolSize;
    const pings = [];
    for (let i = 0; i < n; i++) {
      pings.push(
        this.dispatch({
          prompt: "Reply with exactly: OK",
          timeout_ms: 30000,
          _priming: true,
        }).catch((e) => ({ ok: false, error: e.message }))
      );
    }
    const results = await Promise.all(pings);
    this.warm = results.some((r) => r && r.ok);
    return { warmed: results.filter((r) => r && r.ok).length, available: true, mode: "cold-prime" };
  }

  /**
   * Dispatch a one-shot. Resolves with a job record once the worker exits
   * (sync semantics). Concurrency beyond maxConcurrent is queued.
   *
   * @returns {Promise<object>} job record { ok, jobId, package, model, ms, status, exit_code, error }
   */
  dispatch(args = {}) {
    return new Promise((resolve, reject) => {
      const item = { args, resolve, reject };
      this.queue.push(item);
      this._drain();
    });
  }

  _drain() {
    while (this.active < this.maxConcurrent && this.queue.length > 0) {
      const item = this.queue.shift();
      // Priming pings stay on the cold one-shot path (they exist only to warm
      // the cold cache; with warm workers they are a no-op caller). Real calls
      // prefer a free warm worker and fall back to cold when none is free.
      //
      // Bootstrap pinning (tiered startup): warm workers are pre-spawned with the
      // DEFAULT ("identity") startup prompt and the neutral cwd. A call asking for
      // a DIFFERENT bootstrap level — "none" (lean fast path) or "full" (repo cwd
      // + bootstrap guide) — has a different system prompt and/or cwd, so it must
      // NOT be served by a warm worker. Route it to the cold path, which honors
      // args.appendSystemPrompt and args.cwd. Default/absent bootstrap → warm.
      const bootstrapLevel = item.args.bootstrap || DEFAULT_BOOTSTRAP;
      const warmEligible = bootstrapLevel === DEFAULT_BOOTSTRAP;
      if (this.warmEnabled && !item.args._priming && warmEligible) {
        // Only a warm worker pinned to the REQUESTED model may serve the call;
        // a model mismatch must fall through to the cold path (which honors
        // args.model) rather than be silently downgraded to the warm model.
        const reqModel = item.args.model || this.defaultModel;
        const worker = this._acquireWarmWorker(reqModel);
        if (worker) {
          this._runWarm(item, worker);
          continue;
        }
      }
      this._run(item);
    }
  }

  /**
   * Run a call on a persistent warm worker (re-targeting it with a new prompt).
   * Produces the SAME public job record shape as the cold path so callers
   * (runCallAgent, durable delivery, GET /call-agent/:jobId) are unchanged.
   */
  _runWarm(item, worker) {
    const { args, resolve } = item;
    const prompt = args.prompt;
    if (!prompt || typeof prompt !== "string") {
      return resolve({ ok: false, error: "prompt_required" });
    }
    const model = worker.model;
    const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
    const jobId = args.jobId || makeJobId("call");

    const job = {
      jobId,
      status: "running",
      model,
      priming: false,
      warm: true,
      started_at: Date.now(),
      deadline: Date.now() + timeout,
      completed_at: null,
      ms: null,
      exit_code: null,
      stdout: "",
      stderr: "",
      package: null,
      error: null,
      _proc: null,
      _worker: worker,
      _settle: null,
    };
    this.jobs.set(jobId, job);
    this.active++;

    let settled = false;
    const settle = () => {
      if (settled) return;
      settled = true;
      this.active = Math.max(0, this.active - 1);
      const rec = {
        ok: job.status === "completed",
        jobId: job.jobId,
        status: job.status,
        model: job.model,
        ms: job.ms,
        exit_code: job.exit_code,
        package: job.package,
        error: job.error,
        warm: true,
      };
      if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
      this._drain();
      resolve(rec);
    };
    job._settle = () => {
      // Reaper path: the turn blew its deadline. Kill the worker (its session is
      // wedged); auto-restart re-tops the pool. Mark the job per its set status.
      if (job.status === "running") {
        job.status = "timeout";
        job.completed_at = Date.now();
        job.ms = job.completed_at - job.started_at;
        job.error = "timeout";
        try {
          worker.kill("turn_timeout");
        } catch {
          /* ignore */
        }
      }
      settle();
    };

    worker
      .send(prompt, { timeout_ms: timeout })
      .then((res) => {
        if (settled) return; // reaper already settled this
        job.completed_at = Date.now();
        job.ms = job.completed_at - job.started_at;
        if (res.ok) {
          job.status = "completed";
          job.exit_code = 0;
          job.package = res.package;
        } else {
          job.status = "failed";
          job.error = res.error || (res.is_error ? "agent_error" : "warm_failed");
          if (res.package) job.package = res.package;
        }
        settle();
      })
      .catch((e) => {
        if (settled) return;
        job.status = "failed";
        job.error = `warm_dispatch: ${e.message}`;
        job.completed_at = Date.now();
        job.ms = job.completed_at - job.started_at;
        settle();
      });
  }

  _run({ args, resolve, reject }) {
    if (!this.available()) {
      return resolve({
        ok: false,
        error: "binary_not_found",
        message: "claude CLI not found in PATH or AppData/npm",
      });
    }
    const prompt = args.prompt;
    if (!prompt || typeof prompt !== "string") {
      return resolve({ ok: false, error: "prompt_required" });
    }
    const model = args.model || this.defaultModel;
    const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
    const jobId = args.jobId || makeJobId(args._priming ? "warm" : "call");
    const cwd = args.cwd || this.neutralCwd;

    const job = {
      jobId,
      status: "running",
      model,
      priming: !!args._priming,
      started_at: Date.now(),
      deadline: Date.now() + timeout,
      completed_at: null,
      ms: null,
      exit_code: null,
      stdout: "",
      stderr: "",
      package: null,
      error: null,
      _proc: null,
      _settle: null,
    };
    this.jobs.set(jobId, job);
    this.active++;

    let settled = false;
    const settle = () => {
      if (settled) return;
      settled = true;
      this.active = Math.max(0, this.active - 1);
      // Build the public record (no internal handles).
      const rec = {
        ok: job.status === "completed",
        jobId: job.jobId,
        status: job.status,
        model: job.model,
        ms: job.ms,
        exit_code: job.exit_code,
        package: job.package,
        error: job.error,
      };
      if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
      this._drain();
      resolve(rec);
    };
    job._settle = () => settle();

    const cmdArgs = this.buildArgs({
      prompt,
      model,
      appendSystemPrompt: args.appendSystemPrompt,
    });

    let proc;
    try {
      const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
      const command = isCmdShim ? "cmd" : this.binary;
      const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
      proc = this._spawn(command, spawnArgs, {
        cwd,
        env: process.env, // inherits CLI login session; no ANTHROPIC_API_KEY injected
        stdio: ["ignore", "pipe", "pipe"],
        shell: false,
        windowsHide: true,
      });
    } catch (e) {
      job.status = "failed";
      job.error = `spawn_failed: ${e.message}`;
      job.completed_at = Date.now();
      job.ms = job.completed_at - job.started_at;
      return settle();
    }

    job._proc = proc;
    job.pid = proc.pid;

    proc.stdout?.on("data", (d) => {
      job.stdout = `${job.stdout}${d.toString()}`.slice(-200000);
    });
    proc.stderr?.on("data", (d) => {
      job.stderr = `${job.stderr}${d.toString()}`.slice(-20000);
    });
    proc.on("error", (e) => {
      if (job.status !== "running") return;
      job.status = "failed";
      job.error = `proc_error: ${e.message}`;
      job.completed_at = Date.now();
      job.ms = job.completed_at - job.started_at;
      settle();
    });
    proc.on("close", (code) => {
      if (job.status !== "running") return; // reaper/kill already settled
      job.exit_code = code;
      job.completed_at = Date.now();
      job.ms = job.completed_at - job.started_at;
      if (code === 0) {
        job.status = "completed";
        job.package = job.stdout.trim();
      } else {
        job.status = "failed";
        job.error = `exit_${code}`;
      }
      settle();
    });
  }

  getJob(jobId) {
    const job = this.jobs.get(jobId);
    if (!job) return null;
    return {
      jobId: job.jobId,
      status: job.status,
      model: job.model,
      ms: job.ms,
      exit_code: job.exit_code,
      package: job.package,
      error: job.error,
      started_at: job.started_at,
      completed_at: job.completed_at,
    };
  }

  stats() {
    let running = 0,
      completed = 0,
      failed = 0;
    for (const j of this.jobs.values()) {
      if (j.status === "running") running++;
      else if (j.status === "completed") completed++;
      else failed++;
    }
    const warmReady = this._warmWorkers.filter((w) => w.isIdle()).length;
    const warmBusy = this._warmWorkers.filter((w) => w.isBusy()).length;
    return {
      active: this.active,
      queued: this.queue.length,
      maxConcurrent: this.maxConcurrent,
      poolSize: this.poolSize,
      // `warm` reflects REAL persistent workers when warm mode is on (WP-P2):
      // true iff at least one live worker exists. Cold mode keeps legacy meaning.
      warm: this.warmEnabled ? this._liveWarmCount() > 0 : this.warm,
      warmEnabled: this.warmEnabled,
      warmCircuitTripped: this._warmCircuitTripped,
      warmWorkers: {
        target: this.warmSize,
        live: this._liveWarmCount(),
        ready: warmReady,
        busy: warmBusy,
        consecutiveImmediateDeaths: this._consecutiveImmediateDeaths,
      },
      jobs: { running, completed, failed, total: this.jobs.size },
    };
  }

  shutdown() {
    this._shuttingDown = true;
    this.stopReaper();
    for (const job of this.jobs.values()) {
      if (job.status === "running") this._killJob(job, "shutdown");
    }
    // Clear session pins before tearing down workers.
    this._sessionPins.clear();
    // Tear down persistent warm workers so no orphan `claude` processes linger.
    for (const w of this._warmWorkers) {
      try {
        w.kill("shutdown");
      } catch {
        /* already gone */
      }
    }
    this._warmWorkers = [];
  }
}

// ── WP-P7: hop-limited sub-agent delegation ──────────────────────────────────
//
// THE DEADLOCK we must make impossible:
//   The warm/interactive pool has only ~2 workers. A PARENT call_agent worker is
//   itself a running `claude` process. If that parent calls call_agent and the
//   sub-call needs a *pool* worker while all pool workers are held by parents
//   blocked on their own sub-calls → classic resource deadlock. So delegated
//   (sub-agent) calls MUST NOT touch the AgentPool. They run on this separate
//   lane, which:
//     1. NEVER references an AgentPool, warm worker, or its queue. It only
//        cold-spawns its own `claude` one-shots, capped by `maxInFlight`. The
//        interactive pool's latency/availability is therefore unaffected by
//        delegation depth or fan-out.
//     2. Enforces a strict HOP BUDGET (carried in agent_context.delegation):
//        a call at hop >= max is REJECTED with a clean error and NO spawn, so a
//        runaway delegation loop is impossible and tree depth is bounded.
//     3. Enforces a daemon-wide in-flight cap so a wide fan-out can't exhaust
//        resources.
//
// Hop accounting: the delegation context on an INCOMING call describes the call
// itself — `{ hop: N, max: M }` means "this is a hop-N delegated call, budget M".
// A top-level (interactive) call has no delegation context (hop is treated as 0).
// When a worker at hop N spawns a sub-agent, the daemon computes the child's
// context as `{ hop: N+1, max: M }` and rejects if `N+1 > M`. Equivalently, an
// incoming delegated call is admitted iff `hop <= max`; hop is the depth of the
// call being served, and a call at `hop > max` (or `hop == max` asking to go
// deeper) is refused.

/**
 * Normalize / validate a delegation marker from agent_context.delegation.
 * Returns { hop, max } with safe integer coercion, or null if absent/invalid.
 * A present-but-malformed marker normalizes to hop 0 with the default max so a
 * caller cannot smuggle a negative hop or a giant max past the budget.
 */
export function normalizeDelegation(input, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
  if (input == null) return null;
  if (typeof input !== "object") return null;
  let hop = Number(input.hop);
  let max = Number(input.max);
  if (!Number.isFinite(hop) || hop < 0) hop = 0;
  hop = Math.floor(hop);
  if (!Number.isFinite(max) || max < 0) max = defaultMax;
  max = Math.floor(max);
  return { hop, max };
}

/**
 * Decide whether an incoming DELEGATED call may proceed, and compute the child
 * delegation context the spawned sub-agent should carry if IT delegates further.
 *
 * @param {object|null} delegation  normalized { hop, max } of the incoming call
 * @param {object} [opts]
 * @param {number} [opts.defaultMax]
 * @returns {{ allow: boolean, hop: number, max: number, childContext?: object, error?: string }}
 */
export function evaluateHopBudget(delegation, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
  const d = normalizeDelegation(delegation, { defaultMax }) || { hop: 0, max: defaultMax };
  // The call being served is at depth `hop`. It is admissible iff it does not
  // exceed the budget. `hop > max` means a parent tried to spawn one level too
  // deep — refuse with a clean error, no spawn.
  if (d.hop > d.max) {
    return {
      allow: false,
      hop: d.hop,
      max: d.max,
      error: "delegation_hop_exceeded",
    };
  }
  // Child context for any sub-agent THIS call spawns: one hop deeper, same max.
  return {
    allow: true,
    hop: d.hop,
    max: d.max,
    childContext: { hop: d.hop + 1, max: d.max },
  };
}

/**
 * Single source of truth for the routing decision: should THIS call_agent
 * invocation go through the delegation lane instead of the interactive pool?
 *
 * The gate (`enabled`, from CLAUTH_AGENT_DELEGATION) is checked FIRST: when the
 * gate is OFF this returns false unconditionally and `agent_context.delegation`
 * is never even read — that is the byte-stable-off guarantee. A call is delegated
 * iff the gate is on AND the caller supplied an `agent_context.delegation`
 * marker (only a sub-agent invocation carries one).
 *
 * @param {object} args
 * @param {boolean} args.enabled        CLAUTH_AGENT_DELEGATION on?
 * @param {object}  [args.agent_context]
 * @returns {boolean}
 */
export function shouldDelegate({ enabled, agent_context } = {}) {
  if (!enabled) return false; // gate OFF → delegation marker ignored entirely
  if (!agent_context || typeof agent_context !== "object") return false;
  return agent_context.delegation != null;
}

/**
 * DelegationLane — a cold-spawn-only, concurrency-capped execution lane for
 * DELEGATED (sub-agent) call_agent invocations. Structurally distinct from
 * AgentPool: it holds no warm workers, owns no AgentPool reference, and shares
 * no queue/slot with the interactive pool. That separation is the anti-deadlock
 * guarantee — sub-agent spawns can never starve or hold an interactive slot.
 *
 * It deliberately reuses the SAME lean one-shot recipe (model, skip-permissions,
 * user-only settings, strict-mcp, lean system prompt) as AgentPool's cold path,
 * but on an independent set of processes with its own `maxInFlight` cap.
 */
export class DelegationLane {
  /**
   * @param {object} opts
   * @param {string} [opts.binary]        resolved claude binary
   * @param {number} [opts.maxInFlight]   daemon-wide concurrent sub-agent cap
   * @param {number} [opts.maxHops]       hop-budget default ceiling
   * @param {string} [opts.defaultModel]
   * @param {number} [opts.defaultTimeoutMs]
   * @param {string} [opts.neutralCwd]
   * @param {string} [opts.appendSystemPrompt]
   * @param {function} [opts.spawnImpl]   inject a fake spawn for tests
   */
  constructor(opts = {}) {
    this.binary = opts.binary || resolveClaudeBinary(opts.binary);
    this.maxInFlight = Number.isFinite(opts.maxInFlight)
      ? Math.max(1, opts.maxInFlight)
      : DEFAULT_DELEGATION_MAX_INFLIGHT;
    this.maxHops = Number.isFinite(opts.maxHops) ? Math.max(0, opts.maxHops) : DEFAULT_DELEGATION_MAX_HOPS;
    this.defaultModel = opts.defaultModel || DEFAULT_MODEL;
    this.defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs) ? opts.defaultTimeoutMs : DEFAULT_TIMEOUT_MS;
    this.neutralCwd = opts.neutralCwd || path.join(os.tmpdir(), "clauth-call-agent-delegation");
    this.appendSystemPrompt = opts.appendSystemPrompt || LEAN_SYSTEM_PROMPT;
    this._spawn = opts.spawnImpl || spawn;

    this.active = 0; // live sub-agent cold spawns
    this.totalSpawned = 0;
    this.totalRejected = 0;
    this._shuttingDown = false;
    this._procs = new Set(); // live child procs for shutdown teardown

    try {
      fs.mkdirSync(this.neutralCwd, { recursive: true });
    } catch {
      /* best effort */
    }
  }

  available() {
    return this.binary != null;
  }

  buildArgs({ prompt, model, appendSystemPrompt }) {
    return [
      "-p",
      prompt,
      "--model",
      model || this.defaultModel,
      "--dangerously-skip-permissions",
      "--setting-sources",
      "user",
      "--strict-mcp-config",
      "--append-system-prompt",
      appendSystemPrompt || this.appendSystemPrompt,
    ];
  }

  /**
   * Dispatch a DELEGATED sub-agent call. Enforces the hop budget and the
   * in-flight cap, then cold-spawns a one-shot. Returns a job record shaped like
   * AgentPool.dispatch's so callers are uniform. On rejection it resolves with
   * `{ ok:false, error, delegated:true, ... }` and NEVER spawns.
   *
   * @param {object} args { prompt, model, timeout_ms, delegation:{hop,max}, jobId, appendSystemPrompt }
   * @returns {Promise<object>}
   */
  dispatch(args = {}) {
    return new Promise((resolve) => {
      if (!this.available()) {
        return resolve({ ok: false, error: "binary_not_found", delegated: true });
      }
      const prompt = args.prompt;
      if (!prompt || typeof prompt !== "string") {
        return resolve({ ok: false, error: "prompt_required", delegated: true });
      }
      // Hop budget — the single most important guard. A call past budget is
      // refused cleanly with NO spawn, so a delegation loop cannot run away.
      const budget = evaluateHopBudget(args.delegation, { defaultMax: this.maxHops });
      if (!budget.allow) {
        this.totalRejected++;
        return resolve({
          ok: false,
          error: budget.error,
          delegated: true,
          hop: budget.hop,
          max: budget.max,
          message: `delegation hop ${budget.hop} exceeds max ${budget.max}`,
        });
      }
      // Daemon-wide in-flight cap — a wide fan-out cannot exhaust resources.
      if (this.active >= this.maxInFlight) {
        this.totalRejected++;
        return resolve({
          ok: false,
          error: "delegation_capacity",
          delegated: true,
          active: this.active,
          maxInFlight: this.maxInFlight,
          message: `delegation lane full (${this.active}/${this.maxInFlight})`,
        });
      }
      if (this._shuttingDown) {
        return resolve({ ok: false, error: "delegation_shutting_down", delegated: true });
      }

      const model = args.model || this.defaultModel;
      const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
      const jobId = args.jobId || makeJobId("deleg");
      const cwd = args.cwd || this.neutralCwd;
      const startedAt = Date.now();

      const cmdArgs = this.buildArgs({ prompt, model, appendSystemPrompt: args.appendSystemPrompt });

      let proc;
      try {
        const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
        const command = isCmdShim ? "cmd" : this.binary;
        const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
        proc = this._spawn(command, spawnArgs, {
          cwd,
          env: process.env, // inherits CLI login; never sets ANTHROPIC_API_KEY
          stdio: ["ignore", "pipe", "pipe"],
          shell: false,
          windowsHide: true,
        });
      } catch (e) {
        return resolve({
          ok: false,
          error: `spawn_failed: ${e.message}`,
          delegated: true,
          jobId,
        });
      }

      this.active++;
      this.totalSpawned++;
      this._procs.add(proc);

      let stdout = "";
      let stderr = "";
      let settled = false;
      // NOTE: intentionally NOT unref'd. While a sub-agent is in flight this
      // timer is the only thing that can settle a hung one-shot, so it must keep
      // the loop alive until it fires (it clears on normal close). The daemon
      // owns process lifecycle; shutdown() kills all live procs explicitly.
      const timer = setTimeout(() => {
        if (settled) return;
        try {
          proc.kill("SIGKILL");
        } catch {
          /* already gone */
        }
        finish({ status: "timeout", error: "timeout" });
      }, timeout);

      const finish = (extra) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        this.active = Math.max(0, this.active - 1);
        this._procs.delete(proc);
        const ms = Date.now() - startedAt;
        const status = extra.status;
        resolve({
          ok: status === "completed",
          jobId,
          status,
          model,
          ms,
          exit_code: extra.exit_code ?? null,
          package: status === "completed" ? stdout.trim() : null,
          error: extra.error || null,
          delegated: true,
          hop: budget.childContext.hop, // depth of this spawned sub-agent
          max: budget.max,
          ...(stderr && status !== "completed" ? { stderr: stderr.slice(-2000) } : {}),
        });
      };

      proc.stdout?.on("data", (d) => {
        stdout = `${stdout}${d.toString()}`.slice(-200000);
      });
      proc.stderr?.on("data", (d) => {
        stderr = `${stderr}${d.toString()}`.slice(-20000);
      });
      proc.on("error", (e) => finish({ status: "failed", error: `proc_error: ${e.message}` }));
      proc.on("close", (code) => {
        if (code === 0) finish({ status: "completed", exit_code: 0 });
        else finish({ status: "failed", exit_code: code, error: `exit_${code}` });
      });
    });
  }

  stats() {
    return {
      active: this.active,
      maxInFlight: this.maxInFlight,
      maxHops: this.maxHops,
      totalSpawned: this.totalSpawned,
      totalRejected: this.totalRejected,
    };
  }

  shutdown() {
    this._shuttingDown = true;
    for (const proc of this._procs) {
      try {
        proc.kill("SIGKILL");
      } catch {
        /* already gone */
      }
    }
    this._procs.clear();
  }
}

export {
  LEAN_SYSTEM_PROMPT,
  IDENTITY_SYSTEM_PROMPT,
  resolveClaudeBinary,
  buildWarmArgs,
  buildFullSystemPrompt,
  makeJobId,
};

// composeCorpus / buildCorpusSection / loadBootstrapDoc are exported inline
// above (function declarations with `export`).
