// dashboard/js/render-dispatch.js — Engine status, dispatch, and log rendering extracted from dashboard.html

const COMPLETED_PER_PAGE = 20;
const LOG_PER_PAGE = 50;
let _completedPage = 0;
let _logPage = 0;

// Fetch engine/dispatch.json straight off disk through the /state/<path>
// static-file passthrough so dispatch state flips (queued→active, active→
// completed, skipReason changes on pending entries) surface within one 4 s
// poll regardless of /api/status outer-cache state (issue #2949).
//
// completedTotal isn't on disk — it's metrics-derived (sum across
// agents.tasksCompleted + agents.tasksErrored). The renderer already falls
// back to completed.length when completedTotal is missing, so we just
// overlay the cached value from the previous /api/status snapshot for
// continuity. Engine-side _withCompletionReportSummary enrichment (which
// reads per-dispatch sidecar files) is preserved on the cached snapshot
// too, so the modal "view report" links keep working — we only overlay the
// fresh status/timestamp fields onto the cached row when ids match.
async function fetchDispatchFromDisk(cachedDispatch) {
  try {
    const r = await fetch('/state/engine/dispatch.json');
    if (!r.ok) return null;
    const fresh = await r.json();
    if (!fresh || typeof fresh !== 'object') return null;
    // Cap completed to last 20 to match engine/queries.js getDispatchQueue
    // — render-dispatch only paginates within this window, and dispatch.json
    // on disk is already capped to 100 by the engine, so this is the same
    // shape the renderer has always seen.
    if (Array.isArray(fresh.completed) && fresh.completed.length > 20) {
      fresh.completed = fresh.completed.slice(-20);
    }
    // Overlay completion-report summaries from the cached snapshot keyed by
    // dispatch id. They live in sidecar files the client can't read, but
    // they're slow to change so the cached overlay is fine.
    if (cachedDispatch) {
      const cachedById = new Map();
      for (const list of ['pending', 'active', 'completed']) {
        for (const d of (cachedDispatch[list] || [])) {
          if (d && d.id) cachedById.set(d.id, d);
        }
      }
      for (const list of ['pending', 'active', 'completed']) {
        if (!Array.isArray(fresh[list])) continue;
        fresh[list] = fresh[list].map((d) => {
          if (!d || !d.id) return d;
          const cached = cachedById.get(d.id);
          if (!cached) return d;
          const merged = Object.assign({}, d);
          if (cached._completionReport !== undefined && merged._completionReport === undefined) {
            merged._completionReport = cached._completionReport;
          }
          return merged;
        });
      }
      if (fresh.completedTotal == null && cachedDispatch.completedTotal != null) {
        fresh.completedTotal = cachedDispatch.completedTotal;
      }
    }
    return fresh;
  } catch {
    return null;
  }
}

function _completedPrev() { if (_completedPage > 0) { _completedPage--; refresh({ preserveCompletedScroll: false }); } }
function _completedNext() { _completedPage++; refresh({ preserveCompletedScroll: false }); } // clamped in renderDispatch
function _logPrev() { if (_logPage > 0) { _logPage--; refresh({ preserveEngineLogScroll: false }); } }
function _logNext() { _logPage++; refresh({ preserveEngineLogScroll: false }); } // clamped in renderEngineLog

// Engine restart grace state (W-mpfw3hgm001gc594). After the operator clicks
// "Restart engine" we suppress the STALE indicators for ENGINE_RESTART_GRACE_MS
// while the new engine spawns and writes its first heartbeat. The state is
// module-scoped (not on window) so any re-render during the grace window can
// reconstruct the success indicator — earlier code mutated textContent on the
// click target, which got blown away on the next /api/status render.
const _ENGINE_RESTART_GRACE_MS = 30000;
const _ENGINE_RESTART_MAX_RETRIES = 3;
let _engineRestartState = null; // { restartedAt: number, pid: number|string, retryCount: number }

function _isEngineRestartGrace(now) {
  const t = (typeof now === 'number') ? now : Date.now();
  return !!(_engineRestartState && (t - _engineRestartState.restartedAt < _ENGINE_RESTART_GRACE_MS));
}

function _shouldSuppressStaleSignals(state, now) {
  // All three stale signals (badge, alert, version warning) consult this helper
  // so they share a single source of truth for the post-restart grace window.
  if (state === 'running') return false; // engine recovered — show real state
  return _isEngineRestartGrace(now);
}

function _resetEngineRestartStateForTest() {
  _engineRestartState = null;
}

function renderEngineStatus(engine) {
  const badge = document.getElementById('engine-badge');
  let state = engine?.state || 'stopped';
  // Server stamps engine.heartbeatStale + heartbeatAgeMs at snapshot build time
  // (dashboard.js _buildStatusFastState). Avoids client-side wall-clock math
  // against a possibly-cached heartbeat — the bug pattern that produced false-
  // positive STALE banners after control.json was dropped from the mtime tracker.
  const staleMs = engine?.heartbeatAgeMs || 0;
  if (state === 'running' && engine?.heartbeatStale) state = 'stale';

  // Clear restart grace as soon as the engine reports a fresh heartbeat — the
  // new engine has caught up, so STALE/restart banners should vanish.
  if (state === 'running' && _engineRestartState) {
    _engineRestartState = null;
  }

  const suppressStale = _shouldSuppressStaleSignals(state);
  let displayState = state;
  if (state === 'stale' && suppressStale) displayState = 'restarting';

  badge.className = 'engine-badge ' + (displayState === 'stale' ? 'stopped' : (displayState === 'restarting' ? 'paused' : displayState));
  badge.textContent = displayState === 'stale' ? 'STALE' : displayState.toUpperCase();
  badge.title = displayState === 'stale'
    ? 'Engine claims running but heartbeat is stale (>2min). It may have crashed. Run: node engine.js start'
    : displayState === 'restarting'
      ? 'Engine restart in progress (PID ' + (_engineRestartState?.pid || '?') + ') — waiting for first heartbeat.'
      : displayState === 'stopped' ? 'Engine is stopped. Run: node engine.js start' : '';
  renderEngineAlert(state, staleMs);
}

function _wireEngineRestartClick(button) {
  button.onclick = async function() {
    this.classList.add('clicked');
    this.textContent = 'Restarting...';
    const priorRetries = _engineRestartState?.retryCount || 0;
    try {
      const res = await fetch('/api/engine/restart', { method: 'POST' });
      const data = await res.json();
      if (data.ok) {
        // Persist restart state so re-renders during the grace window can
        // reconstruct the success message even after this DOM node is gone.
        _engineRestartState = {
          restartedAt: Date.now(),
          pid: data.pid,
          retryCount: priorRetries + 1,
        };
        showToast('cmd-toast', 'Engine restarted — PID ' + data.pid, true);
        // Trigger a refresh sooner than the 4s poll cadence so the success
        // indicator surfaces quickly. The grace window itself (30s) plus
        // ongoing 4s polling will catch the heartbeat advance whenever it
        // lands; no fixed timeout assumption needed.
        setTimeout(() => refresh(), 1500);
      } else {
        this.textContent = 'Failed: ' + (data.error || 'unknown');
        this.classList.remove('clicked');
      }
    } catch (e) {
      this.textContent = 'Failed: ' + e.message;
      this.classList.remove('clicked');
    }
  };
}

function _renderEngineRestartSuccessBanner(el) {
  const pid = _engineRestartState?.pid || '?';
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal restart PID and fixed status markup (no user data flows in)
  el.innerHTML =
    '<span class="engine-alert-msg" style="color:var(--green)">&#x2713; Engine restarted (PID ' + pid + ') — waiting for first heartbeat...</span>';
  el.style.display = 'flex';
}

function _renderEngineRestartRetryBanner(el) {
  const attempts = _engineRestartState?.retryCount || 0;
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal restart retry count and fixed action markup (no user data flows in)
  el.innerHTML =
    '<span class="engine-alert-msg">&#x26A0;&#xFE0F; Engine restart didn\'t take — heartbeat still stale (attempt ' + attempts + ' of ' + _ENGINE_RESTART_MAX_RETRIES + ').</span>' +
    '<span class="engine-alert-action" id="engine-alert-restart">Retry restart</span>';
  _wireEngineRestartClick(document.getElementById('engine-alert-restart'));
  el.style.display = 'flex';
}

function _renderEngineStaleBanner(el, staleMs) {
  const mins = Math.max(1, Math.round(staleMs / 60000));
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal heartbeat age and fixed action markup (no user data flows in)
  el.innerHTML =
    '<span class="engine-alert-msg">&#x26A0;&#xFE0F; Engine heartbeat is stale (' + mins + 'm old). Dispatch may be stuck.</span>' +
    '<span class="engine-alert-action" id="engine-alert-restart">Restart engine</span>';
  _wireEngineRestartClick(document.getElementById('engine-alert-restart'));
  el.style.display = 'flex';
}

function renderEngineAlert(state, staleMs) {
  const el = document.getElementById('engine-alert');
  if (!el) return;

  // Engine recovered — clear everything (restart state already nulled in
  // renderEngineStatus when state flipped to 'running').
  if (state !== 'stale') {
    el.style.display = 'none';
    el.innerHTML = '';
    return;
  }

  // Stale + within grace window → persistent success indicator.
  if (_isEngineRestartGrace()) {
    _renderEngineRestartSuccessBanner(el);
    return;
  }

  // Stale + past grace + we previously restarted but didn't recover.
  // Offer a retry banner instead of snapping back to the original alert,
  // up to MAX_RETRIES. After that, fall back to the original alert.
  if (_engineRestartState && _engineRestartState.retryCount < _ENGINE_RESTART_MAX_RETRIES) {
    _renderEngineRestartRetryBanner(el);
    return;
  }

  _renderEngineStaleBanner(el, staleMs);
}

function renderAdoThrottleAlert(adoThrottle) {
  const el = document.getElementById('ado-throttle-alert');
  if (!el) return;
  if (!adoThrottle || !adoThrottle.throttled) {
    el.style.display = 'none';
    el.innerHTML = '';
    return;
  }
  const resumeTime = formatLocalTime(adoThrottle.retryAfter);
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal ADO throttle timestamp and counters (no user data flows in)
  el.innerHTML =
    '<span class="engine-alert-msg">&#x26A0;&#xFE0F; ADO rate-limited — resume ~' + resumeTime + ' (' + adoThrottle.consecutiveHits + ' consecutive hit' + (adoThrottle.consecutiveHits !== 1 ? 's' : '') + ')</span>';
  el.style.display = 'flex';
}

function renderGhThrottleAlert(ghThrottle) {
  const el = document.getElementById('gh-throttle-alert');
  if (!el) return;
  if (!ghThrottle || !ghThrottle.throttled) {
    el.style.display = 'none';
    el.innerHTML = '';
    return;
  }
  const resumeTime = formatLocalTime(ghThrottle.retryAfter);
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal GitHub throttle timestamp and counters (no user data flows in)
  el.innerHTML =
    '<span class="engine-alert-msg">&#x26A0;&#xFE0F; GitHub rate-limited — resume ~' + resumeTime + ' (' + ghThrottle.consecutiveHits + ' consecutive hit' + (ghThrottle.consecutiveHits !== 1 ? 's' : '') + ')</span>';
  el.style.display = 'flex';
}

function renderDispatch(dispatch, opts) {
  opts = opts || {};
  if (!dispatch) return;

  // Stats
  const stats = document.getElementById('dispatch-stats');
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal dispatch counts (no user data flows in)
  stats.innerHTML =
    '<div class="dispatch-stat"><div class="dispatch-stat-num yellow">' + (dispatch.active || []).length + '</div><div class="dispatch-stat-label">Active</div></div>' +
    '<div class="dispatch-stat"><div class="dispatch-stat-num blue">' + (dispatch.pending || []).length + '</div><div class="dispatch-stat-label">Pending</div></div>' +
    '<div class="dispatch-stat"><div class="dispatch-stat-num green">' + (dispatch.completedTotal || (dispatch.completed || []).length) + '</div><div class="dispatch-stat-label">Completed</div></div>';

  // Shared row markup for the Active and Pending lists. The two differ only in
  // the trailing chip: Active shows started_at, Pending shows skipReason.
  const dispatchItemHtml = (d, trailing) =>
    '<div class="dispatch-item">' +
      '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
      '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
      '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
      renderStuckChip(d) +
      trailing +
    '</div>';

  // Active
  const activeEl = document.getElementById('dispatch-active');
  if ((dispatch.active || []).length > 0) {
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: dispatch type, agent, task)
    activeEl.innerHTML = '<div style="font-size:var(--text-base);color:var(--green);margin-bottom:6px;font-weight:600">ACTIVE</div><div class="dispatch-list">' +
      dispatch.active.map(d => dispatchItemHtml(d,
        '<span class="dispatch-time">' + shortTime(d.started_at) + '</span>'
      )).join('') + '</div>';
  } else {
    activeEl.innerHTML = '<div style="color:var(--muted);font-size:var(--text-base);margin-bottom:8px">No active dispatches</div>';
  }

  // Pending
  const pendingEl = document.getElementById('dispatch-pending');
  if ((dispatch.pending || []).length > 0) {
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: dispatch type, agent, task, skip reason)
    pendingEl.innerHTML = '<div style="font-size:var(--text-base);color:var(--yellow);margin:8px 0 6px;font-weight:600">PENDING</div><div class="dispatch-list">' +
      dispatch.pending.map(d => dispatchItemHtml(d,
        d.skipReason ? '<span style="font-size:var(--text-xs);color:var(--muted);margin-left:6px" title="' + escHtml(d.skipReason) + '">' + escHtml(d.skipReason.replace(/_/g, ' ')) + '</span>' : ''
      )).join('') + '</div>';
  } else {
    pendingEl.innerHTML = '';
  }

  // Completed
  const completedEl = document.getElementById('completed-content');
  const completedCount = document.getElementById('completed-count');
  const completedScroll = opts.preserveCompletedScroll === false
    ? null
    : captureDashboardScrollState(completedEl, [':self', '.pr-table-wrap']);
  const completed = (dispatch.completed || []).slice().reverse();
  completedCount.textContent = completed.length;

  if (completed.length > 0) {
    const totalCompPages = Math.ceil(completed.length / COMPLETED_PER_PAGE);
    if (_completedPage >= totalCompPages) _completedPage = totalCompPages - 1;
    if (_completedPage < 0) _completedPage = 0;
    const compStart = _completedPage * COMPLETED_PER_PAGE;
    const pageCompleted = completed.slice(compStart, compStart + COMPLETED_PER_PAGE);

    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: dispatch id, type, agent, task, result, reason)
    completedEl.innerHTML = '<table class="pr-table"><thead><tr><th>ID</th><th>Type</th><th>Agent</th><th>Task</th><th>Result</th><th>Completed</th></tr></thead><tbody>' +
      pageCompleted.map(d => {
        const isError = d.result === 'error';
        const agentId = (d.agent || '').toLowerCase();
        const errorBtn = isError
          ? ' <button class="error-details-btn" data-agent="' + escHtml(agentId) + '" data-reason="' + escHtml(d.reason || 'No reason recorded') + '" data-task="' + escHtml((d.task || '').slice(0, 100)) + '" onclick="showErrorDetails(this.dataset.agent, this.dataset.reason, this.dataset.task)" title="View error details">details</button>'
          : '';
        return '<tr>' +
          '<td style="font-family:Consolas;font-size:var(--text-sm)" title="' + escHtml(d.id || '') + '">' + escHtml(d.id || '') + '</td>' +
          '<td><span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span></td>' +
          '<td>' + escHtml(d.agentName || d.agent || '') + '</td>' +
          '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml((d.task || '').slice(0, 60)) + '</td>' +
          '<td style="color:' + (d.result === 'success' ? 'var(--green)' : 'var(--red)') + '">' + escHtml(d.result || '') + errorBtn + '</td>' +
          '<td class="pr-date">' + shortTime(d.completed_at) + '</td>' +
        '</tr>';
      }).join('') + '</tbody></table>';
    const compPager = renderPager({
      start: compStart, perPage: COMPLETED_PER_PAGE, total: completed.length,
      page: _completedPage, totalPages: totalCompPages,
      onPrev: '_completedPrev()', onNext: '_completedNext()',
    });
    // eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
    if (compPager) completedEl.insertAdjacentHTML('beforeend', compPager);
  } else {
    completedEl.innerHTML = '<p class="empty">No completed dispatches yet.</p>';
  }
  restoreDashboardScrollState(completedEl, completedScroll);
}

// Pull the engine log straight off disk through /state/engine/log.json.
// On-disk file is the full append-only array; engine/queries.js getEngineLog
// slices to the last 50 for /api/status. We replicate that slice client-side
// after the disk fetch. ETag/304 keeps the steady state cheap; a new log
// line forces a fresh download (file is ~500 KB at high engine traffic, but
// it changes only on engine writes — not on every 4 s poll).
async function fetchEngineLogFromDisk() {
  try {
    const r = await fetch('/state/engine/log.json');
    if (!r.ok) return null;
    const raw = await r.json();
    const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : []);
    return arr.slice(-50);
  } catch {
    return null;
  }
}

function renderEngineLog(log, opts) {
  opts = opts || {};
  const el = document.getElementById('engine-log');
  if (!el) return;
  const scrollState = opts.preserveEngineLogScroll === false
    ? null
    : captureDashboardScrollState(el, [':self']);
  if (!log || log.length === 0) {
    el.innerHTML = '<div class="empty">No log entries yet.</div>';
    restoreDashboardScrollState(el, scrollState);
    return;
  }
  const reversed = log.slice().reverse();
  const totalLogPages = Math.ceil(reversed.length / LOG_PER_PAGE);
  if (_logPage >= totalLogPages) _logPage = totalLogPages - 1;
  if (_logPage < 0) _logPage = 0;
  const logStart = _logPage * LOG_PER_PAGE;
  const pageLog = reversed.slice(logStart, logStart + LOG_PER_PAGE);

  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: log message)
  el.innerHTML = pageLog.map(e =>
    '<div class="log-entry">' +
      '<span class="log-ts">' + shortTime(e.timestamp) + '</span> ' +
      '<span class="log-level-' + (e.level || 'info') + '">[' + (e.level || 'info') + ']</span> ' +
      escHtml(e.message || '') +
    '</div>'
  ).join('');
  const logPager = renderPager({
    start: logStart, perPage: LOG_PER_PAGE, total: reversed.length,
    page: _logPage, totalPages: totalLogPages,
    onPrev: '_logPrev()', onNext: '_logNext()',
  });
  // eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
  if (logPager) el.insertAdjacentHTML('beforeend', logPager);
  restoreDashboardScrollState(el, scrollState);
}

function shortTime(t) {
  if (!t) return '';
  return formatLocalTime(t);
}

// Stuck-dispatch warning chip (W-mp62taw2000ubcc3): when a dispatch sits in a
// pre-spawn state (worktree-setup, spawning, ready) without progressing for >2
// minutes, the agent appears idle but is silently re-dispatching every tick.
// Surface a STUCK chip so the operator notices instead of waiting for the
// engine log. Threshold is 2 wall-clock minutes — engine ticks every 10s
// (W-mpxpckey000laa4f), so a 2-minute floor distinguishes a slow spawn from
// a wedged one regardless of tick cadence.
const _STUCK_PRE_SPAWN_STATES = new Set(['spawning', 'worktree-setup', 'ready']);
const _STUCK_THRESHOLD_MS = 2 * 60 * 1000;

function renderStuckChip(d) {
  const state = d && d.workerState;
  if (!state || !_STUCK_PRE_SPAWN_STATES.has(state)) return '';
  const at = d.workerStateAt ? Date.parse(d.workerStateAt) : 0;
  if (!at || isNaN(at)) return '';
  const ageMs = Date.now() - at;
  if (ageMs < _STUCK_THRESHOLD_MS) return '';
  const mins = Math.max(1, Math.round(ageMs / 60000));
  const detail = d.workerStateDetail ? ' — ' + d.workerStateDetail : '';
  const title = 'Dispatch stuck in "' + state + '" for ' + mins + 'm' + detail +
    '. The agent appears idle but the dispatch loop may be silently re-spawning each tick. Check engine/log.json for spawnAgent errors.';
  return '<span class="dispatch-stuck" title="' + escHtml(title) + '">STUCK ' + mins + 'm</span>';
}

async function showErrorDetails(agentId, reason, task) {
  document.getElementById('modal-title').textContent = 'Error: ' + task;
  document.getElementById('modal-body').textContent = 'Reason: ' + reason + '\n\nLoading agent output...';
  document.getElementById('modal-body').style.fontFamily = 'Consolas, monospace';
  document.getElementById('modal-body').style.whiteSpace = 'pre-wrap';
  document.getElementById('modal').classList.add('open');

  try {
    const output = await safeFetch('/api/agent/' + agentId + '/output').then(r => r.text());
    const lines = output.split('\n');
    const stderrIdx = lines.findIndex(l => l.startsWith('## stderr'));
    let summary = '';
    if (stderrIdx >= 0) {
      const stderr = lines.slice(stderrIdx + 1).join('\n').trim();
      if (stderr) summary = 'STDERR:\n' + stderr.slice(-2000);
    }
    if (!summary) summary = output.slice(-3000);
    document.getElementById('modal-body').textContent = 'Reason: ' + reason + '\n\n---\n\n' + summary;
  } catch {
    document.getElementById('modal-body').textContent = 'Reason: ' + reason + '\n\n(Could not load agent output)';
  }
}

function renderVersionBanner(version) {
  const el = document.getElementById('version-banner');
  if (!el) return;
  if (!version) { el.style.display = 'none'; return; }

  const v = version.dashboardRunning || version.running || version.disk || '?';
  const commitLabel = version.dashboardRunningCommit ? ' (' + version.dashboardRunningCommit + ')' : '';
  const warnStyle = 'font-size:var(--text-xs);padding:2px 8px;background:rgba(210,153,34,0.15);border:1px solid rgba(210,153,34,0.3);border-radius:4px;color:var(--yellow);cursor:help';

  // During the post-restart grace window the old engine's reported codeVersion
  // can still be in the cached payload — silently swallow the engineStale flag
  // so the user doesn't see "Engine running v… disk has v…" right after a
  // successful restart. The new engine clears the flag once it writes its
  // first heartbeat. dashboardStale is unrelated and must still surface.
  const engineStale = version.engineStale && !_isEngineRestartGrace();

  if (engineStale && version.dashboardStale) {
    el.style.cssText = warnStyle;
    el.textContent = '\u26A0 Engine + Dashboard running old code. Run: minions restart';
    el.title = 'Both processes are running v' + (version.running || '?') + ' but disk has v' + (version.disk || '?');
  } else if (engineStale) {
    el.style.cssText = warnStyle;
    el.textContent = '\u26A0 Engine running v' + (version.running || '?') + ' — disk has v' + (version.disk || '?') + '. Restart engine.';
    el.title = 'The engine process is running older code. Run: minions restart';
  } else if (version.dashboardStale) {
    el.style.cssText = warnStyle;
    el.textContent = '\u26A0 Dashboard running v' + (version.dashboardRunning || '?') + ' — disk has v' + (version.disk || '?') + '. Run: minions restart';
    el.title = 'The dashboard process is running older code. Run: minions restart';
  } else if (version.updateAvailable) {
    el.style.cssText = 'font-size:var(--text-xs);padding:2px 8px;background:rgba(63,185,80,0.1);border:1px solid rgba(63,185,80,0.3);border-radius:4px;color:var(--green);cursor:help';
    el.textContent = 'v' + v + commitLabel + ' — v' + version.latest + ' available. Run: minions update or npm install -g @yemi33/minions@latest';
    el.title = 'A newer version is available on npm. Run minions update to upgrade and restart.';
  } else {
    el.style.cssText = 'font-size:var(--text-xs);color:var(--muted)';
    el.textContent = 'v' + v + commitLabel;
    el.title = 'Minions v' + v + (version.latest ? ' (latest)' : '');
  }
}

window.MinionsDispatch = { renderEngineStatus, renderEngineAlert, renderAdoThrottleAlert, renderGhThrottleAlert, renderVersionBanner, renderDispatch, renderEngineLog, shortTime, showErrorDetails, _isEngineRestartGrace, _shouldSuppressStaleSignals, _resetEngineRestartStateForTest };
