// settings.js — Settings panel functions extracted from dashboard.html

// W-mpmwxkrw000872ec — single source of truth for applying the font-size
// preference at runtime. Mirrors the inline bootstrap script in
// layout.html (which runs before this file loads). Exposed on window so the
// refresh.js reconciler can call it after the first /api/settings fetch.
const FONT_SIZE_VALUES = ['small', 'medium', 'large', 'xlarge'];
function applyFontSizePreference(value) {
  const v = FONT_SIZE_VALUES.includes(value) ? value : 'small';
  try { document.documentElement.setAttribute('data-font-size', v); } catch (e) { /* no DOM */ }
  try { localStorage.setItem('minions:font-size', v); } catch (e) { /* private mode / disabled */ }
}
try { window.applyFontSizePreference = applyFontSizePreference; } catch (e) { /* non-browser */ }

let _settingsData = null;
// Async runtime/model discovery can resolve out of order when the operator
// flips between runtimes quickly. Without a per-target token, a slower Copilot
// response can repaint the Claude model field after the runtime already
// changed, which then leaks a stale cross-runtime model into saveSettings().
const _modelLoadEpochs = {
  runtime: Object.create(null),
  agent: Object.create(null),
};

function _nextModelLoadToken(scope, key) {
  const bucket = _modelLoadEpochs[scope];
  const next = (bucket[key] || 0) + 1;
  bucket[key] = next;
  return next;
}

function _isCurrentModelLoad(scope, key, token) {
  return _modelLoadEpochs[scope][key] === token;
}

async function openSettings() {
  document.getElementById('modal-title').textContent = 'Settings';
  document.getElementById('modal-body').innerHTML = '<p style="color:var(--muted)">Loading...</p>';
  document.getElementById('modal').classList.add('open');
  // Settings needs more horizontal room for the left rail + content split.
  const modalEl = document.querySelector('#modal .modal');
  if (modalEl) modalEl.classList.add('modal-wide');

  _settingsData = null;
  let data;
  let featuresList = [];
  try {
    const [settingsRes, featuresRes] = await Promise.all([
      fetch('/api/settings'),
      fetch('/api/features').catch(() => null),
    ]);
    data = await settingsRes.json();
    _settingsData = data;
    if (featuresRes && featuresRes.ok) {
      const fjson = await featuresRes.json().catch(() => null);
      if (fjson && Array.isArray(fjson.features)) featuresList = fjson.features;
    }
  } catch (e) { showToast('cmd-toast', 'Failed to load settings: ' + e.message, false); return; }

  const e = data.engine || {};
  const c = data.claude || {};
  const agents = data.agents || {};

  // Per-agent override placeholders surface the inherited fleet defaults as
  // muted text — operators see exactly what each agent will resolve to without
  // chasing config files. Empty input clears the override → re-inherit fleet.
  const fleetCliLabel = e.defaultCli || 'copilot';
  const fleetModelLabel = e.defaultModel ? String(e.defaultModel) : 'CLI default';
  const agentRows = Object.entries(agents).map(function([id, a]) {
    return '<tr>' +
      '<td style="font-weight:600">' + escHtml(a.emoji || '') + ' ' + escHtml(a.name || id) + '</td>' +
      '<td><input data-agent="' + escHtml(id) + '" data-field="role" value="' + escHtml(a.role || '') + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)"></td>' +
      '<td><input data-agent="' + escHtml(id) + '" data-field="skills" value="' + escHtml((a.skills || []).join(', ')) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)"></td>' +
      '<td data-runtime-cli="' + escHtml(id) + '" style="min-width:110px">' +
        // Initial loading placeholder — initRuntimeFleetUI() replaces this with a
        // <select> populated from /api/runtimes once the registry resolves.
        '<input value="' + escHtml(a.cli || '') + '" placeholder="' + escHtml(fleetCliLabel) + ' (fleet)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-base)">' +
      '</td>' +
      '<td data-runtime-model="' + escHtml(id) + '" style="min-width:140px">' +
        // Loading placeholder — initRuntimeFleetUI() replaces this with a
        // <select> populated from /api/runtimes/<resolved-cli>/models.
        '<input value="' + escHtml(a.model || '') + '" placeholder="' + escHtml(fleetModelLabel) + ' (fleet)" disabled style="width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-base)">' +
      '</td>' +
      '<td><input data-agent="' + escHtml(id) + '" data-field="monthlyBudgetUsd" value="' + escHtml(a.monthlyBudgetUsd != null ? String(a.monthlyBudgetUsd) : '') + '" placeholder="unlimited" style="width:70px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base);text-align:right"></td>' +
    '</tr>';
  }).join('');

  // ── Section bodies — every original control is grouped under one of these
  // tabs. Top of the rail is Runtime + Auto-fix (Caleb's #1 feedback was that
  // these were buried under generic Engine headers). The disclosure formerly
  // wrapping the 8 advanced runtime toggles was split into Copilot Tuning and
  // Claude Tuning tabs so each runtime's knobs live behind its own rail entry.
  const paneRuntime =
    '<h3>Runtime &amp; Models</h3>' +
    '<div class="settings-pane-sub">Single source of truth for which CLI runtime + model the fleet spawns. Per-agent overrides live in the Agents table below.</div>' +
    '<div id="set-runtime-section" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:16px">' +
      '<div style="display:grid;grid-template-columns:1fr 2fr;gap:8px;margin-bottom:8px">' +
        '<div data-search="default cli fleet runtime">' +
          '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Default CLI</label>' +
          '<select id="set-defaultCli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
            '<option value="">Loading…</option>' +
          '</select>' +
          '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Fleet-wide runtime — registered adapters from <code>/api/runtimes</code></div>' +
        '</div>' +
        '<div data-search="default model fleet">' +
          '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Default Model</label>' +
          '<div id="set-defaultModel-wrap"><input id="set-defaultModel" value="' + escHtml(e.defaultModel || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"></div>' +
          '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = let the runtime pick its own default</div>' +
        '</div>' +
      '</div>' +
      // CC overrides — collapsed by default
      '<details id="set-cc-overrides-details"' + ((e.ccCli || e.ccModel) ? ' open' : '') + ' style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px">' +
        '<summary style="cursor:pointer;font-size:var(--text-base);color:var(--text);user-select:none">Customize CC separately ' +
          '<span style="font-size:var(--text-xs);color:var(--muted)">(Command Center + doc-chat use the fleet defaults unless overridden)</span>' +
        '</summary>' +
        '<div style="display:grid;grid-template-columns:1fr 2fr 1fr;gap:8px;margin-top:8px">' +
          '<div data-search="cc cli command center runtime">' +
            '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">CC CLI</label>' +
            '<select id="set-ccCli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
              '<option value="">Loading…</option>' +
            '</select>' +
            '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = inherit Default CLI</div>' +
          '</div>' +
          '<div data-search="cc model command center">' +
            '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">CC Model</label>' +
            // Wrap the input in a dedicated container so loadModelsForRuntime
            // (which does `wrap.innerHTML = …` on the input's parent) only
            // swaps THIS element. Without the wrap it would wipe the label
            // and the hint below, breaking vertical alignment with the
            // sibling CC CLI / Effort columns.
            '<div id="set-ccModel-wrap"><input id="set-ccModel" value="' + escHtml(e.ccModel || '') + '" placeholder="(inherits Default Model)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"></div>' +
            '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = inherit Default Model</div>' +
          '</div>' +
          '<div data-search="cc effort reasoning">' +
            '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Effort</label>' +
            '<select id="set-ccEffort" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
              '<option value=""' + (!e.ccEffort ? ' selected' : '') + '>Default</option>' +
              '<option value="low"' + (e.ccEffort === 'low' ? ' selected' : '') + '>Low</option>' +
              '<option value="medium"' + (e.ccEffort === 'medium' ? ' selected' : '') + '>Medium</option>' +
              '<option value="high"' + (e.ccEffort === 'high' ? ' selected' : '') + '>High</option>' +
            '</select>' +
            '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">CC reasoning depth</div>' +
          '</div>' +
        '</div>' +
        // W-mpmwxni2000c25c7-d — per-turn watchdog. Surfaced under CC overrides
        // because it gates CC/doc-chat error visibility (not the agent fleet).
        '<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:8px">' +
          settingsField('CC Turn Timeout', 'set-ccTurnTimeoutMs', e.ccTurnTimeoutMs || 300000, 'ms', 'Per-turn no-progress watchdog for CC + doc-chat. Resets on every liveness signal (token chunk, tool call, tool update) so an actively-streaming turn — long shell command, deep search, sub-agent loop — survives indefinitely up to the outer 1h hard ceiling. Only true silence past this window fires event: error with code: cc-turn-timeout, stops the spinner, and offers Retry. Clamped to 10000–3600000 ms.') +
        '</div>' +
      '</details>' +
    '</div>' +
    '<h4>Agents</h4>' +
    '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:6px">CLI / Model placeholders show the fleet default each agent will inherit. Pick a value to pin per-agent; clear to re-inherit. Per-agent monthly budget overrides the fleet ceiling.</div>' +
    '<table style="width:100%;border-collapse:collapse;margin-bottom:16px;font-size:var(--text-base)">' +
      '<tr style="text-align:left;color:var(--muted)"><th style="padding:4px">Agent</th><th style="padding:4px">Role</th><th style="padding:4px">Skills</th><th style="padding:4px">CLI</th><th style="padding:4px">Model</th><th style="padding:4px">Budget $/mo</th></tr>' +
      agentRows +
    '</table>';

  const paneAutoFix =
    '<h3>Auto-fix &amp; Review Loop</h3>' +
    '<div class="settings-pane-sub">Dispatch gates that decide when an agent is auto-spawned in response to PR build failures, merge conflicts, review verdicts, and human comments. All require the matching provider polling toggle (Polling tab).</div>' +
    '<div class="settings-stack" style="margin-bottom:16px">' +
      settingsToggle('Auto-fix Builds', 'set-autoFixBuilds', e.autoFixBuilds !== false, 'Shared dispatch gate: auto-fix agent when a PR build fails; also requires that PR provider polling is enabled') +
      settingsToggle('Auto-fix Conflicts', 'set-autoFixConflicts', e.autoFixConflicts !== false, 'Shared dispatch gate: auto-fix agent when a PR merge conflict is detected; also requires that PR provider polling is enabled') +
      settingsToggle('Auto-review PRs', 'set-autoReviewPrs', e.autoReviewPrs !== false, 'Shared dispatch gate: review agent for newly opened agent PRs; also requires that PR provider polling is enabled') +
      settingsToggle('Auto-re-review PRs', 'set-autoReReviewPrs', e.autoReReviewPrs !== false, 'Shared dispatch gate: review agent after a fix push is awaiting re-review; also requires that PR provider polling is enabled') +
      settingsToggle('Auto-fix Review Feedback', 'set-autoFixReviewFeedback', e.autoFixReviewFeedback !== false, 'Shared dispatch gate: fix agent for minions changes-requested verdicts; also requires that PR provider polling is enabled') +
      settingsToggle('Auto-fix Human Comments', 'set-autoFixHumanComments', e.autoFixHumanComments !== false, 'Shared dispatch gate: fix agent for actionable human PR comments; also requires that PR provider polling is enabled') +
      settingsToggle('Auto-apply review vote to PR', 'set-autoApplyReviewVote', !!e.autoApplyReviewVote, 'When ON, Minions review verdicts (APPROVE / REQUEST_CHANGES) automatically flip the platform vote on ADO/GitHub. When OFF (default), verdicts are informational only and the human casts the final vote.') +
      settingsToggle('Eval Loop', 'set-evalLoop', e.evalLoop !== false, 'Auto-review implementations and iterate fix cycles until pass') +
      settingsToggle('Auto-decompose', 'set-autoDecompose', e.autoDecompose !== false, 'Large implement items are auto-split into sub-tasks') +
      settingsToggle('Auto-complete PRs', 'set-autoCompletePrs', !!e.autoCompletePrs, 'Auto-merge PRs when builds pass and review is approved (opt-in)') +
      settingsToggle('Auto-approve Plans', 'set-autoApprovePlans', !!e.autoApprovePlans, 'PRDs are approved automatically without human review') +
      settingsToggle('Auto-archive Plans', 'set-autoArchive', !!e.autoArchive, 'Automatically archive plans after verify completes (off = manual archive via dashboard)') +
      settingsToggle('Auto-consolidate Memory', 'set-autoConsolidateMemory', !!e.autoConsolidateMemory, 'Periodically spawn the KB sweep (dedup + compress + normalize knowledge/) from the engine tick on a 4h cadence. Inbox→notes consolidation already runs every tick (gated by the Consolidation Threshold above); this toggle controls only the KB sweep that was previously dashboard-button-only.') +
    '</div>' +
    '<div class="settings-grid-2">' +
      settingsField('Eval Max Cost', 'set-evalMaxCost', e.evalMaxCost === null || e.evalMaxCost === undefined ? '' : e.evalMaxCost, '$', 'USD ceiling per work item across all eval iterations (blank = no limit)') +
    '</div>';

  // W-mpmwxkrw000872ec — Appearance pane. Hosts dashboard-wide visual
  // preferences (font-size scale today; reserved for future theme picks).
  // Kept narrow + featured near the rail top so the setting is discoverable
  // rather than buried under generic Engine headers.
  const paneAppearance =
    '<h3>Appearance</h3>' +
    '<div class="settings-pane-sub">Dashboard-wide visual preferences. Persisted in browser localStorage + server-side settings so they survive a reload from a cold cache.</div>' +
    '<div class="settings-grid-2">' +
      '<div data-search="font size scale appearance dashboard">' +
        '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Font Size</label>' +
        '<select id="set-fontSize" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
          (function() {
            var current = (e.fontSize || 'small');
            var opts = [['small', 'Small (current default)'], ['medium', 'Medium'], ['large', 'Large'], ['xlarge', 'Extra Large']];
            return opts.map(function(o) {
              return '<option value="' + o[0] + '"' + (current === o[0] ? ' selected' : '') + '>' + o[1] + '</option>';
            }).join('');
          })() +
        '</select>' +
        '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Scales the whole dashboard. Persisted in browser + server.</div>' +
      '</div>' +
    '</div>';

  const projectsHtml = (data.projects || []).map(function(p) {
    // Cross-reference live /api/status to pull in the auto-detected
    // remoteDefaultBranch (read-only) — settings already has localPath +
    // mainBranch from /api/settings, but origin/HEAD is probe-derived and
    // lives in /api/status only. W-mpg3whgp000d09ec / #2732.
    var liveProj = null;
    try {
      if (window._lastStatus && Array.isArray(window._lastStatus.projects)) {
        liveProj = window._lastStatus.projects.find(function(x) { return x.name === p.name; }) || null;
      }
    } catch (e) { liveProj = null; }
    var remoteDefault = (liveProj && liveProj.remoteDefaultBranch) || '';
    var mismatch = !!(liveProj && liveProj.branchMismatch);
    var localBranch = (liveProj && liveProj.gitBranch) || '';
    var driftNote = mismatch
      ? '<div style="font-size:var(--text-sm);color:var(--yellow);margin-top:4px">⚠ Configured main (<code>' + escHtml(p.mainBranch || '') + '</code>) differs from origin/HEAD (<code>' + escHtml(remoteDefault) + '</code>) — config is likely stale.</div>'
      : '';
    var pathRow = p.localPath
      ? '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">' + escHtml(p.localPath) + '</div>'
      : '';
    var branchGrid = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">' +
      settingsField('Configured main branch', 'set-mainBranch-' + p.name, p.mainBranch || '', '', 'Used by branch-naming + dependency-merge to identify mainline. Empty = auto-detect from origin/HEAD.') +
      '<div>' +
        '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Remote default <span style="opacity:0.6">(read-only)</span></label>' +
        '<input id="set-remoteDefaultBranch-' + p.name + '" value="' + escHtml(remoteDefault || '(unset)') + '" readonly style="width:100%;padding:4px 6px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-md);font-family:ui-monospace,SFMono-Regular,Menlo,monospace">' +
        '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Parsed from <code>git symbolic-ref refs/remotes/origin/HEAD</code>' + (localBranch ? '. Local HEAD: <code>' + escHtml(localBranch) + '</code>' : '') + '.</div>' +
      '</div>' +
    '</div>';
    // P-a3f9b207 — per-project worktreeMode dropdown + warning chip. Default
    // 'isolated' (engine creates a dedicated worktree per dispatch); 'live'
    // runs the agent directly in p.localPath for repos where worktrees are
    // unworkable. Chip is hidden by default and toggled reactively below.
    var currentWtMode = (p.worktreeMode === 'live') ? 'live' : 'isolated';
    var wtModeSearch = 'worktree mode isolated live checkout dispatch';
    var worktreeModeBlock =
      '<div data-search="' + escHtml(wtModeSearch) + '" style="margin-bottom:6px">' +
        '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Worktree mode</label>' +
        '<select id="set-worktreeMode-' + escHtml(p.name) + '" data-worktree-mode-select="' + escHtml(p.name) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
          '<option value="isolated"' + (currentWtMode === 'isolated' ? ' selected' : '') + '>Isolated (default)</option>' +
          '<option value="live"' + (currentWtMode === 'live' ? ' selected' : '') + '>Live checkout</option>' +
        '</select>' +
        '<div data-worktree-mode-chip="' + escHtml(p.name) + '" style="' + (currentWtMode === 'live' ? '' : 'display:none;') + 'margin-top:6px;padding:6px 8px;background:rgba(234,179,8,0.12);border:1px solid var(--yellow);border-radius:4px;color:var(--yellow);font-size:var(--text-xs);line-height:1.4">' +
          '⚠ Live mode: dispatches run directly in this repo\'s checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.' +
        '</div>' +
      '</div>';
    return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
      '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
        '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
        '<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:var(--text-xs);padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
      '</div>' +
      pathRow +
      branchGrid +
      worktreeModeBlock +
      driftNote +
      '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
      settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
      settingsToggle('Discover from Work Items', 'set-ws-wi-' + p.name, p.workSources.workItems.enabled, 'Auto-discover work from ADO/GitHub work items') +
      '</div></div>';
  }).join('');

  const paneProjects =
    '<h3>Projects</h3>' +
    '<div class="settings-pane-sub">Repositories linked to this Minions install. Each project controls its own discovery sources + mainline branch. Add new projects via the Projects bar at the top of the dashboard or <code>minions add &lt;path&gt;</code>.</div>' +
    (projectsHtml || '<div class="settings-pane-empty">No projects linked yet.</div>');

  const panePolling =
    '<h3>Polling</h3>' +
    '<div class="settings-pane-sub">Cadence for fetching PR build status, votes, and comments from the platforms. Disabling a provider here turns the matching Auto-fix gates into no-ops.</div>' +
    '<div class="settings-stack" style="margin-bottom:12px">' +
      settingsToggle('ADO Polling', 'set-adoPollEnabled', e.adoPollEnabled !== false, 'Keeps ADO PR build results, votes, and comments fresh each tick; ADO PR dispatch gates are inert when this is off') +
      settingsToggle('GitHub Polling', 'set-ghPollEnabled', e.ghPollEnabled !== false, 'Keeps GitHub PR build results, votes, and comments fresh each tick; GitHub PR dispatch gates are inert when this is off') +
    '</div>' +
    '<div class="settings-grid-2">' +
      settingsField('PR Status Poll Frequency', 'set-prPollStatusEvery', e.prPollStatusEvery ?? 12, 'ticks', 'Poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default tick rate)') +
      settingsField('PR Comments Poll Frequency', 'set-prPollCommentsEvery', e.prPollCommentsEvery ?? 12, 'ticks', 'Poll PR human comments every N ticks for both ADO and GitHub (~12 min at default tick rate)') +
    '</div>';

  const paneConcurrency =
    '<h3>Concurrency &amp; Timeouts</h3>' +
    '<div class="settings-pane-sub">How many agents run at once and how long they get before being killed for silence or runaway duration.</div>' +
    '<div class="settings-grid-2">' +
      settingsField('Max Concurrent Agents', 'set-maxConcurrent', e.maxConcurrent || 5, '', 'Max agents working simultaneously') +
      settingsField('Min Tick Interval', 'set-tickInterval', e.tickInterval || 10000, 'ms', 'Minimum gap between tick starts. Slow ticks (PR polls, reconcilers) may delay the next slot — see `running` indicator on /engine.') +
      settingsField('Agent Timeout', 'set-agentTimeout', e.agentTimeout || 18000000, 'ms', 'Kill agent after this duration') +
      settingsField('Heartbeat Timeout', 'set-heartbeatTimeout', e.heartbeatTimeout || 300000, 'ms', 'No output = dead after this') +
      settingsField('Idle Alert', 'set-idleAlertMinutes', e.idleAlertMinutes || 15, 'min', 'Alert after agent idle this long') +
      settingsField('Agent Busy Reassign', 'set-agentBusyReassignMs', e.agentBusyReassignMs || 600000, 'ms', 'Reassign work to another agent after it waits this long on a busy agent') +
      settingsField('Max Retries Per Agent', 'set-maxRetriesPerAgent', e.maxRetriesPerAgent ?? 2, '', 'After the same agent fails the same work item this many times, the next retry reassigns to a different eligible agent (consults routing.md + availability). Falls back to the same agent only when no alternate is available. Counted separately from total maxRetries (which still caps overall retries).') +
      settingsField('Restart Grace Period', 'set-restartGracePeriod', e.restartGracePeriod || 1200000, 'ms', 'Grace period before orphan detection on restart') +
      settingsField('Shutdown Timeout', 'set-shutdownTimeout', e.shutdownTimeout || 300000, 'ms', 'Max wait for agents during graceful shutdown') +
      settingsField('Meeting Round Timeout', 'set-meetingRoundTimeout', e.meetingRoundTimeout || 900000, 'ms', 'Auto-advance meeting round after this') +
      settingsField('Steering Deferred Max', 'set-steeringDeferredMaxMs', e.steeringDeferredMaxMs || 900000, 'ms', 'Max wait for a runtime to emit a resumable checkpoint before a deferred steering message is flagged stranded. After this, the engine warns to live-output, marks _steeringStranded on the dispatch, and (when the steering store is present) sets store status=stranded. Default 15min; range 60s–4h.') +
      settingsField('Steering Max Kill Retries', 'set-steeringMaxKillRetries', e.steeringMaxKillRetries ?? 3, '', 'Cap on graceful+escalation kill attempts after a steering kill is issued. Ladder waits 30s → 60s → 120s between attempts (last interval reused). Attempt 1 is graceful; attempts 2..cap are platform hard kills (taskkill /F /T on Windows; descendant-tree SIGKILL + pkill on Unix). Past cap, the engine gives up with a [steering-stuck] log + inbox notice. Default 3; range 1–5.') +
    '</div>' +
    '<h4 style="margin:14px 0 4px;font-size:var(--text-md)">Spawn-phase watchdog</h4>' +
    '<div class="settings-pane-sub">Kills agent processes that ran startup-only events (MCP init, hook fires) but never emitted real task progress, while CPU usage stayed below the threshold. Defense against MCP-init wedges that otherwise burn a worker slot until the 5h hard timeout. Fires only on fresh spawns (not engine-restart re-attaches).</div>' +
    '<div class="settings-stack" style="margin-bottom:8px">' +
      settingsToggle('Spawn-phase Watchdog', 'set-spawnPhaseWatchdogEnabled', e.spawnPhaseWatchdogEnabled !== false, 'Enable the spawn-phase MCP-init wedge detector. Default ON. Turn OFF only if you suspect false positives against a runtime whose legitimate startup exceeds the grace window.') +
    '</div>' +
    '<div class="settings-grid-2">' +
      settingsField('Spawn-phase Grace', 'set-spawnPhaseGraceMs', e.spawnPhaseGraceMs || 120000, 'ms', 'Time after spawn before the watchdog can fire. Default 2min. A healthy startup ships a real event in seconds; this is generous headroom for slow MCP servers and corporate DNS.') +
      settingsField('Spawn-phase CPU floor', 'set-spawnPhaseMaxCpuSeconds', e.spawnPhaseMaxCpuSeconds ?? 5, 's', 'Process is "idle" if cumulative CPU seconds ≤ this. Default 5s. A genuinely busy startup burns CPU on tokenizer bootstrap, MCP IPC, or model warmup; a wedged child sits in epoll near 0s.') +
    '</div>';

  const paneWorktree =
    '<h3>Worker Pool &amp; Worktrees</h3>' +
    '<div class="settings-pane-sub">Persistent CC worker pool and git worktree provisioning. Tune these only if you see worktree-create timeouts or slow CC cold-spawns.</div>' +
    '<div class="settings-stack" style="margin-bottom:12px">' +
      settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', (e.ccUseWorkerPool === undefined ? ((e.ccCli || e.defaultCli) === 'copilot') : !!e.ccUseWorkerPool), 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn. Copilot-only (Agent Client Protocol transport); Claude does not implement ACP, so this toggle has no effect when CC runtime is Claude. Default ON for copilot (cold-spawn ~20s on Windows); forced OFF for non-copilot CC runtimes regardless of this toggle.') +
      // W-mq6f2fe0000557fa — opt-in: auto-kill orphan spawn-agent processes
      // holding a stuck worktree's cwd. Gated by safe-reap criteria (cmdline
      // matches spawn-agent.js + basename + age > agentTimeout * 2). Default
      // OFF because killing a foreign process is destructive.
      settingsToggle('Auto-reap orphan worktree holders', 'set-autoReapOrphanWorktreeHolders', !!e.autoReapOrphanWorktreeHolders,
        'When ON, the periodic orphan-worktree sweep automatically kills unambiguous orphan `spawn-agent.js` processes holding a stuck worktree\'s cwd (Windows file-lock pattern, e.g. W-mq1k8z6o003acd89 / PID 16828). Safe-reap criteria: cmdline must match spawn-agent.js, reference the worktree basename, AND process age must exceed `engine.agentTimeout * 2`. Default OFF because killing a foreign process is destructive. The holder SCAN runs either way and is appended to the escalation inbox note.') +
    '</div>' +
    '<div class="settings-grid-2">' +
      settingsField('Worktree Create Timeout', 'set-worktreeCreateTimeout', e.worktreeCreateTimeout || 300000, 'ms', 'Timeout for git worktree add (increase for large repos/Windows)') +
      settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
      settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
      settingsField('Assert-Clean Status Probe Timeout', 'set-assertCleanStatusTimeoutMs', e.assertCleanStatusTimeoutMs || 10000, 'ms', 'Timeout for the `git status --porcelain` preflight probe in assertCleanSharedWorktree. Raise this (e.g. 60000) on GVFS/Plastic-backed monorepos where sparse hydration runs longer than the 10s default. On timeout the engine quarantines the bad worktree and emits a RETRYABLE failure so the next dispatch starts fresh. Clamped 1000–120000ms.') +
      settingsField('Orphan-holder scan timeout', 'set-orphanHolderScanTimeoutMs', e.orphanHolderScanTimeoutMs || 5000, 'ms', 'Cap on the cross-platform scan (PowerShell / /proc walk / lsof) that identifies the OS process holding a stuck worktree\'s cwd. Bumps up only matter on heavily-loaded hosts where the holder scan races the sweep tick. Clamped 1000–30000ms.') +
    '</div>';

  const paneCopilot =
    '<h3>Copilot Tuning</h3>' +
    '<div class="settings-pane-sub">Knobs that only apply when the runtime is Copilot. Includes the split-brain warning for the built-in MCPs toggle.</div>' +
    '<div class="settings-stack" style="margin-bottom:12px">' +
      // Tooltip on copilotDisableBuiltinMcps MUST warn about the split-brain risk
      settingsToggle('Copilot: disable built-in MCPs', 'set-copilotDisableBuiltinMcps', e.copilotDisableBuiltinMcps !== false,
        '⚠ When OFF, Copilot agents can autonomously create PRs/labels/comments via the github-mcp-server, bypassing pull-requests.json tracking — Minions and Copilot end up with split views of the same PR. Keep ON unless you understand the risk.') +
      settingsToggle('Copilot: suppress AGENTS.md', 'set-copilotSuppressAgentsMd', e.copilotSuppressAgentsMd !== false, '--no-custom-instructions: stops AGENTS.md auto-load from fighting Minions playbook prompts') +
      settingsToggle('Copilot: reasoning summaries', 'set-copilotReasoningSummaries', !!e.copilotReasoningSummaries, '--enable-reasoning-summaries (Anthropic-family models only)') +
    '</div>' +
    '<div class="settings-grid-2">' +
      '<div data-search="copilot stream mode incremental batched">' +
        '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Copilot stream</label>' +
        '<select id="set-copilotStreamMode" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
          '<option value="on"' + ((e.copilotStreamMode || 'on') === 'on' ? ' selected' : '') + '>on (incremental)</option>' +
          '<option value="off"' + (e.copilotStreamMode === 'off' ? ' selected' : '') + '>off (batched)</option>' +
        '</select>' +
      '</div>' +
      settingsField('Copilot fallback model', 'set-copilotFallbackModel', e.copilotFallbackModel || '', 'e.g. gpt-5.4', 'Copilot has no --fallback-model flag. On a MODEL_UNAVAILABLE (overloaded/503) retry, the engine OVERRIDES --model with this value (Copilot only).') +
    '</div>';

  const paneClaude =
    '<h3>Claude Tuning</h3>' +
    '<div class="settings-pane-sub">Knobs that only apply when the runtime is Claude. Permission bypass is runtime-owned and not configurable here.</div>' +
    '<div class="settings-stack" style="margin-bottom:12px">' +
      settingsToggle('Claude bare mode', 'set-claudeBareMode', !!e.claudeBareMode, '--bare suppresses CLAUDE.md auto-discovery; pair with explicit ccSystemPrompt or context will be lost') +
    '</div>' +
    '<div class="settings-grid-2">' +
      settingsField('Claude fallback model', 'set-claudeFallbackModel', e.claudeFallbackModel || '', 'e.g. sonnet', 'Passed via Claude --fallback-model. The Claude CLI swaps to this only on rate-limit (429). Used together with engine.copilotFallbackModel for the engine-level MODEL_UNAVAILABLE (overloaded/503) retry — see CLAUDE.md.') +
    '</div>' +
    '<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:12px">' +
      settingsField('Allowed Tools', 'set-allowedTools', c.allowedTools || '', '', 'Claude allow-list passed through for compatibility; runtime bypass flags are adapter-owned.') +
    '</div>' +
    '<div data-search="permission bypass dangerously-skip-permissions autopilot allow-all" style="font-size:var(--text-sm);color:var(--muted);margin-top:12px;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface-subtle, rgba(130,160,210,0.08))">' +
      'Permission bypass is runtime-owned: Claude agents use <code>--dangerously-skip-permissions</code>; Copilot agents use <code>--autopilot --allow-all --no-ask-user</code>. There is no dashboard permission-mode setting.' +
    '</div>';

  const paneBudget =
    '<h3>Budget</h3>' +
    '<div class="settings-pane-sub">Fleet-wide spend ceiling. Per-agent monthly caps live in the Agents table on the Runtime &amp; Models tab.</div>' +
    '<div class="settings-grid-2">' +
      settingsField('Max budget (USD)', 'set-maxBudgetUsd', e.maxBudgetUsd != null ? String(e.maxBudgetUsd) : '', '', 'Fleet ceiling for --max-budget-usd. 0 is a valid cap (read-only / dry-run). Empty = no cap. Claude only.') +
    '</div>';

  const paneMaxTurns =
    '<h3>Max Turns by Task Type</h3>' +
    '<div class="settings-pane-sub">How many tool-use turns each task type gets before forced stop. Blank = built-in default. The fleet-wide Max Turns acts as a fallback for any task type not listed.</div>' +
    '<div class="settings-grid-2" style="margin-bottom:12px">' +
      settingsField('Max Turns (fleet default)', 'set-maxTurns', e.maxTurns || 100, '', 'Claude CLI --max-turns per agent') +
    '</div>' +
    '<div class="settings-grid-3">' +
      settingsField('Explore', 'set-mt-explore', (e.maxTurnsByType || {}).explore || '', '', 'Default: 30') +
      settingsField('Ask', 'set-mt-ask', (e.maxTurnsByType || {}).ask || '', '', 'Default: 20') +
      settingsField('Review', 'set-mt-review', (e.maxTurnsByType || {}).review || '', '', 'Default: 30') +
      settingsField('Implement', 'set-mt-implement', (e.maxTurnsByType || {}).implement || '', '', 'Default: 75') +
      settingsField('Fix', 'set-mt-fix', (e.maxTurnsByType || {}).fix || '', '', 'Default: 75') +
      settingsField('Test', 'set-mt-test', (e.maxTurnsByType || {}).test || '', '', 'Default: 50') +
      settingsField('Verify', 'set-mt-verify', (e.maxTurnsByType || {}).verify || '', '', 'Default: 100') +
      settingsField('Plan', 'set-mt-plan', (e.maxTurnsByType || {}).plan || '', '', 'Default: 30') +
      settingsField('Decompose', 'set-mt-decompose', (e.maxTurnsByType || {}).decompose || '', '', 'Default: 15') +
    '</div>';

  // Toggles persist immediately via POST /api/features/toggle — no Save needed.
  const paneFeatures =
    '<h3>Feature Flags</h3>' +
    '<div class="settings-pane-sub">In-progress UX or behavior gates. Toggles persist immediately. Registry: <code>engine/features.js</code>. Env override: <code>MINIONS_FEATURE_&lt;NAME&gt;=1</code>.</div>' +
    '<details id="settings-features-details" open style="border:1px solid var(--border);border-radius:6px;padding:12px">' +
      '<summary style="cursor:pointer;font-size:var(--text-md);color:var(--text);user-select:none;margin-bottom:8px">Show experimental flags ' +
        '<span style="font-size:var(--text-sm);color:var(--muted)">(' + featuresList.length + ' registered)</span>' +
      '</summary>' +
      (featuresList.length === 0
        ? '<div style="font-size:var(--text-base);color:var(--muted);padding:12px;border:1px dashed var(--border);border-radius:4px;text-align:center">No experimental features registered. Add entries to <code>engine/features.js</code> to gate new work.</div>'
        : '<div style="display:flex;flex-direction:column;gap:8px">' +
          featuresList.map(function(f) {
            const checked = f.enabled ? ' checked' : '';
            const expiredBadge = f.expired
              ? ' <span style="font-size:var(--text-xs);padding:1px 5px;background:rgba(220,80,80,0.15);color:var(--red);border-radius:3px;margin-left:4px">EXPIRED</span>'
              : '';
            const meta = [];
            if (f.addedIn) meta.push('added in ' + escHtml(f.addedIn));
            if (f.expires) meta.push('expires ' + escHtml(f.expires));
            meta.push(f.default ? 'default: on' : 'default: off');
            const searchText = (f.id + ' ' + (f.description || '')).toLowerCase().replace(/"/g, '');
            return '<label data-feature-id="' + escHtml(f.id) + '" data-search="' + escHtml(searchText) + '" style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid var(--border);border-radius:4px;cursor:pointer">' +
              '<input type="checkbox" data-feature-toggle="' + escHtml(f.id) + '"' + checked + ' style="margin-top:3px;cursor:pointer">' +
              '<div style="flex:1">' +
                '<div style="font-size:var(--text-base);font-weight:600;color:var(--text)">' + escHtml(f.id) + expiredBadge + '</div>' +
                (f.description ? '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:2px">' + escHtml(f.description) + '</div>' : '') +
                '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:3px">' + meta.join(' · ') + '</div>' +
              '</div>' +
            '</label>';
          }).join('') +
        '</div>') +
    '</details>';

  const paneAdvanced =
    '<h3>Advanced</h3>' +
    '<div class="settings-pane-sub">Routing table + low-level engine knobs that most operators never touch. Change with care.</div>' +
    '<h4>Routing Table</h4>' +
    '<div data-search="routing table playbook agent assignment" style="margin-bottom:16px">' +
      '<textarea id="set-routing" rows="10" style="width:100%;padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:monospace;font-size:var(--text-base);resize:vertical">' + escHtml(data.routing || '') + '</textarea>' +
    '</div>' +
    '<h4>Inbox</h4>' +
    '<div class="settings-grid-2">' +
      settingsField('Consolidation Threshold', 'set-inboxConsolidateThreshold', e.inboxConsolidateThreshold || 5, 'notes', 'Inbox notes before auto-consolidation') +
      settingsField('Version Check Interval', 'set-versionCheckInterval', e.versionCheckInterval || 3600000, 'ms', 'How often to check npm for updates (default: 1 hour)') +
    '</div>' +
    '<h4>Operator &amp; Comments</h4>' +
    '<div class="settings-grid-2">' +
      settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
      settingsField('Ignored Comment Authors', 'set-ignoredCommentAuthors', (e.ignoredCommentAuthors || []).join(', '), '', 'Comma-separated usernames — comments auto-closed, never trigger fixes') +
    '</div>' +
    '<h4>Experimental Behavior</h4>' +
    '<div class="settings-stack">' +
      settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
      settingsToggle('Disable model discovery', 'set-disableModelDiscovery', !!e.disableModelDiscovery, 'Skip /api/runtimes/<name>/models REST calls fleet-wide. Settings UI falls back to free-text.') +
      settingsToggle('QA: dual-write JSON sidecars', 'set-qaDualWriteJson', e.qaDualWriteJson !== false, 'Keep mirroring qa-runs.json and qa-sessions.json on every mutation alongside the SQLite source of truth. Default ON. Disable only once external tooling has been migrated off the JSON files — disabling drops the rollback path back to the JSON store.') +
    '</div>';

  // Section registry — order is intentional (Runtime + Auto-fix surface first
  // per Caleb's feedback). Each entry maps a rail-button id → pane HTML.
  const sections = [
    { id: 'runtime', label: 'Runtime & Models', featured: true, html: paneRuntime },
    { id: 'autofix', label: 'Auto-fix & Review Loop', featured: true, html: paneAutoFix },
    { id: 'appearance', label: 'Appearance', html: paneAppearance },
    { id: 'projects', label: 'Projects', html: paneProjects },
    { id: 'polling', label: 'Polling', html: panePolling },
    { id: 'concurrency', label: 'Concurrency & Timeouts', html: paneConcurrency },
    { id: 'worktree', label: 'Worker Pool & Worktrees', html: paneWorktree },
    { id: 'copilot', label: 'Copilot Tuning', html: paneCopilot },
    { id: 'claude', label: 'Claude Tuning', html: paneClaude },
    { id: 'budget', label: 'Budget', html: paneBudget },
    { id: 'maxturns', label: 'Max Turns', html: paneMaxTurns },
    { id: 'features', label: 'Feature Flags', html: paneFeatures },
    { id: 'advanced', label: 'Advanced', html: paneAdvanced },
  ];

  // localStorage-persisted active tab. Falls back to the first section when no
  // saved value or the saved value points at a section we no longer render.
  const SAVED_TAB_KEY = 'minions.settings.activeTab';
  let activeTab = sections[0].id;
  try {
    const saved = localStorage.getItem(SAVED_TAB_KEY);
    if (saved && sections.some(s => s.id === saved)) activeTab = saved;
  } catch { /* localStorage may be blocked in some embeds */ }

  const railHtml = sections.map(s =>
    '<button class="settings-rail-btn' + (s.featured ? ' featured' : '') + (s.id === activeTab ? ' active' : '') + '" data-settings-tab="' + s.id + '" type="button">' +
      '<span class="rail-label">' + escHtml(s.label) + '</span>' +
      '<span class="match-count" style="display:none">0</span>' +
    '</button>'
  ).join('');

  const panesHtml = sections.map(s =>
    '<div class="settings-pane' + (s.id === activeTab ? ' active' : '') + '" data-settings-pane="' + s.id + '">' +
      s.html +
    '</div>'
  ).join('');

  const html =
    '<div class="settings-layout">' +
      '<aside class="settings-rail">' +
        '<div class="settings-search-wrap">' +
          '<input id="settings-search" class="settings-search" type="text" placeholder="Search settings…" autocomplete="off">' +
        '</div>' +
        '<nav class="settings-rail-nav" id="settings-rail-nav">' + railHtml + '</nav>' +
      '</aside>' +
      '<div class="settings-content" id="settings-content">' +
        '<div id="settings-status" style="font-size:var(--text-base);min-height:16px;margin-bottom:8px"></div>' +
        panesHtml +
      '</div>' +
    '</div>';

  document.getElementById('modal-title').textContent = 'Settings';

  // Add save button to modal header actions (next to copy/close)
  const actions = document.querySelector('.modal-header-actions');
  if (!actions) return;
  if (!document.getElementById('modal-settings-reset')) {
    const resetBtn = document.createElement('button');
    resetBtn.id = 'modal-settings-reset';
    resetBtn.className = 'modal-copy is-danger';
    resetBtn.textContent = 'Reset';
    resetBtn.onclick = resetSettingsToDefaults;
    actions.insertBefore(resetBtn, actions.lastElementChild);
  }
  if (!document.getElementById('modal-settings-save')) {
    const saveBtn = document.createElement('button');
    saveBtn.id = 'modal-settings-save';
    saveBtn.className = 'modal-copy is-success';
    saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Save';
    saveBtn.onclick = saveSettings;
    actions.insertBefore(saveBtn, actions.lastElementChild);
  }
  const body = document.getElementById('modal-body');
  body.classList.add('settings-body');
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: engine, claude, agents, projects, routing, features)
  body.innerHTML = html;
  body.style.fontFamily = '';
  body.style.whiteSpace = '';
  document.getElementById('modal').classList.add('open');

  // ── Wire up rail buttons (tab switching + localStorage persist) ─────────
  body.querySelectorAll('[data-settings-tab]').forEach(function(btn) {
    btn.addEventListener('click', function() {
      const id = btn.getAttribute('data-settings-tab');
      _selectSettingsTab(id);
      try { localStorage.setItem(SAVED_TAB_KEY, id); } catch { /* ignore */ }
    });
  });

  // ── Cross-tab search box ───────────────────────────────────────────────
  // Filters every [data-search] row across every pane. Empty query restores
  // the active-tab view; non-empty query shows ALL panes with matches and
  // hides unmatched rows so operators can see results without tab-hopping.
  const searchInput = document.getElementById('settings-search');
  if (searchInput) {
    searchInput.addEventListener('input', function() {
      _applySettingsSearch(searchInput.value || '');
    });
  }

  // ── Runtime fleet wiring (P-7a5c1f8e) ──────────────────────────────────────
  // 1. Load registered runtimes into the defaultCli + ccCli dropdowns.
  // 2. Load models for the selected defaultCli into the defaultModel input.
  // 3. On defaultCli change → re-fetch models so the input never shows stale list.
  // The same pattern wires ccCli → ccModel; ccCli inherits defaultCli when unset.
  initRuntimeFleetUI(e, agents);

  document.querySelectorAll('input[data-feature-toggle]').forEach(function(input) {
    input.addEventListener('change', async function() {
      const id = input.getAttribute('data-feature-toggle');
      const enabled = input.checked;
      if (window.MinionsFeatures && typeof window.MinionsFeatures._setLocal === 'function') {
        window.MinionsFeatures._setLocal(id, enabled);
      }
      try {
        const r = await fetch('/api/features/toggle', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ id, enabled }),
        });
        if (!r.ok) {
          const j = await r.json().catch(() => ({}));
          throw new Error(j.error || ('HTTP ' + r.status));
        }
        showToast('cmd-toast', 'Feature "' + id + '" ' + (enabled ? 'enabled' : 'disabled'), true);
      } catch (err) {
        input.checked = !enabled;
        if (window.MinionsFeatures && typeof window.MinionsFeatures._setLocal === 'function') {
          window.MinionsFeatures._setLocal(id, !enabled);
        }
        showToast('cmd-toast', 'Failed to toggle "' + id + '": ' + err.message, false);
      }
    });
  });

  // P-a3f9b207 — toggle the live-mode warning chip reactively when the
  // operator flips the per-project worktreeMode dropdown (before save). The
  // chip is rendered once with display:none for isolated projects and
  // visible for already-live projects; this handler only flips the
  // display style — no markup is regenerated.
  document.querySelectorAll('[data-worktree-mode-select]').forEach(function(sel) {
    sel.addEventListener('change', function() {
      const projName = sel.getAttribute('data-worktree-mode-select');
      const chip = document.querySelector('[data-worktree-mode-chip="' + (window.CSS && CSS.escape ? CSS.escape(projName) : projName) + '"]');
      if (!chip) return;
      chip.style.display = (sel.value === 'live') ? '' : 'none';
    });
  });
}

async function initRuntimeFleetUI(engineCfg, agentsCfg) {
  const cliSelect = document.getElementById('set-defaultCli');
  const ccCliSelect = document.getElementById('set-ccCli');
  if (!cliSelect || !ccCliSelect) return;

  // Fetch the registry; render an empty fallback on failure so the rest of the
  // settings panel still works.
  let runtimes = [];
  try {
    const r = await fetch('/api/runtimes');
    const d = await r.json();
    runtimes = Array.isArray(d.runtimes) ? d.runtimes : [];
  } catch { /* ignore — we'll surface a free-text-only path below */ }

  // Always include 'copilot' as a fallback option even if /api/runtimes is empty;
  // legacy installs without the registry endpoint should still see something pickable.
  const names = runtimes.length ? runtimes.map(rt => rt.name) : ['copilot'];
  const currentDefault = engineCfg.defaultCli || 'copilot';
  const currentCc = engineCfg.ccCli || '';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runtime names)
  cliSelect.innerHTML = names.map(n =>
    '<option value="' + escHtml(n) + '"' + (n === currentDefault ? ' selected' : '') + '>' + escHtml(n) + '</option>'
  ).join('');
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runtime names)
  ccCliSelect.innerHTML =
    '<option value=""' + (!currentCc ? ' selected' : '') + '>Inherit Default CLI</option>' +
    names.map(n =>
      '<option value="' + escHtml(n) + '"' + (n === currentCc ? ' selected' : '') + '>' + escHtml(n) + '</option>'
    ).join('');

  // Hydrate per-agent CLI dropdowns now that we know the registered names. The
  // Agents table renders cells with `data-runtime-cli="<id>"` as a hook.
  const cliCells = document.querySelectorAll('[data-runtime-cli]');
  for (const cell of cliCells) {
    const agentId = cell.getAttribute('data-runtime-cli');
    const agent = (agentsCfg || {})[agentId] || {};
    const current = agent.cli || '';
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, runtime names)
    cell.innerHTML =
      '<select data-agent="' + escHtml(agentId) + '" data-field="cli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)">' +
        '<option value=""' + (!current ? ' selected' : '') + '>(fleet default)</option>' +
        names.map(n =>
          '<option value="' + escHtml(n) + '"' + (n === current ? ' selected' : '') + '>' + escHtml(n) + '</option>'
        ).join('') +
      '</select>';
  }
  // Hydrate per-agent model dropdowns. The model list is keyed off the
  // agent's RESOLVED runtime: per-agent override → fleet default. Without
  // this the input was free-text and a user could (and did) save an agent
  // with cli=claude + model=<some gpt> — invalid combination that crashed
  // dispatch. Refreshing on CLI change clears stale model values.
  const fleetDefaultCli = engineCfg.defaultCli || 'copilot';
  for (const cell of cliCells) {
    const agentId = cell.getAttribute('data-runtime-cli');
    const agent = (agentsCfg || {})[agentId] || {};
    const resolvedCli = agent.cli || fleetDefaultCli;
    loadModelsForAgent(agentId, resolvedCli, agent.model || '');
    // CLI dropdown change → refresh that agent's model dropdown to match.
    const sel = cell.querySelector('select[data-field="cli"]');
    if (sel) {
      sel.addEventListener('change', () => {
        const newCli = sel.value || fleetDefaultCli;
        loadModelsForAgent(agentId, newCli, ''); // clear value: previous model may not exist for the new runtime
      });
    }
  }

  // Models load for the resolved default + CC CLIs. ccCli falls back to
  // defaultCli when unset — same rule as resolveCcCli().
  loadModelsForRuntime(cliSelect.value, 'set-defaultModel', engineCfg.defaultModel || '');
  loadModelsForRuntime(currentCc || cliSelect.value, 'set-ccModel', engineCfg.ccModel || '');

  // CLI change → re-fetch models. NEVER carry the previous runtime's list over.
  cliSelect.addEventListener('change', () => {
    loadModelsForRuntime(cliSelect.value, 'set-defaultModel', '');
    if (!ccCliSelect.value) {
      // CC inherits defaultCli — its model list must follow.
      loadModelsForRuntime(cliSelect.value, 'set-ccModel', '');
    }
  });
  ccCliSelect.addEventListener('change', () => {
    const target = ccCliSelect.value || cliSelect.value;
    loadModelsForRuntime(target, 'set-ccModel', '');
  });
}

/**
 * Replace the input/select at `inputId` with a dropdown when the runtime
 * exposes a model list, or a free-text input when `{ models: null }` (e.g.
 * Claude or model-discovery disabled). The "Default (CLI chooses)" option is
 * always present and submits empty string.
 */
async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
  const wrap = document.getElementById(inputId)?.parentElement;
  if (!wrap) return;
  const token = _nextModelLoadToken('runtime', inputId);
  if (!runtimeName) {
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: currentValue; inputId is an internal fixed DOM id)
    wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="(no runtime selected)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-md)">';
    return;
  }
  let payload = { models: null };
  try {
    const res = await fetch('/api/runtimes/' + encodeURIComponent(runtimeName) + '/models');
    if (res.ok) payload = await res.json();
  } catch { /* fall through to free-text */ }

  if (!_isCurrentModelLoad('runtime', inputId, token)) return;
  const models = Array.isArray(payload.models) ? payload.models : null;
  if (!models || models.length === 0) {
    // Free-text fallback — let the user type anything (custom Anthropic /
    // OpenAI model IDs, future models, etc.).
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: currentValue; inputId is an internal fixed DOM id)
    wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">';
    return;
  }
  // Dropdown. The first option submits empty string → "Default (CLI chooses)".
  let opts = '<option value=""' + (!currentValue ? ' selected' : '') + '>Default (CLI chooses)</option>';
  for (const m of models) {
    const id = m.id || m.name || '';
    if (!id) continue;
    const label = m.name && m.name !== id ? (id + ' — ' + m.name) : id;
    opts += '<option value="' + escHtml(id) + '"' + (id === currentValue ? ' selected' : '') + '>' + escHtml(label) + '</option>';
  }
  // If the current value isn't in the model list (custom / older choice),
  // surface it as a selectable option so the user doesn't lose it on next save.
  if (currentValue && !models.some(m => (m.id || m.name) === currentValue)) {
    opts += '<option value="' + escHtml(currentValue) + '" selected>' + escHtml(currentValue) + ' (custom)</option>';
  }
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: model id, model label, currentValue; inputId is an internal fixed DOM id)
  wrap.innerHTML = '<select id="' + inputId + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' + opts + '</select>';
}

/**
 * Per-agent model hydrator. Replaces the placeholder input in the cell
 * `[data-runtime-model="<agentId>"]` with a <select> of valid models for the
 * given runtime. Output element keeps `data-agent` + `data-field="model"` so
 * the existing save flow picks it up unchanged. Free-text input fallback
 * when the runtime returns no model list (Claude / discovery disabled).
 */
async function loadModelsForAgent(agentId, runtimeName, currentValue) {
  const cell = document.querySelector('[data-runtime-model="' + agentId + '"]');
  if (!cell) return;
  const baseAttrs = 'data-agent="' + escHtml(agentId) + '" data-field="model"';
  const baseStyle = 'width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)';
  const token = _nextModelLoadToken('agent', agentId);
  if (!runtimeName) {
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, currentValue)
    cell.innerHTML = '<input ' + baseAttrs + ' value="' + escHtml(currentValue || '') + '" placeholder="(no runtime)" disabled style="' + baseStyle + ';color:var(--muted)">';
    return;
  }
  let payload = { models: null };
  try {
    const res = await fetch('/api/runtimes/' + encodeURIComponent(runtimeName) + '/models');
    if (res.ok) payload = await res.json();
  } catch { /* fall through to free-text */ }

  if (!_isCurrentModelLoad('agent', agentId, token)) return;
  const models = Array.isArray(payload.models) ? payload.models : null;
  if (!models || models.length === 0) {
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, runtimeName, currentValue)
    cell.innerHTML = '<input ' + baseAttrs + ' value="' + escHtml(currentValue || '') + '" placeholder="' + escHtml(runtimeName) + ' default" style="' + baseStyle + '">';
    return;
  }
  let opts = '<option value=""' + (!currentValue ? ' selected' : '') + '>(fleet/default)</option>';
  for (const m of models) {
    const id = m.id || m.name || '';
    if (!id) continue;
    const label = m.name && m.name !== id ? (id + ' — ' + m.name) : id;
    opts += '<option value="' + escHtml(id) + '"' + (id === currentValue ? ' selected' : '') + '>' + escHtml(label) + '</option>';
  }
  // Preserve unknown saved values so a user-set custom ID survives the next save.
  if (currentValue && !models.some(m => (m.id || m.name) === currentValue)) {
    opts += '<option value="' + escHtml(currentValue) + '" selected>' + escHtml(currentValue) + ' (custom — invalid for ' + escHtml(runtimeName) + '?)</option>';
  }
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, model id, model label, currentValue, runtimeName)
  cell.innerHTML = '<select ' + baseAttrs + ' style="' + baseStyle + '">' + opts + '</select>';
}

function settingsToggle(label, id, checked, hint) {
  // data-search powers the cross-tab search box — lowercase label + hint so
  // the input filter can substring-match without dragging the visible text
  // through .toLowerCase() on every keystroke.
  const searchKey = (String(label || '') + ' ' + String(hint || '')).toLowerCase().replace(/"/g, '');
  return '<div class="settings-row" data-search="' + escHtml(searchKey) + '" style="display:flex;align-items:center;gap:8px;padding:4px 0">' +
    '<input type="checkbox" id="' + id + '"' + (checked ? ' checked' : '') + ' style="accent-color:var(--blue);width:16px;height:16px;cursor:pointer">' +
    '<label for="' + id + '" style="font-size:var(--text-md);color:var(--text);cursor:pointer">' + escHtml(label) + '</label>' +
    (hint ? '<span style="font-size:var(--text-xs);color:var(--muted)">' + escHtml(hint) + '</span>' : '') +
  '</div>';
}

function settingsField(label, id, value, unit, hint) {
  const searchKey = (String(label || '') + ' ' + String(hint || '') + ' ' + String(unit || '')).toLowerCase().replace(/"/g, '');
  return '<div class="settings-row" data-search="' + escHtml(searchKey) + '">' +
    '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">' + escHtml(label) + (unit ? ' <span style="opacity:0.6">(' + escHtml(unit) + ')</span>' : '') + '</label>' +
    '<input id="' + id + '" value="' + escHtml(String(value)) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
    (hint ? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">' + escHtml(hint) + '</div>' : '') +
  '</div>';
}

// ── Settings tab + search helpers ─────────────────────────────────────────
// Extracted from openSettings so the tab switch / search-filter logic can be
// unit-tested without rebuilding the whole modal. Both operate on whatever
// DOM is currently mounted under #modal-body — no module state required.
function _selectSettingsTab(id) {
  const body = document.getElementById('modal-body');
  if (!body) return;
  body.querySelectorAll('[data-settings-tab]').forEach(function(btn) {
    btn.classList.toggle('active', btn.getAttribute('data-settings-tab') === id);
  });
  body.querySelectorAll('[data-settings-pane]').forEach(function(pane) {
    pane.classList.toggle('active', pane.getAttribute('data-settings-pane') === id);
  });
}

function _applySettingsSearch(rawQuery) {
  const body = document.getElementById('modal-body');
  if (!body) return;
  const q = String(rawQuery || '').trim().toLowerCase();
  const searching = q.length > 0;
  body.classList.toggle('searching', searching);

  // Reset every row + every rail badge before re-applying.
  body.querySelectorAll('[data-search]').forEach(function(row) {
    row.classList.remove('no-match', 'match-highlight');
  });
  const panes = body.querySelectorAll('[data-settings-pane]');
  const railBtns = body.querySelectorAll('[data-settings-tab]');

  if (!searching) {
    // Restore single-active-pane mode.
    panes.forEach(function(p) { p.classList.remove('no-match'); });
    railBtns.forEach(function(btn) {
      btn.classList.remove('no-match');
      const badge = btn.querySelector('.match-count');
      if (badge) badge.style.display = 'none';
    });
    return;
  }

  // Per-pane match counts drive both the rail badge and the pane visibility.
  // Folding row.textContent into the haystack means operators can search by
  // any visible label/hint string, not just the curated data-search tokens.
  panes.forEach(function(pane) {
    const paneId = pane.getAttribute('data-settings-pane');
    let matches = 0;
    pane.querySelectorAll('[data-search]').forEach(function(row) {
      const haystack = (row.getAttribute('data-search') || '').toLowerCase();
      const txt = (row.textContent || '').toLowerCase();
      if (haystack.includes(q) || txt.includes(q)) {
        row.classList.add('match-highlight');
        matches++;
      } else {
        row.classList.add('no-match');
      }
    });
    pane.classList.toggle('no-match', matches === 0);
    const btn = body.querySelector('[data-settings-tab="' + paneId + '"]');
    if (btn) {
      btn.classList.toggle('no-match', matches === 0);
      const badge = btn.querySelector('.match-count');
      if (badge) {
        badge.textContent = String(matches);
        badge.style.display = matches > 0 ? 'inline-block' : 'none';
      }
    }
  });
}

async function saveSettings() {
  const status = document.getElementById('settings-status');
  const saveBtn = document.getElementById('modal-settings-save');
  status.textContent = 'Saving...';
  status.style.color = 'var(--blue)';
  if (saveBtn) { saveBtn.disabled = true; saveBtn.style.opacity = '0.6'; saveBtn.innerHTML = 'Saving...'; }

  try {
    const enginePayload = {
      tickInterval: document.getElementById('set-tickInterval').value,
      maxConcurrent: document.getElementById('set-maxConcurrent').value,
      inboxConsolidateThreshold: document.getElementById('set-inboxConsolidateThreshold').value,
      agentTimeout: document.getElementById('set-agentTimeout').value,
      maxTurns: document.getElementById('set-maxTurns').value,
      heartbeatTimeout: document.getElementById('set-heartbeatTimeout').value,
      worktreeCreateTimeout: document.getElementById('set-worktreeCreateTimeout').value,
      worktreeCreateRetries: document.getElementById('set-worktreeCreateRetries').value,
      worktreeRoot: document.getElementById('set-worktreeRoot').value,
      assertCleanStatusTimeoutMs: document.getElementById('set-assertCleanStatusTimeoutMs').value,
      idleAlertMinutes: document.getElementById('set-idleAlertMinutes').value,
      shutdownTimeout: document.getElementById('set-shutdownTimeout').value,
      restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
      meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
      steeringDeferredMaxMs: document.getElementById('set-steeringDeferredMaxMs').value,
      steeringMaxKillRetries: document.getElementById('set-steeringMaxKillRetries').value,
      spawnPhaseWatchdogEnabled: document.getElementById('set-spawnPhaseWatchdogEnabled').checked,
      spawnPhaseGraceMs: document.getElementById('set-spawnPhaseGraceMs').value,
      spawnPhaseMaxCpuSeconds: document.getElementById('set-spawnPhaseMaxCpuSeconds').value,
      operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
      autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
      evalLoop: document.getElementById('set-evalLoop').checked,
      autoDecompose: document.getElementById('set-autoDecompose').checked,
      allowTempAgents: document.getElementById('set-allowTempAgents').checked,
      autoArchive: document.getElementById('set-autoArchive').checked,
      autoConsolidateMemory: document.getElementById('set-autoConsolidateMemory').checked,
      autoApplyReviewVote: document.getElementById('set-autoApplyReviewVote').checked,
      autoFixBuilds: document.getElementById('set-autoFixBuilds').checked,
      autoFixConflicts: document.getElementById('set-autoFixConflicts').checked,
      autoReviewPrs: document.getElementById('set-autoReviewPrs').checked,
      autoReReviewPrs: document.getElementById('set-autoReReviewPrs').checked,
      autoFixReviewFeedback: document.getElementById('set-autoFixReviewFeedback').checked,
      autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
      autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
      ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
      autoReapOrphanWorktreeHolders: !!document.getElementById('set-autoReapOrphanWorktreeHolders')?.checked,
      orphanHolderScanTimeoutMs: document.getElementById('set-orphanHolderScanTimeoutMs')?.value,
      adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
      ghPollEnabled: document.getElementById('set-ghPollEnabled').checked,
      prPollStatusEvery: document.getElementById('set-prPollStatusEvery').value,
      prPollCommentsEvery: document.getElementById('set-prPollCommentsEvery').value,
      evalMaxCost: document.getElementById('set-evalMaxCost').value || null,
      agentBusyReassignMs: document.getElementById('set-agentBusyReassignMs').value,
      maxRetriesPerAgent: document.getElementById('set-maxRetriesPerAgent').value,
      ignoredCommentAuthors: document.getElementById('set-ignoredCommentAuthors').value,
      versionCheckInterval: document.getElementById('set-versionCheckInterval').value,
      // Runtime fleet (P-7a5c1f8e). Empty strings are intentional — they signal
      // "clear this override". The server deletes the key from config.engine.
      defaultCli: (document.getElementById('set-defaultCli')?.value ?? '').trim(),
      defaultModel: (document.getElementById('set-defaultModel')?.value ?? '').trim(),
      ccCli: (document.getElementById('set-ccCli')?.value ?? '').trim(),
      ccModel: (document.getElementById('set-ccModel')?.value ?? '').trim(),
      ccEffort: document.getElementById('set-ccEffort').value || null,
      ccTurnTimeoutMs: document.getElementById('set-ccTurnTimeoutMs')?.value,
      claudeBareMode: !!document.getElementById('set-claudeBareMode')?.checked,
      claudeFallbackModel: (document.getElementById('set-claudeFallbackModel')?.value ?? '').trim(),
      copilotFallbackModel: (document.getElementById('set-copilotFallbackModel')?.value ?? '').trim(),
      copilotDisableBuiltinMcps: !!document.getElementById('set-copilotDisableBuiltinMcps')?.checked,
      copilotSuppressAgentsMd: !!document.getElementById('set-copilotSuppressAgentsMd')?.checked,
      copilotStreamMode: document.getElementById('set-copilotStreamMode')?.value || 'on',
      copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
      maxBudgetUsd: (document.getElementById('set-maxBudgetUsd')?.value ?? '').trim(),
      disableModelDiscovery: !!document.getElementById('set-disableModelDiscovery')?.checked,
      qaDualWriteJson: !!document.getElementById('set-qaDualWriteJson')?.checked,
      // W-mpmwxkrw000872ec — global font-size scale. Allowlist validation
      // (and clamp messaging) lives server-side in handleSettingsUpdate.
      fontSize: document.getElementById('set-fontSize')?.value || 'small',
      maxTurnsByType: (function() {
        var mbt = {};
        var types = ['explore', 'ask', 'review', 'implement', 'fix', 'test', 'verify', 'plan', 'decompose'];
        for (var i = 0; i < types.length; i++) {
          var v = document.getElementById('set-mt-' + types[i])?.value?.trim();
          if (v) mbt[types[i]] = Number(v);
        }
        return mbt;
      })(),
    };

    const claudePayload = {
      allowedTools: document.getElementById('set-allowedTools').value,
    };

    const agentsPayload = {};
    document.querySelectorAll('[data-agent][data-field]').forEach(function(el) {
      const id = el.dataset.agent;
      const field = el.dataset.field;
      if (!agentsPayload[id]) agentsPayload[id] = {};
      agentsPayload[id][field] = el.value;
    });

    const currentProjects = (_settingsData && Array.isArray(_settingsData.projects)) ? _settingsData.projects : [];
    const projectsPayload = currentProjects.map(function(p) {
      // W-mpg3whgp000d09ec / #2732 — mainBranch is now editable from Settings →
      // Projects. Empty string = clear the override; the field stays optional.
      const mainBranchInput = document.getElementById('set-mainBranch-' + p.name);
      const mainBranchValue = mainBranchInput ? mainBranchInput.value.trim() : (p.mainBranch || '');
      // P-a3f9b207 — per-project worktreeMode. Normalize to 'isolated' for
      // any value other than 'live' so a stale DOM never POSTs garbage; the
      // server-side validator (shared.validateWorktreeMode) is the
      // authoritative gate for unknown values.
      const wtModeInput = document.getElementById('set-worktreeMode-' + p.name);
      const wtModeValue = (wtModeInput && wtModeInput.value === 'live') ? 'live' : 'isolated';
      return {
        name: p.name,
        mainBranch: mainBranchValue || null,
        worktreeMode: wtModeValue,
        workSources: {
          pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
          workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
        }
      };
    });

    // Save config
    const res = await fetch('/api/settings', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ engine: enginePayload, claude: claudePayload, agents: agentsPayload, projects: projectsPayload })
    });
    const result = await res.json();
    if (!res.ok) throw new Error(result.error);

    // Save routing separately
    const routing = document.getElementById('set-routing').value;
    const rRes = await fetch('/api/settings/routing', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: routing })
    });
    if (!rRes.ok) { const d = await rRes.json(); throw new Error(d.error); }

    if (result.clamped && result.clamped.length > 0) {
      status.textContent = 'Saved — some values adjusted: ' + result.clamped.join(', ');
      status.style.color = 'var(--yellow)';
      showToast('cmd-toast', 'Settings saved (some values clamped to allowed range)', false);
    } else {
      status.textContent = 'Saved. Engine picks up changes on next tick.';
      status.style.color = 'var(--green)';
      showToast('cmd-toast', 'Settings saved', true);
    }
    // W-mpmwxkrw000872ec — apply font-size immediately + persist to
    // localStorage so a hard reload still reflects the new pick (server is
    // the cold-load authority; localStorage is the no-flash fast path).
    try { applyFontSizePreference(enginePayload.fontSize); } catch (err) { /* tolerated — change shows on next reload */ }
    if (saveBtn) { saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Saved'; saveBtn.style.color = 'var(--green)'; saveBtn.style.borderColor = 'var(--green)'; }
    setTimeout(function() {
      if (saveBtn) { saveBtn.disabled = false; saveBtn.style.opacity = ''; saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Save'; }
    }, 2000);
  } catch (e) {
    status.textContent = 'Error: ' + e.message;
    status.style.color = 'var(--red)';
    if (saveBtn) { saveBtn.disabled = false; saveBtn.style.opacity = ''; saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Save'; }
    showToast('cmd-toast', 'Settings save failed: ' + e.message, false);
  }
}

async function addProject() {
  try {
    const browseRes = await fetch('/api/projects/browse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
    const browseData = await browseRes.json();
    if (!browseRes.ok) { alert('Error: ' + (browseData.error || 'unknown')); return; }
    if (browseData.cancelled || !browseData.path) return;

    const addRes = await fetch('/api/projects/add', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ path: browseData.path })
    });
    const addData = await addRes.json();
    if (!addRes.ok) { alert('Failed: ' + (addData.error || 'unknown')); return; }
    const addedProject = {
      name: addData.name,
      description: (addData.detected && addData.detected.description) || '',
      path: addData.path,
      localPath: addData.path,
    };
    if (_settingsData && Array.isArray(_settingsData.projects)) {
      const exists = _settingsData.projects.some(function(p) {
        return p.name === addedProject.name || String(p.localPath || p.path || '').replace(/\\/g, '/') === String(addedProject.localPath || '').replace(/\\/g, '/');
      });
      if (!exists) _settingsData.projects = _settingsData.projects.concat([addedProject]);
    }
    if (typeof optimisticallyAddProject === 'function') optimisticallyAddProject(addedProject);
    try { showToast('cmd-toast', 'Project "' + addData.name + '" added', true); } catch { /* expected */ }
    refresh();
  } catch (e) { alert('Error: ' + e.message); }
}

async function resetSettingsToDefaults() {
  if (!confirm('Reset all engine, CLI, and agent settings to defaults? This cannot be undone.')) return;
  const status = document.getElementById('settings-status');
  try {
    status.textContent = 'Resetting...';
    status.style.color = 'var(--blue)';
    const res = await fetch('/api/settings/reset', { method: 'POST' });
    if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Reset failed'); }
    status.textContent = 'Reset complete. Reloading...';
    status.style.color = 'var(--green)';
    showToast('cmd-toast', 'Settings reset to defaults', true);
    setTimeout(() => openSettings(), 500);
  } catch (e) {
    status.textContent = 'Error: ' + e.message;
    status.style.color = 'var(--red)';
  }
}

async function removeProject(name) {
  if (!confirm('Remove project "' + name + '"? Pending work cancels, active agents are killed, data dir is archived to projects/.archived/.')) return;
  const prevProjects = (_settingsData && Array.isArray(_settingsData.projects)) ? _settingsData.projects.slice() : null;
  showToast('cmd-toast', 'Removing project "' + name + '"...', true);
  markDeleted('project:' + name);
  if (_settingsData && Array.isArray(_settingsData.projects)) {
    _settingsData.projects = _settingsData.projects.filter(function(p) { return p.name !== name; });
  }
  const card = document.querySelector('[data-settings-project="' + CSS.escape(name) + '"]');
  if (card) card.remove();
  if (window._lastStatus && Array.isArray(window._lastStatus.projects) && typeof renderProjects === 'function') {
    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) {
      if (prevProjects) _settingsData.projects = prevProjects;
      clearDeleted('project:' + name);
      if (window._lastStatus && Array.isArray(window._lastStatus.projects) && typeof renderProjects === 'function') {
        renderProjects(window._lastStatus.projects);
      }
      showToast('cmd-toast', 'Remove failed: ' + (d.error || 'unknown'), false);
      openSettings();
      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.cleanedWorktrees) parts.push(d.cleanedWorktrees + ' worktree(s) cleaned');
    if (d.archivedPlans?.length) parts.push(d.archivedPlans.length + ' plan/PRD archived');
    if (d.archivedTo) parts.push('archived to ' + d.archivedTo);
    if (d.pipelineRefs?.length) parts.push('! pipelines still reference: ' + d.pipelineRefs.join(', '));
    showToast('cmd-toast', parts.join(' — '), true);
    setTimeout(() => openSettings(), 600);
  } catch (e) {
    if (prevProjects) _settingsData.projects = prevProjects;
    clearDeleted('project:' + name);
    if (window._lastStatus && Array.isArray(window._lastStatus.projects) && typeof renderProjects === 'function') {
      renderProjects(window._lastStatus.projects);
    }
    showToast('cmd-toast', 'Error: ' + e.message, false);
    openSettings();
  }
}

window.MinionsSettings = { openSettings, saveSettings, addProject, removeProject, resetSettingsToDefaults, _selectSettingsTab, _applySettingsSearch };
