/**
 * engine/cc-worker-pool.js — Persistent per-tab Copilot worker pool (W-mp3ioyh300056f36).
 *
 * Sub-task B of W-mp2w003600196c51 ("CC perf: persistent per-tab Copilot
 * worker"). Wraps `copilot --acp` (Agent Client Protocol — long-lived JSON-RPC
 * server over stdin/stdout) so that a Command Center / doc-chat tab keeps a
 * single warm Copilot process across N turns instead of paying the
 * spawn + initialize + session/new (~14 s) cost on every turn.
 *
 * Public API:
 *
 *   pool.getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd })
 *     → SessionHandle: {
 *         sessionId,
 *         stream(promptText, { onChunk, onDone, onError, signal, systemPromptText }) → Promise,
 *         cancel(),                  // session/cancel notification
 *         close(),                   // tear down this tab
 *       }
 *   pool.closeTab(tabId)             // cancel inflight, kill proc, drop entry
 *   pool.shutdown()                  // close every tab and stop the idle reaper
 *
 * Internal model (one process per tab):
 *
 *   tabs: Map<tabId, Worker> where Worker = {
 *     proc, sessionId, model, effort, mcpServers, mcpServersHash,
 *     systemPromptHash, cwd, lastUsedAt,
 *     pending: Map<id, { resolve, reject }>, inflight, readBuf, nextReqId
 *   }
 *
 *   TODO (sub-task C/D follow-up): multiplex N tabs onto one ACP process
 *   (`session/new` per tab, single `proc`). ACP supports it; left as a
 *   follow-up perf win once one-process-per-tab is proven in the dashboard.
 *
 * Eviction rules:
 *   - mcpServers changes      → kill proc entirely + respawn
 *   - systemPromptHash change → keep proc, send a fresh `session/new`
 *   - model / effort change   → no eviction (state updated in place)
 *   - closeTab                → `session/cancel` if inflight, then kill proc
 *   - Idle > 10 min           → kill proc (reaper sweep every 60 s)
 *
 * Auth precheck: when `copilot --acp` exits before the initialize handshake
 * completes, surface
 *   "copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login
 *    is complete (...)"
 * so the dashboard can surface a precise reason instead of a generic timeout.
 *
 * No new npm deps — Node built-ins only (child_process, events, timers, crypto).
 *
 * See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
 * for the ACP protocol probe (line framing, request/response correlation,
 * cold-vs-warm timings, cancel semantics, configOptions surface).
 */

const { spawn } = require('child_process');
const crypto = require('crypto');

// W-mpmwxni2000c25c7-c — typed error codes the pool emits through every
// failure exit so the consumer (CC streaming handler / doc-chat pool
// wrapper / SSE writer) can render a structured error envelope instead of
// parsing the stderr string. Matches the `{ message, code, retriable }`
// shape sub-item b standardized on for the dashboard's SSE envelope and
// the runtime adapter parseError() contract (engine/runtimes/*.js).
const ERROR_CODES = Object.freeze({
  // spawn() threw synchronously OR the child process emitted an 'error'
  // event (binary missing on PATH, exec failed, EPERM, etc.). Retriable
  // because a transient PATH / fs glitch may recover.
  WORKER_SPAWN_FAILED: 'worker-spawn-failed',
  // The worker process exited DURING the ACP handshake (initialize or
  // session/new) — usually `copilot login` is incomplete or the CLI
  // version is too old. Also fires when session/new returns no
  // sessionId. Retriable: the engine swaps to a fallback model / a re-auth
  // may unblock the next attempt.
  ACP_HANDSHAKE_FAILED: 'acp-handshake-failed',
  // The worker process exited AFTER a successful handshake (the daemon
  // died mid-turn). Retriable — the next call cold-spawns a fresh worker.
  WORKER_DIED: 'worker-died',
  // The consumer's per-turn timeout fired before the ACP session/prompt
  // resolved. Owned by the dashboard pool wrappers (cc-worker-pool itself
  // has no turn timeout) but exported here so all callers stringify the
  // same constant. Retriable — most timeouts are transient.
  CC_TURN_TIMEOUT: 'cc-turn-timeout',
});

// Build a typed Error carrying the `{ message, code, retriable }` envelope
// fields the consumer expects. Plain Errors flow through unchanged; the
// helper only stamps the extra metadata. Keep retriable defaulting to
// `true` so a caller that forgets to set it still gets the safe default
// (the legacy pre-typed-error code path treated every failure as retriable).
function _typedError(message, code, retriable = true) {
  const err = new Error(message);
  err.code = code;
  err.retriable = retriable;
  return err;
}

// 10 minutes — matches the work-item spec.
const IDLE_REAPER_MS = 10 * 60 * 1000;
// Reaper sweep cadence. Not exposed as ENGINE_DEFAULTS to keep the pool
// dependency-free; sub-task C/D can plumb a config knob if needed.
const REAPER_INTERVAL_MS = 60 * 1000;
// Cap concurrent warm spawns triggered by tab/modal open. Without a cap, a
// user spamming new tabs would fan out N parallel cold spawns; with one, the
// excess waits in FIFO order while the first few warm. The actual user-driven
// getSession() path is NOT throttled — only the pre-warm path is.
const WARM_MAX_CONCURRENT = 3;

// Test seam — every external side effect goes through `_internals` so
// test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate.
const _internals = {
  spawnAcp({ cwd } = {}) {
    return spawn(
      'copilot',
      ['--acp', '--allow-all', '--max-autopilot-continues', '1'],
      {
        stdio: ['pipe', 'pipe', 'pipe'],
        cwd: cwd || process.cwd(),
        windowsHide: true,
        // Don't pass shell:true — Copilot is a binary on PATH and a shell
        // wrapper would swallow stdin/stdout framing.
      }
    );
  },
  killImmediate(proc) {
    // Lazy require so unit tests that don't load engine/shared still work.
    try {
      const shared = require('./shared');
      shared.killImmediate(proc);
    } catch {
      try { proc.kill('SIGKILL'); } catch { /* already dead */ }
    }
  },
  now() { return Date.now(); },
};

const _tabs = new Map();
let _reaperTimer = null;

// CC_POOL_TRACE-gated structured trace logger. Off by default; enable via
// `CC_POOL_TRACE=1 minions restart` to dump every getSession lifecycle
// transition, stream sessionId capture, and session/update notification
// match/mismatch to stderr. Added for W-mpdavudb000v8446 follow-up so the
// next investigation cycle can correlate engine state with the user-perceived
// first-message hang. NO PII — only tabId (caller-supplied), sessionIds
// (opaque ACP ids), and protocol flags. Safe to leave on in dev/staging.
function _trace(...parts) {
  if (!process.env.CC_POOL_TRACE) return;
  try { process.stderr.write('[cc-pool] ' + parts.join(' ') + '\n'); }
  catch { /* swallow telemetry errors */ }
}

function _hashMcpServers(mcpServers) {
  // Stable hash via JSON.stringify; mcpServers is an array of plain objects
  // in practice (name/command/env) so the natural key order is fine.
  const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
  return crypto.createHash('sha256').update(json).digest('hex');
}

// Bug B (issue #2479): `model` and `effort` were stored on the Worker but
// never forwarded to ACP `session/new`, so any `ccModel` override was silently
// dropped on the pool path. Build the params object here and only include
// fields that are defined — sending `model: undefined` would force the daemon
// onto its built-in default instead of letting it pick whatever the user has
// configured globally.
function _buildSessionNewParams({ cwd, mcpServers, model, effort }) {
  const params = { cwd, mcpServers: mcpServers || [] };
  if (model !== undefined && model !== null && model !== '') params.model = model;
  if (effort !== undefined && effort !== null && effort !== '') params.effort = effort;
  return params;
}

class Worker {
  constructor({ tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd }) {
    this.tabId = tabId;
    this.model = model;
    this.effort = effort;
    this.mcpServers = mcpServers || [];
    this.mcpServersHash = mcpServersHash;
    this.systemPromptHash = systemPromptHash;
    this.cwd = cwd || process.cwd();

    this.proc = null;
    this.sessionId = null;
    this.pending = new Map();      // id → { resolve, reject }
    this.inflight = null;          // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
    this.readBuf = '';
    this.nextReqId = 1;
    this.lastUsedAt = _internals.now();
    this.killed = false;
    this.spawnError = null;
    this.firstSystemPromptSent = false;
    // In-flight spawn+initialize+session/new promise. Set by getSession()
    // before the worker is registered in _tabs, cleared after the handshake
    // settles. Racing getSession() callers await this to avoid the
    // "warm-reuse path returns sessionId=null while init is still pending"
    // hang on first message of a freshly-warmed tab (W-mpd45blx00072f04).
    //
    // Follow-up investigation (W-mpdavudb000v8446) verified the post-ab141995
    // engine path holds the necessary invariants (see
    // test/unit/cc-worker-pool-fresh-tab-race.test.js):
    //   * after `await worker.initPromise`, worker.sessionId is the real id
    //   * Worker.stream sets inflight.sessionId to that same real id
    //   * session/prompt is written with sessionId === inflight.sessionId
    // When the symptom recurs (intermittent first-message hang despite the
    // fix), it's almost certainly downstream of the pool — SSE delivery,
    // browser-side render, or telemetry overstating delivery. Set
    // `CC_POOL_TRACE=1` to dump every state transition + sessionId snapshot
    // through the pool to stderr so the next investigation can correlate
    // engine state with the user-perceived hang.
    this.initPromise = null;
  }

  // ── Spawn + initialize handshake ────────────────────────────────────────
  async _spawnAndInit() {
    let proc;
    try {
      proc = _internals.spawnAcp({ cwd: this.cwd });
    } catch (err) {
      // spawn() threw synchronously — typically ENOENT (copilot binary not
      // on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
      // can show "install the CLI / fix PATH" guidance.
      throw _typedError(
        `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
        ERROR_CODES.WORKER_SPAWN_FAILED,
        true
      );
    }
    this.proc = proc;

    proc.stdout.on('data', (chunk) => this._onStdout(chunk));
    // stderr is captured for diagnostics but we don't act on it.
    if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });

    // Race the init handshake against an early process exit so an auth
    // failure (which kills the daemon before initialize completes) surfaces
    // a clear error instead of hanging forever.
    let earlyExitReject;
    const earlyExitPromise = new Promise((_, reject) => {
      earlyExitReject = (code) => {
        this.killed = true;
        // Early exit DURING the handshake = acp-handshake-failed (almost
        // always missing `copilot login`, stale CLI, or daemon crash on
        // boot). Retriable so re-auth or a CLI upgrade can recover.
        const err = _typedError(
          `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`,
          ERROR_CODES.ACP_HANDSHAKE_FAILED,
          true
        );
        this.spawnError = err;
        this._failAllPending(err);
        reject(err);
      };
    });
    const earlyExitHandler = (code) => earlyExitReject(code);
    proc.once('exit', earlyExitHandler);

    const errorHandler = (err) => {
      // proc 'error' event fires when the OS can't actually start the child
      // (ENOENT after a successful spawn() call, etc.). Treat as a spawn
      // failure even though we made it past the synchronous spawn() above.
      const wrapped = _typedError(
        `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
        ERROR_CODES.WORKER_SPAWN_FAILED,
        true
      );
      this.spawnError = wrapped;
      this.killed = true;
      this._failAllPending(wrapped);
    };
    proc.on('error', errorHandler);

    try {
      await Promise.race([
        this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
        earlyExitPromise,
      ]);
      const result = await Promise.race([
        this._call('session/new', _buildSessionNewParams({
          cwd: this.cwd, mcpServers: this.mcpServers, model: this.model, effort: this.effort,
        })),
        earlyExitPromise,
      ]);
      this.sessionId = result && result.sessionId;
      if (!this.sessionId) {
        // Handshake completed without an error but the daemon didn't hand
        // back a sessionId — protocol violation or partial init failure.
        throw _typedError(
          'copilot --acp failed -- session/new returned no sessionId',
          ERROR_CODES.ACP_HANDSHAKE_FAILED,
          true
        );
      }
    } finally {
      // Either the handshake finished (swap to a persistent exit handler that
      // just marks killed) or it failed (proc is dying anyway).
      proc.removeListener('exit', earlyExitHandler);
    }
    proc.on('exit', () => {
      this.killed = true;
      // Post-handshake exit = the daemon died mid-conversation. Retriable
      // because the next call will cold-spawn a fresh worker.
      const err = _typedError(
        'copilot --acp process exited',
        ERROR_CODES.WORKER_DIED,
        true
      );
      this._failAllPending(err);
      // Settle inflight too if it's still hanging
      if (this.inflight && !this.inflight.settled) {
        const cb = this.inflight.onError;
        this.inflight.settled = true;
        this.inflight = null;
        if (cb) try { cb(err); } catch { /* swallow */ }
      }
    });
  }

  _failAllPending(err) {
    for (const { reject } of this.pending.values()) {
      try { reject(err); } catch { /* swallow */ }
    }
    this.pending.clear();
  }

  // ── Newline-delimited JSON-RPC line framing ────────────────────────────
  _onStdout(chunk) {
    this.readBuf += chunk.toString('utf8');
    let i;
    while ((i = this.readBuf.indexOf('\n')) >= 0) {
      const line = this.readBuf.slice(0, i).trim();
      this.readBuf = this.readBuf.slice(i + 1);
      if (!line) continue;
      let obj;
      try { obj = JSON.parse(line); } catch { continue; }
      this._handleMessage(obj);
    }
  }

  _handleMessage(obj) {
    // Response (matches an outbound id)
    if (obj.id != null && this.pending.has(obj.id)) {
      const { resolve, reject } = this.pending.get(obj.id);
      this.pending.delete(obj.id);
      if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
      else resolve(obj.result);
      return;
    }
    // Notification (no id) — only `session/update` matters for streaming.
    if (obj.method === 'session/update' && obj.params) {
      // Trace EVERY session/update notification, including drops — this is
      // exactly where the W-mpd45blx00072f04 hang manifested (chunks dropped
      // because inflight.sessionId was null). Logging both the notification
      // sid and the inflight sid lets the next investigation cycle prove
      // whether the engine still drops chunks. (W-mpdavudb000v8446)
      const notifSid = obj.params.sessionId;
      const inflightSid = this.inflight ? this.inflight.sessionId : null;
      const updKind = obj.params.update && obj.params.update.sessionUpdate;
      if (!this.inflight) {
        _trace(`tab=${this.tabId} session/update dropped: no inflight (notifSid=${notifSid} kind=${updKind})`);
        return;
      }
      if (notifSid !== inflightSid) {
        _trace(`tab=${this.tabId} session/update dropped: sid mismatch (notifSid=${notifSid} inflightSid=${inflightSid} kind=${updKind})`);
        return;
      }
      _trace(`tab=${this.tabId} session/update delivered: sid=${notifSid} kind=${updKind}`);
      const update = obj.params.update;
      if (!update) return;
      if (update.sessionUpdate === 'agent_message_chunk') {
        const text = _extractChunkText(update.content);
        if (text && this.inflight.onChunk) {
          try { this.inflight.onChunk(text); } catch { /* swallow */ }
        }
      } else if (update.sessionUpdate === 'tool_call' && this.inflight.onToolUse) {
        // ACP `tool_call` (pending) → chip label via _mapAcpToolCallToToolUse.
        const mapped = _mapAcpToolCallToToolUse(update);
        if (mapped) {
          try { this.inflight.onToolUse(mapped.name, mapped.input, update.toolCallId); }
          catch { /* swallow */ }
        }
      } else if (update.sessionUpdate === 'tool_call_update' && this.inflight.onToolUpdate) {
        // Terminal-state updates only (completed/failed). In-progress updates
        // exist in the ACP spec but add chip churn without actionable info.
        if (update.status === 'completed' || update.status === 'failed') {
          try { this.inflight.onToolUpdate(update.toolCallId, update.status); }
          catch { /* swallow */ }
        }
      }
    }
  }

  _writeFrame(obj) {
    if (this.killed) return;
    if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
    try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
    catch { /* pipe may be broken; exit handler will fire */ }
  }

  _call(method, params) {
    return new Promise((resolve, reject) => {
      const id = this.nextReqId++;
      this.pending.set(id, { resolve, reject });
      this._writeFrame({ jsonrpc: '2.0', id, method, params });
    });
  }

  _notify(method, params) {
    this._writeFrame({ jsonrpc: '2.0', method, params });
  }

  // ── Stream a single turn ───────────────────────────────────────────────
  stream(promptText, opts = {}) {
    const { onChunk, onToolUse, onToolUpdate, onDone, onError, signal, systemPromptText } = opts;
    if (this.killed) {
      const err = new Error('cc-worker-pool: tab is closed');
      if (onError) try { onError(err); } catch { /* swallow */ }
      return Promise.resolve();
    }
    if (this.inflight) {
      const err = new Error('cc-worker-pool: a prompt is already in flight on this tab');
      if (onError) try { onError(err); } catch { /* swallow */ }
      return Promise.resolve();
    }
    this.lastUsedAt = _internals.now();

    // Inject the <system> block on the first prompt of a session — Copilot
    // ACP has no in-protocol "set system prompt" method (per Ripley's note),
    // so the per-session adapter pattern of <system>...</system> prepended
    // to the first user message remains correct.
    let prompt = promptText;
    if (!this.firstSystemPromptSent && systemPromptText) {
      prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
    }
    this.firstSystemPromptSent = true;

    const id = this.nextReqId++;
    const inflight = {
      id,
      sessionId: this.sessionId,
      onChunk,
      onToolUse,
      onToolUpdate,
      onDone,
      onError,
      signal,
      signalHandler: null,
      settled: false,
    };
    this.inflight = inflight;
    // W-mpdavudb000v8446 — trace the sessionId captured by inflight at the
    // exact moment Worker.stream commits to a write. inflight.sessionId is
    // the value session/update notifications must match against in
    // _handleMessage; if it's ever null, every chunk for this turn is silently
    // dropped (the ab141995 hang signature). Pair with the [cc-pool] dispatch
    // log on the dashboard side to correlate engine state with user-perceived
    // delivery.
    _trace(`tab=${this.tabId} stream begin: worker.sessionId=${this.sessionId} inflight.sessionId=${inflight.sessionId} reqId=${id}`);

    if (signal && typeof signal.addEventListener === 'function') {
      inflight.signalHandler = () => this.cancel();
      try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
    }

    return new Promise((resolve) => {
      const finalize = (err, result) => {
        if (inflight.settled) return;
        inflight.settled = true;
        if (this.inflight === inflight) this.inflight = null;
        this.lastUsedAt = _internals.now();
        if (signal && inflight.signalHandler) {
          try { signal.removeEventListener('abort', inflight.signalHandler); }
          catch { /* swallow */ }
        }
        if (err) {
          if (onError) try { onError(err); } catch { /* swallow */ }
        } else {
          if (onDone) try { onDone(result); } catch { /* swallow */ }
        }
        resolve();
      };

      this.pending.set(id, {
        resolve: (result) => finalize(null, result),
        reject: (err) => finalize(err, null),
      });
      this._writeFrame({
        jsonrpc: '2.0',
        id,
        method: 'session/prompt',
        params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
      });
    });
  }

  cancel() {
    if (!this.inflight || this.killed) return;
    // session/cancel is a JSON-RPC notification (no id). The in-flight
    // session/prompt response will arrive with stopReason=cancelled per the
    // ACP spec — we don't synthesize a fake response client-side.
    this._notify('session/cancel', { sessionId: this.inflight.sessionId });
  }

  async newSession({ mcpServers, systemPromptHash, model, effort }) {
    // Cancel any inflight before swapping the underlying session.
    if (this.inflight) {
      this.cancel();
      // Wait briefly for the cancelled response to settle so we don't leak
      // a stale inflight reference into the new session.
      const inflight = this.inflight;
      await new Promise((resolve) => {
        const start = _internals.now();
        const wait = () => {
          if (!inflight || inflight.settled || _internals.now() - start > 1000) resolve();
          else setTimeout(wait, 5);
        };
        wait();
      });
    }
    // Bug B (issue #2479): if the caller is rotating the session because the
    // system prompt changed, they may also be passing a fresh model/effort —
    // update bookkeeping BEFORE session/new so the new fields land on the
    // daemon. Falls through to whatever we already had when callers omit them.
    if (model !== undefined) this.model = model;
    if (effort !== undefined) this.effort = effort;
    const result = await this._call('session/new', _buildSessionNewParams({
      cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
    }));
    this.sessionId = result && result.sessionId;
    this.systemPromptHash = systemPromptHash;
    this.firstSystemPromptSent = false;
  }

  close() {
    if (this.killed) return;
    this.killed = true;
    // Best-effort cancel of an inflight prompt so the daemon doesn't keep
    // generating into a torn-down session before we kill the proc.
    if (this.inflight) {
      try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
      catch { /* swallow */ }
    }
    if (this.proc) {
      try { _internals.killImmediate(this.proc); } catch { /* already dead */ }
    }
    this._failAllPending(new Error('cc-worker-pool: worker closed'));
    if (this.inflight && !this.inflight.settled) {
      const cb = this.inflight.onError;
      this.inflight.settled = true;
      this.inflight = null;
      if (cb) try { cb(new Error('cc-worker-pool: worker closed')); } catch { /* swallow */ }
    }
  }
}

function _extractChunkText(content) {
  if (content == null) return '';
  if (typeof content === 'string') return content;
  if (Array.isArray(content)) {
    let out = '';
    for (const block of content) {
      if (block && typeof block.text === 'string') out += block.text;
    }
    return out;
  }
  if (typeof content === 'object' && typeof content.text === 'string') return content.text;
  return '';
}

// Map an ACP `tool_call` session/update notification to the {name, input} shape
// the dashboard's formatToolSummary already understands. ACP's `kind` is a
// coarse category (execute|read|edit|search|fetch|think|other); we translate to
// the closest Claude tool name so the existing chip formatters keep working
// (Bash → "$ <cmd>", Read → "Reading <path>", etc.). Unknown kinds fall back
// to ACP's human-readable `title` with the raw input attached, which renders
// through the default `<title>(<key>: <val>)` formatter.
// Map an ACP `tool_call` notification to a chip label. Strategy:
//
//   1. Detect well-known shapes by rawInput field presence and route through
//      formatToolSummary's Claude-tool formatters (Bash → "$ <cmd>",
//      Grep → "Searching `pat` in path", Read → "Reading <path>"). Preserves
//      the granular detail you actually want when reading the chip list.
//
//   2. Fall back to ACP's human-curated `title` for anything that doesn't
//      match. Title is always populated and immune to field-name drift, so
//      the empty-`$` failure mode (kind:execute with no `command`) becomes
//      a clean "Find work item W-..." chip instead of a broken stub.
//
// Kind-based routing is intentionally NOT used — ACP overloads `kind:read`
// for both file-view and grep, and `kind:execute` sometimes arrives without
// a `command`. Field-detection on rawInput is more reliable.
function _mapAcpToolCallToToolUse(update) {
  if (!update || update.sessionUpdate !== 'tool_call') return null;
  const rawInput = (update.rawInput && typeof update.rawInput === 'object') ? update.rawInput : {};
  const title = update.title || update.kind || 'Tool';

  if (typeof rawInput.command === 'string' && rawInput.command) {
    return { name: 'Bash', input: { command: rawInput.command } };
  }
  if (typeof rawInput.pattern === 'string' && rawInput.pattern) {
    return { name: 'Grep', input: { pattern: rawInput.pattern, path: rawInput.paths || rawInput.path || '.' } };
  }
  if (typeof rawInput.path === 'string' && rawInput.path) {
    const isEdit = String(update.kind || '').toLowerCase() === 'edit';
    return { name: isEdit ? 'Edit' : 'Read', input: { file_path: rawInput.path } };
  }
  if (typeof rawInput.url === 'string' && rawInput.url) {
    return { name: 'WebFetch', input: { url: rawInput.url } };
  }
  return { name: title, input: {} };
}

// ── Public API ────────────────────────────────────────────────────────────

async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
  if (!tabId) throw new Error('cc-worker-pool.getSession: tabId is required');
  const mcpServersHash = _hashMcpServers(mcpServers);
  let worker = _tabs.get(tabId);
  // Track which lifecycle path we took so the dashboard's [cc-timing] log can
  // attribute warm-reuse vs new-session vs cold-spawn. One of:
  //   'warm-reuse'  — proc + session both reused (no spawn, no session/new)
  //   'new-session' — proc reused, fresh session/new (sysprompt hash changed)
  //   'cold-spawn'  — fresh proc + initialize + session/new
  let lifecycle = 'warm-reuse';
  _trace(`tab=${tabId} getSession entry: worker.exists=${!!worker} worker.sessionId=${worker?.sessionId ?? 'null'} worker.initPromise=${worker?.initPromise ? 'pending' : 'null'}`);

  if (worker) {
    // W-mpd45blx00072f04: if the existing worker is still mid-init (warm
    // fired but session/new hasn't resolved yet), await the in-flight init
    // BEFORE evaluating warm-reuse / newSession / cold-spawn — otherwise we
    // return a SessionHandle with sessionId=null and the caller's first
    // session/prompt fires with a null sessionId, causing every subsequent
    // session/update notification to be dropped by _handleMessage's
    // sessionId-match guard. User-visible symptom: first message on a
    // freshly-warmed CC tab hangs (no chunks streamed, eventual onDone
    // with empty text).
    if (worker.initPromise) {
      _trace(`tab=${tabId} getSession await-init: joining in-flight initPromise`);
      try {
        await worker.initPromise;
      } catch (err) {
        // Warm init failed (e.g., auth). The originating call has already
        // (or is about to) delete _tabs[tabId] and close the worker in its
        // own catch handler. Surface the same error to this caller so the
        // dashboard's spawn-failed path runs instead of hanging.
        _trace(`tab=${tabId} getSession await-init failed: ${err.message}`);
        throw err;
      }
      // Re-read in case the failing initPromise's cleanup already ran.
      worker = _tabs.get(tabId) || null;
      _trace(`tab=${tabId} getSession await-init done: worker.exists=${!!worker} worker.sessionId=${worker?.sessionId ?? 'null'}`);
    }
  }

  if (worker) {
    if (worker.killed) {
      _tabs.delete(tabId);
      worker = null;
    } else if (worker.mcpServersHash !== mcpServersHash) {
      // mcpServers shape changed → must respawn the proc; the daemon
      // resolves MCP server config at process boot, not per session.
      _tabs.delete(tabId);
      worker.close();
      worker = null;
    } else if (worker.systemPromptHash !== systemPromptHash) {
      // System prompt changed → keep the warm process, drop the session
      // and create a fresh one. Saves the ~2.1 s initialize handshake.
      // Pass model/effort so the NEW session picks up any override the
      // caller has rotated in (Bug B / issue #2479).
      await worker.newSession({ mcpServers, systemPromptHash, model, effort });
      worker.lastUsedAt = _internals.now();
      lifecycle = 'new-session';
    } else {
      // Warm reuse — only update bookkeeping. model/effort changes on a
      // warm session are tracked here but not pushed via configOptions in
      // sub-task B; sub-task C/D will wire that to ACP `session/configure`
      // (or equivalent) once the dashboard exposes the toggle live.
      worker.model = model;
      worker.effort = effort;
      worker.lastUsedAt = _internals.now();
    }
  }

  if (!worker) {
    worker = new Worker({
      tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
    });
    _tabs.set(tabId, worker);
    // Set initPromise BEFORE awaiting so concurrent getSession() callers
    // landing during the spawn+initialize+session/new round-trip can detect
    // and await it (W-mpd45blx00072f04). Clear on settle so callers that
    // arrive AFTER init succeeds skip the no-op await. Attach the clear
    // handler as both success+failure listeners (not .finally()) so the
    // chained promise has a rejection handler and doesn't surface as an
    // unhandled rejection when init throws.
    const initPromise = worker._spawnAndInit();
    worker.initPromise = initPromise;
    const clearInit = () => {
      // Only clear if we're still the active promise — defensive against
      // a future refactor that calls _spawnAndInit twice for the same
      // Worker (current code path never does).
      if (worker.initPromise === initPromise) worker.initPromise = null;
    };
    initPromise.then(clearInit, clearInit);
    try {
      await initPromise;
    } catch (err) {
      _tabs.delete(tabId);
      try { worker.close(); } catch { /* already torn down */ }
      throw err;
    }
    lifecycle = 'cold-spawn';
  }

  _ensureReaper();

  // W-mpdavudb000v8446 — trace the handle being returned. If lifecycle is
  // 'warm-reuse' but sessionId is null/empty, the engine has hit a state the
  // ab141995 fix was supposed to prevent — surface it loudly. The empty-id
  // case is also caught defensively below so callers can react instead of
  // wedging on a null-sid session/prompt frame.
  _trace(`tab=${tabId} getSession return: lifecycle=${lifecycle} sessionId=${worker.sessionId ?? 'null'}`);
  if (!worker.sessionId) {
    // This is the bug class the ab141995 fix closed; if it ever recurs the
    // engine should fail loudly rather than hand back a half-initialized
    // handle. Throwing here lets the dashboard surface spawn-failed instead
    // of the silent thinking-dots-forever symptom. Mark non-retriable —
    // this is a real engine bug, not a transient pool failure; the next
    // attempt would hit the same race.
    throw _typedError(
      `cc-worker-pool: getSession returning handle with null sessionId (tab=${tabId} lifecycle=${lifecycle}) — engine race regression, see W-mpd45blx00072f04 / W-mpdavudb000v8446`,
      ERROR_CODES.ACP_HANDSHAKE_FAILED,
      false
    );
  }

  return {
    sessionId: worker.sessionId,
    lifecycle,
    stream: (promptText, opts) => worker.stream(promptText, opts),
    cancel: () => worker.cancel(),
    close: () => closeTab(tabId),
  };
}

function closeTab(tabId) {
  const worker = _tabs.get(tabId);
  if (!worker) return;
  _tabs.delete(tabId);
  worker.close();
}

let _warmInflight = 0;
const _warmQueue = [];

function _drainWarmQueue() {
  while (_warmInflight < WARM_MAX_CONCURRENT && _warmQueue.length > 0) {
    const next = _warmQueue.shift();
    _warmInflight++;
    next().finally(() => {
      _warmInflight--;
      _drainWarmQueue();
    });
  }
}

// Pre-warm a tab: spawn the process, run the initialize handshake, and create
// the ACP session (including MCP server init) without sending a prompt. The
// returned session is registered in `_tabs` exactly as if getSession had built
// it, so the next real call is a warm-reuse hit. Already-warm tabs are a
// no-op. Concurrent calls beyond WARM_MAX_CONCURRENT queue in FIFO order.
async function warmTab(args = {}) {
  if (!args.tabId) throw new Error('cc-worker-pool.warmTab: tabId is required');
  return new Promise((resolve, reject) => {
    _warmQueue.push(async () => {
      try {
        const handle = await getSession(args);
        resolve({ tabId: args.tabId, lifecycle: handle.lifecycle, sessionId: handle.sessionId });
      } catch (err) {
        reject(err);
      }
    });
    _drainWarmQueue();
  });
}

// Cancel the currently in-flight prompt on this tab without killing the
// worker. Sends ACP `session/cancel` so the remote daemon stops generating;
// the warm process + initialized MCP servers + session state are preserved
// so the next prompt skips the ~7-8 s cold-spawn cost. No-op if the tab has
// no worker or no inflight prompt.
function cancelInflight(tabId) {
  const worker = _tabs.get(tabId);
  if (!worker || worker.killed || !worker.inflight) return false;
  try { worker.cancel(); } catch { /* swallow */ }
  return true;
}

function shutdown() {
  for (const worker of _tabs.values()) {
    try { worker.close(); } catch { /* swallow */ }
  }
  _tabs.clear();
  if (_reaperTimer) {
    clearInterval(_reaperTimer);
    _reaperTimer = null;
  }
}

function _ensureReaper() {
  if (_reaperTimer) return;
  _reaperTimer = setInterval(_reapIdleTabs, REAPER_INTERVAL_MS);
  if (typeof _reaperTimer.unref === 'function') _reaperTimer.unref();
}

function _reapIdleTabs() {
  const now = _internals.now();
  for (const [tabId, worker] of [..._tabs]) {
    if (worker.inflight) continue;
    if (now - worker.lastUsedAt > IDLE_REAPER_MS) {
      _tabs.delete(tabId);
      try { worker.close(); } catch { /* already torn down */ }
    }
  }
}

module.exports = {
  getSession,
  warmTab,
  closeTab,
  cancelInflight,
  shutdown,
  // Exposed for unit tests; engine code MUST go through the public API.
  _internals,
  _tabs,
  _reapIdleTabs,
  _buildSessionNewParams,
  IDLE_REAPER_MS,
  REAPER_INTERVAL_MS,
  WARM_MAX_CONCURRENT,
  // W-mpmwxni2000c25c7-c — typed-error envelope contract. Exported so the
  // dashboard pool wrappers (and their tests) reference the same string
  // constants and so the doc-chat timeout path can stamp the same
  // `{ message, code, retriable }` shape the pool itself emits.
  ERROR_CODES,
  _typedError,
};
