// render-other.js — Projects, MCP servers, metrics, token usage renderers extracted from dashboard.html

function renderProjects(projects) {
  const list = document.getElementById('projects-list');
  const visible = (projects || []).filter(p => typeof isDeleted === 'function' ? !isDeleted('project:' + p.name) : true);
  if (!visible.length) {
    list.innerHTML = '<span style="color:var(--muted);font-style:italic">No projects linked.</span>' +
      '<span onclick="addProject()" class="empty-state-add" style="margin-left:8px">+ Add Project</span>' +
      '<span onclick="openScanProjectsModal()" class="empty-state-add empty-state-add-blue">Scan</span>';
    return;
  }
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: project name, path, branch metadata)
  list.innerHTML = visible.map(p =>
    '<span data-project="' + escHtml(p.name) + '" title="' + escHtml(p.path || '') + '" style="display:inline-flex;align-items:center;gap:6px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 10px;color:var(--blue);font-weight:500;cursor:help">' +
      escHtml(p.name) +
      _renderProjectBranch(p) +
      '<span onclick="event.stopPropagation();projectChipRemove(\'' + escHtml(p.name) + '\')" title="Remove project (cancels pending work, archives data dir)" style="color:var(--muted);font-weight:600;cursor:pointer;padding:0 2px;line-height:1" onmouseover="this.style.color=\'var(--red)\'" onmouseout="this.style.color=\'var(--muted)\'">&times;</span>' +
    '</span>'
  ).join('') +
  '<span onclick="addProject()" class="empty-state-add empty-state-add-muted">+ Add</span>' +
  '<span onclick="openScanProjectsModal()" class="empty-state-add empty-state-add-blue">Scan</span>';

}

// Renders the per-project current-branch indicator. Driven by the gitState
// classifier emitted by getProjectGitStatus() in engine/queries.js.
// Adds a title attribute carrying the full branch name so users can still
// read it on hover after the CSS ellipsis-truncate (W-mpayac6d000b7d33).
//
// W-mpg3whgp000d09ec / #2732: also surfaces configured mainBranch drift —
// shows "current → main" when local branch differs from the configured main,
// a warning chip when branchMismatch (configured ≠ origin/HEAD) OR when the
// local branch is behind main, and a compact "↑N ↓M" chip when ahead/behind
// counts are non-zero.
function _renderProjectBranch(p) {
  if (!p) return '';
  if (p.gitState === 'missing') return '<span class="project-warn" title="Project localPath does not exist on disk">(path not found)</span>';
  if (p.gitState === 'non-git') return '<span class="project-muted" title="Project path exists but is not a git repository">(not a git repo)</span>';
  if (p.gitState !== 'ok' || !p.gitBranch) return '';
  const branch = escHtml(p.gitBranch);
  const mainBranch = p.mainBranch ? escHtml(p.mainBranch) : '';
  const remoteDefault = p.remoteDefaultBranch ? escHtml(p.remoteDefaultBranch) : '';
  const branchMismatch = !!p.branchMismatch;
  const ahead = Number.isFinite(p.ahead) ? p.ahead : null;
  const behind = Number.isFinite(p.behind) ? p.behind : null;
  const branchDiffersFromMain = !!(mainBranch && p.gitBranch !== p.mainBranch);
  const titleBits = ['on: ' + p.gitBranch];
  if (mainBranch) titleBits.push('configured main: ' + p.mainBranch);
  if (remoteDefault) titleBits.push('origin/HEAD: ' + p.remoteDefaultBranch);
  if (ahead !== null && behind !== null) titleBits.push('↑' + ahead + ' ↓' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch));
  if (p.gitDetached) titleBits.push('(detached)');
  if (p.gitDirty) titleBits.push('(dirty)');
  const titleAttr = ' title="' + escHtml(titleBits.join(' • ')) + '"';
  const dirty = p.gitDirty ? ' <span class="dot-dirty" title="Working tree has uncommitted changes">●</span>' : '';
  const mainSuffix = branchDiffersFromMain
    ? ' <span class="muted">→ ' + mainBranch + '</span>'
    : '';
  const detachedSuffix = p.gitDetached ? ' <span class="muted">(detached)</span>' : '';
  const aheadBehind = (ahead !== null && behind !== null && (ahead > 0 || behind > 0))
    ? ' <span class="project-ab" title="' + escHtml('Ahead ' + ahead + ' / behind ' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">↑' + ahead + ' ↓' + behind + '</span>'
    : '';
  // Warning chip surfaces when the configured main disagrees with the remote
  // default branch (the #2732 case: config=master, origin/HEAD=main) OR when
  // the local branch is behind the configured main.
  let warnChip = '';
  if (branchMismatch) {
    warnChip = ' <span class="project-warn" title="' + escHtml('Configured mainBranch (' + p.mainBranch + ') differs from origin/HEAD (' + p.remoteDefaultBranch + ') — config is likely stale') + '">⚠ main drift</span>';
  } else if (behind !== null && behind > 0) {
    warnChip = ' <span class="project-warn" title="' + escHtml('Local branch is ' + behind + ' commits behind origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">⚠ behind</span>';
  }
  return '<span class="project-branch"' + titleAttr + '>on: ' + branch + mainSuffix + detachedSuffix + dirty + '</span>' + aheadBehind + warnChip;
}

function _projectCachePath(project) {
  return String((project && (project.localPath || project.path)) || '').replace(/\\/g, '/');
}

function optimisticallyAddProject(project) {
  if (!project || !project.name) return;
  const next = {
    name: project.name,
    description: project.description || '',
    localPath: _projectCachePath(project),
    path: _projectCachePath(project),
  };
  if (!window._lastStatus) window._lastStatus = {};
  const current = Array.isArray(window._lastStatus.projects) ? window._lastStatus.projects.slice() : [];
  const nextPath = _projectCachePath(next);
  if (current.some(function(p) { return p.name === next.name || (nextPath && _projectCachePath(p) === nextPath); })) return;
  current.push(next);
  window._lastStatus.projects = current;
  if (typeof cmdUpdateProjectList === 'function') cmdUpdateProjectList(current);
  renderProjects(current);
}

async function projectChipRemove(name) {
  if (!confirm('Remove project "' + name + '"? Pending work cancels, active agents are killed, data dir is archived to projects/.archived/.')) return;
  showToast('cmd-toast', 'Removing project "' + name + '"...', true);
  markDeleted('project:' + name);
  if (window._lastStatus && Array.isArray(window._lastStatus.projects)) {
    renderProjects(window._lastStatus.projects);
  }
  try {
    const res = await fetch('/api/projects/remove', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name })
    });
    const d = await res.json().catch(() => ({}));
    if (!res.ok || !d.ok) {
      // Revert: refresh restores the chip from server state
      clearDeleted('project:' + name);
      if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
      showToast('cmd-toast', 'Remove failed: ' + (d.error || 'unknown'), false);
      if (typeof refresh === 'function') refresh();
      return;
    }
    var parts = ['Removed "' + name + '"'];
    if (d.cancelledItems) parts.push(d.cancelledItems + ' WI cancelled');
    if (d.drainedDispatches) parts.push(d.drainedDispatches + ' dispatch drained');
    if (d.archivedPlans?.length) parts.push(d.archivedPlans.length + ' plan/PRD archived');
    if (d.archivedTo) parts.push('archived');
    if (d.pipelineRefs?.length) parts.push('! pipelines still reference: ' + d.pipelineRefs.join(', '));
    showToast('cmd-toast', parts.join(' — '), true);
    if (typeof refresh === 'function') refresh();
  } catch (e) {
    clearDeleted('project:' + name);
    if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
    showToast('cmd-toast', 'Error: ' + e.message, false);
    if (typeof refresh === 'function') refresh();
  }
}

function renderMcpServers(servers) {
  const el = document.getElementById('mcp-list');
  const countEl = document.getElementById('mcp-count');
  countEl.textContent = servers.length;
  if (!servers.length) {
    el.innerHTML = '<p class="empty">No MCP servers found. Add them via <code>claude mcp add</code> / <code>copilot mcp add</code>, install a plugin that ships one, or drop a <code>.mcp.json</code> into a registered project — they\'ll appear here automatically.</p>';
    return;
  }
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: server source, status, args, command, name)
  el.innerHTML = '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">' +
    servers.map(s =>
      '<div style="font-size:var(--text-base);padding:5px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text)" title="' + escHtml([s.source, s.status, s.args || s.command].filter(Boolean).join(' · ')) + '">' +
        escHtml(s.name) +
        (s.source ? ' <span style="color:var(--muted)">(' + escHtml(s.source) + ')</span>' : '') +
      '</div>'
    ).join('') +
  '</div>' +
  '<p style="font-size:var(--text-sm);color:var(--muted);margin:0">Mirrors <code style="color:var(--blue)">claude mcp list</code> + <code style="color:var(--blue)">copilot mcp list --json</code>, plus workspace <code>.mcp.json</code> from each registered project. Cached ~5 min.</p>';
}

function renderMetrics(metrics) {
  const el = document.getElementById('metrics-content');
  const agents = Object.entries(metrics).filter(([k]) => !k.startsWith('_'));
  if (agents.length === 0) {
    el.innerHTML = '<p class="empty">No metrics yet. Metrics appear after agents complete tasks.</p>';
    renderTokenUsage(metrics);
    return;
  }
  // Consolidate temp-* agents into one row
  var permanent = agents.filter(function(a) { return !a[0].startsWith('temp-'); });
  var temps = agents.filter(function(a) { return a[0].startsWith('temp-'); });
  var rows = permanent.slice();
  if (temps.length > 0) {
    var merged = { tasksCompleted: 0, tasksErrored: 0, prsCreated: 0, prsApproved: 0, prsRejected: 0, reviewsDone: 0 };
    for (var t = 0; t < temps.length; t++) {
      var tm = temps[t][1];
      merged.tasksCompleted += tm.tasksCompleted || 0;
      merged.tasksErrored += tm.tasksErrored || 0;
      merged.prsCreated += tm.prsCreated || 0;
      merged.prsApproved += tm.prsApproved || 0;
      merged.prsRejected += tm.prsRejected || 0;
      merged.reviewsDone += tm.reviewsDone || 0;
      merged.totalRuntimeMs = (merged.totalRuntimeMs || 0) + (tm.totalRuntimeMs || 0);
    }
    rows.push(['Temp Agents (' + temps.length + ')', merged]);
  }

  function fmtAvgRuntime(m) {
    const total = m.totalRuntimeMs || 0;
    const count = m.timedTasks || 0;
    if (!count || !total) return '-';
    const avgMin = total / count / 60000;
    return avgMin < 1 ? '<1m' : Math.round(avgMin) + 'm';
  }

  let html = '<table class="pr-table"><thead><tr><th>Agent</th><th>Done</th><th>Errors</th><th>PRs</th><th>Approved</th><th>Rejected</th><th>Rate</th><th>Reviews</th><th>Avg Runtime</th></tr></thead><tbody>';
  for (const [id, m] of rows) {
    const totalTasks = (m.tasksCompleted || 0) + (m.tasksErrored || 0);
    const ratio = totalTasks > 0 ? m.tasksCompleted / totalTasks : 0;
    const rate = totalTasks > 0 ? Math.round(ratio * 100) + '%' : '-';
    const rateColor = totalTasks > 0 ? (ratio >= 0.7 ? 'var(--green)' : 'var(--red)') : 'var(--muted)';
    html += '<tr>' +
      '<td style="font-weight:600">' + escHtml(id) + '</td>' +
      '<td style="color:var(--green)">' + (m.tasksCompleted || 0) + '</td>' +
      '<td style="color:' + (m.tasksErrored > 0 ? 'var(--red)' : 'var(--muted)') + '">' + (m.tasksErrored || 0) + '</td>' +
      '<td>' + (m.prsCreated || 0) + '</td>' +
      '<td style="color:var(--green)">' + (m.prsApproved || 0) + '</td>' +
      '<td style="color:' + (m.prsRejected > 0 ? 'var(--red)' : 'var(--muted)') + '">' + (m.prsRejected || 0) + '</td>' +
      '<td style="color:' + rateColor + ';font-weight:600">' + rate + '</td>' +
      '<td>' + (m.reviewsDone || 0) + '</td>' +
      '<td style="color:var(--muted)">' + fmtAvgRuntime(m) + '</td>' +
    '</tr>';
  }
  html += '</tbody></table>';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agent id)
  el.innerHTML = html;
  renderLlmPerf(metrics);
  renderTokenUsage(metrics);
}

function renderLlmPerf(metrics) {
  const el = document.getElementById('llm-perf-content');
  if (!el) return;
  const engine = metrics._engine;
  if (!engine || Object.keys(engine).length === 0) {
    el.innerHTML = '<p class="empty">No LLM performance data yet.</p>';
    return;
  }
  // Split entries: per-LLM-call rows vs per-agent-task agent-dispatch row.
  // `calls` semantics differ between the two — agent-dispatch counts dispatched
  // work items (with runtimeMs = wall-clock of the agent process), the others
  // count LLM API round-trips (with totalDurationMs = sum of per-call latencies).
  const filtered = Object.entries(engine).filter(([k]) => !k.startsWith('test'));
  const perCall = filtered.filter(([k]) => k !== 'agent-dispatch')
    .sort((a, b) => (b[1].calls || 0) - (a[1].calls || 0));
  const perTask = filtered.filter(([k]) => k === 'agent-dispatch');
  function renderRow(type, m) {
    const calls = m.calls || 0;
    const totalMs = m.totalDurationMs || 0;
    const timedCalls = m.timedCalls || 0;
    const avgMs = timedCalls > 0 ? totalMs / timedCalls : 0;
    const fmtTotal = totalMs < 60000 ? Math.round(totalMs / 1000) + 's' : Math.round(totalMs / 60000) + 'm';
    const fmtAvg = avgMs < 1000 ? Math.round(avgMs) + 'ms' : avgMs < 60000 ? Math.round(avgMs / 1000) + 's' : Math.round(avgMs / 60000) + 'm';
    const cost = m.costUsd ? '$' + m.costUsd.toFixed(2) : '-';
    return '<tr><td style="font-weight:600">' + escHtml(type) + '</td>' +
      '<td>' + calls + '</td>' +
      '<td style="color:var(--muted)">' + (totalMs ? fmtTotal : '-') + '</td>' +
      '<td style="color:var(--blue)">' + (avgMs ? fmtAvg : '-') + '</td>' +
      '<td style="color:var(--muted)">' + cost + '</td></tr>';
  }
  let html = '<table class="pr-table"><thead><tr><th>Call Type</th><th>Calls</th><th>Total Time</th><th>Avg Time</th><th>Cost</th></tr></thead><tbody>';
  for (const [type, m] of perCall) {
    html += renderRow(type, m);
  }
  if (perTask.length > 0) {
    // Visual divider + caption row to distinguish per-call (above) from
    // per-task (below) — column units differ.
    html += '<tr><td colspan="5" style="border-top:2px solid var(--border);padding-top:6px;font-size:var(--text-sm);color:var(--muted);font-style:italic">— per agent task (calls = dispatched work items, time = wall-clock of agent process) —</td></tr>';
    for (const [type, m] of perTask) {
      html += renderRow(type + ' (per task)', m);
    }
  }
  html += '</tbody></table>';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: LLM call type)
  el.innerHTML = html;
}

// Aggregate _engine entries for the Token Usage summary tiles.
// Excludes:
//   - 'agent-dispatch' (already counted in per-agent metrics[agentId] totals;
//     summing both would double-count agent spend in the tile)
//   - 'test-*' / '_test*' (matching engine/llm.js trackEngineUsage filter so
//     stale test categories don't inflate engineCalls)
function _aggregateEngineUsageForTokenTile(engine) {
  let cost = 0, input = 0, output = 0, cache = 0, calls = 0;
  for (const [k, e] of Object.entries(engine || {})) {
    if (k === 'agent-dispatch') continue;
    if (k.startsWith('test-') || k.startsWith('_test')) continue;
    cost += e.costUsd || 0;
    input += e.inputTokens || 0;
    output += e.outputTokens || 0;
    cache += e.cacheRead || 0;
    calls += e.calls || 0;
  }
  return { cost, input, output, cache, calls };
}

function renderTokenUsage(metrics) {
  const el = document.getElementById('token-usage-content');
  const agents = Object.entries(metrics).filter(([k]) => !k.startsWith('_'));
  const daily = metrics._daily || {};
  const engine = metrics._engine || {};

  // Aggregate agent totals
  let agentCost = 0, agentInput = 0, agentOutput = 0, agentCache = 0;
  for (const [, m] of agents) {
    agentCost += m.totalCostUsd || 0;
    agentInput += m.totalInputTokens || 0;
    agentOutput += m.totalOutputTokens || 0;
    agentCache += m.totalCacheRead || 0;
  }

  // Aggregate engine totals (excludes agent-dispatch + test-* — see helper)
  const engAgg = _aggregateEngineUsageForTokenTile(engine);
  const engineCost = engAgg.cost, engineInput = engAgg.input,
    engineOutput = engAgg.output, engineCache = engAgg.cache,
    engineCalls = engAgg.calls;

  const totalCost = agentCost + engineCost;
  const totalInput = agentInput + engineInput;
  const totalOutput = agentOutput + engineOutput;
  const totalCache = agentCache + engineCache;

  if (totalCost === 0 && Object.keys(daily).length === 0) {
    el.innerHTML = '<p class="empty">No usage data yet. Token tracking starts on next agent completion.</p>';
    return;
  }

  const fmtTokens = (n) => n >= 1000000 ? (n / 1000000).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(0) + 'K' : String(n);
  const fmtCost = (n) => '$' + n.toFixed(2);

  // Summary tiles
  let html = '<div class="token-tiles">';
  html += '<div class="token-tile"><div class="token-tile-label">Total Cost</div><div class="token-tile-value">' + fmtCost(totalCost) + '</div></div>';
  html += '<div class="token-tile"><div class="token-tile-label">Input Tokens</div><div class="token-tile-value">' + fmtTokens(totalInput) + '</div></div>';
  html += '<div class="token-tile"><div class="token-tile-label">Output Tokens</div><div class="token-tile-value">' + fmtTokens(totalOutput) + '</div></div>';
  html += '<div class="token-tile"><div class="token-tile-label">Cache Reads</div><div class="token-tile-value">' + fmtTokens(totalCache) + '</div></div>';

  // Today's cost
  const today = new Date().toISOString().slice(0, 10);
  const todayData = daily[today];
  if (todayData) {
    html += '<div class="token-tile"><div class="token-tile-label">Today</div><div class="token-tile-value">' + fmtCost(todayData.costUsd) + '</div><div class="token-tile-sub">' + (todayData.tasks || 0) + ' tasks</div></div>';
  }
  html += '</div>';

  // Daily bar chart (last 14 days)
  const days = Object.keys(daily).sort().slice(-14);
  if (days.length > 1) {
    const maxCost = Math.max(...days.map(d => daily[d].costUsd || 0), 0.01);
    html += '<div style="font-size:var(--text-sm);color:var(--muted);margin:8px 0 4px">Daily Cost (last ' + days.length + ' days)</div>';
    html += '<div class="token-chart">';
    for (const day of days) {
      const d = daily[day];
      const pct = Math.max(((d.costUsd || 0) / maxCost) * 100, 2);
      html += '<div class="token-bar" style="height:' + pct + '%"><div class="token-bar-tip">' + day.slice(5) + ': ' + fmtCost(d.costUsd) + ' / ' + (d.tasks || 0) + ' tasks</div></div>';
    }
    html += '</div>';
    html += '<div class="token-chart-labels">';
    for (const day of days) {
      html += '<span>' + day.slice(8) + '</span>';
    }
    html += '</div>';
  }

  // Per-agent token table (consolidate temp agents)
  var permAgents = agents.filter(function(a) { return !a[0].startsWith('temp-'); });
  var tempAgents = agents.filter(function(a) { return a[0].startsWith('temp-'); });
  var tokenRows = permAgents.slice();
  if (tempAgents.length > 0) {
    var tempMerged = { totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCacheRead: 0, tasksCompleted: 0, tasksErrored: 0, model: '' };
    for (var ti = 0; ti < tempAgents.length; ti++) {
      var tm = tempAgents[ti][1];
      tempMerged.totalCostUsd += tm.totalCostUsd || 0;
      tempMerged.totalInputTokens += tm.totalInputTokens || 0;
      tempMerged.totalOutputTokens += tm.totalOutputTokens || 0;
      tempMerged.totalCacheRead += tm.totalCacheRead || 0;
      tempMerged.tasksCompleted += tm.tasksCompleted || 0;
      tempMerged.tasksErrored += tm.tasksErrored || 0;
      if (!tempMerged.model && tm.model) tempMerged.model = tm.model;
    }
    tokenRows.push(['Temp Agents (' + tempAgents.length + ')', tempMerged]);
  }
  var agentsWithUsage = tokenRows.filter(function(a) { return (a[1].totalCostUsd || 0) > 0; });
  if (agentsWithUsage.length > 0) {
    html += '<div style="font-size:var(--text-sm);color:var(--muted);margin:12px 0 4px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Agent Usage</div>';
    html += '<table class="token-agent-table"><thead><tr><th style="width:20%">Agent</th><th style="width:10%">Model</th><th style="width:14%">Cost</th><th style="width:14%">Input</th><th style="width:14%">Output</th><th style="width:14%">Cache</th><th style="width:14%">$/task</th></tr></thead><tbody>';
    for (const [id, m] of agentsWithUsage.sort((a, b) => (b[1].totalCostUsd || 0) - (a[1].totalCostUsd || 0))) {
      const tasks = (m.tasksCompleted || 0) + (m.tasksErrored || 0);
      const perTask = tasks > 0 ? fmtCost((m.totalCostUsd || 0) / tasks) : '-';
      const modelLabel = (m.model || '').replace(/^claude-/, '').replace(/\[.*\]$/, '') || '-';
      html += '<tr>' +
        '<td style="font-weight:600">' + escHtml(id) + '</td>' +
        '<td style="color:var(--muted);font-size:var(--text-sm)">' + escHtml(modelLabel) + '</td>' +
        '<td>' + fmtCost(m.totalCostUsd || 0) + '</td>' +
        '<td>' + fmtTokens(m.totalInputTokens || 0) + '</td>' +
        '<td>' + fmtTokens(m.totalOutputTokens || 0) + '</td>' +
        '<td>' + fmtTokens(m.totalCacheRead || 0) + '</td>' +
        '<td style="color:var(--muted)">' + perTask + '</td>' +
      '</tr>';
    }
    html += '</tbody></table>';
  }

  // Engine (Haiku) usage table
  const engineEntries = Object.entries(engine).filter(([, e]) => (e.costUsd || 0) > 0 || (e.calls || 0) > 0);
  if (engineEntries.length > 0) {
    const labels = { 'consolidation': 'Consolidation', 'command-center': 'Command Center', 'doc-chat': 'Doc Chat', 'kb-sweep': 'KB Sweep', 'schedule-parse': 'Schedule Parse' };
    html += '<div style="font-size:var(--text-sm);color:var(--muted);margin:12px 0 4px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Engine Usage</div>';
    html += '<table class="token-agent-table"><thead><tr><th style="width:20%">Operation</th><th style="width:10%">Calls</th><th style="width:14%">Cost</th><th style="width:14%">Input</th><th style="width:14%">Output</th><th style="width:14%">Cache</th><th style="width:14%">$/call</th></tr></thead><tbody>';
    for (const [cat, e] of engineEntries.sort((a, b) => (b[1].costUsd || 0) - (a[1].costUsd || 0))) {
      const perCall = (e.calls || 0) > 0 ? fmtCost((e.costUsd || 0) / e.calls) : '-';
      html += '<tr>' +
        '<td style="font-weight:600">' + escHtml(labels[cat] || cat) + '</td>' +
        '<td>' + (e.calls || 0) + '</td>' +
        '<td>' + fmtCost(e.costUsd || 0) + '</td>' +
        '<td>' + fmtTokens(e.inputTokens || 0) + '</td>' +
        '<td>' + fmtTokens(e.outputTokens || 0) + '</td>' +
        '<td>' + fmtTokens(e.cacheRead || 0) + '</td>' +
        '<td style="color:var(--muted)">' + perCall + '</td>' +
      '</tr>';
    }
    html += '</tbody></table>';
  }

  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agent id, model label, engine category)
  el.innerHTML = html;
}


async function openScanProjectsModal() {
  document.getElementById('modal-title').textContent = 'Scan for Projects';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: default scan path)
  document.getElementById('modal-body').innerHTML =
    '<div style="display:flex;flex-direction:column;gap:12px">' +
      '<div style="display:flex;gap:8px;align-items:flex-end">' +
        '<label style="flex:1;color:var(--text);font-size:var(--text-md)">Directory to scan' +
          '<input id="scan-path" value="' + escHtml(window.__MINIONS_HOME || '~') + '" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' +
        '</label>' +
        '<label style="width:60px;color:var(--text);font-size:var(--text-md)">Depth' +
          '<input id="scan-depth" type="number" value="3" min="1" max="6" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' +
        '</label>' +
        '<button onclick="_runProjectScan()" class="btn-primary-lg" style="white-space:nowrap">Scan</button>' +
      '</div>' +
      '<div id="scan-results" style="color:var(--muted);font-size:var(--text-md)">Click Scan to find git repos in the directory.</div>' +
      '<div class="cmd-toast" id="scan-toast" style="margin-top:0"></div>' +
    '</div>';
  document.getElementById('modal-body').style.whiteSpace = 'normal';
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
  document.getElementById('modal').classList.add('open');
}

async function _runProjectScan() {
  var scanPath = document.getElementById('scan-path')?.value?.trim();
  var depth = document.getElementById('scan-depth')?.value || '3';
  var resultsEl = document.getElementById('scan-results');
  if (!scanPath) { alert('Enter a path'); return; }
  resultsEl.innerHTML = '<span style="color:var(--blue)">Scanning...</span>';

  try {
    var res = await fetch('/api/projects/scan', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ path: scanPath, depth: Number(depth) })
    });
    var data = await res.json();
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: scan error)
    if (!res.ok) { resultsEl.innerHTML = '<span style="color:var(--red)">Error: ' + escHtml(data.error) + '</span>'; return; }
    var repos = data.repos || [];
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: scan path)
    if (repos.length === 0) { resultsEl.innerHTML = '<span style="color:var(--muted)">No git repos found in ' + escHtml(scanPath) + '</span>'; return; }

    var html = '<div style="margin-bottom:8px;font-size:var(--text-base);color:var(--muted)">' + repos.length + ' repos found — select to add:</div>';
    html += '<div style="max-height:400px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">';
    repos.forEach(function(r, i) {
      var linked = r.linked;
      var hostBadge = '<span style="font-size:var(--text-xs);padding:1px 5px;border-radius:3px;background:' +
        (r.host === 'GitHub' ? 'rgba(88,166,255,0.15);color:var(--blue)' : r.host === 'ADO' ? 'rgba(188,140,255,0.15);color:var(--purple)' : 'var(--surface2);color:var(--muted)') +
        '">' + escHtml(r.host) + '</span>';
      html += '<label style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;cursor:' + (linked ? 'default' : 'pointer') + ';opacity:' + (linked ? '0.5' : '1') + '">' +
        '<input type="checkbox" data-scan-idx="' + i + '" ' + (linked ? 'disabled checked' : '') + ' style="accent-color:var(--blue);width:16px;height:16px">' +
        '<div style="flex:1;min-width:0">' +
          '<div style="font-weight:600;font-size:var(--text-md)">' + escHtml(r.name) + ' ' + hostBadge + (linked ? ' <span style="font-size:var(--text-xs);color:var(--green)">linked</span>' : '') + '</div>' +
          '<div style="font-size:var(--text-sm);color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(r.path) + '</div>' +
          (r.description ? '<div style="font-size:var(--text-sm);color:var(--muted)">' + escHtml(r.description) + '</div>' : '') +
        '</div>' +
      '</label>';
    });
    html += '</div>';
    html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">' +
      '<div style="display:flex;gap:8px">' +
        '<span onclick="_scanSelectAll()" style="font-size:var(--text-sm);color:var(--blue);cursor:pointer;text-decoration:underline">Select all</span>' +
        '<span onclick="_scanSelectNone()" style="font-size:var(--text-sm);color:var(--muted);cursor:pointer;text-decoration:underline">Clear</span>' +
      '</div>' +
      '<button onclick="_addSelectedProjects()" style="padding:6px 16px;background:var(--green);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">Add Selected</button>' +
    '</div>';
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: repo host, name, path, description)
    resultsEl.innerHTML = html;
    window._scanRepos = repos;
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: error message)
  } catch (e) { resultsEl.innerHTML = '<span style="color:var(--red)">Error: ' + escHtml(e.message) + '</span>'; }
}

function _scanSelectAll() {
  document.querySelectorAll('[data-scan-idx]').forEach(function(cb) { if (!cb.disabled) cb.checked = true; });
}
function _scanSelectNone() {
  document.querySelectorAll('[data-scan-idx]').forEach(function(cb) { if (!cb.disabled) cb.checked = false; });
}

async function _addSelectedProjects() {
  var checkboxes = document.querySelectorAll('[data-scan-idx]:checked:not(:disabled)');
  if (checkboxes.length === 0) { alert('Select at least one repo'); return; }
  var repos = window._scanRepos || [];
  var added = 0;
  for (var cb of checkboxes) {
    var repo = repos[parseInt(cb.dataset.scanIdx)];
    if (!repo) continue;
    try {
      var res = await fetch('/api/projects/add', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ path: repo.path })
      });
      var data = await res.json().catch(function() { return {}; });
      if (res.ok) {
        added++;
        var addedName = data.name || repo.name;
        optimisticallyAddProject({
          name: addedName,
          description: (data.detected && data.detected.description) || repo.description || '',
          path: data.path || repo.path,
          localPath: data.path || repo.path,
        });
        cb.disabled = true;
        cb.closest('label').style.opacity = '0.5';
        showToast('scan-toast', added + ' project(s) added', true);
      }
    } catch { /* continue with next */ }
  }
  if (added > 0) {
    refresh();
  }
}

window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRemove, renderMcpServers, renderMetrics, renderLlmPerf, renderTokenUsage, _aggregateEngineUsageForTokenTile, openScanProjectsModal };

// ─── keep_processes panel (W-mp68q6ke0010de68) ─────────────────────────────
// Polls /api/keep-processes every refresh (engine page only) and renders a
// table of active agents/<id>/keep-pids.json declarations with one-click
// "Kill PID" buttons that hit POST /api/keep-processes/kill.
//
// Mount-point registry (W-mpd5ewhj000oc5c5 — QA tab dual-render). Both the
// Engine page and the QA page register a mount; renderKeepProcesses() writes
// to every mount that's currently in the DOM. The Engine page mount is
// registered eagerly so the existing engine.html behavior is unchanged.
const _keepProcessesMounts = [
  { contentId: 'keep-processes-content', countId: 'keep-processes-count' },
];

function mountKeepProcessesPanel(opts) {
  if (!opts || !opts.contentId) return;
  for (const m of _keepProcessesMounts) {
    if (m.contentId === opts.contentId) return;
  }
  _keepProcessesMounts.push({ contentId: opts.contentId, countId: opts.countId || null });
}

function unmountKeepProcessesPanel(contentId) {
  for (let i = _keepProcessesMounts.length - 1; i >= 0; i--) {
    if (_keepProcessesMounts[i].contentId === contentId) _keepProcessesMounts.splice(i, 1);
  }
}

async function renderKeepProcesses() {
  // Resolve every registered mount currently in the DOM (Engine + QA pages).
  // Skip the fetch entirely when nothing is mounted.
  const liveMounts = _keepProcessesMounts
    .map(m => ({ ...m, root: document.getElementById(m.contentId), countEl: m.countId ? document.getElementById(m.countId) : null }))
    .filter(m => m.root);
  if (!liveMounts.length) return;
  let html;
  let countText;
  let items;
  let fetchErr = null;
  try {
    const res = await fetch('/api/keep-processes');
    const data = await res.json();
    items = (data && Array.isArray(data.items)) ? data.items : [];
  } catch (e) {
    fetchErr = e;
  }
  // Dedup against managed-processes: agents that declare a process via
  // managed-spawn.json AND leave it running via keep_processes show up in
  // both panels. Managed is canonical (engine owns the lifecycle), so we
  // suppress any PID already tracked there from the keep-processes table
  // (W-mpdad3mq000m53bb).
  const managed = (window.MinionsManagedProcesses && typeof window.MinionsManagedProcesses.getLastItems === 'function')
    ? window.MinionsManagedProcesses.getLastItems()
    : [];
  const managedPidSet = new Set(
    (Array.isArray(managed) ? managed : [])
      .map(m => Number(m && m.pid))
      .filter(n => Number.isFinite(n))
  );
  let rawCount = 0;
  let filtered = items;
  if (!fetchErr && items) {
    rawCount = items.length;
    filtered = [];
    for (const it of items) {
      if (!it.valid) { filtered.push(it); continue; }
      // Shallow clone so we don't mutate the fetched array — caller may
      // re-render the same payload on a 304 tick.
      const pids = Array.isArray(it.pids) ? it.pids.filter(p => !managedPidSet.has(Number(p && p.pid))) : [];
      if (!pids.length) continue;
      filtered.push({ ...it, pids });
    }
  }
  if (fetchErr) {
    countText = '?';
    html = '<span style="color:var(--red)">Failed to load: ' + escHtml(fetchErr.message) + '</span>';
  } else if (!filtered.length) {
    countText = '0';
    const baseEmpty = '<p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p>';
    html = (rawCount > 0)
      ? baseEmpty.replace('</p>', ' <span style="color:var(--muted)">(all PIDs are tracked as managed processes above)</span></p>')
      : baseEmpty;
  } else {
    countText = String(filtered.length);
    html = filtered.map(function (it) {
      if (!it.valid) {
        return '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:8px;background:var(--surface2)">' +
          '<div style="color:var(--red);font-weight:600">' + escHtml(it.agentId) + ' INVALID</div>' +
          '<div style="font-size:var(--text-base);color:var(--muted)">reason: ' + escHtml(it.reason || '?') + '</div>' +
          '<div style="font-size:var(--text-sm);color:var(--muted)">file: ' + escHtml(it.filePath || '') + '</div>' +
          '</div>';
      }
      const pidRows = (it.pids || []).map(function (p) {
        const aliveBadge = p.alive
          ? '<span style="color:var(--green)">●</span> alive'
          : '<span style="color:var(--muted)">○</span> dead';
        return '<tr>' +
          '<td style="padding:2px 8px;font-family:monospace">' + p.pid + '</td>' +
          '<td style="padding:2px 8px;font-size:var(--text-base)">' + aliveBadge + '</td>' +
          '<td style="padding:2px 8px;text-align:right">' +
            (p.alive
              ? '<button onclick="killKeepPid(\'' + escHtml(it.agentId) + '\',' + p.pid + ')" style="font-size:var(--text-sm);padding:2px 6px;color:var(--red);border:1px solid var(--red);background:transparent;border-radius:3px;cursor:pointer">Kill now</button>'
              : '') +
          '</td>' +
          '</tr>';
      }).join('');
      const portsLine = (it.ports && it.ports.length) ? ' ports: ' + it.ports.join(', ') : '';
      return '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:8px;background:var(--surface2)">' +
        '<div style="font-weight:600">' + escHtml(it.agentId) +
          (it.wi_id ? ' <span style="color:var(--muted);font-weight:400">(' + escHtml(it.wi_id) + ')</span>' : '') +
        '</div>' +
        '<div style="font-size:var(--text-base);color:var(--muted)">' + escHtml(it.purpose || '(no purpose set)') + portsLine + '</div>' +
        '<div style="font-size:var(--text-sm);color:var(--muted)">cwd: ' + escHtml(it.cwd || '?') +
          ' expires in ' + (it.expires_in_minutes != null ? it.expires_in_minutes : '?') + 'min age ' + (it.age_minutes != null ? it.age_minutes : '?') + 'min</div>' +
        '<table style="margin-top:6px;width:100%;border-collapse:collapse">' + pidRows + '</table>' +
        '</div>';
    }).join('');
  }
  // Use DocumentFragment instead of innerHTML assignment to keep this
  // function out of the dynamic-innerHTML regression gate (see
  // test/unit.test.js DYNAMIC_INNERHTML_BASELINE). All embedded
  // user-controlled fields above are wrapped in escHtml(). Build the
  // fragment once per mount (DocumentFragment nodes get adopted on first
  // append so each mount needs its own copy).
  for (const m of liveMounts) {
    if (m.countEl) m.countEl.textContent = countText;
    // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: fetch error, agent id, reason, file path, work item id, purpose, cwd)
    const frag = document.createRange().createContextualFragment(html);
    m.root.replaceChildren(frag);
  }
}

async function killKeepPid(agentId, pid) {
  if (!confirm('Kill PID ' + pid + ' (agent ' + agentId + ')? This is immediate.')) return;
  try {
    const res = await fetch('/api/keep-processes/kill', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ agentId, pid }),
    });
    const data = await res.json().catch(() => ({}));
    if (!res.ok) {
      alert('Failed: ' + (data.error || res.status));
      return;
    }
    if (typeof showToast === 'function') showToast('cmd-toast', 'Killed PID ' + pid + ' (' + (data.action || 'ok') + ')', true);
  } catch (e) {
    alert('Network error: ' + e.message);
  }
  renderKeepProcesses();
}

window.MinionsKeepProcesses = { renderKeepProcesses, killKeepPid, mountKeepProcessesPanel, unmountKeepProcessesPanel };

