/**
 * engine/managed-spawn.js — Item 1 of plan P-7a3b1c92.
 *
 * Schema, validator, sidecar reader, acceptance helper, and playbook hint for
 * the managed-spawn primitive. Mirrors engine/keep-process-sweep.js
 * beat-for-beat: an agent writes agents/<id>/managed-spawn.json describing
 * long-running services it wants the engine to spawn + healthcheck on its
 * behalf; this module validates the file, returns a structured acceptance
 * decision the engine's onAgentClose handler uses to gate the dispatch, and
 * exposes a hint block the playbook injects so the agent knows the contract.
 *
 * Engine spawn / state-file / healthcheck loop / sweep wiring lives in later
 * plan items (P-7a3b1c93+). This module is pure: no spawns, no network, no
 * mutation of shared state.
 *
 * Design template: engine/keep-process-sweep.js. Divergences are justified
 * inline; symmetry with keep-pids is the convention.
 */

const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const { spawn, exec } = require('child_process');
const shared = require('./shared');
const queries = require('./queries');

const { log, ENGINE_DEFAULTS } = shared;

const MANAGED_SPAWN_FILENAME = 'managed-spawn.json';
const MANAGED_PROCESSES_STATE_FILE = 'managed-processes.json';
const MANAGED_LOGS_DIR = 'managed-logs';
const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';

const HEALTHCHECK_TYPES = ['http', 'command'];

// Kebab-case service names: lowercase letters, digits, single internal hyphens,
// no leading/trailing hyphen. Mirrors the spawn-name conventions used elsewhere
// in the codebase (worktree-pool, dispatch IDs).
const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

function _agentsDir() {
  return queries.AGENTS_DIR || path.join(shared.MINIONS_DIR, 'agents');
}

function _resolveRequireGitWorkdir(opts) {
  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  if (opts && Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
    return !!opts.requireGitWorkdir;
  }
  if (limits.requireGitWorkdir === false) return false;
  return true;
}

function _isOnAllowlist(name, allowlist) {
  if (!Array.isArray(allowlist) || typeof name !== 'string' || name.length === 0) return false;
  const base = path.basename(name).toLowerCase();
  const baseNoExt = base.replace(/\.(exe|cmd|bat|ps1|sh)$/, '');
  for (const entry of allowlist) {
    if (typeof entry !== 'string') continue;
    const e = entry.toLowerCase();
    if (e === base || e === baseNoExt) return true;
  }
  return false;
}

// Env-key shape guard. Bash/POSIX-style identifier: leading letter or
// underscore, followed by letters / digits / underscores. Rejects keys with
// shell metachars ('FOO;BAR', 'FOO BAR', '$FOO') regardless of deny rules —
// argv smuggling protection, separate from the credential-shape threat
// model below.
const _ENV_KEY_SHAPE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

// Compile envKeyDenyPatterns once per validator invocation and cache. The
// returned object also carries the override Set for fast exact-match lookup.
// All patterns are compiled case-insensitively so '_secret' / '_SECRET' are
// equivalent; overrides remain case-sensitive (exact match).
function _compileDenyRules(limits, projectExtraPatterns) {
  const globalPatterns = Array.isArray(limits.envKeyDenyPatterns) ? limits.envKeyDenyPatterns : [];
  const extra = Array.isArray(projectExtraPatterns) ? projectExtraPatterns : [];
  const overridesArr = Array.isArray(limits.envKeyDenyOverrides) ? limits.envKeyDenyOverrides : [];
  const sources = [];
  const compiled = [];
  for (const src of globalPatterns.concat(extra)) {
    if (typeof src !== 'string' || src.length === 0) continue;
    let re;
    try { re = new RegExp(src, 'i'); }
    catch (_e) { continue; }
    sources.push(src);
    compiled.push(re);
  }
  const overrides = new Set();
  for (const k of overridesArr) {
    if (typeof k === 'string' && k.length > 0) overrides.add(k);
  }
  return { sources, compiled, overrides };
}

// Check an env key against compiled deny rules. Returns
// { allowed: true } when the key passes, or
// { allowed: false, reason: 'env-key-denied (matched pattern X)' }
// when blocked. Does NOT enforce the shape guard — callers do that
// separately so shape-failures get a distinct reason.
function _envKeyAllowed(key, denyRules) {
  if (typeof key !== 'string' || key.length === 0) {
    return { allowed: false, reason: 'env-key-empty' };
  }
  if (denyRules.overrides.has(key)) return { allowed: true };
  for (let i = 0; i < denyRules.compiled.length; i++) {
    if (denyRules.compiled[i].test(key)) {
      return { allowed: false, reason: 'env-key-denied (matched pattern ' + denyRules.sources[i] + ')' };
    }
  }
  return { allowed: true };
}

// Find the configured project whose `localPath` contains the spec's cwd.
// Returns the most-specific match (longest matching localPath) so nested
// project layouts (e.g. /repos/parent and /repos/parent/sub) prefer the
// inner one. Returns null when no project matches or when inputs are
// missing. Pure — does not touch disk.
function _resolveProjectForCwd(cwd, projects) {
  if (typeof cwd !== 'string' || cwd.length === 0) return null;
  if (!Array.isArray(projects) || projects.length === 0) return null;
  let best = null;
  let bestLen = -1;
  for (const p of projects) {
    if (!p || typeof p !== 'object') continue;
    const lp = typeof p.localPath === 'string' ? p.localPath : '';
    if (!lp) continue;
    if (!shared.isPathInsideOrEqual(cwd, lp)) continue;
    const len = path.resolve(lp).length;
    if (len > bestLen) {
      best = p;
      bestLen = len;
    }
  }
  return best;
}

// Extract the first executable-like token from a shell-style healthcheck cmd
// string so it can be checked against the executable allowlist. Strips
// surrounding quotes and any leading path. Returns '' when no candidate
// found.
function _firstExecutable(cmd) {
  if (typeof cmd !== 'string') return '';
  const trimmed = cmd.trim();
  if (trimmed.length === 0) return '';
  const m = trimmed.match(/^["']?([^\s"']+)["']?/);
  if (!m) return '';
  return m[1];
}

function _validateHealthcheck(hc, limits) {
  if (!hc || typeof hc !== 'object' || Array.isArray(hc)) {
    return { ok: false, reason: 'healthcheck-missing' };
  }
  if (HEALTHCHECK_TYPES.indexOf(hc.type) < 0) {
    return { ok: false, reason: 'healthcheck-type-invalid (' + hc.type + ')' };
  }
  const timeoutS = Number(hc.timeout_s);
  if (!Number.isFinite(timeoutS) || timeoutS <= 0 || timeoutS > 3600) {
    return { ok: false, reason: 'healthcheck-timeout-invalid' };
  }
  let intervalS = hc.interval_s == null
    ? Number(limits.defaultHealthIntervalSec) || 1
    : Number(hc.interval_s);
  if (!Number.isFinite(intervalS) || intervalS <= 0 || intervalS > 600) {
    return { ok: false, reason: 'healthcheck-interval-invalid' };
  }

  if (hc.type === 'http') {
    if (typeof hc.url !== 'string' || hc.url.length === 0) {
      return { ok: false, reason: 'healthcheck-url-missing' };
    }
    if (!/^https?:\/\//i.test(hc.url)) {
      return { ok: false, reason: 'healthcheck-url-not-absolute' };
    }
    if (hc.expect_status == null) {
      return { ok: false, reason: 'healthcheck-expect-status-missing' };
    }
    const status = Number(hc.expect_status);
    if (!Number.isInteger(status) || status < 100 || status > 599) {
      return { ok: false, reason: 'healthcheck-expect-status-invalid' };
    }
    return {
      ok: true,
      value: {
        type: 'http',
        url: hc.url,
        expect_status: status,
        interval_s: intervalS,
        timeout_s: timeoutS,
      },
    };
  }

  // command
  if (typeof hc.cmd !== 'string' || hc.cmd.length === 0) {
    return { ok: false, reason: 'healthcheck-cmd-missing' };
  }
  if (hc.cmd.length > 1000) {
    return { ok: false, reason: 'healthcheck-cmd-too-long' };
  }
  if (typeof hc.shell !== 'boolean') {
    return { ok: false, reason: 'healthcheck-shell-not-boolean' };
  }
  if (typeof hc.expect_regex !== 'string' || hc.expect_regex.length === 0) {
    return { ok: false, reason: 'healthcheck-expect-regex-missing' };
  }
  if (hc.expect_regex.length > 500) {
    return { ok: false, reason: 'healthcheck-expect-regex-too-long' };
  }
  try { new RegExp(hc.expect_regex); }
  catch (e) { return { ok: false, reason: 'healthcheck-expect-regex-invalid (' + e.message + ')' }; }

  // Same allowlist gate as the spec's `cmd` field. Healthcheck commands run
  // through child_process.exec under engine ownership; without the gate this
  // would be an arbitrary-code-execution path back into the engine process
  // tree. Reject anything whose first token is not on the allowlist.
  const execName = _firstExecutable(hc.cmd);
  if (!_isOnAllowlist(execName, limits.executableAllowlist)) {
    return { ok: false, reason: 'healthcheck-cmd-not-on-allowlist (' + execName + ')' };
  }

  return {
    ok: true,
    value: {
      type: 'command',
      cmd: hc.cmd,
      shell: hc.shell,
      expect_regex: hc.expect_regex,
      interval_s: intervalS,
      timeout_s: timeoutS,
    },
  };
}

function _validateSpec(spec, index, limits, opts) {
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
    return { ok: false, reason: 'not-an-object' };
  }

  // name
  if (typeof spec.name !== 'string' || spec.name.length === 0) {
    return { ok: false, reason: 'name-missing' };
  }
  const maxName = Math.max(8, Number(limits.maxNameLength) || 64);
  if (spec.name.length > maxName) {
    return { ok: false, reason: 'name-too-long (>' + maxName + ')' };
  }
  if (!_KEBAB_RE.test(spec.name)) {
    return { ok: false, reason: 'name-not-kebab-case (' + spec.name + ')' };
  }

  // cmd
  if (typeof spec.cmd !== 'string' || spec.cmd.length === 0) {
    return { ok: false, reason: 'cmd-missing' };
  }
  if (spec.cmd.length > 200) {
    return { ok: false, reason: 'cmd-too-long' };
  }
  if (!_isOnAllowlist(spec.cmd, limits.executableAllowlist)) {
    return { ok: false, reason: 'cmd-not-on-allowlist (' + spec.cmd + ')' };
  }

  // args
  const argsRaw = spec.args == null ? [] : spec.args;
  if (!Array.isArray(argsRaw)) return { ok: false, reason: 'args-not-array' };
  const maxArgs = Math.max(1, Number(limits.maxArgsCount) || 64);
  if (argsRaw.length > maxArgs) {
    return { ok: false, reason: 'args-too-many (>' + maxArgs + ')' };
  }
  const args = [];
  for (const a of argsRaw) {
    if (typeof a !== 'string') return { ok: false, reason: 'arg-not-string' };
    if (a.length > 500) return { ok: false, reason: 'arg-too-long' };
    args.push(a);
  }

  // cwd (optional; validated when present + requireGitWorkdir is on)
  if (spec.cwd != null && typeof spec.cwd !== 'string') {
    return { ok: false, reason: 'cwd-not-string' };
  }
  if (typeof spec.cwd === 'string' && spec.cwd.length > 500) {
    return { ok: false, reason: 'cwd-too-long' };
  }
  if (_resolveRequireGitWorkdir(opts) && typeof spec.cwd === 'string' && spec.cwd.length > 0) {
    const wt = shared.isValidGitWorktree(spec.cwd, { memo: opts._gitWorktreeMemo });
    if (!wt.ok) {
      // W-mpbpa01y000qcdc2 — enrich the reject reason so the dispatcher prompt
      // can distinguish a stale `cwd` (typoed / belonged to a torn-down
      // worktree) from a real subdir whose `.git` lives at an ancestor. The
      // legacy substring `invalid-workdir:` is preserved so the engine close-
      // handler gate still matches.
      const detail = [
        wt.reason,
        'cwd-exists:' + (wt.exists ? 'true' : 'false'),
        'nearest-ancestor:' + (wt.nearestGitAncestor || 'null'),
        'worktree-root:' + (wt.worktreeRoot || 'null'),
      ].join(' | ');
      return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + detail };
    }
  }

  // env (optional). Validation has three independent layers:
  //   1. Shape guard: key matches /^[A-Za-z_][A-Za-z0-9_]*$/ (argv-smuggling
  //      protection). Reason: env-key-invalid-shape.
  //   2. Denylist: key does NOT match any credential-shaped pattern unless
  //      explicitly listed in envKeyDenyOverrides. Per-project tightening
  //      (project.managedSpawnExtraDenyPatterns) can add MORE patterns
  //      when spec.cwd resolves under that project's localPath — projects
  //      can only tighten, never loosen (asymmetric on purpose; no
  //      per-project override list).
  //   3. Value sanity: string, ≤1000 chars.
  const envRaw = spec.env == null ? {} : spec.env;
  if (typeof envRaw !== 'object' || Array.isArray(envRaw)) {
    return { ok: false, reason: 'env-not-object' };
  }
  const envKeys = Object.keys(envRaw);
  const maxEnv = Math.max(1, Number(limits.maxEnvVars) || 32);
  if (envKeys.length > maxEnv) {
    return { ok: false, reason: 'env-too-many (>' + maxEnv + ')' };
  }
  const projectForSpec = _resolveProjectForCwd(
    typeof spec.cwd === 'string' ? spec.cwd : '',
    opts && Array.isArray(opts.projects) ? opts.projects : null,
  );
  const projectExtraPatterns = projectForSpec && Array.isArray(projectForSpec.managedSpawnExtraDenyPatterns)
    ? projectForSpec.managedSpawnExtraDenyPatterns
    : null;
  const denyRules = _compileDenyRules(limits, projectExtraPatterns);
  const env = {};
  for (const k of envKeys) {
    if (!_ENV_KEY_SHAPE_RE.test(k)) {
      return { ok: false, reason: 'env-key-invalid-shape (' + k + ')' };
    }
    const decision = _envKeyAllowed(k, denyRules);
    if (!decision.allowed) {
      return { ok: false, reason: decision.reason };
    }
    const v = envRaw[k];
    if (typeof v !== 'string') return { ok: false, reason: 'env-value-not-string (' + k + ')' };
    if (v.length > 1000) return { ok: false, reason: 'env-value-too-long (' + k + ')' };
    env[k] = v;
  }

  // ports (optional)
  const portsRaw = spec.ports == null ? [] : spec.ports;
  if (!Array.isArray(portsRaw)) return { ok: false, reason: 'ports-not-array' };
  if (portsRaw.length > 20) return { ok: false, reason: 'ports-too-many (>20)' };
  const ports = [];
  for (const p of portsRaw) {
    const n = Number(p);
    if (!Number.isInteger(n) || n < 1024 || n > 65535) {
      return { ok: false, reason: 'port-invalid (' + p + ')' };
    }
    ports.push(n);
  }

  // ttl_minutes (optional, defaults to defaultTtlMinutes; capped at maxTtlMinutes)
  const maxTtl = Math.max(1, Number(limits.maxTtlMinutes) || 1440);
  const defaultTtl = Math.max(1, Number(limits.defaultTtlMinutes) || 240);
  let ttlMinutes;
  if (spec.ttl_minutes == null) {
    ttlMinutes = defaultTtl;
  } else {
    const n = Number(spec.ttl_minutes);
    if (!Number.isFinite(n) || n <= 0) return { ok: false, reason: 'ttl-invalid' };
    if (n > maxTtl) return { ok: false, reason: 'ttl-too-long (>' + maxTtl + 'min)' };
    ttlMinutes = Math.floor(n);
  }

  // attrs (optional, opaque to the engine but capped at maxAttrsBytes serialized)
  const attrsRaw = spec.attrs == null ? {} : spec.attrs;
  if (typeof attrsRaw !== 'object' || Array.isArray(attrsRaw)) {
    return { ok: false, reason: 'attrs-not-object' };
  }
  let attrsSerialized;
  try { attrsSerialized = JSON.stringify(attrsRaw); }
  catch (e) { return { ok: false, reason: 'attrs-not-serializable (' + e.message + ')' }; }
  const maxAttrsBytes = Math.max(64, Number(limits.maxAttrsBytes) || 2048);
  if (Buffer.byteLength(attrsSerialized, 'utf8') > maxAttrsBytes) {
    return { ok: false, reason: 'attrs-too-large (>' + maxAttrsBytes + 'B)' };
  }

  // healthcheck (required)
  const hc = _validateHealthcheck(spec.healthcheck, limits);
  if (!hc.ok) return { ok: false, reason: hc.reason };

  return {
    ok: true,
    value: {
      name: spec.name,
      cmd: spec.cmd,
      args: args,
      cwd: typeof spec.cwd === 'string' ? spec.cwd : '',
      env: env,
      ports: ports,
      ttl_minutes: ttlMinutes,
      attrs: JSON.parse(attrsSerialized),
      healthcheck: hc.value,
    },
  };
}

function validateManagedSpawnRecord(parsed, opts) {
  opts = opts || {};
  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  const maxSpecs = Math.max(1, Number(limits.maxSpecsPerFile) || 5);

  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
    return { ok: false, reason: 'not-an-object' };
  }
  if (!Array.isArray(parsed.specs)) return { ok: false, reason: 'specs-missing' };
  if (parsed.specs.length === 0) return { ok: false, reason: 'specs-empty' };
  if (parsed.specs.length > maxSpecs) {
    return { ok: false, reason: 'specs-too-many (>' + maxSpecs + ')' };
  }

  // Resolve `projects` for per-project env-deny tightening. Callers (tests,
  // future engine wiring) may pre-supply `opts.projects` for determinism;
  // when undefined we lazy-load from MINIONS_DIR/config.json so the engine
  // close-handler doesn't have to change signature. A read failure or a
  // config without `projects` falls back to `[]` — unchanged behavior, the
  // global deny patterns remain the only source.
  let projects;
  if (Array.isArray(opts.projects)) {
    projects = opts.projects;
  } else {
    try { projects = shared.getProjects(); }
    catch (_e) { projects = []; }
  }
  const specOpts = Object.assign({}, opts, { projects: projects, _gitWorktreeMemo: new Map() });

  const seen = new Set();
  const out = [];
  // W-mpbpa01y000qcdc2 — share a memo across all specs in this file so
  // adjacent `cwd`s under the same worktree root don't re-stat the same
  // ancestors. Threaded through `opts._gitWorktreeMemo` into `_validateSpec`,
  // then into `shared.isValidGitWorktree`.
  for (let i = 0; i < parsed.specs.length; i++) {
    const v = _validateSpec(parsed.specs[i], i, limits, specOpts);
    if (!v.ok) {
      // Preserve workdir-rejection prefix at the top level so the engine
      // close-handler gate can key off it the same way it does for
      // keep-pids (INVALID_WORKDIR_REASON_PREFIX).
      if (typeof v.reason === 'string' && v.reason.indexOf(INVALID_WORKDIR_REASON_PREFIX) === 0) {
        return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'spec[' + i + ']: ' + v.reason.slice(INVALID_WORKDIR_REASON_PREFIX.length) };
      }
      return { ok: false, reason: 'spec[' + i + ']: ' + v.reason };
    }
    if (seen.has(v.value.name)) {
      return { ok: false, reason: 'spec[' + i + ']: duplicate-name (' + v.value.name + ')' };
    }
    seen.add(v.value.name);
    out.push(v.value);
  }

  if (parsed.written_by != null && typeof parsed.written_by !== 'string') {
    return { ok: false, reason: 'written_by-not-string' };
  }
  if (parsed.wi_id != null && typeof parsed.wi_id !== 'string') {
    return { ok: false, reason: 'wi_id-not-string' };
  }

  return {
    ok: true,
    value: {
      specs: out,
      written_by: typeof parsed.written_by === 'string' ? parsed.written_by : '',
      wi_id: typeof parsed.wi_id === 'string' ? parsed.wi_id : '',
    },
  };
}

function readManagedSpawnFile(agentId, opts) {
  opts = opts || {};
  const filePath = path.join(_agentsDir(), String(agentId || ''), MANAGED_SPAWN_FILENAME);
  let raw;
  try { raw = fs.readFileSync(filePath, 'utf8'); }
  catch (_e) { return null; }
  let parsed = null;
  try { parsed = JSON.parse(raw); }
  catch (e) {
    return { agentId: agentId, filePath: filePath, valid: false, reason: 'json-parse: ' + e.message, parsed: null };
  }
  const v = validateManagedSpawnRecord(parsed, opts);
  if (!v.ok) return { agentId: agentId, filePath: filePath, valid: false, reason: v.reason, parsed: parsed };
  return { agentId: agentId, filePath: filePath, valid: true, value: v.value, parsed: parsed };
}

// Pure engine-side acceptance helper. Mirrors evaluateKeepPidsAcceptance:
// returns a structured summary the engine close handler uses to decide
// whether to (a) silently accept (no file present, or valid), (b) reject as
// workdir-invalid → fail dispatch + inbox alert + unlink sidecar, or
// (c) reject as schema-invalid → fail dispatch + inbox alert + unlink.
// No side effects; caller owns kill + unlink + WI mutation.
function evaluateManagedSpawnAcceptance(agentId, opts) {
  opts = opts || {};
  const filePath = path.join(_agentsDir(), String(agentId || ''), MANAGED_SPAWN_FILENAME);
  if (!fs.existsSync(filePath)) {
    return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, filePath: filePath };
  }
  const rec = readManagedSpawnFile(agentId, opts);
  if (!rec) {
    // Race: file existed in existsSync above but was unlinked before read.
    return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, filePath: filePath };
  }
  if (rec.valid) {
    return {
      exists: true,
      accepted: true,
      isWorkdirRejection: false,
      reason: null,
      record: rec.value,
      filePath: rec.filePath,
    };
  }
  const reason = rec.reason || 'unknown';
  const isWorkdirRejection = typeof reason === 'string' && reason.indexOf(INVALID_WORKDIR_REASON_PREFIX) === 0;
  return {
    exists: true,
    accepted: false,
    isWorkdirRejection: isWorkdirRejection,
    reason: reason,
    record: null,
    filePath: rec.filePath,
    parsedRaw: rec.parsed || null,
  };
}

function buildManagedSpawnHint(opts) {
  opts = opts || {};
  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  const maxTtl = Math.max(1, Number(limits.maxTtlMinutes) || 1440);
  const defaultTtl = Math.max(1, Number(limits.defaultTtlMinutes) || 240);
  const maxSpecs = Math.max(1, Number(limits.maxSpecsPerFile) || 5);
  const ttlIn = Number(opts.ttlMinutes);
  const ttl = Number.isFinite(ttlIn) && ttlIn > 0
    ? Math.min(maxTtl, Math.floor(ttlIn))
    : defaultTtl;
  const agentId = opts.agentId || '<your-agent-id>';
  const wiId = opts.workItemId || '<this-work-item-id>';
  const minionsDir = opts.minionsDir || '<minions-dir>';
  const portIn = Number(opts.dashboardPort);
  const dashboardPort = Number.isFinite(portIn) && portIn > 0 ? portIn : 7331;

  const lines = [
    '',
    '',
    '---',
    '',
    '## Engine-managed long-running services (managed_spawn flag)',
    '',
    'This work item permits you to **describe** long-running services in a sidecar file; the engine will **own the spawn** and the lifecycle. Use this instead of hand-rolling detached-spawn pipelines yourself.',
    '',
    'BEFORE you write your completion report, write the sidecar:',
    '',
    '  `' + minionsDir + '/agents/' + agentId + '/managed-spawn.json`',
    '',
    'with this exact JSON shape (one or more specs, max ' + maxSpecs + ' per file):',
    '',
    '```json',
    '{',
    '  "specs": [',
    '    {',
    '      "name": "constellation-host",',
    '      "cmd": "bun",',
    '      "args": ["run", "dev"],',
    '      "cwd": "D:/repos/constellation",',
    '      "env": { "VITE_HOST": "127.0.0.1" },',
    '      "ports": [3001],',
    '      "ttl_minutes": ' + ttl + ',',
    '      "attrs": { "base_url": "http://localhost:3001", "framework": "vite" },',
    '      "healthcheck": {',
    '        "type": "http",',
    '        "url": "http://localhost:3001/health",',
    '        "expect_status": 200,',
    '        "interval_s": 1,',
    '        "timeout_s": 60',
    '      }',
    '    }',
    '  ],',
    '  "written_by": "' + agentId + '",',
    '  "wi_id": "' + wiId + '"',
    '}',
    '```',
    '',
    'Healthcheck types: `http` (GET URL, asserts status code) or `command` (runs a shell command, asserts stdout matches `expect_regex`). For `command`, set `shell: true` and `expect_regex` to a JavaScript regex string.',
    '',
    '### What the engine does for you',
    '',
    '1. Reads your sidecar after you exit.',
    '2. Spawns each spec detached (the working Windows pattern is centralised in the engine — you do **not** need to write `Start-Process` or `spawn({ detached: true })` yourself).',
    '3. Drives the healthcheck loop until each spec passes its first check (within `timeout_s`).',
    '4. **Fails this dispatch (ERROR) if any spec fails its healthcheck.** Surviving siblings stay alive; failing PIDs are killed. When at least one spec stays healthy (partial failure) the engine also writes a `_managedSpawnPartial` annotation onto the WI (status stays `done`) so the dashboard surfaces a warning chip instead of silently swallowing the failure. When ALL specs fail the WI is demoted to FAILED via the normal force-demote path.',
    '5. Auto-injects a `## Live managed processes` block into downstream agents\' prompts (scoped to your project) so the next dispatch can find the service without you telling it.',
    '6. Sweeps dead PIDs / TTL-expired specs every ' + (limits.sweepEvery || 30) + ' ticks; kills + unlinks at TTL.',
    '',
    '### Caps the engine enforces (validator rejects anything over)',
    '',
    '- Specs per file: ≤ ' + maxSpecs,
    '- Name: kebab-case, ≤ 64 chars, unique within file',
    '- Executable (`cmd` and any `command` healthcheck cmd): on the engine\'s allowlist (node, bun, npm, npx, python, docker, adb, gradle, mvn, pwsh, …)',
    '- Env keys: any well-formed POSIX identifier (`/^[A-Za-z_][A-Za-z0-9_]*$/`) is accepted EXCEPT keys matching credential-shaped deny patterns (`*_SECRET`, `*_TOKEN`, `*_API_KEY`, `*_PASSWORD`, `*_PRIVATE_KEY`, `*_CREDENTIALS`, `*_AUTH`, `*_PAT`, `AWS_*`, `AZURE_*`, `GCP_*`, `GH_TOKEN`, `GITHUB_TOKEN`, `OPENAI_*`, `ANTHROPIC_*`, `COPILOT_*`, `DOCKER_AUTH*`, `NPM_TOKEN`). Region/profile names (`AWS_REGION`, `AWS_PROFILE`, …) are exempt. If your service genuinely needs a credential-looking var, rename it (e.g. `DATABASE_URL` not `DB_API_KEY`) or stash the value in `attrs` and have the child read it from there. **The engine forwards env values verbatim — the deny shape is a tripwire, not a scrubber. Do not put real credentials in env even if the key passes.** Projects may tighten by adding patterns to `config.projects[N].managedSpawnExtraDenyPatterns`; projects cannot loosen.',
    '- Ports: 1024–65535, ≤ 20 per spec',
    '- TTL: ≤ ' + maxTtl + ' minutes (hard cap), defaults to ' + defaultTtl + ' if omitted',
    '- `attrs` serialized: ≤ 2048 bytes (opaque blob the engine surfaces to downstream agents)',
    '',
    'If your file is invalid the engine marks this dispatch ERROR with `failure_class: invalid-managed-spawn` (non-retryable) — fix the file shape, don\'t retry blindly.',
    '',
    '### Mandatory: smoke-test before writing the sidecar',
    '',
    'Before writing `managed-spawn.json`, run each `cmd args` in the declared `cwd` for at least 5 seconds. Confirm the process stays alive AND its healthcheck endpoint returns the expected status. Kill the test process. THEN write the sidecar. Sidecars written from guessed-at commands are how dispatches silently lose specs — the engine spawns what you declared; if you declared a command that crashes, the engine kills it and removes it from state without alerting the WI as failed. The cost of guessing wrong is the user finding out hours later that half the stack is down.',
    '',
    'Concrete failure class to avoid: workspace-filter resolution (e.g. `bun -F <name>` or `pnpm --filter <name>`) failing because the workspace deps were never installed in the worktree. The command exits 1 in <1s with `No packages matched the filter` and the engine kills the spec without surfacing anything beyond a partial-healthcheck annotation. Smoke-test catches this in seconds; the user finding out catches it in hours.',
    '',
    'Smoke-test pattern (PowerShell — Windows):',
    '',
    '```powershell',
    '# Replace <cwd>, <cmd>, <args...>, <healthcheck-url>, <expected-status> with your spec values.',
    'Push-Location <cwd>',
    '$proc = Start-Process -FilePath <cmd> -ArgumentList \'<arg1>\',\'<arg2>\' -PassThru -WindowStyle Hidden -RedirectStandardOutput smoke.out -RedirectStandardError smoke.err',
    'try {',
    '  $deadline = (Get-Date).AddSeconds(15)',
    '  $healthy  = $false',
    '  while ((Get-Date) -lt $deadline) {',
    '    if ($proc.HasExited) { throw "spec exited early with code $($proc.ExitCode); tail smoke.err for the reason" }',
    '    try {',
    '      $r = Invoke-WebRequest -Uri \'<healthcheck-url>\' -UseBasicParsing -TimeoutSec 2',
    '      if ($r.StatusCode -eq <expected-status>) { $healthy = $true; break }',
    '    } catch { Start-Sleep -Milliseconds 500 }',
    '  }',
    '  if (-not $healthy) { throw "spec stayed alive but healthcheck never returned <expected-status> within 15s" }',
    '  Write-Host "smoke-test OK — spec stayed alive AND healthcheck passed"',
    '} finally {',
    '  if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }',
    '  Pop-Location',
    '}',
    '```',
    '',
    'Smoke-test pattern (bash — macOS/Linux):',
    '',
    '```bash',
    '# Replace <cwd>, <cmd>, <args...>, <healthcheck-url>, <expected-status> with your spec values.',
    'cd <cwd>',
    '<cmd> <arg1> <arg2> >smoke.out 2>smoke.err &',
    'pid=$!',
    'healthy=0',
    'for _ in $(seq 1 15); do',
    '  if ! kill -0 "$pid" 2>/dev/null; then echo "spec exited early; tail smoke.err for the reason" >&2; break; fi',
    '  if [ "$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 2 \'<healthcheck-url>\')" = "<expected-status>" ]; then healthy=1; break; fi',
    '  sleep 1',
    'done',
    'kill "$pid" 2>/dev/null || true',
    'wait "$pid" 2>/dev/null || true',
    '[ "$healthy" = 1 ] && echo "smoke-test OK — spec stayed alive AND healthcheck passed" || { echo "smoke-test FAILED" >&2; exit 1; }',
    '```',
    '',
    'A passing smoke-test is the entry gate to writing the sidecar — not a nice-to-have. If you skip it, you are betting the WI completion against a command you never ran.',
    '',
    '### Monorepo `cwd` tip (W-mpbpa01y000qcdc2)',
    '',
    '**Only worktree ROOTS have a `.git` entry.** In a multi-package repo (yarn workspaces, pnpm, bun workspaces, lerna, lage, turborepo), `packages/<pkg>/` does NOT contain its own `.git` — the engine\'s workdir validator walks up to ' + (limits.gitWorktreeMaxParentDepth || 6) + ' parent directories looking for one, so a per-package `cwd` IS accepted, but the **canonical recipe** is to keep `cwd` at the worktree root and target the package via the runtime\'s workspace flag:',
    '',
    '```jsonc',
    '{',
    '  "name": "constellation-host",',
    '  "cmd": "bun",',
    '  "args": ["-F", "@scope/server", "run", "dev"],   // bun workspace filter',
    '  "cwd": "<worktree-root>",                         // NOT <worktree-root>/packages/server',
    '  "healthcheck": { "type": "http", "url": "http://localhost:3001/health", "expect_status": 200, "interval_s": 1, "timeout_s": 60 }',
    '}',
    '```',
    '',
    'Equivalent flags for other runtimes: `pnpm --filter <pkg>`, `yarn workspace <pkg>`, `npm run -w <pkg>`, `lage run <task> --to <pkg>`. Keeping `cwd` at the root makes the spec portable across machines (no hard-coded package path) and avoids per-package `node_modules` resolution surprises.',
    '',
    '### Verify before exit',
    '',
    'After you write the file, query the engine to confirm acceptance:',
    '',
    '  curl -s http://localhost:' + dashboardPort + '/api/managed-processes',
    '',
    'Each of your specs should appear with `healthy: true` once the engine\'s healthcheck loop fires (this happens after your agent process exits — the engine drives it). You don\'t need to wait for `healthy: true` yourself; just confirm the file is valid by re-reading it locally.',
    '',
  ];
  return lines.join('\n');
}

// ─── Item 2 (P-2d5e8f04) ─────────────────────────────────────────────────────
//
// Engine-side spawn + locked state file. The engine calls these from its
// onAgentClose handler after the managed-spawn sidecar is accepted by
// evaluateManagedSpawnAcceptance above:
//
//   1. openManagedLog(name)        → opens an append fd under MINIONS_DIR/
//                                    engine/managed-logs/<name>.log. Child
//                                    stdio is wired to this fd (NOT a pipe)
//                                    so the detached process survives our
//                                    exit on Windows.
//   2. spawnManagedSpec(spec, ctx) → uses the proven bin/minions.js
//                                    spawnDashboard detached-spawn pattern.
//                                    Returns {pid, started_at, log_path}.
//   3. recordManagedSpec(spec,     → writes one entry to
//      runtime, ctx)                 engine/managed-processes.json via
//                                    mutateJsonFileLocked. Replaces any
//                                    existing entry with the same name
//                                    (idempotent under retry).
//   4. recordManagedBatch(items,   → same but one lock for N specs (the
//      ctx)                          close-handler call site spawns each
//                                    then persists them together).
//   5. removeManagedSpec(name)     → locked unlink of the entry; best-effort
//                                    process.kill of the recorded PID
//                                    OUTSIDE the lock callback. No-op when
//                                    the entry is missing.
//   6. listManagedSpecs({project}) → reads the state file, optionally
//                                    filters by owner_project. Used later
//                                    by item 4 (dashboard) + item 6
//                                    (playbook injection) + items 3/7
//                                    (sweep + boot reconcile).
//
// All state writes go through mutateJsonFileLocked per the repo convention
// ('Key conventions' in copilot-instructions.md). Callbacks stay synchronous
// and fast — no kill / no spawn inside the lock callback. Healthcheck loops,
// dispatch ERROR gating on healthcheck failure, per-tick sweep, boot
// reconcile, and dashboard endpoints are deferred to items 3/4/5/7.

function _getStatePath() {
  return path.join(shared.MINIONS_DIR, 'engine', MANAGED_PROCESSES_STATE_FILE);
}

function _getLogsDir() {
  return path.join(shared.MINIONS_DIR, 'engine', MANAGED_LOGS_DIR);
}

function openManagedLog(name) {
  if (typeof name !== 'string' || name.length === 0 || !_KEBAB_RE.test(name)) {
    throw new Error('openManagedLog: name must be a non-empty kebab-case string');
  }
  // P-8a4d6f29 — rotate-on-open is centralised in shared.openAppendLogFd so
  // bin/minions.js (dashboard/engine stdio) and managed-spawn share the same
  // ".1 sibling" rotation. Append mode is preserved so a post-boot respawn
  // (item 7) doesn't clobber prior output.
  const dir = _getLogsDir();
  const { fd, logPath } = shared.openAppendLogFd(name + '.log', dir);
  return { fd: fd, logPath: logPath };
}

// Build the child env: sanitised sidecar env + PATH + Windows-essential vars
// (SYSTEMROOT, USERPROFILE, TEMP, etc.) without which detached node/bun
// children fail on Windows. Host vars OUTSIDE this list are deliberately not
// forwarded — a managed spec must declare what it needs in spec.env (the
// validator enforces the env allowlist).
const _WIN_ESSENTIAL_ENV_KEYS = [
  'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
  'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
  'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH', 'HOME',
  'PROCESSOR_ARCHITECTURE', 'PATHEXT', 'COMSPEC', 'OS',
];

function _buildChildEnv(specEnv) {
  const env = Object.assign({}, specEnv || {});
  if (!env.PATH && process.env.PATH) env.PATH = process.env.PATH;
  for (const k of _WIN_ESSENTIAL_ENV_KEYS) {
    if (env[k] == null && process.env[k] != null) env[k] = process.env[k];
  }
  return env;
}

function spawnManagedSpec(spec, ctx) {
  if (!spec || typeof spec !== 'object') throw new Error('spawnManagedSpec: spec required');
  if (typeof spec.cmd !== 'string' || spec.cmd.length === 0) {
    throw new Error('spawnManagedSpec: spec.cmd required');
  }
  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  // Defensive re-check of the executable allowlist. The validator gates this
  // at sidecar-read time too; this guards future direct callers and tests.
  if (!_isOnAllowlist(spec.cmd, limits.executableAllowlist)) {
    throw new Error('spawnManagedSpec: cmd not-on-allowlist (' + spec.cmd + ')');
  }
  ctx = ctx || {};
  const { fd: logFd, logPath } = openManagedLog(spec.name);
  const cwd = (typeof spec.cwd === 'string' && spec.cwd.length > 0) ? spec.cwd : undefined;
  const env = _buildChildEnv(spec.env);
  const argv = Array.isArray(spec.args) ? spec.args : [];
  let child;
  try {
    // Working Windows-correct detached spawn — same shape as
    // bin/minions.js spawnDashboard. DO NOT switch stdio to 'pipe' or
    // 'inherit': pipes die on EPIPE once the parent exits.
    child = spawn(spec.cmd, argv, {
      cwd: cwd,
      env: env,
      detached: true,
      stdio: ['ignore', logFd, logFd],
      windowsHide: true,
    });
  } catch (e) {
    try { fs.closeSync(logFd); } catch (_e) {}
    throw e;
  }
  // Close our copy of the fd in the parent — the child holds its own dup.
  try { fs.closeSync(logFd); } catch (_e) {}
  if (!child || !child.pid) {
    throw new Error('spawnManagedSpec: spawn failed for ' + spec.cmd);
  }
  child.unref();
  const startedAt = Date.now();
  log('info', 'managed-spawn born: name=' + spec.name + ' pid=' + child.pid
    + ' owner_project=' + (ctx.owner_project || '')
    + ' owner_wi=' + (ctx.owner_wi || ''));
  return { pid: child.pid, started_at: startedAt, log_path: logPath };
}

function _initialStateShape() {
  return { specs: [] };
}

function _toStateRecord(spec, runtime, ctx) {
  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  const defaultTtl = Math.max(1, Number(limits.defaultTtlMinutes) || 240);
  const ttlMin = Number.isFinite(spec.ttl_minutes) && spec.ttl_minutes > 0
    ? spec.ttl_minutes : defaultTtl;
  const started = Number.isFinite(runtime && runtime.started_at) ? runtime.started_at : Date.now();
  return {
    name: spec.name,
    pid: runtime && Number.isInteger(runtime.pid) ? runtime.pid : null,
    owner_agent: (ctx && ctx.owner_agent) || '',
    owner_wi: (ctx && ctx.owner_wi) || '',
    owner_project: (ctx && ctx.owner_project) || '',
    cmd: spec.cmd,
    args: Array.isArray(spec.args) ? spec.args.slice() : [],
    cwd: typeof spec.cwd === 'string' ? spec.cwd : '',
    env: Object.assign({}, spec.env || {}),
    ports: Array.isArray(spec.ports) ? spec.ports.slice() : [],
    attrs: spec.attrs && typeof spec.attrs === 'object'
      ? JSON.parse(JSON.stringify(spec.attrs)) : {},
    healthcheck: spec.healthcheck && typeof spec.healthcheck === 'object'
      ? JSON.parse(JSON.stringify(spec.healthcheck)) : null,
    started_at: started,
    ttl_expires_at: started + (ttlMin * 60 * 1000),
    last_health_at: null,
    healthy: false,
    alive: true,
    log_path: (runtime && runtime.log_path) || '',
  };
}

function recordManagedSpec(spec, runtime, ctx) {
  if (!spec || !spec.name) throw new Error('recordManagedSpec: spec.name required');
  const statePath = _getStatePath();
  shared.mutateManagedProcesses((data) => {
    if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
      data = _initialStateShape();
    }
    const idx = data.specs.findIndex(s => s && s.name === spec.name);
    const rec = _toStateRecord(spec, runtime, ctx);
    if (idx >= 0) data.specs[idx] = rec;
    else data.specs.push(rec);
    return data;
  }, { defaultValue: _initialStateShape() });
}

function recordManagedBatch(items, ctx) {
  if (!Array.isArray(items) || items.length === 0) return;
  const statePath = _getStatePath();
  shared.mutateManagedProcesses((data) => {
    if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
      data = _initialStateShape();
    }
    for (const entry of items) {
      const spec = entry && entry.spec;
      const runtime = entry && entry.runtime;
      if (!spec || !spec.name) continue;
      const idx = data.specs.findIndex(s => s && s.name === spec.name);
      const rec = _toStateRecord(spec, runtime, ctx);
      if (idx >= 0) data.specs[idx] = rec;
      else data.specs.push(rec);
    }
    return data;
  }, { defaultValue: _initialStateShape() });
}

function removeManagedSpec(name) {
  if (typeof name !== 'string' || name.length === 0) return;
  let killPid = null;
  const statePath = _getStatePath();
  shared.mutateManagedProcesses((data) => {
    if (!data || !Array.isArray(data.specs)) return data;
    const idx = data.specs.findIndex(s => s && s.name === name);
    if (idx < 0) return data;
    const rec = data.specs[idx];
    if (rec && Number.isInteger(rec.pid) && rec.pid > 0) killPid = rec.pid;
    data.specs.splice(idx, 1);
    return data;
  }, { defaultValue: _initialStateShape() });
  // Kill OUTSIDE the lock — never run process ops inside a lock callback
  // (copilot-instructions.md "Keep lock callbacks synchronous and fast").
  if (killPid != null) {
    try { shared.killByPidImmediate(killPid); }
    catch (e) { log('warn', 'managed-spawn removeManagedSpec: kill ' + killPid + ' failed: ' + e.message); }
  }
}

function listManagedSpecs(opts) {
  opts = opts || {};
  const statePath = _getStatePath();
  let raw;
  try { raw = fs.readFileSync(statePath, 'utf8'); }
  catch (_e) { return []; }
  let parsed;
  try { parsed = JSON.parse(raw); }
  catch (_e) { return []; }
  const specs = (parsed && Array.isArray(parsed.specs)) ? parsed.specs : [];
  if (opts.project) return specs.filter(s => s && s.owner_project === opts.project);
  return specs.slice();
}

// ─── Item 3 (P-9c1f47a6) ─────────────────────────────────────────────────────
//
// Healthcheck implementations + dispatch SUCCESS/ERROR gate.
//
//   runHealthcheck(spec)         → fires one probe (http or command), resolves
//                                  {healthy, error, lastCheckAt}. Pure — no
//                                  state mutation, no PID kills. Used by both
//                                  waitForFirstHealth and the per-spec
//                                  liveness loop a future item wires in.
//   waitForFirstHealth(spec,opts)→ self-scheduled async loop. Polls
//                                  runHealthcheck every spec.healthcheck
//                                  .interval_s. On first healthy → flips the
//                                  state file's `healthy: true` +
//                                  `last_health_at` (locked write) and
//                                  resolves {healthy:true}. On
//                                  spec.healthcheck.timeout_s elapsed
//                                  without a pass → resolves
//                                  {healthy:false, error:'timeout: ...'}
//                                  without throwing (caller decides what to
//                                  do with the rejection — the engine close-
//                                  handler maps it to dispatch ERROR with
//                                  FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED).
//   tailManagedLog(name, lines)  → reads up to N tail bytes of the named log
//                                  and returns the last `lines` lines joined.
//                                  Used by the engine close-handler to attach
//                                  log evidence to inbox alerts on
//                                  healthcheck failure.
//
// Per the plan, healthcheck loops are PER-SPEC and self-scheduled — NEVER
// driven from the tick cycle. The tick coupling regression was the original
// design constraint that drove the entire architecture choice.
// (No state writes inside the lock callback except `healthy` + `last_health_at`
// at first-pass; idle liveness updates batch every healthBackoffSec and land
// in a follow-up item.)

const _HC_CMD_TIMEOUT_MS = 5000; // hard ceiling for one healthcheck probe

function _httpProbe(url, expectStatus, timeoutMs) {
  return new Promise((resolve) => {
    let settled = false;
    const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
    let req;
    try {
      const client = url.startsWith('https:') ? https : http;
      req = client.get(url, { timeout: timeoutMs }, (res) => {
        // Consume the body so the socket can close even on no listeners.
        res.resume();
        res.on('end', () => {
          if (res.statusCode === expectStatus) finish({ healthy: true, error: null });
          else finish({ healthy: false, error: 'http status ' + res.statusCode + ' (expected ' + expectStatus + ')' });
        });
        res.on('error', (e) => finish({ healthy: false, error: 'http response error: ' + e.message }));
      });
      req.on('error', (e) => finish({ healthy: false, error: 'http request error: ' + e.message }));
      req.on('timeout', () => {
        try { req.destroy(new Error('timeout')); } catch (_e) {}
        finish({ healthy: false, error: 'http request timeout after ' + timeoutMs + 'ms' });
      });
    } catch (e) {
      finish({ healthy: false, error: 'http probe threw: ' + e.message });
    }
  });
}

function _commandProbe(cmd, useShell, expectRegex, timeoutMs) {
  return new Promise((resolve) => {
    let regex;
    try { regex = new RegExp(expectRegex); }
    catch (e) { return resolve({ healthy: false, error: 'expect_regex invalid: ' + e.message }); }
    exec(cmd, {
      timeout: timeoutMs,
      shell: useShell ? undefined : false, // exec defaults to /bin/sh or cmd.exe when shell undefined
      windowsHide: true,
      maxBuffer: 1024 * 1024,
    }, (err, stdout, stderr) => {
      if (err && err.killed) {
        return resolve({ healthy: false, error: 'healthcheck command timeout after ' + timeoutMs + 'ms' });
      }
      if (err) {
        // Non-zero exit. The plan says match against stdout, so a non-zero
        // exit with matching stdout is still "unhealthy" because the
        // process errored — treat command errors as unhealthy with the
        // stderr tail for diagnostics.
        const tail = (stderr || '').trim().slice(-200);
        return resolve({ healthy: false, error: 'healthcheck command exit ' + (err.code != null ? err.code : '?') + (tail ? ': ' + tail : '') });
      }
      const out = String(stdout || '');
      if (regex.test(out)) return resolve({ healthy: true, error: null });
      return resolve({ healthy: false, error: 'healthcheck stdout did not match regex /' + expectRegex + '/' });
    });
  });
}

async function runHealthcheck(spec) {
  if (!spec || !spec.healthcheck || typeof spec.healthcheck !== 'object') {
    return { healthy: false, error: 'spec.healthcheck missing', lastCheckAt: Date.now() };
  }
  const hc = spec.healthcheck;
  const timeoutMs = Math.min(_HC_CMD_TIMEOUT_MS, Math.max(500, (Number(hc.timeout_s) || 5) * 1000));
  let result;
  if (hc.type === 'http') {
    result = await _httpProbe(hc.url, Number(hc.expect_status), timeoutMs);
  } else if (hc.type === 'command') {
    result = await _commandProbe(hc.cmd, !!hc.shell, hc.expect_regex, timeoutMs);
  } else {
    result = { healthy: false, error: 'healthcheck type unknown: ' + hc.type };
  }
  result.lastCheckAt = Date.now();
  return result;
}

// Flip state.healthy=true + last_health_at on a single locked write. Used by
// waitForFirstHealth on the first pass. Callers MUST be outside any other
// lock — this acquires its own.
function _markHealthy(name, now) {
  const statePath = _getStatePath();
  shared.mutateManagedProcesses((data) => {
    if (!data || !Array.isArray(data.specs)) return data;
    const rec = data.specs.find(s => s && s.name === name);
    if (!rec) return data;
    rec.healthy = true;
    rec.last_health_at = now;
    return data;
  }, { defaultValue: _initialStateShape() });
}

function waitForFirstHealth(spec, opts) {
  opts = opts || {};
  const intervalMs = Math.max(100, (Number(spec.healthcheck && spec.healthcheck.interval_s) || 1) * 1000);
  const timeoutMs = Math.max(intervalMs, (Number(spec.healthcheck && spec.healthcheck.timeout_s) || 30) * 1000);
  const deadline = Date.now() + timeoutMs;
  return new Promise((resolve) => {
    let stopped = false;
    let lastError = null;
    const tick = async () => {
      if (stopped) return;
      const result = await runHealthcheck(spec);
      if (stopped) return;
      if (result.healthy) {
        stopped = true;
        try { _markHealthy(spec.name, result.lastCheckAt); }
        catch (e) { log('warn', 'managed-spawn waitForFirstHealth: state write failed for ' + spec.name + ': ' + e.message); }
        return resolve({ healthy: true, error: null, lastCheckAt: result.lastCheckAt });
      }
      lastError = result.error;
      if (Date.now() >= deadline) {
        stopped = true;
        return resolve({
          healthy: false,
          error: 'timeout: spec ' + spec.name + ' did not become healthy within ' + Math.round(timeoutMs / 1000) + 's (last: ' + (lastError || 'no probes ran') + ')',
          lastCheckAt: result.lastCheckAt,
        });
      }
      setTimeout(tick, intervalMs);
    };
    // First probe fires immediately so a fast service doesn't pay an
    // interval_s delay.
    tick();
  });
}

function tailManagedLog(name, lines) {
  const linesN = Math.max(1, Math.min(1000, Number(lines) || 100));
  const logPath = path.join(_getLogsDir(), name + '.log');
  let raw;
  try { raw = fs.readFileSync(logPath, 'utf8'); }
  catch (_e) { return ''; }
  const arr = raw.split(/\r?\n/);
  return arr.slice(-linesN).join('\n');
}

// ─── Item 6 (P-1f9c3a45) ─────────────────────────────────────────────────────
//
// Playbook auto-inject of the live-managed-processes block. Called by
// engine/playbook.js renderPlaybook (project-scoped, computed once per render).
// Filters listManagedSpecs() to specs where:
//   - owner_project === opts.project
//   - healthy === true
//   - alive === true
//
// Returns '' when nothing matches — playbook caller can append unconditionally.
// Serialized payload is capped at ENGINE_DEFAULTS.managedSpawn.promptContextMaxBytes
// (default 2048). When the full block exceeds the cap, falls back to a compact
// list (name + base_url + ports), keeping the dashboard endpoint footer so the
// dispatched agent can still discover the rest via /api/managed-processes.
//
// Project arg is required — never inject "all specs" to avoid cross-project
// port/URL leakage (see plan §"Auto-injected prompt context could leak…").
function buildLiveManagedProcessesBlock(opts) {
  opts = opts || {};
  const project = typeof opts.project === 'string' ? opts.project : '';
  if (!project) return '';
  let specs;
  try { specs = listManagedSpecs({ project: project }); }
  catch (_e) { return ''; }
  if (!Array.isArray(specs)) return '';
  specs = specs.filter(s => s && s.healthy === true && s.alive === true);
  if (specs.length === 0) return '';

  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  const cap = Math.max(256, Number(limits.promptContextMaxBytes) || 2048);
  const portIn = Number(opts.dashboardPort);
  const dashboardPort = Number.isFinite(portIn) && portIn > 0 ? portIn : 7331;
  const dashboardUrl = 'http://localhost:' + dashboardPort + '/api/managed-processes?project='
    + encodeURIComponent(project);

  const header = [
    '',
    '',
    '---',
    '',
    '## Live managed processes for project ' + project,
    '',
    'These services were spawned by earlier work items in this project and are currently healthy. Reuse them — do not re-spawn duplicates. Ports, URLs, and PIDs are owned by the engine; query `/api/managed-processes` if you need fresh state.',
    '',
  ];

  // ── Full rendering — name + pid + ports + attrs + log + ttl per spec ───
  const fullLines = header.slice();
  for (const s of specs) {
    fullLines.push('### ' + s.name);
    fullLines.push('');
    if (Number.isInteger(s.pid) && s.pid > 0) fullLines.push('- pid: ' + s.pid);
    if (Array.isArray(s.ports) && s.ports.length) {
      fullLines.push('- ports: ' + s.ports.join(', '));
    }
    if (s.attrs && typeof s.attrs === 'object') {
      const attrKeys = Object.keys(s.attrs);
      if (attrKeys.length) {
        const parts = attrKeys.map(k => k + ': ' + JSON.stringify(s.attrs[k]));
        fullLines.push('- attrs: ' + parts.join('; '));
      }
    }
    if (typeof s.log_path === 'string' && s.log_path) fullLines.push('- log: ' + s.log_path);
    if (Number.isFinite(s.ttl_expires_at)) {
      fullLines.push('- ttl_expires_at: ' + new Date(s.ttl_expires_at).toISOString());
    }
    fullLines.push('');
  }
  fullLines.push('Full details / kill / restart: `curl -s ' + dashboardUrl + '`');
  fullLines.push('');
  const full = fullLines.join('\n');
  if (Buffer.byteLength(full, 'utf8') <= cap) return full;

  // ── Compact fallback — name + base_url + ports ─────────────────────────
  const compactLines = header.slice();
  for (const s of specs) {
    const baseUrl = (s.attrs && typeof s.attrs.base_url === 'string') ? s.attrs.base_url : '';
    const portStr = (Array.isArray(s.ports) && s.ports.length) ? ' (ports ' + s.ports.join(',') + ')' : '';
    compactLines.push('- **' + s.name + '**' + (baseUrl ? ' — ' + baseUrl : '') + portStr);
  }
  compactLines.push('');
  compactLines.push('_' + specs.length + ' live service(s) — full details truncated above the '
    + cap + '-byte prompt cap. Query `' + dashboardUrl + '` for attrs, logs, and TTL._');
  compactLines.push('');
  return compactLines.join('\n');
}

// ─── Item 4 (P-4b8d2e57) ─────────────────────────────────────────────────────
//
// Discovery API helpers: ETag fingerprinting for list endpoints and
// state-driven respawn from a previously recorded spec.
//
//   computeManagedSpecsEtag({project}) → returns a stable 16-char sha1 prefix
//                                        over the JSON-serialised list (filtered
//                                        by project if supplied). Same content
//                                        → same etag; ANY field change → new
//                                        etag. Honored by the dashboard
//                                        endpoints via If-None-Match → 304.
//   restartManagedSpec(name)           → looks up the spec by name, kills the
//                                        old PID (if alive), re-spawns it using
//                                        the saved spec shape (cmd/args/cwd/
//                                        env/healthcheck), and replaces the
//                                        state row with healthy:false +
//                                        alive:true + new pid + new
//                                        started_at. Throws if the name is
//                                        unknown — caller maps to HTTP 404.
//                                        Healthcheck loop for the new PID is
//                                        the caller's job (the dashboard
//                                        endpoint kicks it off async; the
//                                        boot-reconcile path / item 7 does too
//                                        on engine restart).

const crypto = require('crypto');

function computeManagedSpecsEtag(opts) {
  const specs = listManagedSpecs(opts || {});
  // Deterministic serialization: sort by name so the etag isn't sensitive to
  // insertion order in the state file.
  const sorted = specs.slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
  const json = JSON.stringify(sorted);
  return crypto.createHash('sha1').update(json).digest('hex').slice(0, 16);
}

function restartManagedSpec(name) {
  if (typeof name !== 'string' || name.length === 0) {
    throw new Error('restartManagedSpec: name required');
  }
  const existing = listManagedSpecs().find(s => s && s.name === name);
  if (!existing) {
    throw new Error('restartManagedSpec: spec not found: ' + name);
  }
  // Reconstruct a sidecar-shaped spec from the persisted state row. The
  // state shape keeps cmd/args/cwd/env/ports/attrs/healthcheck verbatim
  // (see _toStateRecord), so this is a direct projection.
  const spec = {
    name: existing.name,
    cmd: existing.cmd,
    args: Array.isArray(existing.args) ? existing.args.slice() : [],
    cwd: existing.cwd || '',
    env: Object.assign({}, existing.env || {}),
    ports: Array.isArray(existing.ports) ? existing.ports.slice() : [],
    ttl_minutes: undefined, // re-uses ttl_expires_at via _toStateRecord; pass through default
    attrs: existing.attrs && typeof existing.attrs === 'object' ? existing.attrs : {},
    healthcheck: existing.healthcheck || null,
  };
  // Kill the old PID before respawn (best-effort; outside of any lock).
  if (Number.isInteger(existing.pid) && existing.pid > 0) {
    try { shared.killByPidImmediate(existing.pid); }
    catch (e) { log('warn', 'restartManagedSpec: kill of old PID ' + existing.pid + ' failed: ' + e.message); }
  }
  const ctx = {
    owner_agent: existing.owner_agent || '',
    owner_wi: existing.owner_wi || '',
    owner_project: existing.owner_project || '',
  };
  const runtime = spawnManagedSpec(spec, ctx);
  // recordManagedSpec replaces by name (item 2 idempotency contract) and
  // resets healthy:false / alive:true / new started_at / new ttl_expires_at.
  recordManagedSpec(spec, runtime, ctx);
  log('info', 'managed-spawn restart: name=' + name + ' old_pid=' + existing.pid + ' new_pid=' + runtime.pid);
  return runtime;
}

// ─── Item 7 (P-8a4d6f29) ─────────────────────────────────────────────────────
//
// TTL sweep + boot reconcile + project-removal cleanup + log rotation.
//
//   sweepManagedSpawn(opts)              → tick-driven walk of the state file:
//                                          1. probe each pid (process.kill 0);
//                                             dead-but-not-expired entries are
//                                             dropped from state (no kill —
//                                             the OS already reaped them).
//                                          2. ttl_expires_at past now → batch
//                                             kill via killByPidsImmediate +
//                                             drop from state.
//                                          3. rotate log_path when size >
//                                             logRotateBytes (rename to .1,
//                                             overwrite any prior .1).
//                                          Returns {scanned, ttlExpired,
//                                          deadDropped, killedPids,
//                                          rotatedLogs, malformed}.
//   bootReconcileManagedSpawn(opts)      → one-shot equivalent for the engine
//                                          boot path. Same drop-dead + kill-
//                                          expired pass, plus a single
//                                          runHealthcheck() probe per
//                                          surviving spec to refresh
//                                          `healthy` / `last_health_at`.
//                                          Returns a Promise so callers can
//                                          Promise.race it against the
//                                          bootReconcileMaxMs ceiling.
//   removeManagedSpecsForProject(name)   → centralised project-removal hook.
//                                          Kills + drops every spec whose
//                                          owner_project matches, unlinks the
//                                          log + log.1. Returns {killed,
//                                          unlinked}. engine/projects.js
//                                          removeProject calls this — no
//                                          managed-process awareness elsewhere.
//
// Per the plan, healthcheck loops stay PER-SPEC and self-scheduled — the tick
// cycle never iterates all specs to drive a probe. The sweep ONLY handles
// liveness/TTL/log-rotation; it does not re-attach healthcheck timers
// (boot reconcile does that once at startup; the engine close-handler does it
// at first spawn).

function _rotateManagedLog(logPath, cap) {
  if (!logPath || typeof logPath !== 'string') return false;
  let size = 0;
  try { size = fs.statSync(logPath).size; }
  catch (_e) { return false; }
  if (!Number.isFinite(cap) || cap <= 0 || size <= cap) return false;
  const rotated = logPath + '.1';
  try {
    try { fs.unlinkSync(rotated); }
    catch (e) { if (e && e.code !== 'ENOENT') throw e; }
    fs.renameSync(logPath, rotated);
    return true;
  } catch (e) {
    log('warn', 'managed-spawn rotate: ' + logPath + ' failed: ' + e.message);
    return false;
  }
}

function _runManagedReconcile(opts) {
  opts = opts || {};
  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
  const rotateBytes = Number.isFinite(opts.rotateBytes)
    ? opts.rotateBytes
    : (Number(limits.logRotateBytes) || 10 * 1024 * 1024);
  const isAlive = typeof opts.isAlive === 'function'
    ? opts.isAlive
    : shared.isPidAlive;
  const killBatch = typeof opts.killBatch === 'function'
    ? opts.killBatch
    : shared.killByPidsImmediate;
  const stats = {
    scanned: 0,
    ttlExpired: 0,
    deadDropped: 0,
    killedPids: 0,
    rotatedLogs: 0,
    malformed: 0,
  };
  const statePath = _getStatePath();
  const ttlPidsToKill = [];
  const survivors = []; // [{name, log_path}] post-mutation, used for log rotation + bootReconcile probes
  shared.mutateManagedProcesses((data) => {
    if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
      stats.malformed++;
      return _initialStateShape();
    }
    const kept = [];
    for (const rec of data.specs) {
      if (!rec || typeof rec !== 'object' || typeof rec.name !== 'string') {
        stats.malformed++;
        continue;
      }
      stats.scanned++;
      const ttlExpired = Number.isFinite(rec.ttl_expires_at) && rec.ttl_expires_at <= now;
      const alive = Number.isInteger(rec.pid) && rec.pid > 0 && isAlive(rec.pid);
      if (ttlExpired) {
        stats.ttlExpired++;
        if (alive) ttlPidsToKill.push(rec.pid);
        continue; // drop from state
      }
      if (!alive) {
        stats.deadDropped++;
        continue; // dead + not expired → drop
      }
      kept.push(rec);
      survivors.push({ name: rec.name, log_path: rec.log_path || '', healthy: rec.healthy === true, last_health_at: rec.last_health_at || 0 });
    }
    data.specs = kept;
    return data;
  }, { defaultValue: _initialStateShape() });
  // Process kills + log rotation OUTSIDE the lock callback. The copilot-
  // instructions key conventions explicitly forbid kills/network/awaits inside
  // a lock callback.
  if (ttlPidsToKill.length > 0) {
    try { stats.killedPids = killBatch(ttlPidsToKill); }
    catch (e) { log('warn', 'managed-spawn sweep: kill batch failed: ' + e.message); }
  }
  for (const surv of survivors) {
    if (_rotateManagedLog(surv.log_path, rotateBytes)) stats.rotatedLogs++;
  }
  return { stats: stats, survivors: survivors };
}

function sweepManagedSpawn(opts) {
  return _runManagedReconcile(opts).stats;
}

// Refresh `healthy` + `last_health_at` on the state row after a fresh probe.
// Symmetric with _markHealthy but accepts the unhealthy case too (flipping
// healthy=true→false would mask a real degradation — keep healthy sticky and
// rely on the per-spec loop to refresh post-success, or item 7 boot reconcile
// to (re)establish initial truth on engine restart).
function _markBootProbe(name, result) {
  const statePath = _getStatePath();
  shared.mutateManagedProcesses((data) => {
    if (!data || !Array.isArray(data.specs)) return data;
    const rec = data.specs.find(s => s && s.name === name);
    if (!rec) return data;
    if (result && result.healthy === true) {
      rec.healthy = true;
      rec.last_health_at = Number.isFinite(result.lastCheckAt) ? result.lastCheckAt : Date.now();
    } else {
      // Survivor failed its boot probe — clear healthy so dashboard reflects
      // truth. The per-spec healthcheck loop (re-attached in a future item) or
      // the next dispatch's waitForFirstHealth will move it back to true.
      rec.healthy = false;
    }
    return data;
  }, { defaultValue: _initialStateShape() });
}

async function bootReconcileManagedSpawn(opts) {
  opts = opts || {};
  const limits = ENGINE_DEFAULTS.managedSpawn || {};
  const backoffMs = Math.max(0, (Number(limits.healthBackoffSec) || 30) * 1000);
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
  const { stats, survivors } = _runManagedReconcile(opts);
  // Re-probe survivors with a single healthcheck each. Skip ones already
  // healthy within healthBackoffSec — boot reconcile must stay bounded.
  const probed = [];
  for (const surv of survivors) {
    if (surv.healthy && (now - surv.last_health_at) < backoffMs) continue;
    // Load the full spec record for the healthcheck shape (boot-reconcile
    // needs `healthcheck` block, which survivors[] omits to keep the lock
    // callback small).
    const list = listManagedSpecs();
    const rec = list.find(s => s && s.name === surv.name);
    if (!rec || !rec.healthcheck) continue;
    try {
      const r = await runHealthcheck({ name: rec.name, healthcheck: rec.healthcheck });
      try { _markBootProbe(rec.name, r); }
      catch (e) { log('warn', 'managed-spawn bootReconcile state write failed for ' + rec.name + ': ' + e.message); }
      probed.push({ name: rec.name, healthy: !!r.healthy });
    } catch (e) {
      log('warn', 'managed-spawn bootReconcile probe failed for ' + rec.name + ': ' + e.message);
    }
  }
  return { stats: stats, probed: probed };
}

function removeManagedSpecsForProject(projectName) {
  if (typeof projectName !== 'string' || projectName.length === 0) {
    return { killed: 0, unlinked: 0, scanned: 0 };
  }
  const statePath = _getStatePath();
  let toKill = [];
  let logPaths = [];
  shared.mutateManagedProcesses((data) => {
    if (!data || typeof data !== 'object' || !Array.isArray(data.specs)) {
      return _initialStateShape();
    }
    const keep = [];
    for (const rec of data.specs) {
      if (rec && rec.owner_project === projectName) {
        if (Number.isInteger(rec.pid) && rec.pid > 0) toKill.push(rec.pid);
        if (rec.log_path) logPaths.push(rec.log_path);
        continue; // drop
      }
      keep.push(rec);
    }
    data.specs = keep;
    return data;
  }, { defaultValue: _initialStateShape() });
  let killed = 0;
  if (toKill.length > 0) {
    try { killed = shared.killByPidsImmediate(toKill); }
    catch (e) { log('warn', 'managed-spawn removeForProject: kill batch failed for ' + projectName + ': ' + e.message); }
  }
  let unlinked = 0;
  for (const p of logPaths) {
    for (const candidate of [p, p + '.1']) {
      try { fs.unlinkSync(candidate); unlinked++; }
      catch (e) { if (e && e.code !== 'ENOENT') log('warn', 'managed-spawn removeForProject: unlink ' + candidate + ' failed: ' + e.message); }
    }
  }
  return { killed: killed, unlinked: unlinked, scanned: toKill.length };
}

module.exports = {
  MANAGED_SPAWN_FILENAME: MANAGED_SPAWN_FILENAME,
  MANAGED_PROCESSES_STATE_FILE: MANAGED_PROCESSES_STATE_FILE,
  MANAGED_LOGS_DIR: MANAGED_LOGS_DIR,
  INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
  HEALTHCHECK_TYPES: HEALTHCHECK_TYPES,
  validateManagedSpawnRecord: validateManagedSpawnRecord,
  readManagedSpawnFile: readManagedSpawnFile,
  evaluateManagedSpawnAcceptance: evaluateManagedSpawnAcceptance,
  buildManagedSpawnHint: buildManagedSpawnHint,
  // Item 2 (P-2d5e8f04): engine spawn + locked state file.
  openManagedLog: openManagedLog,
  spawnManagedSpec: spawnManagedSpec,
  recordManagedSpec: recordManagedSpec,
  recordManagedBatch: recordManagedBatch,
  removeManagedSpec: removeManagedSpec,
  listManagedSpecs: listManagedSpecs,
  getStatePath: _getStatePath,
  // Item 3 (P-9c1f47a6): healthcheck implementations + first-pass waiter.
  runHealthcheck: runHealthcheck,
  waitForFirstHealth: waitForFirstHealth,
  tailManagedLog: tailManagedLog,
  // Item 6 (P-1f9c3a45): playbook auto-inject of live managed processes.
  buildLiveManagedProcessesBlock: buildLiveManagedProcessesBlock,
  // Item 4 (P-4b8d2e57): discovery API (etag + state-driven respawn).
  computeManagedSpecsEtag: computeManagedSpecsEtag,
  restartManagedSpec: restartManagedSpec,
  // Item 7 (P-8a4d6f29): TTL sweep + boot reconcile + project cleanup.
  sweepManagedSpawn: sweepManagedSpawn,
  bootReconcileManagedSpawn: bootReconcileManagedSpawn,
  removeManagedSpecsForProject: removeManagedSpecsForProject,
};
