#!/usr/bin/env node
/**
 * spawn-agent.js — Runtime-agnostic wrapper for spawning a CLI runtime.
 *
 * As of P-9c4f2d6a this script knows nothing CLI-specific. All binary
 * resolution, arg construction, and prompt prep flows through the runtime
 * adapter resolved from `--runtime <name>` (defaults to 'claude').
 *
 * Usage:
 *   node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [adapter opts...] [unknown args...]
 *
 * Recognized adapter opts (parsed and forwarded to `runtime.buildArgs(opts)`):
 *   --model <m>                 → opts.model
 *   --max-turns <n>             → opts.maxTurns
 *   --allowedTools <list>       → opts.allowedTools
 *   --effort <level>            → opts.effort
 *   --resume <sessionId>        → opts.sessionId
 *   --max-budget-usd <n>        → opts.maxBudget
 *   --bare                      → opts.bare = true
 *   --fallback-model <m>        → opts.fallbackModel
 *   --output-format <f>         → opts.outputFormat
 *   --verbose / --no-verbose    → opts.verbose
 *   --stream <on|off>           → opts.stream                (Copilot)
 *   --disable-builtin-mcps      → opts.disableBuiltinMcps    (Copilot)
 *   --no-custom-instructions    → opts.suppressAgentsMd      (Copilot)
 *   --enable-reasoning-summaries→ opts.reasoningSummaries    (Copilot)
 *
 * Legacy --permission-mode <X> is dropped: the new Claude adapter emits
 * `--dangerously-skip-permissions` itself, so passing the legacy flag from a
 * pre-P-2a6d9c4f engine.js would only produce a duplicate flag. Other unknown
 * args are forwarded verbatim to the runtime binary as a defensive escape
 * hatch.
 */

const fs = require('fs');
const os = require('os');
const path = require('path');
const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, readFileNoFollow } = require('./shared');
const { resolveRuntime } = require('./runtimes');
const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
const keepProcessSweep = require('./keep-process-sweep');

// ─── Pure helpers (exported for tests) ──────────────────────────────────────

/**
 * Parse argv into { promptFile, sysPromptFile, runtimeName, opts, passthrough }.
 * Returns null when the two positional args are missing.
 */
function parseSpawnArgs(argv) {
  const args = (argv || []).slice(2);
  if (args.length < 2) return null;

  const promptFile = args[0];
  const sysPromptFile = args[1];
  let runtimeName = 'claude';
  const opts = {};
  const passthrough = [];

  for (let i = 2; i < args.length; i++) {
    const a = args[i];
    const peek = () => args[++i];

    switch (a) {
      case '--runtime':                    runtimeName = peek(); break;
      case '--model':                      opts.model = peek(); break;
      case '--max-turns':                  opts.maxTurns = peek(); break;
      case '--allowedTools':               opts.allowedTools = peek(); break;
      case '--effort':                     opts.effort = peek(); break;
      case '--resume':                     opts.sessionId = peek(); break;
      case '--max-budget-usd':             opts.maxBudget = peek(); break;
      case '--bare':                       opts.bare = true; break;
      case '--fallback-model':             opts.fallbackModel = peek(); break;
      case '--output-format':              opts.outputFormat = peek(); break;
      case '--verbose':                    opts.verbose = true; break;
      case '--no-verbose':                 opts.verbose = false; break;
      case '--stream':                     opts.stream = peek(); break;
      case '--disable-builtin-mcps':       opts.disableBuiltinMcps = true; break;
      case '--no-custom-instructions':     opts.suppressAgentsMd = true; break;
      case '--enable-reasoning-summaries': opts.reasoningSummaries = true; break;
      // LEGACY: dropped — the runtime adapter emits its own permission flag.
      // Pre-P-2a6d9c4f engine.js still passes `--permission-mode bypassPermissions`;
      // letting it through would duplicate the permission flag for Claude.
      case '--permission-mode':            i++; break;
      default:                             passthrough.push(a); break;
    }
  }

  return { promptFile, sysPromptFile, runtimeName, opts, passthrough };
}

/**
 * Compose the final spawn invocation. Pure: no FS, no spawn. Caller is
 * responsible for writing the sysprompt tmp file (when `sysPromptFile` is
 * supplied via opts) and for cleanup.
 *
 * Returns:
 *   {
 *     bin, leadingArgs, args,         // → spawn(execPath OR bin, [bin?, ...leadingArgs, ...args])
 *     deliveryMode,                   // 'stdin' | 'arg' (runtime adapter decision)
 *     finalPrompt,                    // adapter-built prompt text (may include sysprompt for some runtimes)
 *     usingNodeShim,                  // true → runtime returned a non-native binary (Claude cli.js)
 *   }
 */
function buildSpawnInvocation({ runtime, resolved, promptText, sysPromptText, opts, passthrough, addDirs }) {
  const adapterOpts = { ...opts };
  if (Array.isArray(addDirs) && addDirs.length) adapterOpts.addDirs = addDirs;
  const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
  const deliveryMode = typeof runtime.getPromptDeliveryMode === 'function'
    ? runtime.getPromptDeliveryMode(adapterOpts)
    : (runtime.capabilities && runtime.capabilities.promptViaArg ? 'arg' : 'stdin');
  if (deliveryMode === 'arg') {
    adapterOpts.prompt = finalPrompt;
  }
  const adapterArgs = runtime.buildArgs(adapterOpts);
  const { bin, native, leadingArgs = [] } = resolved;
  return {
    bin,
    native,
    leadingArgs,
    args: [...adapterArgs, ...(passthrough || [])],
    deliveryMode,
    finalPrompt,
    usingNodeShim: !native,
  };
}

function normalizeRuntimeExit(code, signal) {
  if (Number.isInteger(code)) return code;
  if (signal) return 128;
  return 1;
}

function shouldInjectAdoTokenEnv(env = process.env) {
  return String(env.MINIONS_REPO_HOST || '').trim().toLowerCase() === 'ado';
}

function injectAdoTokenEnv(env, { execSync: _execSync, acquireToken, warn = (msg) => process.stderr.write(msg + '\n') } = {}) {
  let result;
  try {
    result = typeof acquireToken === 'function'
      ? acquireToken()
      : acquireAdoTokenSync({ execSync: _execSync });
  } catch (err) {
    warn(`spawn-agent.js: ADO token fetch failed: ${err.message}`);
    return false;
  }
  const token = typeof result === 'string' ? result : result?.token;
  if (!isLikelyAdoToken(token)) {
    warn('spawn-agent.js: invalid ADO token; continuing without Azure DevOps PAT env');
    return false;
  }
  env.AZURE_DEVOPS_EXT_PAT = token;
  env.AZURE_DEVOPS_EXT_AZURE_RM_PAT = token;
  return true;
}

function injectAdoTokenEnvForRepoHost(env, opts) {
  if (!shouldInjectAdoTokenEnv(env)) return false;
  return injectAdoTokenEnv(env, opts);
}

function formatProcessExitSentinel(exitCode, signal) {
  return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
}

/**
 * Pre-push stale-HEAD guard for fix-task dispatches (P-c8f2d5e3).
 *
 * When the engine reuses an existing worktree on a PR branch that was rebased
 * upstream (force-push), the local HEAD can sit behind origin/<branch>. The
 * first push from that worktree silently overwrites the rebased history — a
 * confirmed silent-overwrite footgun captured in team memory.
 *
 * This helper runs:
 *   git fetch origin <branch>
 *   git rev-list --count HEAD..origin/<branch>
 * inside the worktree. When the count is > 0 it throws a clear, actionable
 * error so engine.spawnAgent can abort the dispatch before invoking the
 * runtime CLI — i.e. before the agent has a chance to push.
 *
 * The fetch is best-effort: if origin doesn't have the ref yet (first push on
 * a fresh branch, common for shared-branch plan items), the helper returns
 * `{ ok: true, skipped: 'no-upstream' }` instead of failing — there's no
 * rebased tip to overwrite. Any other fetch failure is also treated as a
 * skip with `skipped: 'fetch-failed'` so transient network issues don't
 * brick an otherwise-healthy dispatch.
 *
 * @param {object} args
 * @param {string} args.branch - PR branch name (already sanitized)
 * @param {string} args.cwd    - Worktree path
 * @param {function} [args.exec] - Async exec(cmd, opts) — injectable for tests
 * @param {object}   [args.gitOpts] - Options passed through to exec
 * @returns {Promise<{ok: true, behindCount: number, skipped?: string}>}
 * @throws {Error & {code: 'STALE_HEAD'}} when local HEAD is behind origin
 */
async function assertStaleHeadOk({ branch, cwd, exec, gitOpts } = {}) {
  if (!branch) throw new Error('assertStaleHeadOk: branch is required');
  if (!cwd) throw new Error('assertStaleHeadOk: cwd is required');
  const execFn = typeof exec === 'function'
    ? exec
    : require('./shared').execAsync;
  const opts = { ...(gitOpts || {}), cwd };

  // Best-effort fetch. Branch-missing-on-origin is a legitimate state (first
  // push on a freshly-cut feature branch) and must NOT block dispatch.
  try {
    await execFn(`git fetch origin "${branch}"`, opts);
  } catch (err) {
    const msg = (err && (err.stderr?.toString?.() || err.message || '')) + '';
    if (/couldn'?t find remote ref|not found in upstream|unknown revision/i.test(msg)) {
      return { ok: true, behindCount: 0, skipped: 'no-upstream' };
    }
    // Other failures (network/auth/timeout) — skip rather than block.
    return { ok: true, behindCount: 0, skipped: 'fetch-failed' };
  }

  let countOut;
  try {
    countOut = await execFn(`git rev-list --count HEAD..origin/${branch}`, opts);
  } catch (err) {
    // origin/<branch> resolution failed AFTER fetch — treat as no-upstream.
    return { ok: true, behindCount: 0, skipped: 'rev-list-failed' };
  }
  const raw = typeof countOut === 'string'
    ? countOut
    : (countOut?.stdout?.toString?.() ?? String(countOut ?? ''));
  const behindCount = parseInt(String(raw).trim(), 10);
  if (!Number.isFinite(behindCount) || behindCount <= 0) {
    return { ok: true, behindCount: Number.isFinite(behindCount) ? behindCount : 0 };
  }
  const err = new Error(
    `PR branch was rebased; local HEAD is stale (${behindCount} commits behind origin). ` +
    `Run \`git pull --rebase origin ${branch}\` first.`
  );
  err.code = 'STALE_HEAD';
  err.behindCount = behindCount;
  err.branch = branch;
  throw err;
}

// The orphan reaper recovers an agent's exit code by scanning live-output.log for
// `[process-exit] code=N`. The previous design wrote the sentinel to stdout, hoping
// the engine's stdout consumer (engine.js) would copy it into the file — but when
// the engine was force-killed during a restart on Windows, the broken pipe
// silently swallowed the sentinel and the recovery never happened (see #2265 for
// the read-side recovery; this is the upstream fix). The orphan reaper only reads
// the file, so the simplest correct thing is to write the file directly. No
// stdout, no timeouts, no callbacks — one synchronous append.
function writeProcessExitSentinel({
  exitCode,
  signal = null,
  outputPath = process.env.MINIONS_LIVE_OUTPUT_PATH,
} = {}) {
  const sentinel = formatProcessExitSentinel(exitCode, signal);
  let fileWritten = false;
  if (outputPath) {
    try {
      fs.appendFileSync(outputPath, sentinel);
      fileWritten = true;
    } catch { /* file unavailable; orphan reaper falls back to runtime result event (#2265) */ }
  }
  return { sentinel, fileWritten };
}

function _appendOutputFallback(outputPath, chunk, prefix = '') {
  if (!outputPath) return false;
  try {
    fs.appendFileSync(outputPath, prefix + chunk.toString());
    return true;
  } catch {
    return false;
  }
}

function createParentPipeForwarder(stream, outputPath, prefix = '') {
  let pipeBroken = false;
  if (stream && typeof stream.on === 'function') {
    stream.on('error', () => {
      pipeBroken = true;
    });
  }
  return (chunk) => {
    if (pipeBroken || !stream || typeof stream.write !== 'function') {
      _appendOutputFallback(outputPath, chunk, prefix);
      return;
    }
    try {
      stream.write(chunk, (err) => {
        if (!err) return;
        pipeBroken = true;
        _appendOutputFallback(outputPath, chunk, prefix);
      });
    } catch {
      pipeBroken = true;
      _appendOutputFallback(outputPath, chunk, prefix);
    }
  };
}

/**
 * Build the `--add-dir` list passed to the runtime CLI. Pure: takes
 * `{ runtime, minionsDir, homeDir, exists }` and returns an ordered, deduped
 * array of dirs the agent should be able to read from outside its worktree.
 *
 * Order: minionsDir first (so playbooks/system-prompt are always reachable),
 * followed by every existing dir from `runtime.getUserAssetDirs({ homeDir })`.
 * Non-existent asset dirs are dropped — Claude CLI rejects unknown `--add-dir`
 * entries. The dedup compares resolved paths so we never emit minionsDir twice
 * (e.g. when runtime asset dir IS the minions repo in unusual setups).
 *
 * `exists` is injectable for tests; defaults to `fs.existsSync`.
 */
function computeAddDirs({ runtime, minionsDir, homeDir, exists = fs.existsSync } = {}) {
  const out = [minionsDir];
  const seen = new Set([path.resolve(minionsDir)]);
  const assetDirs = typeof runtime?.getUserAssetDirs === 'function'
    ? runtime.getUserAssetDirs({ homeDir })
    : [];
  for (const d of assetDirs) {
    if (!d) continue;
    const resolved = path.resolve(d);
    if (seen.has(resolved)) continue;
    if (!exists(d)) continue;
    out.push(d);
    seen.add(resolved);
  }
  return out;
}

// ─── Main script execution ──────────────────────────────────────────────────

function _installHint(name, runtime) {
  // Adapters expose `installHint` as the canonical message; fall back to a
  // generic line when an adapter without one is registered (defensive — every
  // bundled adapter sets it, but custom registrations may not).
  if (runtime && typeof runtime.installHint === 'string' && runtime.installHint) {
    return runtime.installHint;
  }
  return `${name} CLI binary not found on PATH`;
}

function main() {
  const parsed = parseSpawnArgs(process.argv);
  if (!parsed) {
    console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
    process.exit(1);
  }
  const { promptFile, sysPromptFile, runtimeName, opts, passthrough } = parsed;

  const env = cleanChildEnv();
  injectAdoTokenEnvForRepoHost(env);

  let runtime;
  try { runtime = resolveRuntime(runtimeName); }
  catch (err) {
    console.error(`FATAL: ${err.message}`);
    process.exit(78);
  }

  // P-f6-tmp-toctou: read prompt/sysprompt with O_NOFOLLOW on POSIX so a
  // sibling agent that planted a symlink at the prompt path cannot redirect
  // our read to an attacker-controlled file. Windows: fs.constants.O_NOFOLLOW
  // is undefined → the bitwise-or is a no-op and the open still succeeds
  // (Windows non-junction reads don't follow symlinks the same way POSIX does).
  const promptText = readFileNoFollow(promptFile, 'utf8');
  const sysPromptText = readFileNoFollow(sysPromptFile, 'utf8');

  // Sys prompt tmp file — only when (a) NOT resuming and (b) the adapter
  // accepts a system prompt as a separate file. For runtimes that bake the
  // system prompt into the user prompt (e.g. Copilot), sysPromptText is
  // already merged inside `runtime.buildPrompt(prompt, sys)`.
  const isResume = opts.sessionId != null;
  const sysTmpPath = sysPromptFile + '.tmp';
  const wantsSystemPromptFile = typeof runtime.usesSystemPromptFile === 'function'
    ? runtime.usesSystemPromptFile({ isResume, opts })
    : (!isResume && runtime.capabilities && runtime.capabilities.systemPromptFile);
  if (wantsSystemPromptFile) {
    fs.writeFileSync(sysTmpPath, sysPromptText);
    opts.sysPromptFile = sysTmpPath;
  }

  // Skill discovery dirs — agents run with CWD set to an external repo
  // worktree, so runtime-native global assets would otherwise be invisible.
  // The adapter owns both where those assets live and how to surface them.
  const minionsDir = path.resolve(__dirname, '..');
  const addDirs = computeAddDirs({ runtime, minionsDir, homeDir: os.homedir() });

  let resolved;
  try { resolved = runtime.resolveBinary({ env }); }
  catch (err) {
    console.error(`FATAL: ${runtimeName} runtime resolveBinary failed: ${err.message}`);
    process.exit(78);
  }
  if (!resolved) {
    console.error(`FATAL: Cannot find ${runtimeName} CLI — ${_installHint(runtimeName, runtime)}`);
    process.exit(78);
  }

  const invocation = buildSpawnInvocation({
    runtime, resolved,
    promptText, sysPromptText,
    opts, passthrough, addDirs,
  });

  // Debug log (async — not on critical path). Lives inside the per-dispatch
  // dir (path.dirname(promptFile)) so it inherits the unguessable parent
  // (P-f6-tmp-toctou) instead of sharing engine/tmp/spawn-debug.log across all
  // concurrent dispatches.
  const tmpDir = path.dirname(promptFile);
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
  const debugPath = path.join(tmpDir, 'spawn-debug.log');
  fs.writeFile(
    debugPath,
    `spawn-agent.js at ${ts()}\nruntime=${runtimeName}\nbin=${invocation.bin}\nnative=${invocation.native}\n` +
      `leadingArgs=${invocation.leadingArgs.join(' ')}\nprompt=${promptFile}\nsysPrompt=${sysPromptFile}\n` +
      `delivery=${invocation.deliveryMode}\nargs=${invocation.args.join(' ').slice(0, 800)}\n`,
    () => {},
  );

  // Build the actual exec form. If the runtime returns a non-native binary
  // (e.g. Claude's cli.js), shim it under the current node process.
  const execBin = invocation.native ? invocation.bin : process.execPath;
  const execArgs = invocation.native
    ? [...invocation.leadingArgs, ...invocation.args]
    : [invocation.bin, ...invocation.leadingArgs, ...invocation.args];

  const proc = runFile(execBin, execArgs, { stdio: ['pipe', 'pipe', 'pipe'], env });

  fs.appendFile(debugPath, `PID=${proc.pid || 'none'}\n`, () => {});

  // Write PID file for parent engine to verify spawn (async — engine checks after 5s)
  const pidFile = promptFile.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
  fs.writeFile(pidFile, String(proc.pid || ''), () => {});

  // Deliver the prompt — stdin (Claude default) vs argv (handled by adapter).
  if (invocation.deliveryMode === 'stdin') {
    try {
      proc.stdin.write(invocation.finalPrompt);
      proc.stdin.end();
    } catch (err) {
      console.error(`FATAL: stdin write failed (broken pipe): ${err.message}`);
      fs.appendFileSync(debugPath, `STDIN ERROR: ${err.message}\n`);
      killImmediate(proc);
      process.exit(1);
    }
  } else {
    // Adapter has already spliced the prompt into argv (--prompt <text>).
    // Close stdin so the runtime doesn't wait on it.
    try { proc.stdin.end(); } catch { /* may already be closed */ }
  }

  // Clean up sys tmp (only created for non-resume sessions on adapters that
  // use --system-prompt-file).
  //
  // We deliberately DO NOT use a fixed-delay setTimeout here. Claude CLI reads
  // `--system-prompt-file` lazily during init; on cold starts (MCP boot,
  // `--add-dir` traversal, Windows process startup) the read can land well
  // after any short timer fires, leaving Claude to bail with
  // "System prompt file not found". The exit/SIGTERM handler below — plus the
  // matching unlinks in engine/dispatch.js and engine/meeting.js on dispatch
  // completion — are sufficient to keep tmp files from leaking.
  function _cleanupSpawnTempFiles() {
    if (wantsSystemPromptFile) {
      try { fs.unlinkSync(sysTmpPath); } catch { /* may already be cleaned */ }
    }
  }
  process.on('exit', _cleanupSpawnTempFiles);
  process.on('SIGTERM', () => { _cleanupSpawnTempFiles(); process.exit(143); });

  const forwardStdout = createParentPipeForwarder(process.stdout, process.env.MINIONS_LIVE_OUTPUT_PATH);
  const forwardStderr = createParentPipeForwarder(process.stderr, process.env.MINIONS_LIVE_OUTPUT_PATH, '[stderr] ');

  // Capture stderr separately for debugging
  let stderrBuf = '';
  proc.stderr.on('data', (chunk) => {
    stderrBuf += chunk.toString();
    forwardStderr(chunk);
  });

  // Forward output without depending on the parent engine pipe staying alive.
  proc.stdout.on('data', forwardStdout);

  // MCP startup timeout: kill if no stdout within 3 minutes
  const MCP_STARTUP_TIMEOUT = 180000; // 3 minutes
  let gotFirstOutput = false;
  const startupTimer = setTimeout(() => {
    if (!gotFirstOutput) {
      const msg = `TIMEOUT: ${runtimeName} CLI produced no output after ${MCP_STARTUP_TIMEOUT / 1000}s (likely MCP server startup stall)`;
      console.error(msg);
      fs.appendFileSync(debugPath, msg + '\n');
      killGracefully(proc);
    }
  }, MCP_STARTUP_TIMEOUT);
  proc.stdout.once('data', () => { gotFirstOutput = true; clearTimeout(startupTimer); });

  // Descendant snapshot loop (orphan-MCP fix). The runtime CLI (claude/copilot)
  // spawns one node child per configured MCP server (Azure, Playwright, Loop,
  // …). When the runtime exits normally on Windows, those grandchildren are
  // reparented and survive indefinitely — they don't watch stdin for EOF, and
  // taskkill /T against the dead parent PID no longer finds them. We snapshot
  // the descendant tree periodically while the runtime is alive so the close
  // handler can reap any survivors by PID after the parent has gone.
  const trackedDescendants = new Set();
  let snapshotInFlight = false;
  function snapshotDescendants() {
    if (!proc.pid || snapshotInFlight) return;
    snapshotInFlight = true;
    try {
      for (const pid of listProcessDescendants(proc.pid)) trackedDescendants.add(pid);
    } catch { /* best-effort */ }
    finally { snapshotInFlight = false; }
  }
  // Initial snapshot is deferred: MCP children spawn ~1-3s after runtime boot,
  // and a 0ms snapshot would always be empty. Recurring snapshots run every
  // 30s — Get-CimInstance enumerates every process on the box, so anything
  // hotter starves the cleanup tick with N concurrent agents.
  const initialSnapshotTimer = setTimeout(snapshotDescendants, 3000);
  if (initialSnapshotTimer.unref) initialSnapshotTimer.unref();
  const descTimer = setInterval(snapshotDescendants, 30000);
  if (descTimer.unref) descTimer.unref();

  // Track the real OS exit code via the 'exit' event. Node's 'close' event
  // can report code=0 on Windows when the OS-level exit was non-zero
  // (observed empirically with both Claude Code CLI and Copilot CLI exiting
  // with OS exit code 1 silently during long PowerShell tool calls — procdump
  // captured the 1, but the engine's onAgentClose saw the spawn-agent's parent
  // pipe report code=0). The 'exit' event fires earlier and carries the OS code
  // more reliably; 'close' waits for stdio teardown which can race.
  let realExitFromEvent = null;
  let realSignalFromEvent = null;
  let sentinelWritten = false;
  proc.on('exit', (code, signal) => {
    if (Number.isInteger(code)) realExitFromEvent = code;
    if (signal) realSignalFromEvent = signal;
  });
  proc.on('close', (code, signal) => {
    clearTimeout(startupTimer);
    clearTimeout(initialSnapshotTimer);
    clearInterval(descTimer);

    // Compute the exit code and write the [process-exit] sentinel FIRST,
    // before the descendant snapshot/reap. The engine's orphan reaper uses
    // the sentinel as the single signal that "the runtime exited cleanly
    // with code N"; if we delay it behind `snapshotDescendants()` (which
    // shells out to `Get-CimInstance` on Windows and can block 1-5+s),
    // there's a window where the runtime PID we track is already dead but
    // no sentinel exists yet. After an engine restart that path triggers
    // `canReapDeadProcess` and the dispatch gets auto-retried as orphaned
    // even though it completed normally. See W-mpbn93ou000611b3 / the
    // 2026-05-18 ripley-explore regression.
    //
    // Prefer the 'exit' event's code/signal when present (Node's 'close'
    // event can report code=0 on Windows when the OS-level exit was
    // non-zero — see the long-form note above the exit handler).
    const effectiveCode = (realExitFromEvent != null) ? realExitFromEvent : code;
    const effectiveSignal = realSignalFromEvent || signal;
    const exitCode = normalizeRuntimeExit(effectiveCode, effectiveSignal);
    if (sentinelWritten) {
      // Defense-in-depth: never write a duplicate sentinel. We observed pairs
      // of [process-exit] code=0 lines in live-output.log across many failed
      // runs, which suggests close has fired twice in some edge cases (e.g.,
      // shim re-launch on Windows). One sentinel per spawn is the contract.
      // Skip descendant reap on the duplicate close too — the first close
      // already handled it (reaping the same PIDs again is a no-op at best,
      // but skipping is faster and matches the prior early-return contract).
      fs.appendFileSync(debugPath, `EXIT (duplicate close, skipping sentinel): code=${exitCode}${effectiveSignal ? ` signal=${effectiveSignal}` : ''}\n`);
      process.exit(exitCode);
      return;
    }
    sentinelWritten = true;
    const sentinelResult = writeProcessExitSentinel({ exitCode, signal: effectiveSignal });

    // Final snapshot + reap, but only when the runtime actually spawned
    // children. Read-only / very short agents (exit before the 3s initial
    // snapshot fires) skip the wmic shell-out entirely. Runs AFTER the
    // sentinel write so a slow Get-CimInstance call can't gate completion
    // detection — see the hoist note above.
    if (trackedDescendants.size || gotFirstOutput) {
      snapshotDescendants();
      if (trackedDescendants.size) {
        // W-mp68q6ke0010de68 — opt-in keep_processes flag: agents whose work
        // item carried `meta.keep_processes: true` may have written
        // `agents/<id>/keep-pids.json` declaring specific descendant PIDs the
        // engine MUST NOT reap. Resolve agentId from MINIONS_LIVE_OUTPUT_PATH
        // (engine.js:1451 sets it to agents/<agentId>/live-output.log) and
        // subtract validated, alive PIDs from the kill set. Missing or
        // invalid file → fall through to today's behavior (reap everything).
        let toKillPids = [...trackedDescendants];
        let kept = [];
        let keepRecord = null;
        let keepReason = null;
        try {
          const liveOut = process.env.MINIONS_LIVE_OUTPUT_PATH;
          const agentId = liveOut ? path.basename(path.dirname(liveOut)) : '';
          if (agentId) {
            // W-mp6k7ywi000fa33c — per-WI override (set by engine when
            // meta.keep_processes_skip_workdir_check is true) bypasses the
            // requireGitWorkdir check so legitimate non-git keep_processes
            // use cases (e.g., a daemon under /tmp) still anchor.
            const reapOpts = process.env.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK === '1'
              ? { requireGitWorkdir: false }
              : {};
            const plan = keepProcessSweep.computeReapPlan(toKillPids, agentId, reapOpts);
            toKillPids = plan.toKill;
            kept = plan.kept;
            keepRecord = plan.record;
            keepReason = plan.reason;
          }
        } catch (e) {
          try { fs.appendFileSync(debugPath, `KEEP-PIDS error: ${e.message}\n`); } catch {}
        }
        if (kept.length) {
          try {
            fs.appendFileSync(
              debugPath,
              `KEPT pids=[${kept.join(',')}] purpose="${(keepRecord?.purpose || '').slice(0, 200)}" wi=${keepRecord?.wi_id || ''}\n`,
            );
          } catch {}
        } else if (keepReason) {
          try { fs.appendFileSync(debugPath, `KEEP-PIDS skipped: ${keepReason}\n`); } catch {}
        }
        const reaped = toKillPids.length ? killByPidsImmediate(toKillPids) : 0;
        try { fs.appendFileSync(debugPath, `DESCENDANTS reaped=${reaped}/${toKillPids.length} kept=${kept.length}\n`); } catch {}
      }
    }
    fs.appendFileSync(debugPath, `EXIT: code=${exitCode}${effectiveSignal ? ` signal=${effectiveSignal}` : ''} (close=${code} exit=${realExitFromEvent})\nSTDERR: ${stderrBuf.slice(0, 500)}\n`);
    if (!sentinelResult.fileWritten) {
      fs.appendFileSync(debugPath, `EXIT SENTINEL: file write failed for ${process.env.MINIONS_LIVE_OUTPUT_PATH}\n`);
    }
    process.exit(exitCode);
  });
  proc.on('error', (err) => {
    fs.appendFileSync(debugPath, `ERROR: ${err.message}\n`);
    if (!sentinelWritten) {
      sentinelWritten = true;
      writeProcessExitSentinel({ exitCode: 1 });
    }
    process.exit(1);
  });
}

module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder, assertStaleHeadOk };

if (require.main === module) main();
