/**
 * Lazy postMessage broker + consent flow for the frame shell.
 *
 * A separate rollup entry (`frame-shell-broker`) loaded by shell.ts via a
 * `@vite-ignore`'d import() — same placeholder-stamping mechanism as the
 * lazy React chrome. The shell entry chunk therefore carries none of the
 * broker's wire strings (the /api/frame/mcp/* routes, the __frame_cap
 * postMessage discriminators, the consent-store key); the chunk is only
 * fetched when the boot response declares a broker capability.
 *
 * Chunk-isolation rule (CLAUDE.md): this module may not share a static
 * import with shell.ts or frameShellEntry() sees a shared chunk and
 * fails the build. `el()` is duplicated (kept in lock-step with
 * render.ts); `report()` and the shell's mount/iframe state arrive via
 * the BrokerHost callbacks.
 */

// Type-only leaf (erased at emit) — carries the ChromeHandle bridge types
// without creating a runtime edge to the chrome chunk.
import {
  type ChromeHandle,
  type ConsentDialogHandle,
  type ReauthDialogHandle,
  type ReauthRowSpec,
} from "./chrome/types";
// Wire shapes — protocol.ts is the canonical source of truth. This is the
// ONE frame-shell entry that runtime-imports it (see protocol.ts header);
// the module bundles into this chunk alone, so the zero-imports gate in
// frameShellEntry (vite.config.ts) still holds.
import { frameCapReply, parseFrameCap, type McpErr } from "./protocol";

export interface ManifestServer {
  server: string;
  tools: string[];
}

/**
 * One callable same-named connector from the CP's `candidates` list —
 * consent-dialog data ONLY (the page-facing listTools projection drops it;
 * see handleList). `iconUrl` is deliberately not modeled: custom-connector
 * icon URLs are viewer-entered, and the picker's option labels are
 * text-node-only, so the field is never read at all.
 */
interface Candidate {
  id: string;
  host?: string;
  provenance?: string;
  /** RFC 3339. Optional on the wire; every consumer tolerates absence. */
  addedAt?: string;
}

/** Per-server resolution state parsed from a /mcp/servers response.
 * Needs-selection is discriminated by shape: candidates non-empty AND
 * resolvedId absent (never via authStatus — policy.go serverOut docs). */
interface ServersEntry {
  server: string;
  resolvedId?: string;
  candidates: Candidate[];
}

// Selection ids only ever enter the store from CP responses (candidates /
// resolvedId), which are canonical-form uuids; the CP accepts other
// uuid.Parse spellings but the shell never mints them. The read-side gate
// drops anything else a tampered/corrupt store might carry — the CP's
// bootstrap∧name∧callable validation stays the security boundary.
const UUID_RE =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// Candidate-host gate. The CP emits hostname-only (url.Hostname() at
// bootstrap parse, NOT punycoded — a raw-unicode IDN host fails here and
// loses its label), but the picker renders host text next to the
// "Added {date}" / "Managed" trust badges with only " · " as the
// separator — so a host carrying spaces or "·" could FORGE a composite
// badge, and a host that IS a bare badge word ("Managed") would render
// as the badge itself. The charset kills the composite class (no spaces,
// no "·", no unicode lookalikes); the token check below (hostAllowed)
// kills the imitating-token class. Same read-side posture as UUID_RE: drop the
// field, keep the candidate — the label falls back to whatever genuine
// metadata survives, or the ordinal.
// The 128 length bound matches capName's display cap (sync: capName —
// keep in lock-step): an accepted host must render VERBATIM. A longer
// host would be cut mid-string at render, and the cut point — attacker-
// positioned via padding — can land right after an embedded badge word,
// placing it at the rendered trailing badge position this gate validates
// pre-render. No plausible genuine hostname approaches 128; DNS's 253
// ceiling was never load-bearing for a display label.
const HOST_RE = /^[A-Za-z0-9._:-]{1,128}$/;
// The label's reserved vocabulary: the trust badges ("Managed"; the
// leading token of "Added {date}") and the ordinal fallback's leading
// token ("Connector {n}"). In a native <option> the label is one text
// string — no styling can distinguish shell-emitted badge text from host
// text — so a host that could READ as that vocabulary must be dropped.
// Keep in step with candidateLabel.
const BADGE_WORDS = new Set(["managed", "added", "connector"]);
// A token imitates the vocabulary when it IS a badge word, optionally
// digit-suffixed ("Managed1", "Connector2" read as the shell's own text
// at a glance). One equivalence class, applied identically to every
// token — a digit-suffixed token ("acme.evil.example.Managed1") is the
// same imitation as the bare word.
const BADGE_RE = new RegExp(`^(?:${[...BADGE_WORDS].join("|")})\\d*$`);
// Badge-imitation check over the host's alphanumeric TOKENS (the charset
// admits "._:-", so punctuation can fake word boundaries): reject when
// the whole host squashes to an imitating token ("m-a-n-a-g-e-d"), or
// when ANY token is one. Position cannot be consulted here: "where the
// badge word sits in the token list" and "where it renders" are not the
// same property — a badge token followed only by digit tokens
// ("one.example.com.Managed.2") renders at the trailing badge segment,
// imitating the badge plus the occurrence suffix " · {k}" the shell
// itself appends on label collisions — so a badge token anywhere drops
// the host. Coverage, stated precisely: exact and digit-suffixed badge
// tokens anywhere in the host are rejected; unicode/homoglyph variants
// cannot reach here (HOST_RE is ASCII-only); hosts the display cap would
// truncate cannot reach here (HOST_RE's 128 bound, above — truncation
// could otherwise manufacture a trailing badge token the untruncated
// string never had); the non-digit near-miss vocabulary ("Manager.",
// "managedexample.com") is deliberately out of scope rather than chased
// open-endedly; other suffix shapes have not been adversarially swept —
// treat a new imitation class as an extension of BADGE_RE, not a
// refutation of the design. This deliberately drops
// legitimate hosts like "managed.example.com" and "my.managed.host.com"
// — the safe ordinal/metadata fallback costs one label, where an
// accepted imitation costs the viewer's routing choice.
const hostAllowed = (host: string): boolean => {
  if (!HOST_RE.test(host)) return false;
  const tokens = host
    .toLowerCase()
    .split(/[^a-z0-9]+/)
    .filter(Boolean);
  if (tokens.length === 0) return false;
  return (
    !BADGE_RE.test(tokens.join("")) && !tokens.some((t) => BADGE_RE.test(t))
  );
};

const parseCandidates = (raw: unknown): Candidate[] => {
  if (!Array.isArray(raw)) return [];
  const out: Candidate[] = [];
  for (const c of raw) {
    if (!c || typeof c !== "object") continue;
    const id = (c as { id?: unknown }).id;
    if (typeof id !== "string" || !UUID_RE.test(id)) continue;
    const cand: Candidate = { id };
    const host = (c as { host?: unknown }).host;
    if (typeof host === "string" && hostAllowed(host)) cand.host = host;
    const prov = (c as { provenance?: unknown }).provenance;
    if (typeof prov === "string" && prov !== "") cand.provenance = prov;
    const added = (c as { addedAt?: unknown }).addedAt;
    if (typeof added === "string" && added !== "") cand.addedAt = added;
    out.push(cand);
  }
  return out;
};

const parseServersEntries = (body: unknown): ServersEntry[] => {
  const servers = (body as { servers?: unknown } | null)?.servers;
  if (!Array.isArray(servers)) return [];
  const out: ServersEntry[] = [];
  for (const s of servers) {
    if (!s || typeof s !== "object") continue;
    const server = (s as { server?: unknown }).server;
    if (typeof server !== "string" || server === "") continue;
    const entry: ServersEntry = {
      server,
      candidates: parseCandidates((s as { candidates?: unknown }).candidates),
    };
    const rid = (s as { resolvedId?: unknown }).resolvedId;
    if (typeof rid === "string" && UUID_RE.test(rid)) entry.resolvedId = rid;
    out.push(entry);
  }
  return out;
};

/** Boot fields the broker reads. shell.ts passes the raw boot object. */
interface BrokerBoot {
  assetToken: string;
  /** The booted version — self-publish's CAS base. Captured at wire time
   * from the boot that mounted the iframe, never from the page: a page
   * cannot CAS against a version it isn't showing. */
  ver?: string;
  /** Capabilities envelope — the only boot shape the broker reads. */
  capabilities?: {
    mcp?: { token?: string; config?: { servers?: ManifestServer[] } };
    self?: { token?: string };
  };
}

/**
 * The self grant's pseudo-entry in the CONSENT layer (store schema,
 * hasNewCaps re-prompt semantics, persistConsent scope). Never a wire or
 * dialog row: underscores are outside the CP's connector-name grammar
 * (validMcpServer: letters/digits/space/._()-), so no published manifest
 * server can ever collide with it, and every dialog/wire path filters it
 * out by name.
 *
 * Deliberate mcp-parity semantics: a republish that DROPS self leaves the
 * stored sentinel in place, so a later republish that re-adds it re-wires
 * silently (consented once). The grant is per-(viewer, artifact) and the
 * CP re-authorizes every publish; revisit if per-regrant re-prompt is
 * ever wanted for write-class capabilities.
 */
export const SELF_SENTINEL = "__self__";
const selfSentinel = (): ManifestServer => ({
  server: SELF_SENTINEL,
  tools: ["publish"],
});

/** Self-publish reply error — the runtime contract's SelfError shape. */
interface SelfErr {
  code: string;
  message: string;
  live?: string;
}

const selfErr = (code: string, message: string, live?: string): SelfErr =>
  live === undefined ? { code, message } : { code, message, live };

/** Shell-owned state + callbacks the broker needs. */
export interface BrokerHost {
  frameUuid: string;
  /** Normalized origin of the user-content iframe (frameScheme()//frameUchost). */
  frameOrigin: string;
  cpHeaders: Record<string, string>;
  org: string | undefined;
  slot: HTMLElement;
  /** Mount the iframe for `d` and return it (shell.ts's mountIframe). */
  mount: (d: object) => HTMLIFrameElement;
  /** Trigger a shell boot (single-flight) and resolve with the fresh
   * boot's mcp capability token and that boot's org, or null when the
   * boot failed or carried no mcp envelope. Stale-token recovery: an
   * expired mcpToken 403s as plain text, and only a fresh boot can
   * replace it. The org rides along so the caller applies the same
   * binding guard as refreshBrokerToken before writing the slot. */
  requestReboot: () => Promise<{
    token: string;
    org: string | undefined;
    assetToken: string;
  } | null>;
  /** Replace the shell's current broker tear-down (null = unwired). */
  setUnwire: (fn: (() => void) | null) => void;
  /** Correct the shell's capability posture on a STRUCTURAL no-wire exit
   * (unresolvable envelope, wiring failure) so a later wireable boot
   * re-attempts. Deliberately NOT called on consent-lifecycle nulls —
   * the pre-prompt reset and the pending/declined dialog keep the
   * posture, or every later same-ver reboot would re-prompt and reload. */
  setBrokered?: (v: boolean) => void;
  /** Mark content as shown (flips shell's initial-gate semantics). */
  markShown: () => void;
  /** Telemetry counter — telemetry.ts report(). */
  report: (
    code:
      | "broker-consent-error"
      | "broker-selection-drop"
      | "transform-error"
      | "cap-load-error"
      | "reauth-status-fetch"
      | "reauth-prompt"
      | "consent-prompt",
    detail?: string,
  ) => void;
  /** Called when the initial-consent assetToken aged out while the dialog sat open. */
  onStale: () => void;
  /** assetToken freshness window (ms). */
  staleMs: number;
  /** Live chrome handle (null before mount / after degrade). Both broker
   * decision dialogs render through it — frame-dialogs-use-CDS decision;
   * no vanilla fallback (a degraded chrome is a broken artifact: the
   * reauth prompt skips, consent FAILS CLOSED). */
  chrome: () => ChromeHandle | null;
  /** Resolves when the chrome chunk mounts (true) or definitively fails
   * (false). The consent path awaits this before prompting — the prompt
   * can be needed before the chunk finishes loading. */
  chromeReady: () => Promise<boolean>;
  /** The shell's shared decision-surface close slot — clearForRemount and
   * the 403 revocation path fire it. Both chrome-rendered dialogs
   * register here directly. */
  setCloseConsent: (fn: (() => void) | null) => void;
  /** Selector for a LIVE chrome delete-confirm (Base UI). */
  confirmOpenSelector: string;
  /** Close any open nav-confirm dialog (its close() re-derives inert). */
  closeNavConfirm: () => void;
  /** Force the mounted content iframe inert (dialog open — see contentInertClaim). */
  inertContent: () => void;
  /** Re-derive content-iframe interactivity after this dialog goes away. */
  restoreContentInteractivity: () => void;
  /** Fire the currently-registered decision-surface closer (no-op when
   * none). claimDecisionSurface calls this so an open chrome-rendered
   * dialog is closed through its own teardown before a new surface
   * registers — never silently clobbered. */
  callCloseConsent: () => void;
  /** Stamp the shell's decision-surface-open flag — the chrome dialogs'
   * term in contentInertClaim and reveal()'s contentShown skip (they
   * have no DOM marker the shell could query). */
  setDecisionSurfaceOpen: (v: boolean) => void;
  /** Tear down any mounted iframe + its alarms (strict prompt-before-mount). */
  unmount: () => void;
}

function mcpErr(code: string, message: string, server?: string): McpErr {
  const out: McpErr = { code, message };
  if (server !== undefined) out.server = server;
  return out;
}

// Broker-generation clock. Advances ONLY when the broker generation truly
// changes: a new mountBrokered assuming control, or an explicit teardown by
// a non-broker boot path (deferred's teardownBroker calls the invalidator —
// zero-capability boots and broker-load failures route through it). NOT the
// shell's bootEpoch, which counts boot ATTEMPTS: same-version token-refresh
// reboots and failed network reboots bump that without touching the broker,
// and anchoring on it would permanently invalidate a still-live generation's
// bookkeeping. The iframe.isConnected checks remain alongside for
// unmount-without-teardown shapes.
let brokerGeneration = 0;

/** Called by deferred's teardownBroker so non-broker boot shapes (mcp-less
 * re-boot, broker-load failure) retire the prior generation's pending async
 * continuations. */
export function invalidateBrokerGeneration(): void {
  brokerGeneration += 1;
  activeTokenSlot = null;
  activeSelfTokenSlot = null;
}

/** The live bridge's mcp token, mutable in place so the bridge follows the
 * CP's per-boot re-mint instead of aging out at the CP's 1h token TTL.
 * mountBrokered registers a fresh slot per generation (gen pins it); every
 * transport read (`slot.value`) happens at request-serialization time, so
 * an in-place refresh is visible to in-flight closures without re-wiring.
 * org and account are the slot's binding, captured at mount. org is
 * defense-in-depth (every current org-changing boot path nulls the
 * shell's `current` and remounts — a new slot — so a divergent refresh
 * cannot occur today). account is LOAD-BEARING: consent is keyed per
 * account (consentStoreKey) precisely because a shared-browser account
 * switch does NOT remount a same-version tokened session — before the
 * slot existed, the closure-pinned token mismatched the new cookie and
 * every call failed closed with a 403. Rebinding the slot across an
 * account switch would instead execute calls against the NEW viewer's
 * connected services under the OLD viewer's consent grant (the backend
 * enforces scope, not per-viewer consent), so both refresh paths require
 * account equality and otherwise leave the slot stale — restoring the
 * fail-closed 403 posture until a remount re-runs consent. */
interface TokenSlot {
  value: string;
  readonly org: string | undefined;
  readonly account: string;
  readonly gen: number;
}

/** Account UUID segment of an assetToken ({mac}.{org}.{account}.{exp}) —
 * the same viewer-identity parse consentStoreKey keys consent on. */
const assetTokenAccount = (assetToken: string): string =>
  assetToken.split(".")[2] ?? "-";
let activeTokenSlot: TokenSlot | null = null;
/** The self capability's live token slot — same lifecycle as
 * activeTokenSlot (one per broker generation, refreshed in place). */
let activeSelfTokenSlot: TokenSlot | null = null;

/** Same-version token-refresh reboots deliberately skip the remount path
 * (shell.ts — a remount would discard in-iframe state), so shell.ts hands
 * the fresh boot's mcp token here instead. No-ops when no bridge is wired,
 * when the boot carries no resolvable mcp envelope (the teardown/remount
 * paths own those transitions), or when the boot's org diverges from the
 * slot's mount-time binding (see TokenSlot). Unlike the stale-token
 * replay's slot write (which runs after a genuine network await and so
 * checks `gen` too), this write deliberately targets whatever slot is
 * live: the only staleness window is the withDeferred microtask hop, a
 * generation swap inside it would need a full boot round-trip to
 * complete in the same hop, and the worst case is a seconds-older
 * same-principal token that the next refresh cycle replaces. */
export function refreshBrokerToken(
  // Structurally loose so the shell (whose boot type keeps capabilities
  // as an opaque record — chunk isolation) can pass its boot object
  // straight through; resolveMcp runtime-validates every field it reads.
  d: { capabilities?: unknown; assetToken?: unknown },
  bootOrg: string | undefined,
): void {
  const acct =
    typeof d.assetToken === "string" ? assetTokenAccount(d.assetToken) : null;
  if (acct === null) return;
  const token = resolveMcp(d as BrokerBoot)?.token;
  if (
    token &&
    activeTokenSlot &&
    activeTokenSlot.org === bootOrg &&
    activeTokenSlot.account === acct
  ) {
    activeTokenSlot.value = token;
  }
  // Same-generation refresh for the self slot — identical binding guard.
  const selfToken = resolveSelf(d as BrokerBoot)?.token;
  if (
    selfToken &&
    activeSelfTokenSlot &&
    activeSelfTokenSlot.org === bootOrg &&
    activeSelfTokenSlot.account === acct
  ) {
    activeSelfTokenSlot.value = selfToken;
  }
}

/** Shell↔mount coordination for viewer connector selections. Owned by
 * mountBrokered; wireMcp only calls through it. */
interface SelectionSync {
  /** Clear the three selection records — the live selections map, the
   * session commit record (or pref would resurrect a rejected id), and
   * the stored selectedIds — the remedy when the CP confirms it rejects
   * selection-carrying requests (see postMcp's 400 retry). The drop is
   * scoped to its evidence on every axis: generation (no-op for a dead
   * generation — the store is shared and a newer generation may have
   * just committed; same guard rationale as applyEntries' choke point),
   * server (a call-path failure names its server; a servers-path failure
   * cannot attribute and covers all), and VALUE (`sent` is what the
   * rejected request actually carried — a record whose current value
   * differs was committed while the request was in flight, was never
   * seen by the CP, and survives). The beacon fires only when something
   * was genuinely cleared. */
  dropSelections: (server?: string, sent?: unknown) => void;
  /** One /mcp/servers POST with the current selections attached. The single
   * definition of the request pairing: the selections field is present iff
   * the 400-retry key is set — rollback correctness depends on the two
   * moving together, so every caller goes through here. */
  postServers: () => Promise<McpPost>;
  /** Record resolution echoes / apply the collapsed-set auto-switch from a
   * parsed /mcp/servers response (page-initiated listTools included —
   * silent bookkeeping only, never UI). */
  onEntries: (entries: ServersEntry[]) => void;
  /** A call failed closed with selection_required — re-derive the viewer's
   * options (mount-time rule, once per broker generation for any UI). */
  onSelectionRequired: () => void;
}

interface McpPost {
  status: number;
  ok: boolean;
  text: string;
}

const MCP_TIMEOUT_MS = { call: 120_000, servers: 65_000 } as const;
/** The kernel rejects callTool/listTools after this long with no shell
 * reply, relying on the documented invariant that the broker's own
 * timeout always fires first. The whole postMcp round trip — INCLUDING
 * the one 400-retry — must settle inside it, or a late retry can execute
 * a side-effecting tool after the page already saw the call fail.
 * sync: frame-runtime/src/capabilities/mcp/index.ts REPLY_TIMEOUT_MS —
 * keep in lock-step (chunk isolation bars the import). */
const KERNEL_REPLY_MS = 130_000;
/** Slack reserved out of the retry budget for response transit + the
 * drop bookkeeping, so the reply beats the kernel timer. */
const RETRY_SLACK_MS = 2_000;

/**
 * POST an /api/frame/mcp/* body — a pre-serialized string is accepted so
 * the per-call hot path serializes exactly once, under its own input
 * validation. When the body carries a selection field (`selectionKey`
 * non-null) and the CP answers 400, apply the one remedy that covers both
 * reject paths — a rolled-back CP whose strict decoder rejects the unknown
 * field, and a stored selection the CP refuses (both reason-logged
 * server-side): retry ONCE without the field, and drop the stored
 * selections only when that retry SUCCEEDS (the confirmation the selection
 * itself was the problem; a retry that also fails means the 400 was
 * something else, e.g. malformed input, so the viewer's choices are kept).
 * A request that never carried a selection is returned as-is; its 400 is
 * the caller's bug to surface.
 */
function postMcp(
  h: BrokerHost,
  path: "call" | "servers",
  body: Record<string, unknown> | string,
  selectionKey: "serverId" | "selections" | null,
  dropSelections: (server?: string, sent?: unknown) => void,
  /** The one server a `serverId` request implicates — scopes the drop on
   * the call path; a `selections` request carries every selection and
   * cannot attribute its 400, so the servers path leaves this unset. */
  failingServer?: string,
  /** Caps BOTH legs (first attempt and the 400-retry) to a smaller budget
   * than the standalone timeouts — the stale-token replay runs on what is
   * left of the kernel reply window after a reboot leg already spent part
   * of it. Unset = the original standalone budgets. */
  budgetMs?: number,
): Promise<McpPost> {
  const q = h.org ? `?org=${encodeURIComponent(h.org)}` : "";
  const go = (payload: string, budgetMs: number): Promise<McpPost> =>
    fetch(`/api/frame/mcp/${path}${q}`, {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json", ...h.cpHeaders },
      body: payload,
      signal: safeTimeout(budgetMs),
    }).then(async (r) => ({
      status: r.status,
      ok: r.ok,
      text: await r.text(),
    }));
  const firstLegMs = Math.min(MCP_TIMEOUT_MS[path], budgetMs ?? Infinity);
  const payload = typeof body === "string" ? body : JSON.stringify(body);
  if (selectionKey === null) return go(payload, firstLegMs);
  const started = Date.now();
  return go(payload, firstLegMs).then((r) => {
    if (r.status !== 400) return r;
    // The retry runs on the REMAINING kernel budget, never a fresh full
    // timeout: two full legs (120s + 120s on the call path) would outlive
    // the kernel's reply timer, and a late retry both executes the tool
    // after the page saw failure and erases selections nobody can see.
    // No budget left → surface the 400 as-is; the selections are KEPT
    // (the bare retry never ran, so nothing confirmed the selection was
    // the problem — same posture as a retry that fails).
    const remaining =
      Math.min(
        (budgetMs ?? KERNEL_REPLY_MS) - (Date.now() - started),
        MCP_TIMEOUT_MS[path],
      ) - RETRY_SLACK_MS;
    if (remaining <= 0) return r;
    // Cold path: re-parse the payload (plain data by construction) rather
    // than paying a defensive body copy on every request.
    const retry = JSON.parse(payload) as Record<string, unknown>;
    // What was actually SENT (and therefore what the 400 is evidence
    // about) travels to the drop: a selection freshly committed while
    // this request was in flight was never seen by the CP, and the drop
    // must not erase it.
    const sent = retry[selectionKey];
    delete retry[selectionKey];
    return go(JSON.stringify(retry), remaining).then((r2) => {
      if (r2.ok) dropSelections(failingServer, sent);
      return r2;
    });
  });
}

/** True when a CP error body is structured JSON with a string error.code —
 * the shape every policy denial and classified upstream failure has. A
 * plain-text body (the CP's pre-toolbox http.Error rejects) is not. */
export const structuredErrBody = (text: string): boolean => {
  try {
    const e = (JSON.parse(text) as { error?: { code?: unknown } }).error;
    return typeof e?.code === "string";
  } catch {
    return false;
  }
};

// Cap a connector/tool name for display in the consent dialog. 128 matches
// policy.go's tool-name length bound, so policy-valid names render in full
// (the viewer is granting the full scope and must be able to inspect it);
// shell.css wraps/clamps at that length. The cap only bites on policy skew.
// The cap also bounds HOST_RE (sync — keep in lock-step): hostAllowed's
// badge gate is only sound for strings this function returns unchanged, so
// candidate hosts must never be long enough to truncate.
const capName = (s: unknown): string => {
  // parseManifest validates Array.isArray(tools) but not element types;
  // coerce so a non-string entry renders as its string form rather than
  // throwing mid-dialog (which applyBoot would catch and pin MCP off).
  const str = String(s);
  const cp = [...str];
  return cp.length > 128 ? `${cp.slice(0, 127).join("")}…` : str;
};

// Chunk-isolated duplicate of render.ts's el() — see file header.
const el = (
  tag: string,
  cls?: string,
  ...kids: (Node | string)[]
): HTMLElement => {
  const n = document.createElement(tag);
  if (cls) n.className = cls;
  n.append(...kids);
  return n;
};

const byId = (id: string): HTMLElement | null => document.getElementById(id);

/** sync: chrome/util.ts safeTimeout — the legacy-tail fallback for the
 *  missing AbortSignal.timeout static (which THROWS synchronously where
 *  absent), duplicated per bundle entry (the entries may not share
 *  modules; see CLAUDE.md chunk isolation). Keep every copy in
 *  lock-step; safeTimeout.test.ts enforces it. */
const safeTimeout = (ms: number): AbortSignal => {
  if ("timeout" in AbortSignal) return AbortSignal.timeout(ms);
  const ac = new AbortController();
  setTimeout(() => ac.abort(), ms);
  return ac.signal;
};

const mcpQuery = (h: BrokerHost): string =>
  h.org ? `?org=${encodeURIComponent(h.org)}` : "";

/** The shell's re-auth status check's own transport — ONE reader. The
 * frame's listTools path deliberately does NOT share it: that goes
 * through postMcp, which carries selections and the 400-remedy, while
 * this body is literally {slug, mcpToken} and so structurally CANNOT
 * carry a selection — keep it that way; unifying the two transports
 * would re-open the reauth path into the selection-drop machinery.
 * Status checks stamp `src=reauth` as forward-compatible attribution:
 * the CP does not yet read `src` into any metric or trace label (APM
 * strips query strings), so today the split is visible only in raw
 * access logs — the stamp is what lets a CP-side bounded label
 * distinguish shell chrome from frame traffic once it reads it. */
const postMcpServers = (
  h: BrokerHost,
  tokenSlot: TokenSlot,
  src?: "reauth",
): Promise<Response> => {
  const q = mcpQuery(h);
  const qs = src ? `${q}${q ? "&" : "?"}src=${src}` : q;
  return fetch(`/api/frame/mcp/servers${qs}`, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json", ...h.cpHeaders },
    // Token read at serialization time — see TokenSlot.
    body: JSON.stringify({ slug: h.frameUuid, mcpToken: tokenSlot.value }),
    signal: safeTimeout(65_000),
  });
};

/**
 * Kernel telemetry relay (kernel → shell → /api/frame/telemetry), shared
 * by every cap wire so a self-only frame keeps its transform/chunk-load
 * signals. Everything on this path is author-forgeable postMessage, so
 * every detail is re-bounded shell-side before it becomes a metric label.
 * Exactly one wire per generation relays (see wire() in mountBrokered:
 * wireMcp when present, else wireSelf) so dual-cap frames never
 * double-report.
 */
function relayCapTelemetry(h: BrokerHost, data: unknown): void {
  const tm = (data as { __frame_cap_telemetry?: unknown } | null)
    ?.__frame_cap_telemetry;
  if (!tm || typeof tm !== "object") return;
  // Granted-cap chunk-load / install() failure — the no-fallback
  // failure mode. Cap name re-bounded to the known released set;
  // anything else (including forged names) buckets as "other".
  if ((tm as { kind?: unknown }).kind === "cap-load-error") {
    const cap = (tm as { cap?: unknown }).cap;
    h.report("cap-load-error", cap === "mcp" || cap === "self" ? cap : "other");
    return;
  }
  // Transform fail-open telemetry — only the leg enum crosses; the
  // change-id is author-influenceable via init.changes[] and would blow
  // up label cardinality.
  const leg = (tm as { leg?: unknown }).leg;
  h.report(
    "transform-error",
    leg === "returns" || leg === "throws" ? leg : "unknown",
  );
}

/** Self-publish request budget. One bounded POST (the CP's own body-read
 * deadline is 60 s and the commit adds seconds, not minutes), well under
 * the kernel's 130 s reply timer so the page always hears the outcome
 * from us, never from its own timeout. */
const SELF_TIMEOUT_MS = 90_000;
/** The stale-token retry's ceiling — the actual leg is clamped to the
 * remaining kernel reply window at retry time (see wireSelf). */
const SELF_RETRY_TIMEOUT_MS = 30_000;

/**
 * Project a /api/frame/self/{slug} failure onto the runtime contract's
 * SelfErrorCode set — documented fields only, never a request echo. The
 * status mapping mirrors what the CP route actually emits: the 409
 * lost-update guard is the ONE structured-JSON reject ({conflict, live});
 * everything else is plain text, so classification is by status with the
 * two text splits the contract distinguishes (404 is the CP's oracle-safe
 * non-writer deny; 403 splits declared-vs-transport).
 */
function projectSelfErr(status: number, text: string): SelfErr {
  try {
    const j = JSON.parse(text) as {
      conflict?: unknown;
      live?: unknown;
      error?: unknown;
    };
    if (j.conflict === true) {
      return selfErr(
        "conflict",
        "a newer version is live",
        typeof j.live === "string" ? j.live : undefined,
      );
    }
    if (j.error === "frame_daily_push_cap_reached") {
      return selfErr("rate_limited", "daily publish limit reached");
    }
  } catch {
    /* plain-text body */
  }
  if (status === 404) {
    // The CP 404s non-writers without an existence oracle — from a page
    // that could boot this frame, the only reachable meaning is a write
    // the viewer may not make (mid-session revocation included).
    return selfErr("not_writer", "this viewer cannot write this artifact");
  }
  if (status === 403) {
    // sync: api-go/frame/internal/service/selfpublish.go — the declared
    // check's 403 body is "self-publish is not declared by this artifact";
    // a CP copy edit that drops "not declared" silently degrades this to
    // upstream_error (retryable) where the contract wants read-only.
    return text.includes("not declared")
      ? selfErr("not_declared", "the artifact no longer declares self-update")
      : selfErr("upstream_error", `request failed (${status})`);
  }
  if (status === 400) {
    // sync: decodeHTMLField's size reject is "content exceeds %d bytes"
    // (api-go/frame/internal/service/deploy_direct.go) — same lock-step
    // caveat as the 403 split above.
    return /exceeds \d+ bytes/.test(text)
      ? selfErr("too_large", "the page exceeds the size limit")
      : selfErr("invalid_content", "the submitted page was rejected");
  }
  if (status === 413) {
    return selfErr("too_large", "the page exceeds the size limit");
  }
  if (status === 429) return selfErr("rate_limited", "publishing too often");
  return selfErr("upstream_error", `request failed (${status})`);
}

/**
 * Wire the iframe → shell → POST /api/frame/self/{slug} publish bridge.
 *
 * Same boundary as wireMcp: only messages from the iframe's exact origin
 * AND contentWindow are accepted; replies go back to that origin only.
 * The self token (CSRF double-submit) and the CAS base version stay
 * closure-local — the page supplies ONLY the replacement HTML.
 *
 * `granted:false` wires a refusing bridge: a declared-but-unconsented
 * (or consent-declined) frame gets a fast `consent_required` rejection
 * instead of the kernel's 130 s timeout.
 */
function wireSelf(
  h: BrokerHost,
  iframe: HTMLIFrameElement,
  slot: TokenSlot,
  baseVersion: string,
  granted: boolean,
  relayTelemetry: boolean,
): () => void {
  const { frameOrigin, frameUuid } = h;
  const onMessage = (e: MessageEvent): void => {
    // Same connection gate as wireMcp: never spend viewer credentials on
    // an iframe the viewer has already seen replaced.
    if (!iframe.isConnected) return;
    if (e.origin !== frameOrigin || e.source !== iframe.contentWindow) return;
    const cap = parseFrameCap(e.data);
    if (cap && cap.cap === "self") {
      const send = (b: { result: unknown } | { error: SelfErr }): void =>
        iframe.contentWindow?.postMessage(
          frameCapReply(cap.id, b),
          frameOrigin,
        );
      if (cap.method !== "publish") {
        return send({
          error: selfErr("upstream_error", `unknown method ${cap.method}`),
        });
      }
      if (!granted) {
        return send({
          error: selfErr(
            "consent_required",
            "the viewer has not granted self-update for this artifact",
          ),
        });
      }
      const [html] = cap.args;
      if (typeof html !== "string") {
        return send({
          error: selfErr(
            "invalid_content",
            "publish takes a single HTML string",
          ),
        });
      }
      if (baseVersion === "") {
        // A boot without a ver cannot CAS — fail closed rather than let
        // the CP's baseVersion 400 misreport as invalid_content.
        return send({
          error: selfErr("upstream_error", "missing version context"),
        });
      }
      const q = h.org ? `?org=${encodeURIComponent(h.org)}` : "";
      // Body serializes per attempt: the retry below must carry the
      // REFRESHED slot value, not the bytes of the first leg.
      const go = (
        budgetMs: number,
      ): Promise<{ status: number; ok: boolean; text: string }> =>
        fetch(`/api/frame/self/${frameUuid}${q}`, {
          method: "POST",
          credentials: "same-origin",
          headers: { "Content-Type": "application/json", ...h.cpHeaders },
          body: JSON.stringify({
            content: html,
            baseVersion,
            token: slot.value,
          }),
          signal: safeTimeout(budgetMs),
        }).then(async (r) => ({
          status: r.status,
          ok: r.ok,
          text: await r.text(),
        }));
      const finish = (r: {
        status: number;
        ok: boolean;
        text: string;
      }): void => {
        if (!r.ok) {
          const err = projectSelfErr(r.status, r.text);
          send({ error: err });
          // The contract tells a conflicted page the shell is already
          // reloading to the winner — make that true even when the
          // frame-live channel is off or its socket died (same reason
          // the 200 path kicks): without this, the losing tab's frozen
          // baseVersion makes every later publish 409 too.
          if (err.code === "conflict") void h.requestReboot();
          return;
        }
        let version = "";
        try {
          const j = JSON.parse(r.text) as { version?: unknown };
          if (typeof j.version === "string") version = j.version;
        } catch {
          /* malformed */
        }
        if (version === "") {
          return send({
            error: selfErr("upstream_error", "malformed response"),
          });
        }
        send({ result: { version } });
        // Converge the publishing tab even when the live channel is off
        // (flag-gated) or its socket died: the reboot is single-flight
        // shell-side and a no-op if the frame-live push already landed.
        void h.requestReboot();
      };
      const sentToken = slot.value;
      const started = Date.now();
      void go(SELF_TIMEOUT_MS)
        .then((r) => {
          // Stale-token recovery, the postMcp replay posture: a
          // non-"not declared" 403 is the CP's pre-handler reject
          // (expired/invalid token — e.g. no live socket ever refreshed
          // it). requestReboot re-boots; the same-ver path runs
          // refreshBrokerToken, which re-mints THIS slot in place
          // (same-generation binding), so retry once iff the value
          // moved. A ver-change reboot remounts a new generation and
          // leaves this slot cold — the projected error stands and the
          // fresh mount carries the write path forward.
          if (r.status === 403 && !r.text.includes("not declared")) {
            return h.requestReboot().then(() => {
              // The retry runs on the REMAINING kernel budget, never a
              // fresh timeout (same clamp as postMcp's replay): a late
              // reply would land after the kernel already rejected the
              // page's publish, turning a server-side success into a
              // false failure.
              const remainingMs =
                KERNEL_REPLY_MS - (Date.now() - started) - RETRY_SLACK_MS;
              if (slot.value !== sentToken && remainingMs > 0) {
                return go(Math.min(SELF_RETRY_TIMEOUT_MS, remainingMs)).then(
                  finish,
                );
              }
              finish(r);
            });
          }
          finish(r);
        })
        .catch(() =>
          send({ error: selfErr("upstream_error", "request failed") }),
        );
      return;
    }
    if (relayTelemetry) relayCapTelemetry(h, e.data);
  };
  window.addEventListener("message", onMessage);
  // No reauthGen bump here: the reauth prompt family is connector-only
  // machinery; wireMcp's teardown owns that clock.
  return () => {
    window.removeEventListener("message", onMessage);
  };
}

/**
 * Wire the iframe → shell → /api/frame/mcp/{call,servers} postMessage broker.
 *
 * Origin + window-identity is the boundary: only messages from the iframe's
 * exact origin AND contentWindow are accepted; replies go back to that origin
 * only. mcpToken (CSRF double-submit) stays closure-local. The backend
 * enforces manifest ∩ viewer-bootstrap per call — this is the transport.
 *
 * Every rejection is projected to `{code, server?, message}` so frames can
 * branch on .code regardless of whether the failure came from the CP body,
 * fetch/timeout, or shell-side validation.
 *
 * Returns a tear-down that removes the window listener — called on the next
 * swap so brokers don't accumulate across re-boots.
 */
function wireMcp(
  h: BrokerHost,
  iframe: HTMLIFrameElement,
  tokenSlot: TokenSlot,
  consented: readonly ManifestServer[],
  selections: ReadonlyMap<string, string>,
  sync: SelectionSync,
): () => void {
  const { frameOrigin, frameUuid } = h;
  // Bind calls to the manifest the viewer actually consented to. The backend
  // enforces the CURRENT published manifest (so a republish can narrow scope),
  // but this check ensures a republish can't BROADEN scope for an already-open
  // session that wasn't re-wired — the intersection of consented ∩ backend.
  const consentedScope = new Map<string, Set<string>>(
    consented.map((s) => [s.server, new Set(s.tools)]),
  );

  // Project a Frame CP error body to the documented fields only — future
  // backend fields (or accidental request-echo) must not reach the iframe.
  const projectErr = (status: number, text: string, sid?: string): McpErr => {
    try {
      const e = (JSON.parse(text) as { error?: Partial<McpErr> }).error;
      if (e && typeof e.code === "string") {
        const srv = typeof e.server === "string" ? e.server : sid;
        return mcpErr(e.code, String(e.message ?? ""), srv);
      }
    } catch {
      /* non-JSON body */
    }
    // No 401 → needs_reauth mapping: a bare 401 here is most likely the
    // viewer's session expiring (antOnly), not a connector token issue.
    const code =
      status === 404
        ? "server_not_found"
        : status === 429 || status >= 500
          ? "server_unavailable"
          : "upstream_error";
    return mcpErr(code, `request failed (${status})`, sid);
  };
  const netErr = (err: unknown, sid?: string): McpErr =>
    mcpErr(
      err instanceof DOMException && err.name === "TimeoutError"
        ? "server_unavailable"
        : "upstream_error",
      "request failed",
      sid,
    );

  // One shared handler per RPC kind; `send` closes over the caller's
  // correlation id so replies go back as __frame_cap_r.
  type Send = (body: { result: unknown } | { error: McpErr }) => void;

  const handleList = (send: Send): void => {
    void sync
      .postServers()
      .then((r) => {
        if (!r.ok) return send({ error: projectErr(r.status, r.text) });
        // Project to the documented shape only.
        const parsed = JSON.parse(r.text) as {
          servers?: {
            server?: unknown;
            authStatus?: unknown;
            tools?: { name?: unknown; description?: unknown }[];
          }[];
        };
        // Shell-side bookkeeping (resolvedId echoes, collapsed-set
        // auto-switch) — consumes the response BEFORE the projection below
        // strips the selection fields; never renders UI from this path.
        sync.onEntries(parseServersEntries(parsed));
        // Filter to the consented scope — same gate as the callTool path
        // below. The backend result may include connectors the viewer
        // never agreed to (a republish, or a server the viewer declined);
        // their presence and authStatus are not this frame's to learn. The
        // same explicit re-projection is what keeps the new selection
        // fields (candidates, resolvedId — viewer-account data) out of
        // page reach: only the three documented fields are ever forwarded.
        send({
          result: {
            servers: (parsed.servers ?? []).flatMap((s) => {
              const server = String(s.server ?? "");
              const allowed = consentedScope.get(server);
              if (!allowed) return [];
              return [
                {
                  server,
                  authStatus: String(s.authStatus ?? ""),
                  tools: (s.tools ?? []).flatMap((t) => {
                    const name = String(t.name ?? "");
                    if (!allowed.has(name)) return [];
                    return [{ name, description: String(t.description ?? "") }];
                  }),
                },
              ];
            }),
          },
        });
      })
      .catch((err: unknown) => send({ error: netErr(err) }));
  };

  const handleCall = (
    send: Send,
    server: unknown,
    tool: unknown,
    input: unknown,
  ): void => {
    if (typeof server !== "string" || typeof tool !== "string") {
      return send({
        error: mcpErr("bad_request", "server and tool must be strings"),
      });
    }
    if (!consentedScope.get(server)?.has(tool)) {
      return send({
        error: mcpErr(
          "not_in_manifest",
          "outside the scope you consented to. Reload to review.",
          server,
        ),
      });
    }
    // Surface a fail-closed selection_required to the shell AFTER the page's
    // Promise settles — the shell re-derives the viewer's options (and the
    // CP never substitutes a connector the viewer didn't pick). A no-op for
    // every other body, so it wraps every reply path below uniformly.
    const sendSniffed: Send = (b) => {
      send(b);
      if ("error" in b && b.error.code === "selection_required") {
        sync.onSelectionRequired();
      }
    };
    const sel = selections.get(server);
    // input came via structured clone and may contain BigInt/cycles that
    // JSON.stringify throws on — serialize the whole body exactly once,
    // here, so the iframe's Promise settles on bad input and the hot path
    // pays no extra serialization. (postMcp's retry re-parses this payload
    // on its cold 400 path only; a stateful toJSON that passes this
    // stringify yields plain parsed data there, so it cannot throw later.)
    let payload: string;
    try {
      payload = JSON.stringify({
        slug: frameUuid,
        server,
        tool,
        input: input ?? {},
        // Read at serialization time — see TokenSlot.
        mcpToken: tokenSlot.value,
        // Omitted (not null/empty) without a committed selection: an old
        // CP's strict decoder must never see the field speculatively.
        ...(sel !== undefined && { serverId: sel }),
      });
    } catch {
      return send({
        error: mcpErr("bad_request", "input is not JSON-serializable", server),
      });
    }
    const started = Date.now();
    void postMcp(
      h,
      "call",
      payload,
      sel !== undefined ? "serverId" : null,
      sync.dropSelections,
      server,
    )
      .then(async (r) => {
        // Stale-mcpToken recovery: on THIS route the only non-JSON 403s the
        // CP emits are its pre-toolbox rejects (expired/invalid mcpToken,
        // Sec-Fetch-Site) — every policy denial is structured JSON with an
        // error.code. The legit shell never trips Sec-Fetch-Site, so this
        // shape means the token aged out (e.g. no live socket, so no
        // refresh reboot ever ran). Re-boot once (single-flight) and replay
        // once with the token that boot minted — taken from the reboot's
        // OWN result, not the slot, so correctness never depends on the
        // same-version refresh dispatch landing first. No tool executed
        // upstream (the CP rejected before toolbox), so the replay cannot
        // double-fire a side effect. The replay result is surfaced as-is —
        // a second plain 403 is NOT replayed again.
        if (!r.ok && r.status === 403 && !structuredErrBody(r.text)) {
          const fresh = await h.requestReboot();
          // Remaining kernel budget, measured from the ORIGINAL dispatch —
          // the reboot leg spent part of it. Generation must not have
          // moved (a remount during the reboot means a new bridge with its
          // own fresh token owns the session, and this closure's reply
          // belongs to a superseded generation), and the boot's org AND
          // account must match the slot's mount-time binding — the same
          // guards refreshBrokerToken applies. The account guard is
          // load-bearing (an account switch does not remount; consent is
          // per-account — see TokenSlot); the org guard is defense-in-depth
          // (an org-changing boot nulls `current` and remounts).
          const remaining =
            Math.min(
              KERNEL_REPLY_MS - (Date.now() - started),
              MCP_TIMEOUT_MS.call,
            ) - RETRY_SLACK_MS;
          if (
            fresh !== null &&
            fresh.org === tokenSlot.org &&
            assetTokenAccount(fresh.assetToken) === tokenSlot.account &&
            tokenSlot.gen === brokerGeneration &&
            remaining > 0
          ) {
            tokenSlot.value = fresh.token;
            const replay = JSON.parse(payload) as Record<string, unknown>;
            replay.mcpToken = fresh.token;
            r = await postMcp(
              h,
              "call",
              JSON.stringify(replay),
              sel !== undefined ? "serverId" : null,
              sync.dropSelections,
              server,
              remaining,
            );
          }
        }
        const text = r.text;
        if (!r.ok) {
          return sendSniffed({ error: projectErr(r.status, text, server) });
        }
        // CP parses the toolbox SSE/JSON-RPC envelope server-side and returns
        // {result: <mcp-result>} or {error: mcpErrOut}; unwrap here so
        // callTool() resolves with the bare MCP result object. A non-JSON
        // body falls back to raw text.
        let j: unknown;
        try {
          j = JSON.parse(text);
        } catch {
          return sendSniffed({ result: text });
        }
        if (!j || typeof j !== "object" || Array.isArray(j)) {
          return sendSniffed({ result: j });
        }
        const o = j as { result?: unknown; error?: Partial<McpErr> };
        if (o.error) {
          const code =
            typeof o.error.code === "string" ? o.error.code : "upstream_error";
          const srv =
            typeof o.error.server === "string" ? o.error.server : server;
          return sendSniffed({
            error: mcpErr(code, String(o.error.message ?? ""), srv),
          });
        }
        if (!("result" in o)) {
          return sendSniffed({
            error: mcpErr("upstream_error", "malformed response", server),
          });
        }
        sendSniffed({ result: o.result });
      })
      .catch((err: unknown) => sendSniffed({ error: netErr(err, server) }));
  };

  const onMessage = (e: MessageEvent): void => {
    // iframe.isConnected is false after mountIframe's IFRAME_TIMEOUT_MS fallback removed it
    // (or after a swap) — refuse to spend viewer credentials on a connection
    // the viewer has already seen replaced.
    if (!iframe.isConnected) return;
    if (e.origin !== frameOrigin || e.source !== iframe.contentWindow) return;

    // Generic envelope — what kernel-mounted cap modules emit.
    const cap = parseFrameCap(e.data);
    if (cap && cap.cap === "mcp") {
      const send: Send = (b) =>
        iframe.contentWindow?.postMessage(
          frameCapReply(cap.id, b),
          frameOrigin,
        );
      if (cap.method === "listTools") return handleList(send);
      if (cap.method === "callTool") {
        const [s, t, i] = cap.args;
        return handleCall(send, s, t, i);
      }
      return send({
        error: mcpErr("bad_request", `unknown method ${cap.method}`),
      });
    }

    relayCapTelemetry(h, e.data);
  };

  window.addEventListener("message", onMessage);
  // The teardown does NOT post __frame_cap_settle: setUnwire() invokes it on
  // every re-wire (e.g. consent-Continue expanding scope on a live iframe),
  // and a settle there would permanently poison the kernel's latched state.
  // The kernel's own post-init settle covers never-granted; a withdrawn
  // iframe is removed at remount anyway.
  return () => {
    // A torn-down wire also invalidates any pending pre-reveal re-auth
    // wait: every teardown routes through this closure (re-wire
    // replacement, teardownBroker when a re-boot drops all caps, applyBoot's
    // failure unwires), so a superseded boot can never mount a stale
    // prompt over successor content. On a re-wire the bump fires inside
    // setUnwire(), BEFORE the new wire path captures reauthGen — the
    // capture still reads the final value.
    reauthGen++;
    window.removeEventListener("message", onMessage);
  };
}

// ── Consent state ────────────────────────────────────────────────────────
// Consent is a SET of (server, tool) capabilities the viewer has accepted,
// cached in localStorage keyed on viewer identity + slug. We re-prompt only
// when a manifest requests a capability NOT already in that set (so a republish
// that keeps or drops capabilities is silent, and a republish can never
// auto-grant a new one). Any unreadable/corrupt store is treated as
// indeterminate → "nothing consented" → the prompt is the only path to wire.

function consentStoreKey(h: BrokerHost, assetToken: string): string {
  // Key includes viewer identity so an account or org switch on a shared
  // browser re-prompts (localStorage is per-profile, not per-session).
  return `frame-mcp-consent:${h.org ?? "-"}:${assetTokenAccount(assetToken)}:${h.frameUuid}`;
}

/** Pre-selection inputs for the picker and the regrown-set reconciliation:
 * the committed choice (session record in `selectedId`; `storeSelectedId`
 * carries the store's value only when it diverges) and the live incumbent.
 * ONE definition — pickDefault and the reconciliation must consume the same
 * session-first-valid-wins semantics (see `pref` in mountBrokered). */
interface SelectionPref {
  selectedId?: string;
  storeSelectedId?: string;
  resolvedId?: string;
}

/** One stored consent entry. Pre-picker stores carry only {server, tools};
 * both id fields are optional by construction and absence means "no record". */
interface StoredServer {
  tools: Set<string>;
  /** The viewer's committed connector choice for this (duplicated) name —
   * written only by a consent-dialog Continue (the one-remaining
   * auto-switch is session-only and never persisted). Attached to
   * /mcp/call and /mcp/servers requests. */
  selectedId?: string;
  /** Last resolution echo from /mcp/servers — the incumbent record. When a
   * duplicate set later appears, the picker pre-selects this connector (the
   * one that was actually resolving) rather than re-defaulting. */
  resolvedId?: string;
}

/**
 * Read the consented capability set. Returns null on an INDETERMINATE store
 * (storage blocked or corrupt JSON) — distinct from an empty Map (genuinely
 * no prior consent). The caller treats null as deny-by-default.
 */
function readConsented(key: string): Map<string, StoredServer> | null {
  let raw: string | null;
  try {
    raw = localStorage.getItem(key);
  } catch {
    return null; // storage blocked — indeterminate
  }
  if (raw === null) return new Map();
  try {
    const arr = JSON.parse(raw) as {
      server?: unknown;
      tools?: unknown;
      selectedId?: unknown;
      resolvedId?: unknown;
    }[];
    const m = new Map<string, StoredServer>();
    for (const s of Array.isArray(arr) ? arr : []) {
      if (!s || typeof s.server !== "string" || !Array.isArray(s.tools)) {
        continue;
      }
      const entry: StoredServer = {
        tools: new Set(
          s.tools.filter((t): t is string => typeof t === "string"),
        ),
      };
      // Shape-gate stored ids (UUID_RE comment above): a non-uuid value is
      // dropped, not treated as corrupt — the tools set it rides with is
      // still genuine consent.
      if (typeof s.selectedId === "string" && UUID_RE.test(s.selectedId)) {
        entry.selectedId = s.selectedId;
      }
      if (typeof s.resolvedId === "string" && UUID_RE.test(s.resolvedId)) {
        entry.resolvedId = s.resolvedId;
      }
      m.set(s.server, entry);
    }
    return m;
  } catch {
    return null; // corrupt — indeterminate
  }
}

/** True iff the manifest asks for a capability not in `consented`. */
function hasNewCaps(
  servers: readonly ManifestServer[],
  consented: Map<string, StoredServer>,
): boolean {
  for (const s of servers) {
    const ok = consented.get(s.server)?.tools;
    for (const t of s.tools) if (!ok?.has(t)) return true;
  }
  return false;
}

function writeStore(key: string, m: Map<string, StoredServer>): void {
  const arr = [...m].map(([server, e]) => ({
    server,
    tools: [...e.tools],
    ...(e.selectedId !== undefined && { selectedId: e.selectedId }),
    ...(e.resolvedId !== undefined && { resolvedId: e.resolvedId }),
  }));
  try {
    localStorage.setItem(key, JSON.stringify(arr));
  } catch {
    /* storage blocked — state is session-only */
  }
}

/**
 * Persist consented ∪ manifest so a later re-add of a dropped cap is silent.
 * `picks` carries the dialog's committed connector choices (server →
 * connector uuid) — recorded as selectedId on the same write, since Continue
 * commits consent and selection as one gesture. `incumbents` carries the
 * session's observed resolutions (the live resolvedId view): echoes observed
 * BEFORE consent have no store entry to land in, so the consent write is
 * what makes the incumbent durable — without this, a next-session duplicate
 * set could not pre-select the connector that was actually resolving (the
 * rename-join mitigation).
 */
function persistConsent(
  key: string,
  servers: readonly ManifestServer[],
  picks?: ReadonlyMap<string, string>,
  incumbents?: ReadonlyMap<string, string>,
): void {
  const merged = readConsented(key);
  if (merged === null) {
    // Indeterminate store (blocked or corrupt): writing `manifest ∪ nothing`
    // would lossily overwrite whatever is actually in there. Skip persisting —
    // this acceptance stays session-only and the next load re-prompts.
    return;
  }
  for (const s of servers) {
    let entry = merged.get(s.server);
    if (!entry) {
      entry = { tools: new Set() };
      merged.set(s.server, entry);
    }
    for (const t of s.tools) entry.tools.add(t);
  }
  if (picks) {
    for (const [server, id] of picks) {
      const entry = merged.get(server);
      if (entry) entry.selectedId = id;
    }
  }
  if (incumbents) {
    for (const [server, id] of incumbents) {
      const entry = merged.get(server);
      if (entry) entry.resolvedId = id;
    }
  }
  writeStore(key, merged);
}

/**
 * Silent metadata update outside the consent gesture: resolvedId echoes
 * and the 400-remedy selection drop (the auto-switch never writes the
 * store). Never touches the tools sets. An indeterminate store skips the write —
 * same posture as persistConsent; the live `selections` map carries the
 * session.
 */
function updateStoredIds(
  key: string,
  mutate: (m: Map<string, StoredServer>) => void,
): void {
  const m = readConsented(key);
  if (m === null) return;
  mutate(m);
  writeStore(key, m);
}

/** Option label for one duplicate-connector candidate — distinguishing
 * metadata only (duplicates share the display name by definition). Built
 * from CP-derived fields but still rendered as TEXT NODES ONLY (option
 * textContent): host strings originate from viewer-entered connector URLs.
 * No icons — iconUrl is user-controlled for custom connectors and a native
 * <option> can't render one anyway. */
function candidateLabel(c: Candidate, n: number): string {
  const parts: string[] = [];
  if (c.host !== undefined) parts.push(capName(c.host));
  if (c.addedAt !== undefined) {
    const d = new Date(c.addedAt);
    if (!Number.isNaN(d.getTime())) {
      parts.push(
        `Added ${d.toLocaleDateString(undefined, {
          year: "numeric",
          month: "long",
          day: "numeric",
        })}`,
      );
    }
  }
  if (c.provenance === "managed_auth") parts.push("Managed");
  return parts.length > 0 ? parts.join(" · ") : `Connector ${n}`;
}

/**
 * The dropdown's default, in precedence order — viewer-backed signals
 * first, then the always-rung:
 * the viewer's own committed selection (session record first, then the
 * store's — mirroring the reconciliation's validity-gated fallback, so a
 * picker shown while a cross-tab choice is routing pre-selects that choice
 * rather than nothing); the CP's resolution echo; the recorded incumbent
 * (the connector that was resolving before this duplicate set appeared —
 * rubber-stamping the dialog then preserves the status quo rather than
 * honoring a joiner, the rename-join mitigation).
 *
 * When no viewer-backed signal matches (first consent, typically), the
 * dialog still pre-selects — the owner's override (ts 1783342547: always
 * a default, deterministic and predictable): PERSONAL over MANAGED
 * (managed = provenance === "managed_auth" — an auth-method APPROXIMATION
 * of ownership, because the wire carries no true ownership field today:
 * isDirectoryServer's explicit-False bucket mixes org-admin-added with
 * first-party-migrated rows per its own schema comment; an org-managed
 * connector the viewer authed by browser OAuth therefore classes as
 * personal — the residual named in the security disposition, where the
 * host/date/Managed labels remain the control), oldest parseable addedAt
 * within the class (a dated candidate outranks an undated one — a known
 * age beats an unknown), and lowest connector id as the final
 * deterministic tie-break. The personal-first layer closes the
 * first-consent rename-capture for connectors the wire classifies
 * FAITHFULLY: a genuinely-personal connector cannot be planted by an
 * admin, and an all-managed set has no cross-boundary victim stream.
 * THE RESIDUAL IS REAL: an admin-controlled connector the viewer authed
 * by browser OAuth (or carrying no provenance at all — absent classes
 * as personal identically) falls in the approximation's gap, can
 * win the personal class, and then becomes the PRESELECTED default —
 * which the viewer accepts by clicking Continue without reading the
 * labels. Always-preselect makes that strictly weaker than the
 * forced-choice rule it replaced (the label check is now optional on
 * the common path). The CP-side ownership field (tracked follow-up)
 * is what removes this gap; until it lands, the host/date/Managed
 * labels — guaranteed visible behind a full arming window of the
 * patched rows (the entries-patch re-disarm) — are the only control
 * for the misclassified case. Do not
 * strengthen this comment's claims without landing that field first.
 * `ids` is the caller's candidate-id set — the viewer-backed rungs are
 * membership-checked so a vanished id can never pre-select; the
 * always-rung picks among the candidates themselves.
 */
function pickDefault(
  e: ServersEntry,
  pref: SelectionPref,
  ids: ReadonlySet<string>,
): string | null {
  if (pref.selectedId !== undefined && ids.has(pref.selectedId)) {
    return pref.selectedId;
  }
  if (pref.storeSelectedId !== undefined && ids.has(pref.storeSelectedId)) {
    return pref.storeSelectedId;
  }
  if (e.resolvedId !== undefined && ids.has(e.resolvedId)) return e.resolvedId;
  if (pref.resolvedId !== undefined && ids.has(pref.resolvedId)) {
    return pref.resolvedId;
  }
  const managed = (c: Candidate): number =>
    c.provenance === "managed_auth" ? 1 : 0;
  const when = (c: Candidate): number =>
    c.addedAt === undefined ? Number.NaN : new Date(c.addedAt).getTime();
  const first = [...e.candidates].sort((a, b) => {
    const cls = managed(a) - managed(b); // personal (0) before managed (1)
    if (cls !== 0) return cls;
    const ta = when(a);
    const tb = when(b);
    const aOk = !Number.isNaN(ta);
    const bOk = !Number.isNaN(tb);
    if (aOk && bOk && ta !== tb) return ta - tb; // oldest first
    if (aOk !== bOk) return aOk ? -1 : 1; // dated before undated
    return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // id tie-break
  })[0];
  return first ? first.id : null;
}

/**
 * Shared pre-open sequence for every broker-chunk dialog (consent, re-auth):
 * ONE decision surface at a time, then the content-inert claim.
 */
function claimDecisionSurface(h: BrokerHost): void {
  const loading = byId("loading");
  if (loading) loading.hidden = true;
  // ONE decision surface at a time, part 0: an open chrome-rendered reauth
  // dialog registered the closeConsent slot — fire it so that dialog
  // closes through its own teardown (watchers, popup, inert flag) BEFORE
  // this surface's arming overwrites the registration. No-op when the
  // slot is empty.
  h.callCloseConsent();
  // ONE decision surface at a time: the nav-confirm is a fixed
  // full-viewport overlay (.consent.nav) while this consent dialog is
  // slot-scoped — left open, the nav-confirm would contest or occlude this
  // dialog while its own arming guard kept running. Close it — the
  // viewer can re-request the link after deciding here. ORDER MATTERS:
  // this runs BEFORE the inert set below. close()'s re-derive sees neither
  // dialog in this window (our box isn't appended yet, the nav marker just
  // nulled) and un-inerts + focuses the iframe; the inert set re-asserts
  // the claim within the same synchronous task, so no user input can land
  // in the gap, and box.focus() at the end re-anchors focus.
  h.closeNavConfirm();
  // Same for the chrome's delete-confirm (a Base-UI modal with its own
  // focus trap and document-level Escape handler): left open, it would
  // contest this dialog's box.focus()/Tab trap and make Escape/Enter
  // ambiguous. A synthetic Escape on the live node lets Base UI run its
  // own close path (TitleMenu's release rAF-loop then waits for full
  // unmount before touching inert — covered by shell's contentInertClaim).
  document
    .querySelector<HTMLElement>(h.confirmOpenSelector)
    ?.dispatchEvent(
      new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
    );
  // The content iframe is INERT for the dialog's lifetime: the arming
  // guard's quiet-clock reasoning is only sound when there is NO input
  // channel invisible to the document-level reset listeners, and non-inert
  // cross-origin content is exactly such a channel. inert blocks USER
  // input only: frame JS keeps running and already-wired covered brokers
  // still serve its postMessage calls, so granted capabilities continue
  // working while the viewer decides. This also makes the dialog's
  // aria-modal declaration true for assistive tech.
  h.inertContent();
}

/** The consent dialog's synchronous one-surface claim. Set at
 * showConsentDialog ENTRY (before its chrome await) and cleared on every
 * death path, so the picker re-show guard and the reauth reveal-poll —
 * whose guard-to-claim blocks must stay synchronous — see an in-flight
 * consent prompt the same instant it is requested. */
let consentClaim: { closed: boolean } | null = null;

/** How long the consent path waits for the chrome chunk before failing
 * closed. The chunk import races the broker's own load from the same
 * boot, so this only bites on a genuinely broken chrome. */
const CONSENT_CHROME_WAIT_MS = 20_000;

/**
 * Blocking manifest-consent prompt — rendered by the chrome chunk as a
 * CDS dialog (ChromeHandle.showConsentDialog / chrome/ConsentDialog, per
 * the frame-dialogs-use-CDS decision). Shows the frame's declared scope
 * ("This app can access: ...") and calls onContinue/onCancel. The backend
 * enforces manifest ∩ bootstrap regardless — this is the UX consent
 * moment, not the security boundary.
 *
 * CONSENT IS FAIL-CLOSED: the dialog host lives in the async-loaded
 * chrome chunk, so this path awaits chromeReady (bounded) and treats an
 * unavailable chrome as a decline — consent that can't be ASKED is
 * consent NOT GIVEN; content never mounts on a promptless path. The
 * one-surface claim is still taken synchronously at entry (before the
 * await), preserving the zero-await mutex the sibling guards rely on,
 * and the external closer registers just as early so clearForRemount and
 * the 403 revocation path can retire a still-pending prompt.
 *
 * Duplicate-connector picker: `entries` resolves with the viewer's
 * resolution state; any manifest row with ≥2 same-named callable
 * connectors is patched into a picker whose options carry the
 * distinguishing info (host, added date, Managed). The dialog renders as
 * soon as the chrome is live and patches async (the bootstrap behind
 * /mcp/servers can take tens of seconds); a failed fetch leaves the
 * dialog exactly as before, and un-selected duplicated calls fail closed
 * server-side. Continue commits the selections — including an untouched
 * pre-selection — so every consented viewer has a concrete connector
 * choice by construction; with a no-default row, the dialog refuses
 * Continue until the viewer picks, and the commit is re-verified here
 * against the candidate ids each picker was built from ("option values
 * only ever come from CP candidates" holds at the commit, not just at
 * construction).
 */
function showConsentDialog(
  h: BrokerHost,
  servers: readonly ManifestServer[],
  /** Null on the re-show path (statuses were already displayed at
   * consent; the re-show is selection-only) — connection rows and their
   * actions are skipped without a token to act with. */
  tokenSlot: TokenSlot | null,
  cb: {
    onContinue: (picks: ReadonlyMap<string, string>) => void;
    onCancel: () => void;
    /** Per-server resolution state (picker rows) + auth statuses and
     * directory enrichment (connection rows); null = fetch failed. */
    entries: Promise<{
      entries: ServersEntry[];
      auth: AuthStatusResult;
    } | null>;
    /** Stored selection/incumbent records for pre-selection. */
    pref: (server: string) => SelectionPref;
    /**
     * The re-show path: scope is already granted and the ask is the
     * picker, so the header says that — a mid-session viewer shouldn't
     * read the re-shown picker as consent being re-requested. Header
     * copy only; don't hang behavior on this flag.
     */
    reshowForSelection?: boolean;
    /** Render the fixed self-update capability row. Display flag only —
     * the grant itself is the sentinel entry in the consent scope. */
    selfAsk?: boolean;
  },
): void {
  const loading = byId("loading");
  // Pre-open sequence (sibling-dialog closes, content inert) — shared
  // with the reauth prompt. Runs synchronously with the claim below.
  claimDecisionSurface(h);
  const claim = { closed: false };
  let dlg: ConsentDialogHandle | null = null;
  let ctrl: ReturnType<typeof connectorActions> | null = null;
  consentClaim = claim;
  h.setDecisionSurfaceOpen(true);
  // The dialog paints only after the chrome await below — until then keep
  // the loading pane up (claimDecisionSurface hid it for the vanilla
  // render-immediately flow) and anchor focus on the slot so keyboard/AT
  // users aren't stranded on <body> while the content sits inert. Base
  // UI's initialFocus takes over when the dialog mounts.
  if (loading) loading.hidden = false;
  h.slot.tabIndex = -1;
  h.slot.focus();

  /** Every death path: release the claim, slot, flag, and inert posture,
   * restore the loading pane, and re-anchor focus (the focused control
   * just left the DOM — without this it drops to <body> and keyboard/AT
   * users lose their place). */
  const settle = (): void => {
    claim.closed = true;
    if (consentClaim === claim) consentClaim = null;
    ctrl?.stop();
    h.setCloseConsent(null);
    h.setDecisionSurfaceOpen(false);
    h.restoreContentInteractivity();
    if (loading) loading.hidden = false;
    h.slot.tabIndex = -1;
    h.slot.focus();
  };
  // External close (clearForRemount, 403 revocation, a sibling surface's
  // claimDecisionSurface): silent teardown — the generation is being
  // replaced, so neither callback fires. Registered BEFORE the chrome
  // await so a still-pending prompt is retirable too.
  h.setCloseConsent(() => {
    settle();
    dlg?.close();
  });

  // Continue re-verifies each committed value against the candidate set
  // its picker was rendered from — keeps the "option values only ever
  // come from CP candidates" invariant local to the commit.
  const candidateIds = new Map<string, ReadonlySet<string>>();

  let waitTimer = 0;
  const chromeWait = Promise.race([
    h.chromeReady(),
    new Promise<boolean>((r) => {
      waitTimer = window.setTimeout(() => r(false), CONSENT_CHROME_WAIT_MS);
    }),
  ]);
  void chromeWait.then((ok) => {
    window.clearTimeout(waitTimer);
    if (claim.closed) return;
    dlg =
      (ok ? h.chrome() : null)?.showConsentDialog?.(
        servers
          // Belt-and-braces: callers pass connector-only lists here, but
          // the sentinel must never render as a connector row.
          .filter((s) => s.server !== SELF_SENTINEL)
          .map((s) => ({
            // RAW name: it is the join key commitPicks/persistConsent/
            // wireMcp route by — the chrome caps for DISPLAY (capDisplay).
            // A capped key would strand >128-cp names: pick committed
            // under the truncated key, call-time lookup by the raw name
            // misses, and selection_required loops forever.
            server: s.server,
          })),
        {
          reshowForSelection: cb.reshowForSelection === true,
          selfUpdate: cb.selfAsk === true,
          // A live status fetch is coming exactly when this surface hosts
          // connection rows: the initial/swap consent paths pass a live
          // tokenSlot, while the selection-only re-show AND a self-only
          // ask (no /mcp/servers to consult — its tokenSlot arrives null)
          // show no status lifecycle.
          statusesPending: tokenSlot !== null,
        },
        {
          onContinue: (picks) => {
            settle();
            const verified = new Map<string, string>();
            for (const [server, id] of picks) {
              if (candidateIds.get(server)?.has(id) === true) {
                verified.set(server, id);
              }
            }
            cb.onContinue(verified);
          },
          onCancel: () => {
            settle();
            cb.onCancel();
          },
          onAction: (server) => ctrl?.onAction(server),
        },
      ) ?? null;
    if (!dlg) {
      // FAIL CLOSED: consent that can't be asked is consent declined.
      // The initial path paints its decline pane; the swap path keeps
      // the previously-consented subset wired — exactly Cancel.
      h.report("consent-prompt", "chrome-unavailable");
      settle();
      cb.onCancel();
      return;
    }
    // Dialog is painting — drop the loading pane it was covering for
    // (settle() restores it on every death path).
    if (loading) loading.hidden = true;
    // Duplicate-connector picker rows, patched in when resolution state
    // arrives. All option labels are plain strings — candidate hosts
    // derive from viewer-entered connector URLs and are untrusted; the
    // chrome renders them as text. RAW server names key everything (the
    // dialog joins pickers to rows by name; the commit routes by them).
    // Rejection-normalized: the call-site wrapper runs applyEntries AFTER
    // fetchEntries' own .catch, so cb.entries can REJECT (not just resolve
    // null) — and the status-gate below must release on settlement in the
    // Promise sense, fulfilled or rejected, or a degraded path leaves
    // Continue permanently dead.
    const entriesSettled = cb.entries.catch((): null => null);
    void entriesSettled.then((res) => {
      // Settle FIRST, on every settlement: statuses are display
      // enrichment, never a consent requirement (unselected duplicates
      // fail closed server-side), so the Continue gate releases whether
      // the fetch succeeded, failed, or the patch code below throws.
      // Gen/open-guarded — a no-op on a closed or superseded dialog. Same
      // synchronous continuation as the patches, so React batches the
      // settle and the patches into one commit → one arming restart.
      dlg?.settleStatuses();
      if (res === null || claim.closed || dlg?.isOpen() !== true) return;
      const entries = res.entries;
      // Connection rows: the same identity (catalog icon/one-liner) AND
      // connection state the reauth prompt shows — lapsed/absent rows get
      // Reconnect/Connect/Add actions driven by the shared
      // connectorActions controller, healthy rows the Connected check, so
      // the viewer can finish connector setup without leaving consent.
      const auth = res.auth;
      // classifyReauth iterates exactly this scope, so every item is a
      // rendered row by construction. ACCEPTED POSTURE: these actions run
      // PRE-consent (connecting/adding is an org-level operation through
      // the same authz-enforced endpoints as the settings page — the
      // viewer's own account, independent of whether they later grant
      // this app the scope); Continue remains the one grant gesture.
      const items = classifyReauth(servers, auth);
      // Org divergence suppresses the hosted actions exactly as the
      // standalone reauth prompt does (bootOrgIsActive): the actions are
      // boot-org-scoped, so on an ?org= pin / boot-org-autoswitch they'd
      // create/OAuth or read a connector in an org other than the viewer's
      // active one, and the settings fallback would open the wrong org.
      // Leaving ctrl null renders identity-only/healed rows (no action) and
      // leaves the latch below un-consumed, so the standalone prompt still
      // runs — and self-suppresses on the same divergence. Healthy rows keep
      // their Connected check regardless (that path is ctrl-independent).
      if (tokenSlot !== null && items.length > 0 && bootOrgIsActive(h)) {
        ctrl = connectorActions(
          h,
          tokenSlot,
          servers,
          items,
          {
            setBusy: (server, busy) => dlg?.setBusy(server, busy),
            markHealed: (server) => dlg?.markHealed(server),
            isOpen: () => dlg?.isOpen() === true,
          },
          {
            tel: "consent-prompt",
            // The dialog's own lifecycle (settle) stops the controller;
            // a self-observed close just makes that idempotent.
            onGone: () => ctrl?.stop(),
            // Once every hosted row healed there is nothing left to
            // paint — retire the recheck loop (the dialog itself stays
            // open for the Continue decision; markHealed is one-way, so
            // further status fetches would only spend the frame's shared
            // connector rate budget).
            onHealth: (allListed) => {
              if (allListed) ctrl?.stop();
            },
          },
        );
      }
      const pickerRows: {
        server: string;
        options: { id: string; label: string }[];
        defaultId: string | null;
      }[] = [];
      // Only servers the dialog RENDERS may grow a picker: the entries
      // reflect the CURRENT published manifest (or, on re-show, the full
      // resolution state), which can include servers the viewer declined
      // or that drifted in after boot — a rowless picker would never
      // paint yet its default would ride the commit.
      const rowServers = new Set(servers.map((sv) => sv.server));
      for (const e of entries) {
        if (!rowServers.has(e.server)) continue;
        if (e.candidates.length < 2) continue;
        const ids: ReadonlySet<string> = new Set(e.candidates.map((c) => c.id));
        // Label collisions (e.g. two candidates added the same day with
        // no other distinguishing metadata) get an occurrence suffix so
        // the options the viewer chooses BETWEEN never render
        // identically. Derived from list position only — no new CP field.
        const seen = new Map<string, number>();
        const options = e.candidates.map((c, i) => {
          const base = candidateLabel(c, i + 1);
          const k = (seen.get(base) ?? 0) + 1;
          seen.set(base, k);
          return { id: c.id, label: k > 1 ? `${base} · ${k}` : base };
        });
        // pickDefault returns null only on an empty candidate set today
        // (unreachable at ≥2) — retained for the selection-required
        // POSTURE: a null default renders a must-choose picker and the
        // dialog refuses Continue until the viewer picks; never pick FOR
        // the viewer.
        const defaultId = pickDefault(e, cb.pref(e.server), ids);
        pickerRows.push({ server: e.server, options, defaultId });
        candidateIds.set(e.server, ids);
      }
      if (pickerRows.length > 0 && dlg) dlg.patchPickers(pickerRows);
      // Connection rows AFTER pickers: the selection machinery is
      // load-bearing (Continue commits it) — a failure in the display
      // enrichment below must never cost the viewer their pickers.
      const actionByServer = new Map(
        (ctrl?.rowSpecs ?? []).map((r) => [r.server, r]),
      );
      const connRows = servers
        .map((sv) => {
          const st = auth.statuses.get(sv.server);
          const dirE = auth.directory.get(sv.server);
          const act = actionByServer.get(sv.server);
          return {
            server: sv.server,
            // Catalog identity only, pre-consent: the bootstrap-row iconUrl
            // (st.iconUrl, and act.iconUrl which derives from it for lapsed
            // rows) is viewer-org-configured and thus attacker-settable for
            // custom connectors — rendering it as <img> before Continue is a
            // pre-consent IP/UA beacon to the icon host. dirE.iconUrl is the
            // CP directory/catalog icon (trusted). The retired vanilla dialog
            // showed no icons at all here for this exact reason; custom
            // connectors simply render icon-less pre-consent (the row still
            // shows via name + Connected check / action).
            iconUrl: dirE?.iconUrl,
            description: act?.description ?? dirE?.oneLiner,
            action: act?.action,
            inline: act?.inline,
            // Positive health only — a transient degraded "" renders as
            // plain identity, never a false Connected check. Point-in-time:
            // a healthy connector that relapses while the dialog sits open
            // keeps its check (healed is one-way) and surfaces at call
            // time post-grant — the same exposure every pre-hosted-actions
            // grant already had.
            healed:
              act === undefined && REAUTH_HEALTHY.has(st?.status ?? "")
                ? true
                : undefined,
          };
        })
        .filter(
          (r) =>
            r.iconUrl !== undefined ||
            r.description !== undefined ||
            r.action !== undefined ||
            r.healed === true,
        );
      if (connRows.length > 0) dlg.patchConnections(connRows);
      if (ctrl !== null) {
        ctrl.start();
        // The consent dialog IS the connector prompt now: it displayed the
        // lapsed/absent affordances, so the standalone reauth prompt for
        // this page load would be a hostile double-ask — consume its
        // once-per-load latch. (All-healthy displays consume nothing; the
        // reauth classifier wouldn't have prompted anyway.)
        reauthPrompted = true;
      }
    });
  });
}

// ── Connector re-auth prompt ─────────────────────────────────────────────
// Shell-owned, load-time, dismissible: after the broker wires, ask the CP
// for each granted connector's authStatus and, when any granted connector
// is lapsed or missing, prompt in the consent-dialog family pointing at
// claude.ai's connector settings. The check runs at (re)boot only — a
// mid-session lapse surfaces through call-time errors, not this prompt —
// and the prompt shows at most once per page load ("Not now" holds for
// the rest of the load).

// Lapsed = a reconnect fixes it. authStatus "" (private-api degraded fetch
// or end-of-stream fallback — see api-go/frame bootstrapServer.callable)
// is NOT lapsed: reconnecting wouldn't help, so it never prompts.
// sync: these strings are the non-healthy members of McpAuthStatus
// (packages/private-api-client/openapi/types.gen.ts; healthy =
// "authenticated" | "not_required" per api-go/frame bootstrap.go
// callable()). A status added to the enum without updating this set
// fails safe (no prompt) but silently — keep the three in lock-step.
const REAUTH_LAPSED: ReadonlySet<string> = new Set([
  "auth_required",
  "token_invalid",
  "refresh_failed",
]);
// The close condition is POSITIVE health, not absence-of-lapsed: a
// transient degraded "" on a re-check must keep the prompt open (nothing
// was reconnected), mirroring how "" never triggers the prompt either.
const REAUTH_HEALTHY: ReadonlySet<string> = new Set([
  "authenticated",
  "not_required",
]);
export const REVEAL_POLL_MS = 250;
// The wait must outlast shell.ts's IFRAME_TIMEOUT_MS (60s — an artifact may
// legitimately reveal right up to it) plus time spent deferring to sibling
// dialogs; past ~2 minutes the page is in an error state the shell owns
// elsewhere and the prompt would paint over nothing.
export const REVEAL_POLL_MAX = 480;
// Floor between refresh-on-return status re-checks while the prompt is
// open — the re-check spends the frame's shared connector rate budget.
export const RECHECK_MIN_MS = 5_000;
let reauthPrompted = false;
// Bumped on every mountBrokered run so a pending pre-reveal wait from a
// superseded boot can never mount its prompt over the successor's content.
let reauthGen = 0;

// sync: apps/claude-ai/api/sync/mcpAuth.ts MCP_AUTH_BROADCAST_CHANNEL /
// MCP_AUTH_MESSAGE_TYPE, and useMcpAuthPopup's start-auth URL + popup shape
// (components/Chat/ToolUseCells/useMcpAuthPopup.ts). Chunk isolation bars
// importing SPA modules here (see CLAUDE.md § Component conventions) — keep
// these in lock-step. The /connector/{id}/auth_done page broadcasts the
// outcome on this channel; both pages are same-origin with the shell.
const MCP_AUTH_CHANNEL = "mcp-auth-channel";
const MCP_AUTH_MSG_TYPE = "mcp-auth-complete";

interface ReauthItem {
  server: string;
  /** false = the viewer has no matching connector at all ("Connect"). */
  connected: boolean;
  /** The viewer's resolved connector uuid, when /mcp/servers echoed one.
   * Present ⇒ there is a concrete connector to OAuth against, so the
   * prompt can run the sign-in inline (popup) instead of bouncing through
   * the settings tab. Absent for never-connected names — there is nothing
   * to authorize; setup happens in settings. */
  id?: string;
  /** Connector icon (bootstrap for connected rows, directory catalog for
   * never-connected). http(s) only — gated at parse. Display only. */
  iconUrl?: string;
  /** Directory catalog one-liner for never-connected rows' subline. */
  oneLiner?: string;
  /** Directory catalog uuid (stable across accounts) — "Add" deep-links
   * the settings page with this entry's directory card open. */
  directoryId?: string;
  /** One-click connect URL from the catalog — "Add" runs the whole flow
   * inline (create the org connector, then the OAuth popup) and only
   * falls back to settings when the create is refused (HIPAA attestation,
   * org policy, expired session). */
  directoryConnectUrl?: string;
}

/** Directory-catalog enrichment for a manifest name absent from the
 * viewer's bootstrap (serversOut.directory). */
interface DirEntry {
  id: string;
  iconUrl?: string;
  oneLiner?: string;
  /** The entry's fixed MCP server URL — present only when the CP judged
   * it one-click connectable (no install form). Drives inline-add. */
  connectUrl?: string;
}

/** Parse a /mcp/servers body into per-connector auth statuses + directory
 * enrichment, filtered to the given scope — the granted scope post-wire,
 * or the PENDING ask scope at consent time (either way: only connectors
 * this surface is legitimately about; anything else in the response must
 * not be able to summon shell chrome — mirrors handleList's filter).
 * Every value that can reach chrome markup or a same-origin URL is gated
 * here (uuid canonical form, http(s) URLs, display caps). */
function parseAuthStatusBody(
  raw: unknown,
  scope: readonly ManifestServer[],
): AuthStatusResult {
  const body = raw as {
    servers?: {
      server?: unknown;
      authStatus?: unknown;
      resolvedId?: unknown;
      iconUrl?: unknown;
    }[];
    directory?: {
      server?: unknown;
      id?: unknown;
      iconUrl?: unknown;
      oneLiner?: unknown;
    }[];
  } | null;
  const granted = new Set(scope.map((s) => s.server));
  const m = new Map<string, AuthStatusEntry>();
  for (const s of body?.servers ?? []) {
    const server = String(s.server ?? "");
    if (!granted.has(server)) continue;
    const entry: AuthStatusEntry = { status: String(s.authStatus ?? "") };
    // Same read-side uuid gate as the picker path: the id feeds a
    // same-origin start-auth URL, so anything non-canonical is dropped
    // rather than interpolated.
    if (typeof s.resolvedId === "string" && UUID_RE.test(s.resolvedId)) {
      entry.id = s.resolvedId;
    }
    const icon = safeIconUrl(s.iconUrl);
    if (icon) entry.iconUrl = icon;
    m.set(server, entry);
  }
  // Directory enrichment (never-connected names): same scope-only filter —
  // the CP already restricts it to manifest names, but a response is
  // untrusted input to shell chrome regardless.
  const dir = new Map<string, DirEntry>();
  for (const d of body?.directory ?? []) {
    const server = String(d.server ?? "");
    if (!granted.has(server)) continue;
    if (typeof d.id !== "string" || !UUID_RE.test(d.id)) continue;
    const e: DirEntry = { id: d.id };
    const icon = safeIconUrl(d.iconUrl);
    if (icon) e.iconUrl = icon;
    if (typeof d.oneLiner === "string" && d.oneLiner !== "") {
      e.oneLiner = capName(d.oneLiner);
    }
    // Server URLs feed a same-origin create POST body, not markup, but
    // the same http(s)+length gate applies — reuse it.
    const cu = safeIconUrl((d as { connectUrl?: unknown }).connectUrl);
    if (cu) e.connectUrl = cu;
    dir.set(server, e);
  }
  return { statuses: m, directory: dir };
}

/**
 * authStatus per granted server — the same POST the broker proxies for the
 * frame's listTools(), shell-initiated. Returns null on any failure: the
 * prompt is best-effort UX and call-time errors cover the degraded case.
 * Failures are counted (never surfaced to the viewer) so a fleet-wide
 * silent absence of the prompt — endpoint regression, schema drift, rate
 * contention — is visible on the shell-errors rail. detail stays a
 * bounded status/kind label; connector names and statuses never leave
 * the page.
 */
async function fetchAuthStatuses(
  h: BrokerHost,
  tokenSlot: TokenSlot,
  scope: readonly ManifestServer[],
): Promise<AuthStatusResult | null> {
  try {
    const r = await postMcpServers(h, tokenSlot, "reauth");
    if (!r.ok) {
      h.report("reauth-status-fetch", String(r.status));
      return null;
    }
    return parseAuthStatusBody((await r.json()) as unknown, scope);
  } catch {
    h.report("reauth-status-fetch", "error");
    return null;
  }
}

/** Icon URLs render as <img src> in shell chrome — http(s) only, no other
 * schemes, and length-bounded like every other server-supplied string. */
function safeIconUrl(v: unknown): string | undefined {
  if (typeof v !== "string" || v.length > 2048) return undefined;
  return /^https?:\/\//i.test(v) ? v : undefined;
}

/** authStatus + optional resolved connector uuid + icon per granted server. */
interface AuthStatusEntry {
  status: string;
  id?: string;
  iconUrl?: string;
}

/** Parsed /mcp/servers payload for the reauth machinery: per-row statuses
 * plus the directory enrichment for absent (never-connected) names. */
interface AuthStatusResult {
  statuses: Map<string, AuthStatusEntry>;
  directory: Map<string, DirEntry>;
}

/** True when the boot org (`h.org`) is the viewer's ACTIVE org — the
 * `lastActiveOrg` cookie, UUID-gated the same way shell.ts derives the boot
 * org (sync: shell.ts orgCookie + ORG_UUID_RE — same parse AND same gate; a
 * non-UUID cookie is noise shell.ts also rejected, not a divergent org).
 * Connector actions (Reconnect/Connect/Add, inline-add POST, start-auth) are
 * boot-org-scoped, but the settings-page fallback the rows can open resolves
 * under the viewer's ACTIVE org — so when the boot org diverges (an `?org=`
 * URL pin, or the boot-org-autoswitch that re-boots under owner_org without
 * touching the cookie) the actions would land in / read from an org other
 * than the one the viewer believes they're acting in. BOTH the standalone
 * reauth prompt and the consent-hosted connection rows suppress the
 * affordances when this returns false. */
function bootOrgIsActive(h: BrokerHost): boolean {
  const rawOrgCookie = document.cookie.match(
    /(?:^|;\s*)lastActiveOrg=([^;]*)/,
  )?.[1];
  const activeOrg =
    rawOrgCookie &&
    /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
      rawOrgCookie,
    )
      ? rawOrgCookie
      : undefined;
  return h.org === activeOrg;
}

/** Granted servers needing viewer action. A server absent from the response
 * has no callable-or-surfaceable match (backend omits absent/ambiguous) —
 * treated as not connected; its directory-catalog entry (when the CP
 * matched one) supplies icon/one-liner/deep-link. A lapsed server with a
 * resolvedId can instead be re-authed inline (see showReauthPrompt). */
function classifyReauth(
  scope: readonly ManifestServer[],
  r: AuthStatusResult,
): ReauthItem[] {
  const out: ReauthItem[] = [];
  for (const s of scope) {
    const st = r.statuses.get(s.server);
    // Catalog identity rides every row (icon fallback + description) —
    // the CP enriches all manifest names, not just absent ones.
    const d = r.directory.get(s.server);
    if (st === undefined) {
      out.push({
        server: s.server,
        connected: false,
        iconUrl: d?.iconUrl,
        oneLiner: d?.oneLiner,
        directoryId: d?.id,
        directoryConnectUrl: d?.connectUrl,
      });
    } else if (REAUTH_LAPSED.has(st.status)) {
      out.push({
        server: s.server,
        connected: true,
        id: st.id,
        iconUrl: st.iconUrl ?? d?.iconUrl,
        oneLiner: d?.oneLiner,
      });
    }
  }
  return out;
}

/**
 * Load-time re-auth check. Fire-and-forget from the two wire paths; shows
 * the prompt only once the slot holds settled, revealed content. That
 * cannot disturb reveal()'s `contentShown` latch (which skips while
 * chromeDecisionOpen is set): on initial boot, reveal() adds `.ready` and
 * sets the latch in the same synchronous call, so this setTimeout-based
 * poll can never observe between them; on a swap the latch is already
 * true from the first reveal and nothing resets it on that path.
 */
function maybePromptReauth(
  h: BrokerHost,
  tokenSlot: TokenSlot,
  scope: readonly ManifestServer[],
): void {
  if (reauthPrompted) return;
  const gen = reauthGen;
  void fetchAuthStatuses(h, tokenSlot, scope).then((statuses) => {
    if (!statuses || gen !== reauthGen || reauthPrompted) return;
    const fetchedAt = Date.now();
    const affected = classifyReauth(scope, statuses);
    if (affected.length === 0) return;
    let polls = 0;
    const tryShow = (): void => {
      if (gen !== reauthGen || reauthPrompted) return;
      // Org divergence (an ?org= URL pin, or the boot-org-autoswitch path,
      // which re-boots under owner_org without touching lastActiveOrg):
      // the statuses above are boot-org-scoped, but the settings page the
      // primary button opens resolves under the viewer's ACTIVE org — the
      // lapsed connector wouldn't be there and the prompt could never
      // auto-clear. Fail-safe-absent, like every other degraded case, and
      // counted so the suppression stays observable. (bootOrgIsActive is the
      // shared gate — the consent-hosted rows suppress on the same check.)
      if (!bootOrgIsActive(h)) {
        h.report("reauth-prompt", "org-suppressed");
        return;
      }
      // Wait for: a revealed iframe; no in-flight swap candidate (an
      // un-revealed iframe means content is about to be replaced — don't
      // paint over it); and no other decision surface open — consent
      // dialog, nav-confirm (.consent.nav), or chrome delete-confirm.
      // Unlike the consent dialog (which force-closes siblings because it
      // gates content itself), this prompt is never urgent: yanking an
      // open "navigate to this URL?" confirm out from under the viewer to
      // show it would be the sibling-confirm-dismissal bug, so it waits
      // its turn instead.
      if (
        !h.slot.querySelector("iframe.ready") ||
        h.slot.querySelector("iframe:not(.ready)") ||
        // The dialog renders through the chrome chunk — wait for its mount
        // like any other surface precondition (chrome loads at reveal; a
        // degraded chrome exhausts the poll and is counted below).
        !h.chrome() ||
        consentClaim !== null ||
        h.slot.querySelector(".consent.nav") ||
        document.querySelector(h.confirmOpenSelector)
      ) {
        if (++polls <= REVEAL_POLL_MAX) {
          window.setTimeout(tryShow, REVEAL_POLL_MS);
        } else {
          // Forfeited for this load: reveal never settled, or sibling
          // dialogs held the surface past the cap. Counted so systematic
          // starvation (e.g. a frame keeping a confirm open) is visible
          // alongside the other suppressions — without this, the poll
          // path is the one silent absence on the rail.
          h.report("reauth-prompt", "poll-exhausted");
        }
        return;
      }
      reauthPrompted = true;
      // Usage counter on the shell-errors rail (bounded detail, no
      // connector data): a fleet-wide spike in "shown" is the signal that
      // a backend status regression is spamming viewers with prompts.
      h.report("reauth-prompt", "shown");
      showReauthPrompt(h, tokenSlot, scope, affected, fetchedAt);
    };
    tryShow();
  });
}

/** The open reauth dialog's handle, or null. Module-level so the picker
 * re-show deferral can treat it as an occupied decision surface (parity
 * with the vanilla box, which carried the consent id). */
let openReauthDlg: ReauthDialogHandle | null = null;

/** UI surface a connector-actions controller drives — satisfied by both
 * the reauth dialog handle and the consent dialog's connection rows. */
interface ConnectorActionUi {
  setBusy(server: string, busy: boolean): void;
  markHealed(server: string): void;
  /** False once the surface is gone — the controller self-retires via
   * opts.onGone on the next event that observes it. */
  isOpen(): boolean;
}

/**
 * Shared connector-action machinery for the broker's decision dialogs:
 * per-row routing (inline OAuth popup for rows with a resolved uuid,
 * inline-add via the directory catalog, settings tab otherwise), the
 * shared "mcp-auth" popup lifecycle (COOP opener-severing, per-open
 * generation, success backstop), the auth_done BroadcastChannel listener,
 * and the refresh-on-return status recheck that checks rows off as they
 * heal. The reauth prompt and the consent dialog both drive this — one
 * owner for the security-sensitive popup/routing rules.
 *
 * Lifecycle: construct (pure), show your dialog with `rowSpecs`, then
 * `start()` to arm the listeners (never before the surface exists — a
 * focus event would observe isOpen()===false and self-retire). `stop()`
 * is idempotent and releases everything the controller owns; callers keep
 * owning their dialog, slot, flags, and closeConsent registration.
 */
function connectorActions(
  h: BrokerHost,
  tokenSlot: TokenSlot,
  scope: readonly ManifestServer[],
  /** Problem rows (lapsed/absent). MUTATED on inline-add success: the
   * minted uuid promotes the item to the resolved-id path. */
  items: readonly ReauthItem[],
  ui: ConnectorActionUi,
  opts: {
    /** Telemetry code the action/popup counters ride ("reauth-prompt" for
     * the standalone prompt, "consent-prompt" for consent-hosted rows). */
    tel: "reauth-prompt" | "consent-prompt";
    /** The surface was observed closed — retire yourself (the caller runs
     * its own teardown, which includes ctrl.stop()). */
    onGone: () => void;
    /** After each status result: allListedHealthy = every `items` row is
     * positively healthy; fullClear = additionally no OTHER scoped
     * connector has newly lapsed or gone missing. The reauth prompt
     * auto-closes on fullClear; consent just keeps painting. */
    onHealth?: (allListedHealthy: boolean, fullClear: boolean) => void;
  },
): {
  rowSpecs: ReauthRowSpec[];
  onAction: (server: string) => void;
  start: () => void;
  stop: () => void;
  recheck: () => void;
} {
  const byServer = new Map(items.map((a) => [a.server, a]));
  const isInline = (a: ReauthItem): boolean => a.id !== undefined && !!h.org;
  const canInlineAdd = (a: ReauthItem): boolean =>
    a.directoryConnectUrl !== undefined && !!h.org;
  const inlineIds = new Set<string>();
  for (const a of items) if (isInline(a)) inlineIds.add(a.id!);
  // Servers whose inline-add create has been fired (set synchronously at
  // press). A create is ambiguous on the 15s client timeout — the server
  // may have completed it — so a row that has attempted a create must never
  // re-run inlineAdd (a second POST /mcp/remote_servers would mint a
  // duplicate org connector); onAction routes its re-press to settings
  // instead (or to the inline OAuth popup once the row is promoted to a
  // resolved id via the success path).
  const createAttempted = new Set<string>();

  const watch = new AbortController();
  let pendingRecheck = 0;
  let popup: Window | null = null;
  let ctrlStopped = false;
  // Timer id for the auth_done success backstop (closes the popup if its own
  // self-close didn't fire). Tracked so re-opening the shared "mcp-auth"
  // window for another connector cancels the prior connector's backstop,
  // instead of the stale timer closing the new in-flight popup.
  let backstop = 0;
  // Per-open generation for the shared "mcp-auth" window. inlineAdd's create
  // continuation is async, and sibling rows stay clickable meanwhile, so a
  // later row action can re-open (re-navigate) the same WindowProxy before
  // the continuation resolves. The continuation captures the gen at pre-open
  // and only touches the popup if it still owns it — otherwise a stale
  // continuation would clobber the newer connector's live OAuth window.
  let popupGen = 0;

  /** Release everything this controller owns: listeners, BC, timers, and
   * an orphaned OAuth popup. Idempotent; safe before start(). */
  const stop = (): void => {
    if (ctrlStopped) return;
    ctrlStopped = true;
    watch.abort();
    window.clearTimeout(pendingRecheck);
    window.clearTimeout(backstop);
    if (popup && !popup.closed) {
      // Close the shared popup only while it is still OURS (about:blank
      // pre-open or the same-origin start-auth redirect page) — a
      // cross-origin read throws, which means the viewer is mid-provider
      // sign-in: killing it there would abort a half-done OAuth the
      // surface's teardown has no business deciding (a consent Continue
      // must not cost the viewer their reconnect). A completed flow's
      // auth_done page self-closes, so nothing is orphaned.
      try {
        void popup.location.href;
        popup.close();
      } catch {
        /* provider flow in progress — let it finish */
      }
    }
  };

  /** Absolute same-origin start-auth URL for a connector uuid. Absolute (not
   * relative) so `popup.location.replace()` from a pre-opened about:blank
   * window resolves against this origin, not the blank document. sync:
   * useMcpAuthPopup's web path — same URL shape, redirect target, and query.
   * `?_=1` seeds the query so the backend's literal `&step=` append parses. */
  const startAuthUrl = (id: string): string => {
    const redirect = `${location.origin}/connector/${encodeURIComponent(id)}/auth_done?_=1`;
    return (
      `${location.origin}/api/organizations/${encodeURIComponent(h.org!)}/mcp/start-auth/${encodeURIComponent(id)}` +
      `?redirect_url=${encodeURIComponent(redirect)}&open_in_browser=1&product_surface=claude-web&popup=1`
    );
  };

  const popupFeatures = (): string => {
    const w = 600;
    const ht = 700;
    const left = window.screenX + (window.outerWidth - w) / 2;
    const top = window.screenY + (window.outerHeight - ht) / 2;
    return `width=${w},height=${ht},left=${left},top=${top},popup=1`;
  };

  /** Adopt the freshly-opened shared OAuth popup: sever its back-reference
   * and cancel any prior connector's success backstop. The shell ships COOP
   * same-origin-allow-popups, so an un-nulled opener lets the connector's
   * third-party OAuth server reverse-tabnab this tab via
   * window.opener.location; completion arrives over the auth_done
   * BroadcastChannel, so the popup never needs opener, and focus()/close()
   * are unaffected. Mirrors openSettings' noopener. */
  const trackPopup = (win: Window | null): void => {
    window.clearTimeout(backstop);
    popupGen++;
    popup = win;
    if (popup) popup.opener = null;
  };

  /** Open (or navigate) the shared OAuth popup for a connector uuid.
   * Returns false when the browser blocked it — callers fall back to the
   * settings tab so the press is never a silent no-op. The fixed
   * "mcp-auth" window name means a second connector's auth NAVIGATES the
   * existing popup rather than stacking a new one. */
  const openPopup = (id: string): boolean => {
    trackPopup(window.open(startAuthUrl(id), "mcp-auth", popupFeatures()));
    if (!popup) {
      h.report(opts.tel, "popup-blocked");
      return false;
    }
    h.report(opts.tel, "popup-opened");
    popup.focus();
    return true;
  };

  // Settings deep-link (relative). With a directory match, open the settings
  // page with that entry's directory card open (DirectoryModal mounts in the
  // sidebar layout and consumes these params) — the card's own Connect button
  // is the closest thing to inline-add for a connector the org hasn't set up.
  // Without one, the bare page: the add-custom dialog asks for a server URL,
  // the wrong flow for directory connectors.
  const settingsPath = (item: ReauthItem): string => {
    const base = "/customize/connectors";
    return item.directoryId
      ? `${base}?directory-open=true&directory-uuid=${encodeURIComponent(item.directoryId)}`
      : base;
  };

  const openSettings = (item: ReauthItem): void => {
    // sync: deferred.ts wireNav open() — the approved new-tab shape.
    const a = document.createElement("a");
    a.href = settingsPath(item);
    a.target = "_blank";
    a.rel = "noopener noreferrer";
    a.click();
  };

  /** Inline add: create the org connector from its directory entry, then
   * run the OAuth popup for the fresh uuid — the same chain chat's
   * suggested-connectors uses, so the viewer never leaves the page. A
   * refused create (HIPAA attestation, org policy, install form the CP
   * missed, expired session, timeout) falls back to the settings surface,
   * which owns those flows. attestations:[] is correct for non-HIPAA orgs
   * and exactly what a HIPAA org's 400 rejects. On success the item is
   * PROMOTED to the resolved-id path (a.id = minted uuid) so a second
   * press navigates the popup instead of creating a duplicate connector.
   * sync: the dev mock in apps/claude-ai vite.config.ts frameShellDev()
   * answers this exact route in the local loop. */
  const inlineAdd = (a: ReauthItem): void => {
    // Pre-open the OAuth popup synchronously in the click gesture. The create
    // POST below is awaited (up to 15s), and both window.open and the
    // settings-tab anchor-click need transient user activation, which is
    // gone by the time the fetch resolves (always on Safari; on
    // Chromium/Firefox once the create exceeds ~5s) — so opening from the
    // .then would be a silent no-op AFTER an org connector was already
    // created. Pre-open now, then navigate it once the uuid is minted.
    // (Mirrors useMcpAuthPopup / useAgentProxyOAuth2AuthorizationCodeCredential.)
    trackPopup(window.open("about:blank", "mcp-auth", popupFeatures()));
    if (!popup) {
      // Pre-open blocked (strict popup blocker). Fall back to the settings
      // tab synchronously — the click's activation is still live here — and
      // do NOT fire the create: opening it from the async .then would be
      // popup-blocked too, leaving an orphaned org connector with nothing to
      // complete its OAuth. Mirrors openPopup's synchronous guard.
      h.report(opts.tel, "popup-blocked");
      openSettings(a);
      return;
    }
    // The gen of THIS pre-open. If a later row action re-opens the shared
    // "mcp-auth" window before our create resolves, popupGen advances and the
    // continuation below leaves that newer connector's popup untouched.
    const myGen = popupGen;
    // Latch synchronously (before the ambiguous create): a re-press won't
    // re-run inlineAdd, so a timed-out-but-server-completed create can't be
    // duplicated regardless of recheck timing.
    createAttempted.add(a.server);
    ui.setBusy(a.server, true);
    h.report(opts.tel, "inline-add");
    void fetch(
      `/api/organizations/${encodeURIComponent(h.org!)}/mcp/remote_servers`,
      {
        method: "POST",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          name: a.server,
          url: a.directoryConnectUrl!,
          attestations: [],
          mcp_directory_server_uuid: a.directoryId,
        }),
        signal: safeTimeout(15_000),
      },
    )
      .then(async (r) => {
        if (!r.ok) throw new Error(String(r.status));
        const b = (await r.json()) as { uuid?: unknown };
        if (typeof b.uuid !== "string" || !UUID_RE.test(b.uuid)) {
          throw new Error("bad-uuid");
        }
        return b.uuid;
      })
      .then(
        (uuid) => {
          // Do we still own the shared popup? A later row action may have
          // re-navigated it (same "mcp-auth" WindowProxy) — don't clobber it.
          const mine = popupGen === myGen && !!popup && !popup.closed;
          if (!ui.isOpen()) {
            if (mine) popup!.close();
            return;
          }
          ui.setBusy(a.server, false);
          h.report(opts.tel, "inline-add-ok");
          // Promote to the resolved-id path so a re-press opens OAuth via the
          // synchronous openPopup path (and can't create a duplicate).
          a.id = uuid;
          inlineIds.add(uuid);
          if (mine) {
            // The success nav bypasses trackPopup, so cancel any prior
            // connector's backstop before repurposing the window.
            window.clearTimeout(backstop);
            popup!.location.replace(startAuthUrl(uuid));
            h.report(opts.tel, "popup-opened");
            popup!.focus();
          } else {
            // Our pre-opened window was taken over (or blocked at pre-open);
            // the row is promoted, so open settings as a best-effort landing.
            openSettings(a);
          }
        },
        () => {
          const mine = popupGen === myGen && !!popup && !popup.closed;
          if (!ui.isOpen()) {
            if (mine) popup!.close();
            return;
          }
          ui.setBusy(a.server, false);
          h.report(opts.tel, "inline-add-fallback");
          // Refused (HIPAA 400 / policy 403 / session / timeout): navigate the
          // already-open popup to the settings deep-link. A same-origin nav
          // needs no user activation, whereas a fresh openSettings anchor-click
          // this long after the gesture would be popup-blocked. Fall back to a
          // settings tab only if we no longer own a live popup.
          if (mine) {
            popup!.location.replace(`${location.origin}${settingsPath(a)}`);
          } else {
            openSettings(a);
          }
        },
      );
  };

  const onAction = (server: string): void => {
    const a = byServer.get(server);
    if (!a) return;
    if (isInline(a)) {
      if (!openPopup(a.id!)) openSettings(a);
    } else if (canInlineAdd(a) && !createAttempted.has(a.server)) {
      inlineAdd(a);
    } else {
      // Never-connected with no catalog match, or a row whose create
      // was already attempted (ambiguous on timeout) — settings owns it.
      openSettings(a);
    }
  };

  // Refresh-on-return: while the surface is open, returning to this tab
  // re-reads statuses and checks rows off as they heal. The floor keeps a
  // rapid alt-tab loop from spending the frame's shared connector rate
  // budget; an event inside the floor window is DEFERRED, not dropped, so
  // a healing return still lands without needing another focus.
  let checking = false;
  let lastCheck = 0;
  const recheck = (): void => {
    if (ctrlStopped) return;
    if (!ui.isOpen()) {
      opts.onGone();
      return;
    }
    if (document.hidden) return;
    const elapsed = Date.now() - lastCheck;
    if (checking || elapsed < RECHECK_MIN_MS) {
      if (!pendingRecheck) {
        pendingRecheck = window.setTimeout(
          () => {
            pendingRecheck = 0;
            recheck();
          },
          Math.max(RECHECK_MIN_MS - elapsed, REVEAL_POLL_MS),
        );
      }
      return;
    }
    lastCheck = Date.now();
    checking = true;
    void fetchAuthStatuses(h, tokenSlot, scope).then((result) => {
      checking = false;
      if (ctrlStopped) return;
      if (!ui.isOpen()) {
        opts.onGone();
        return;
      }
      if (!result) return;
      // Rows check off as they heal — markHealed is idempotent and
      // one-way dialog-side, so healthy rows are marked every pass with
      // no controller-side bookkeeping to diverge from the statuses.
      const healthy = (a: ReauthItem): boolean =>
        REAUTH_HEALTHY.has(result.statuses.get(a.server)?.status ?? "");
      for (const a of items) {
        if (healthy(a)) ui.markHealed(a.server);
      }
      const allListed = items.every(healthy);
      opts.onHealth?.(
        allListed,
        allListed && classifyReauth(scope, result).length === 0,
      );
    });
  };

  const start = (): void => {
    if (ctrlStopped) return;
    document.addEventListener("visibilitychange", recheck, {
      signal: watch.signal,
    });
    window.addEventListener("focus", recheck, { signal: watch.signal });
    // Inline completion: the auth_done page broadcasts the outcome; success
    // triggers an immediate status re-check, which is what heals rows (the
    // broadcast alone is never trusted). Failure leaves the surface as-is;
    // the popup shows the provider's own error page meanwhile. Listener
    // lifetime is the watcher's, and only expected serverIds are honored
    // (including uuids inline-add mints mid-flow). Skipped entirely when no
    // row can ever complete inline — a settings-only surface shouldn't
    // subscribe to every auth broadcast on the origin.
    if (
      items.some((a) => isInline(a) || canInlineAdd(a)) &&
      typeof BroadcastChannel !== "undefined"
    ) {
      const bc = new BroadcastChannel(MCP_AUTH_CHANNEL);
      watch.signal.addEventListener("abort", () => bc.close());
      bc.onmessage = (e: MessageEvent) => {
        const d = e.data as
          | { type?: unknown; serverId?: unknown; step?: unknown }
          | null
          | undefined;
        if (
          !d ||
          d.type !== MCP_AUTH_MSG_TYPE ||
          typeof d.serverId !== "string" ||
          !inlineIds.has(d.serverId)
        ) {
          return;
        }
        if (!ui.isOpen()) {
          opts.onGone();
          return;
        }
        h.report(
          opts.tel,
          d.step === "success" ? "popup-success" : "popup-error",
        );
        if (d.step === "success" && popup && !popup.closed) {
          // auth_done self-closes after 1.5s; this is the backstop. Track
          // the timer id (cleared by trackPopup and stop) so re-opening the
          // shared "mcp-auth" window for another connector cancels this
          // timer instead of it closing the new connector's in-flight popup.
          window.clearTimeout(backstop);
          backstop = window.setTimeout(() => popup?.close(), 2000);
        }
        lastCheck = 0;
        recheck();
      };
    }
  };

  return {
    rowSpecs: items.map((a) => ({
      server: a.server,
      action: a.connected
        ? ("reconnect" as const)
        : a.directoryId
          ? ("add" as const)
          : ("connect" as const),
      inline: isInline(a) || canInlineAdd(a),
      iconUrl: a.iconUrl,
      description: a.oneLiner,
    })),
    onAction,
    start,
    stop,
    recheck,
  };
}

/**
 * Dismissible connector re-auth prompt — rendered by the chrome chunk as a
 * CDS dialog (ChromeHandle.showReauthDialog / chrome/ConnectorReauthDialog,
 * per the frame-dialogs-use-CDS decision). The connector-action machinery
 * (routing, popup, inline-add, recheck, auth_done listener) is the shared
 * connectorActions controller; this function owns the dialog lifecycle:
 * show, one-surface plumbing, the positive-health auto-clear, and
 * external-teardown registration. The dialog STAYS OPEN until every listed
 * connector is positively healthy or the viewer dismisses.
 */
function showReauthPrompt(
  h: BrokerHost,
  tokenSlot: TokenSlot,
  scope: readonly ManifestServer[],
  affected: readonly ReauthItem[],
  /** When the statuses behind `affected` were read — the mount can trail
   * it by the whole reveal/sibling-dialog wait. */
  fetchedAt: number,
): void {
  let dlg: ReauthDialogHandle | null = null;
  // Teardown runs at most once: stop(false) is also reachable from the
  // controller's recheck continuation, whose fetchAuthStatuses runs on its
  // own 65s timeout, so a stale in-flight fetch can resolve and re-run
  // stop() AFTER an external teardown already closed this dialog and a
  // sibling consent dialog registered its own closeConsent closer. A
  // second run would clobber that closer and steal focus from the open
  // modal — so gate the whole teardown behind this flag.
  let stopped = false;
  /** Retire the controller (+ optionally the dialog, for external/
   * auto-clear paths — viewer dismissal already closed it), release the
   * inert flag, and re-anchor focus (Base UI's restore target is the
   * pre-open focus — the iframe this prompt inerted, which an inert
   * element can't take; without the re-anchor, keyboard/AT users land on
   * <body>). */
  const stop = (closeDlg: boolean): void => {
    if (stopped) return;
    stopped = true;
    ctrl.stop();
    h.setCloseConsent(null);
    if (closeDlg) dlg?.close();
    if (openReauthDlg === dlg) openReauthDlg = null;
    h.setDecisionSurfaceOpen(false);
    h.restoreContentInteractivity();
    h.slot.tabIndex = -1;
    h.slot.focus();
  };

  const ctrl = connectorActions(
    h,
    tokenSlot,
    scope,
    affected,
    {
      setBusy: (server, busy) => dlg?.setBusy(server, busy),
      markHealed: (server) => dlg?.markHealed(server),
      isOpen: () => dlg?.isOpen() === true,
    },
    {
      tel: "reauth-prompt",
      onGone: () => stop(false),
      // Close when every LISTED connector is positively healthy and no
      // other granted connector has newly lapsed or gone missing (a
      // transient degraded "" keeps it open — nothing was reconnected).
      onHealth: (_allListed, fullClear) => {
        if (fullClear) {
          h.report("reauth-prompt", "auto-clear");
          stop(true);
        }
      },
    },
  );

  dlg =
    h.chrome()?.showReauthDialog?.(ctrl.rowSpecs, {
      onAction: ctrl.onAction,
      onDismiss: () => {
        h.report("reauth-prompt", "dismissed");
        stop(false);
      },
    }) ?? null;
  if (!dlg) {
    // The chrome unmounted between the reveal-poll's check and this call
    // (mount() flushes the registry synchronously, so a live handle
    // implies a live registry — this is the degrade race only). Reset the
    // latch so a later successful chrome load can still prompt.
    h.report("reauth-prompt", "chrome-unavailable");
    reauthPrompted = false;
    ctrl.stop();
    return;
  }
  openReauthDlg = dlg;
  // Same content-inert posture as the consent dialog: keyboard/AT must not
  // reach the untrusted iframe under an open decision surface. The flag is
  // the dialog's term in shell.ts contentInertClaim — live swaps and
  // sibling-dialog closes re-derive inert through it.
  h.setDecisionSurfaceOpen(true);
  h.inertContent();
  // External-close hook — clearForRemount, the 403 revocation path, and a
  // sibling surface's claimDecisionSurface close this dialog through the
  // slot (the shared decision-surface lifecycle).
  h.setCloseConsent(() => stop(true));
  ctrl.start();
  // `affected` is the wire-time snapshot; a connector healed during a
  // long pre-mount wait (an already-open settings tab, an in-frame
  // reconnect) would otherwise sit as a wrong prompt until the next
  // focus event. One post-mount recheck retires it. Within the floor
  // the snapshot is as fresh as a recheck would be, so the prompt's
  // common quick-mount path stays at one status fetch. isOpen() is
  // synchronous registry state, so this same-task call is safe.
  if (Date.now() - fetchedAt >= RECHECK_MIN_MS) ctrl.recheck();
}

/** Resolve the mcp token + manifest from the capabilities envelope.
 * Returns null when the envelope lacks what the broker needs to activate.
 * No apps-side feature-flag check: the CP's four-gate (flag ∩ entitlement
 * ∩ ACL ∩ intent) runs server-side and simply omits `capabilities.mcp`
 * when any gate fails. */
/** The self capability resolves iff the CP minted its token — the CP
 * only mints for writer boots of declaring frames with the flag on, so
 * token presence IS the grant signal (config is `{}` today). */
export function resolveSelf(d: BrokerBoot): { token: string } | null {
  const t = d.capabilities?.self?.token;
  return typeof t === "string" && t !== "" ? { token: t } : null;
}

function resolveMcp(
  d: BrokerBoot,
): { token: string; servers: ManifestServer[] } | null {
  const env = d.capabilities?.mcp;
  const token = env?.token;
  const rawServers = env?.config?.servers;
  if (typeof token !== "string" || token === "") return null;
  const raw: unknown[] = Array.isArray(rawServers) ? rawServers : [];
  // Null-safe: Go marshals nil slices as null, and a manifest shape we
  // can't read must not brick the whole frame — filter once so the
  // activation gate, consent dialog, and wireMcp's consented-scope check
  // all agree on what "the manifest" is. Empty ⇒ broker inactive ⇒
  // deny-by-default.
  const servers = raw.filter(
    (s): s is ManifestServer =>
      !!s &&
      typeof s === "object" &&
      typeof (s as ManifestServer).server === "string" &&
      (s as ManifestServer).server !== "" &&
      // Shell-side lexical rejection of the consent-layer sentinel: the
      // CP grammar excludes it too, but an author-controlled name equal
      // to SELF_SENTINEL must never enter the consent store regardless
      // of what upstream admits — a pre-planted "__self__" entry would
      // otherwise satisfy hasNewCaps/selfGranted for a LATER republish
      // that declares self, skipping its consent prompt.
      (s as ManifestServer).server !== SELF_SENTINEL &&
      Array.isArray((s as ManifestServer).tools) &&
      (s as ManifestServer).tools.length > 0,
  );
  return servers.length > 0 ? { token, servers } : null;
}

/**
 * (Re)mount the iframe for a booted version and (re)wire the broker against
 * it. This is the reboot wrapper: a live swap re-mounts the iframe, re-runs
 * the consent check, and re-wires the broker with the fresh token — or leaves
 * it unwired when consent is missing/indeterminate.
 *
 * Runs only when the boot carries a broker capability (shell.ts gates the
 * chunk load). If the manifest then parses empty, falls through to a plain
 * mount with the broker torn down.
 *
 * - consent already has all caps (or a keeps/drops republish) → mount + wire.
 * - new caps, INITIAL boot (nothing ever shown/consented) → prompt BEFORE
 *   mounting: no content renders and NOTHING wires until the viewer accepts —
 *   the pre-live UX. The staleness guard applies because the token can age
 *   while the dialog sits open. Cancel paints the error pane.
 * - new caps, LIVE swap → mount the (already fresh-token) new version now;
 *   servers whose full capability set is already consented re-wire
 *   IMMEDIATELY (an app doesn't lose granted permissions while asking for
 *   more) and only the NEW capabilities wait on the dialog. Continue persists
 *   + wires the full set; Cancel declines only the new capabilities — the
 *   previously-consented subset stays wired. Never auto-grant.
 */
export function mountBrokered(
  h: BrokerHost,
  d: BrokerBoot,
  isInitial: boolean,
  bootAt: number,
): void {
  // Claim the next broker generation (see brokerGeneration's doc for why
  // this is the right clock and the shell's bootEpoch is not). An
  // iframe.isConnected check alone is not a generation test either: the old
  // iframe stays connected through a live swap's load window. Async
  // continuations below compare this before acting.
  const epoch = ++brokerGeneration;
  // Supersede any pre-reveal re-auth wait from a previous (re)boot. A
  // separate clock from brokerGeneration: reauthGen guards the reauth
  // prompt's pre-reveal wait family (#60805), brokerGeneration guards the
  // selection machinery's continuations — each family compares its own.
  reauthGen++;
  const mcp = resolveMcp(d);
  const selfCap = resolveSelf(d);
  // The broker is active iff the CP sent at least one redeemable grant:
  // for mcp BOTH a manifest AND a token (the CP only mints the token when
  // the frame has a manifest AND the gate is on); for self the token
  // alone (writer boots of declaring frames — config is {}).
  if (!mcp && !selfCap) {
    // CP declined every capability (flag/entitlement/ACL). The kernel's
    // own post-install settle fires `not_granted` once init arrives with
    // no grants; no shell-side settle post needed. Structural no-wire:
    // the posture clears so a later resolvable envelope (fresh per-boot
    // CP data) reads as a gain and re-attempts the wire.
    h.setBrokered?.(false);
    h.setUnwire(null);
    activeTokenSlot = null;
    activeSelfTokenSlot = null;
    h.mount(d);
    return;
  }
  const servers = mcp?.servers ?? [];
  // The consent-layer ask: manifest servers plus the self pseudo-entry.
  // Everything consent-shaped (hasNewCaps, the covered filter, the
  // persist scope, wire()'s granted split) reads THIS list; everything
  // connector-shaped (dialog rows, /mcp/servers enrichment, reauth,
  // wireMcp scope) stays on `servers`.
  const askServers = selfCap ? [...servers, selfSentinel()] : servers;
  // The self CAS base — the version this boot mounted (see BrokerBoot.ver).
  const bootVer = typeof d.ver === "string" ? d.ver : "";
  // One slot per generation; closures below capture THIS object, so the
  // same-version refresh path (refreshBrokerToken) and the stale-token
  // replay update the token all of them serialize. org/gen pin the
  // binding — see TokenSlot.
  const tokenSlot: TokenSlot = {
    value: mcp?.token ?? "",
    org: h.org,
    account: assetTokenAccount(d.assetToken),
    gen: epoch,
  };
  // The mcp slot is live only when mcp resolved — an empty-value slot must
  // not become refreshable state (refreshBrokerToken guards on the ACTIVE
  // slot, and every mcp call path is mcp-gated below).
  activeTokenSlot = mcp ? tokenSlot : null;
  const selfSlot: TokenSlot = {
    value: selfCap?.token ?? "",
    org: h.org,
    account: assetTokenAccount(d.assetToken),
    gen: epoch,
  };
  activeSelfTokenSlot = selfCap ? selfSlot : null;

  const key = consentStoreKey(h, d.assetToken);
  const stored = readConsented(key);
  if (stored === null) {
    // Indeterminate consent state: deny (prompt below is the only wire path)
    // and surface the failure rather than silently granting.
    h.report("broker-consent-error");
  }
  const consented = stored ?? new Map<string, StoredServer>();

  // Live per-session selection state (manifest server name → connector
  // uuid), seeded from the store. When storage is blocked this map is the
  // only carrier — the selection is session-only, the same posture consent
  // itself has on an indeterminate store. wireMcp holds this by reference:
  // a dialog commit is visible to in-flight brokers without re-wiring.
  const selections = new Map<string, string>();
  // Last-known resolution per server (the incumbent record), live view of
  // the stored resolvedId — feeds the picker's pre-selection.
  const liveResolved = new Map<string, string>();
  for (const [name, e] of consented) {
    if (e.selectedId !== undefined) selections.set(name, e.selectedId);
    if (e.resolvedId !== undefined) liveResolved.set(name, e.resolvedId);
  }
  // Dialog commits made THIS session — a record no storage failure can
  // erase and no auto-switch can touch. This is what lets every degraded
  // store mode distinguish "the viewer chose this" from "the shell
  // self-routed": a write-blocked-readable store drops the persist but not
  // this map, so a genuine commit is never mistaken for a self-route (and
  // deleted) by the regrown-set reconciliation; and because auto-switches
  // never write here, they can't masquerade as commitments under an
  // unreadable store either.
  const committedThisSession = new Map<string, string>();
  // Pre-selection inputs for the picker. selectedId is the viewer's
  // COMMITTED choice: THIS session's commit record first — it is the newest
  // directly-witnessed gesture, and when persists fail the store can hold a
  // STALE prior-session choice that must not shadow a fresh re-pick — then
  // the store (which is how another tab's commit arrives). In the healthy
  // mode both records carry the same value, so the order only matters in
  // the degraded modes it exists for. The live selections map is
  // deliberately NOT a source — it can be shadowed by a collapsed-set
  // auto-switch, which must never masquerade as a choice the viewer made.
  // Cold path (dialog rows and duplicated entries only), so the fresh
  // store read is fine. Bulk callers (applyEntries, recheck's filter) pass
  // a pre-read `store` snapshot so one invocation pays one read — the
  // precedence definition stays HERE either way; callers never re-derive it.
  const pref = (
    server: string,
    store: Map<string, StoredServer> | null = readConsented(key),
  ): SelectionPref => {
    const out: SelectionPref = {};
    // Both committed records travel: consumers that hold a candidate set
    // (pickDefault, the reconciliation) validity-check selectedId and fall
    // through to storeSelectedId — the same session-first-but-valid-wins
    // semantics everywhere a committed choice is consulted.
    const session = committedThisSession.get(server);
    const stored = store?.get(server)?.selectedId;
    const s = session ?? stored;
    if (s !== undefined) out.selectedId = s;
    if (stored !== undefined && stored !== s) out.storeSelectedId = stored;
    const r = liveResolved.get(server);
    if (r !== undefined) out.resolvedId = r;
    return out;
  };

  /**
   * Apply resolution state from a parsed /mcp/servers response:
   * - `resolvedId` echo → record the incumbent (the picker pre-selects it
   *   when a duplicate set later appears);
   * - needs-selection with exactly ONE candidate (a collapsed duplicate
   *   set) → auto-switch silently — matches today's single-callable-match
   *   semantics, and a prompt with one option would only add friction;
   * - needs-selection with ≥2 candidates → returned to the caller. Only
   *   dialog-bearing paths act on that (never silently pick between real
   *   alternatives); bookkeeping paths ignore it and calls stay fail-closed.
   */
  const applyEntries = (entries: ServersEntry[]): ServersEntry[] => {
    // Generation choke point: THREE async paths land here (recheck, a page
    // listTools continuation that can survive unwiring, and the dialog-time
    // entries fetch), and only recheck carries its own epoch guard. A stale
    // echo written to the SHARED store would outlive this generation — the
    // live one's change-detection compares against its own view and never
    // re-writes — so the guard lives at the single entry every path shares.
    if (epoch !== brokerGeneration) return [];
    const needsPicker: ServersEntry[] = [];
    const echoes = new Map<string, string>();
    // Lazy store snapshot: read at most once per invocation, and only when
    // a ≥2-candidate entry actually consults pref — the steady state
    // (everything resolved) keeps its zero-storage-I/O property.
    let storeSnap: Map<string, StoredServer> | null | undefined;
    for (const e of entries) {
      if (e.resolvedId !== undefined) {
        // Change-detected: in the steady state (everything resolved, which
        // is every response once a frame is set up) this branch records
        // nothing, so pages that poll listTools cost zero storage I/O here.
        if (liveResolved.get(e.server) !== e.resolvedId) {
          echoes.set(e.server, e.resolvedId);
          liveResolved.set(e.server, e.resolvedId);
        }
      } else if (e.candidates.length === 1) {
        // Collapsed set: route to the one callable match for THIS session
        // only. Nothing durable is written here — `candidates` is the
        // CALLABLE set, so a collapse can be transient (the chosen
        // connector mid-reauth while a same-named joiner stays callable):
        // persisting the switch as selectedId would capture the route past
        // recovery, and persisting it as resolvedId would clobber a
        // genuine prior incumbent, inverting the rename-join mitigation
        // the field exists for. The shell self-routed without the CP
        // resolving, which is the weakest incumbency claim there is; if
        // this route genuinely serves, the CP's own resolvedId echo on a
        // later response records the incumbency-of-fact through the echo
        // branch above. (liveResolved is untouched too: it feeds the
        // picker's pre-selection and the consent-time incumbent persist,
        // both of which must reflect resolutions, not self-routes.)
        const id = e.candidates[0]!.id;
        if (selections.get(e.server) !== id) selections.set(e.server, id);
      } else if (e.candidates.length >= 2) {
        // Reconcile the live attachment with the committed records BEFORE
        // anything else happens for a regrown set. Two directions: a
        // committed choice (possibly written by another tab — the store is
        // shared) must actually be attached, or calls keep failing closed
        // while the re-show filter suppresses the picker — a permanent
        // dead-end; and a non-committed self-route must NOT survive
        // regrowth — attached, it keeps resolving, selection_required
        // never fires, and the viewer is never offered the choice the
        // regrown set represents. The record that wins is the first VALID
        // one for this candidate set, session-first: a present-but-invalid
        // session commit must not shadow a valid cross-tab store commit
        // (calls would dead-end once the re-show guard is spent). The
        // invalid session record is deliberately KEPT, not deleted — its
        // absence from the CALLABLE set can be transient (mid-reauth), and
        // the directly-witnessed gesture resumes precedence if its
        // connector returns; deleting it would lose the viewer's choice
        // permanently in the write-blocked store mode. NOTE: this fallback
        // RELIES on pref() being session-first — a present-invalid session
        // commit surfaces from pref and routes to the explicit store read
        // below; reordering pref would silently break that case.
        const valid = (id: string | undefined): id is string =>
          id !== undefined && e.candidates.some((c) => c.id === id);
        if (storeSnap === undefined) storeSnap = readConsented(key);
        const p = pref(e.server, storeSnap);
        const fallback = valid(p.selectedId) ? p.selectedId : p.storeSelectedId;
        if (valid(fallback)) {
          if (selections.get(e.server) !== fallback) {
            selections.set(e.server, fallback);
          }
        } else {
          selections.delete(e.server);
        }
        needsPicker.push(e);
      }
    }
    if (echoes.size > 0) {
      updateStoredIds(key, (m) => {
        for (const [name, id] of echoes) {
          const s = m.get(name);
          if (s) s.resolvedId = id;
        }
      });
    }
    return needsPicker;
  };

  /** Commit dialog picks: the live map first — wireMcp reads it by
   * reference, so the picks must be in place before any call flows — then
   * the store (consent and selection are one gesture). liveResolved rides
   * along so pre-consent incumbent observations become durable here — the
   * change-detection in applyEntries will rightly never re-record them, so
   * this write is their only path into the store. */
  const commitPicks = (
    picks: ReadonlyMap<string, string>,
    scope: readonly ManifestServer[] = askServers,
  ): void => {
    for (const [sname, id] of picks) {
      selections.set(sname, id);
      committedThisSession.set(sname, id);
    }
    persistConsent(key, scope, picks, liveResolved);
  };

  const sync: SelectionSync = {
    dropSelections: (server?: string, sent?: unknown) => {
      // Generation guard, same rationale as applyEntries' choke point:
      // this runs from postMcp's retry continuation — a plain .then on an
      // already-dispatched fetch that a live swap does not abort — so a
      // DEAD generation's retry must not erase a selectedId a newer
      // generation (or another tab) just committed, nor fire a spurious
      // rejection beacon.
      if (epoch !== brokerGeneration) return;
      // Normalize the sent evidence to name → rejected id. Call path:
      // one server, one serialized serverId. Servers path: the whole
      // serialized selections object.
      const rejected = new Map<string, string>();
      if (server !== undefined) {
        if (typeof sent === "string") rejected.set(server, sent);
      } else if (sent && typeof sent === "object") {
        for (const [name, id] of Object.entries(
          sent as Record<string, unknown>,
        )) {
          if (typeof id === "string") rejected.set(name, id);
        }
      }
      if (rejected.size === 0) return;
      // Per-record VALUE check: equal means the 400 really was about this
      // value; different means a fresh commit raced the rejected request,
      // was never seen by the CP, and survives. The session record is
      // cleared with the rest or pref would resurrect a rejected id.
      let cleared = false;
      for (const [name, id] of rejected) {
        if (selections.get(name) === id) {
          selections.delete(name);
          cleared = true;
        }
        if (committedThisSession.get(name) === id) {
          committedThisSession.delete(name);
          cleared = true;
        }
      }
      updateStoredIds(key, (m) => {
        for (const [name, id] of rejected) {
          const e = m.get(name);
          if (e && e.selectedId === id) {
            delete e.selectedId;
            cleared = true;
          }
        }
      });
      // The one data-losing transition in the selection machinery —
      // counted so a CP-side rejection storm erasing viewer choices is
      // visible in shell telemetry, and only when something was genuinely
      // erased. Code only; no connector metadata.
      if (cleared) h.report("broker-selection-drop");
    },
    postServers: () => {
      const body: Record<string, unknown> = {
        slug: h.frameUuid,
        // Read at serialization time — see TokenSlot.
        mcpToken: tokenSlot.value,
      };
      const withSel = selections.size > 0;
      // Selections arrive four ways — a dialog commit, the one-remaining
      // auto-switch, store seeding at mount, or the regrown-set
      // reconciliation adopting a cross-tab store commit — so a returning
      // session's FIRST post can already carry the field. It is still
      // never sent speculatively (no selection state → no field), and
      // whenever an old CP's strict decoder does see it post-rollback,
      // postMcp's 400 remedy is exactly the recovery.
      if (withSel) body.selections = Object.fromEntries(selections);
      return postMcp(
        h,
        "servers",
        body,
        withSel ? "selections" : null,
        sync.dropSelections,
      );
    },
    onEntries: (entries) => {
      applyEntries(entries);
    },
    onSelectionRequired: () => {
      recheck();
    },
  };

  /** One /mcp/servers round trip; null on any failure (the dialog and
   * recheck paths both degrade to "no resolution state" — fail closed,
   * never mis-route). */
  const fetchEntries = (): Promise<{
    entries: ServersEntry[];
    auth: AuthStatusResult;
  } | null> =>
    sync
      .postServers()
      .then((r) => {
        if (!r.ok) return null;
        const body = JSON.parse(r.text) as unknown;
        return {
          entries: parseServersEntries(body),
          auth: parseAuthStatusBody(body, servers),
        };
      })
      .catch(() => null);

  // recheckPending is an in-flight guard (page retry storms collapse into
  // one fetch); pickerReshown is the once-per-generation guard (one
  // re-show per mountBrokered closure — a live version swap starts a new
  // generation and re-arms one re-show; within a generation, a dialog the
  // viewer dismissed must not re-pop on every failing call); the cooldown
  // bounds post-dismissal fetches (each recheck POST prices a CP bootstrap,
  // and once the generation's one re-show is spent the only residual value
  // is the collapsed-set auto-switch).
  let recheckPending = false;
  let pickerReshown = false;
  let lastRecheckDone = 0;
  function recheck(): void {
    if (recheckPending) return;
    if (pickerReshown && Date.now() - lastRecheckDone < 60_000) return;
    recheckPending = true;
    void fetchEntries()
      .then((res) => {
        // Guard on res directly (not the derived entries): the re-show
        // below passes res!.auth, and the assertion must not silently
        // rest on parseServersEntries never returning null.
        if (res === null) return;
        const entries = res.entries;
        // Stale-generation guards: the broker-generation check is the
        // true generation test (mounts and teardowns advance it; the old
        // iframe stays CONNECTED through a live swap's load window, so
        // isConnected alone has a gap there); the isConnected check covers
        // unmount-without-teardown (revocation removes the iframe without
        // a new generation). A stale continuation must neither write
        // bookkeeping (this closure's maps no longer back the live broker)
        // nor pop a dialog over the new content.
        if (epoch !== brokerGeneration) return;
        if (mountedFrame === null || !mountedFrame.isConnected) return;
        // Mount-time rule: the one-remaining case auto-switches inside
        // applyEntries; ≥2 remaining means a real choice — re-show the
        // picker (the consent dialog IS the picker; no second surface).
        // The response may be stale against a COMMITTED selection (one can
        // land while the fetch is in flight — another tab writing the
        // shared store included): drop entries whose committed choice is
        // among their candidates, since re-asking a made choice would
        // consume the generation-once guard on a no-op. Deliberately keyed on
        // the committed record (pref), NOT the live map: an auto-switch is
        // a self-route, and when the response shows the set regrown to ≥2
        // the viewer is owed the disclosure — suppressing the picker
        // because the self-route happens to work would silently bind them
        // to a connector they never chose.
        // One store read serves this whole synchronous continuation: the
        // suppression filter here and the consented-scope intersection
        // below see the same cross-tab freshness either way (no await
        // between them), so re-reading per needs-entry would buy nothing.
        const consentedNow = readConsented(key);
        const needs = applyEntries(entries).filter((e) => {
          const sel = pref(e.server, consentedNow).selectedId;
          return !(sel !== undefined && e.candidates.some((c) => c.id === sel));
        });
        if (needs.length === 0 || pickerReshown) return;
        // ONE decision surface at a time (same invariant as the
        // closeNavConfirm note in showConsentDialog): a swap consent dialog
        // may be open while a covered-but-duplicated server's call 403s —
        // skip WITHOUT consuming the generation-once guard. That dialog
        // renders its own picker rows, so Continue there commits the
        // selection; if it doesn't, the next selection_required after it
        // closes can still re-show.
        // One decision surface at a time: an open (or chrome-await
        // pending) consent dialog, or an open reauth dialog, defers the
        // re-show — it re-fires on the next failing call.
        if (consentClaim !== null) return;
        if (openReauthDlg?.isOpen()) return;
        // Same deferral for the OTHER decision surfaces (nav-confirm,
        // chrome delete-confirm). The INITIAL/SWAP consent dialog may
        // force-close them (claimDecisionSurface) because its trigger is
        // viewer-initiated load/publish; the RE-SHOW's trigger is a failing
        // CALL — page-timeable JS — so reaching claimDecisionSurface here
        // would hand frame authors a dismiss-the-viewer's-pending-decision
        // primitive (the sibling-confirm-dismissal bug the re-auth prompt's
        // wait-set already avoids). Defer WITHOUT consuming the guards:
        // calls keep failing closed, and the next selection_required after
        // the viewer decides re-enters here.
        if (
          h.slot.querySelector(".consent.nav") ||
          document.querySelector(h.confirmOpenSelector)
        ) {
          return;
        }
        // Rows display the CURRENTLY-CONSENTED scope, not the full
        // manifest: a swap's declined capabilities must not be re-listed
        // (the viewer already said no), and Continue here commits a
        // connector choice — it must never widen consent. The store read
        // above is fresh for this continuation; the wired scope is the
        // fallback record for BOTH session-only-consent modes — an
        // unreadable store AND a store that reads fine but whose writes
        // fail (quota/private mode), in which case the intersection comes
        // back empty. What is wired IS what the viewer consented to this
        // session, either way.
        let reshowServers =
          consentedNow === null
            ? wiredScope.filter((s) => s.server !== SELF_SENTINEL)
            : servers
                .map((s) => ({
                  server: s.server,
                  tools: s.tools.filter(
                    (t) => consentedNow.get(s.server)?.tools.has(t) === true,
                  ),
                }))
                .filter((s) => s.tools.length > 0);
        // Union in any wired server the intersection missed: every
        // session-only-consent mode lands here — an unreadable store, a
        // wholly write-blocked one (empty intersection), or writes that
        // broke AFTER partial persistence (non-empty intersection missing
        // the session-only servers). What is wired IS consented.
        for (const ws of wiredScope) {
          if (ws.server === SELF_SENTINEL) continue;
          if (!reshowServers.some((s) => s.server === ws.server)) {
            reshowServers = [...reshowServers, ws];
          }
        }
        // Only a needs entry the dialog can actually RENDER justifies
        // consuming the guards: /mcp/servers reflects the CURRENT published
        // manifest, which can drift from the booted one, and a needs entry
        // for a server with no row would pop a pickerless modal and burn
        // the generation-once guard on nothing.
        if (
          !needs.some((e) => reshowServers.some((s) => s.server === e.server))
        ) {
          return;
        }
        pickerReshown = true;
        // The re-show always opens OVER this generation's content — record
        // shown-ness before the dialog mounts, or reveal() (which skips the
        // flag while a consent dialog is open) can leave contentShown false
        // forever and a later new-caps swap would be misclassified as an
        // initial boot and tear the live artifact down.
        h.markShown();
        // Selection-only re-show: statuses/actions were already displayed
        // at consent time — no controller on this surface.
        showConsentDialog(h, reshowServers, null, {
          reshowForSelection: true,
          // `needs` (unresolved) gates WHETHER to re-show; the dialog then
          // gets every multi-candidate row — resolved ones included, so the
          // viewer can also re-choose (incumbent pre-selected), matching
          // the consent-time dialog.
          // Full entries + auth: showConsentDialog filters pickers to ≥2
          // candidates itself and enriches every rendered row (identity,
          // healed checks — no actions on this selection-only surface).
          entries: Promise.resolve({ entries, auth: res.auth }),
          pref,
          onContinue: (picks) => {
            // Selection-only commit: persist against the consented scope
            // (an idempotent re-union), never the full manifest.
            commitPicks(picks, reshowServers);
          },
          onCancel: () => {
            /* Declined: selections stay as they were; duplicated calls keep
               failing closed with selection_required. No re-prompt for this
               broker generation — the next load or live version swap
               re-arms one. */
          },
        });
      })
      .finally(() => {
        recheckPending = false;
        lastRecheckDone = Date.now();
      });
  }

  // This mount generation's content iframe — one of recheck()'s staleness
  // anchors (see also the epoch check): it covers teardown-without-remount
  // (a 403/404 revocation removes the iframe but starts no new generation).
  let mountedFrame: HTMLIFrameElement | null = null;
  // The scope actually wired this session — the storage-blocked fallback
  // for the re-show dialog's rows: with session-only consent nothing is in
  // the store, but what is wired IS what the viewer consented to.
  let wiredScope: readonly ManifestServer[] = [];

  // Wire the broker against `iframe` with exactly `scope` as the consented
  // capability set, replacing any prior broker. A wiring exception leaves the
  // broker UNWIRED (deny) and surfaces a counter — never a half-wired bridge.
  const wire = (
    iframe: HTMLIFrameElement,
    scope: readonly ManifestServer[],
  ): void => {
    mountedFrame = iframe;
    wiredScope = scope;
    // Split the consent-layer scope: connector entries wire wireMcp, the
    // sentinel's presence is the self grant. wireSelf attaches whenever
    // the CP granted self — ungranted-by-consent wires the refusing
    // bridge (fast consent_required, never the kernel's 130 s timeout).
    const mcpScope = scope.filter((s) => s.server !== SELF_SENTINEL);
    const selfGranted = scope.some((s) => s.server === SELF_SENTINEL);
    // Declared outside the try so the catch can tear down whatever DID
    // attach before the throw — setUnwire(null) alone would invoke the
    // PREVIOUS generation's unwire and leak this one's listener, the
    // half-wired bridge the invariant above forbids.
    const downs: Array<() => void> = [];
    try {
      // Non-empty scope only — the pre-self posture. An unconsented
      // dual-declaring swap must not get a live mcp listener that can
      // drive authenticated /mcp/servers POSTs with an empty projected
      // scope; with nothing covered, mcp calls stay listenerless exactly
      // as before this capability existed.
      if (mcp && mcpScope.length > 0) {
        downs.push(wireMcp(h, iframe, tokenSlot, mcpScope, selections, sync));
      }
      if (selfCap) {
        // Telemetry relay rides exactly one listener per generation:
        // wireMcp carries it when ATTACHED (not merely resolved — see
        // the scope gate above), else wireSelf.
        downs.push(
          wireSelf(
            h,
            iframe,
            selfSlot,
            bootVer,
            selfGranted,
            !mcp || mcpScope.length === 0,
          ),
        );
      }
      h.setUnwire(
        downs.length > 0
          ? () => {
              for (const down of downs) down();
            }
          : null,
      );
    } catch {
      for (const down of downs) down();
      h.setBrokered?.(false); // structural no-wire — see the no-grant exit
      h.setUnwire(null);
      h.report("broker-consent-error");
    }
  };

  if (!hasNewCaps(askServers, consented)) {
    // Already consented, or a republish that only keeps/drops capabilities.
    wire(h.mount(d), askServers);
    // Post-mount selection check: a stored selection may have staled (its
    // connector deleted/renamed) or a duplicate set may have appeared since
    // consent. Async and non-blocking — until it lands, duplicated calls
    // fail closed server-side, never mis-route. Connector machinery: a
    // self-only frame has no selections to check and no mcp token to
    // spend, so it skips.
    if (mcp) recheck();
    // Re-auth prompt (#60805) after the selection check kickoff. Both are
    // async, and the one-surface mutex between them is the module-level
    // consentClaim sentinel — sound because showConsentDialog SETS it
    // synchronously at entry (before its chrome await), so two in-flight
    // continuations serialize and the loser always sees the winner's
    // claim. The loser defers cheaply: the picker keeps its once-guard
    // and re-fires on the next failing call; the re-auth poll retries,
    // then forfeits with a counted suppression. Any future await inserted
    // between a guard and its claim re-opens a double-dialog race — keep
    // the guard-to-claim path synchronous.
    if (mcp) maybePromptReauth(h, tokenSlot, servers);
    return;
  }

  // New capabilities → prompt. Two variants, keyed on isInitial:
  //  - swap (!isInitial): mount the (already fresh-token) new version NOW,
  //    keep fully-covered previously-consented servers wired through the
  //    open dialog, and gate only the NEW capabilities; a decline leaves the
  //    new content running with exactly that consented subset wired.
  //  - initial: prompt BEFORE mounting — no content renders and nothing
  //    wires until the viewer accepts, and a decline paints the error pane.
  //    The assetToken can age past staleMs while the dialog sits open, so
  //    Continue re-boots for a fresh token when stale.
  if (isInitial) {
    // Strict prompt-before-mount: "no content renders and NOTHING wires
    // until the viewer accepts". A previous version's first load may be in
    // flight (contentShown false until reveal()) — tear it down so its
    // alarms/reveal/broker can't interfere with the open dialog.
    h.setUnwire(null);
    h.unmount();
  }
  const f = isInitial ? null : h.mount(d);
  if (f) {
    // The viewer declined nothing yet: servers whose full capability set is
    // already consented stay live while the dialog asks about the rest.
    // Coverage is per-server — a server with even one new tool waits whole.
    const covered = askServers.filter((s) => {
      const ok = consented.get(s.server)?.tools;
      return s.tools.every((t) => ok?.has(t) === true);
    });
    if (covered.length > 0 || selfCap) {
      // selfCap with nothing covered still wires: wireSelf's refusing
      // bridge answers consent_required fast while the dialog is open
      // (and after a decline) — otherwise a swap that ADDS self leaves
      // the kernel's window.claude.self posting into a listenerless
      // window for the 130 s timeout. mcp-only keeps the historical
      // no-wire posture.
      wire(f, covered);
    } else {
      h.setUnwire(null);
    }
  }
  showConsentDialog(h, servers, mcp ? tokenSlot : null, {
    // The self capability's ask renders as the dialog's fixed capability
    // row — a display flag, never a ConsentRowSpec (rows are join-keyed
    // by connector name through pickers/connections/commit).
    selfAsk: selfCap !== null,
    // Kicked at dialog time so picker rows (and their bookkeeping — echo
    // recording, collapsed-set auto-switch) arrive while the viewer reads.
    // Multi-candidate rows get a select whether resolved or not, so a
    // resolved-but-duplicated connector is re-choosable here too.
    // Connector enrichment is mcp machinery — a self-only ask has no
    // /mcp/servers to consult (and no mcp token to spend on it).
    entries: mcp
      ? fetchEntries().then((res) => {
          if (res === null) return null;
          applyEntries(res.entries);
          // Full result: showConsentDialog filters pickers to ≥2
          // candidates itself and enriches every rendered row with
          // identity.
          return res;
        })
      : Promise.resolve(null),
    pref,
    onContinue: (picks) => {
      commitPicks(picks);
      // Two recovery paths route through onStale, which re-boots with a
      // fresh token and re-sets shell's current.ver; the persist above means
      // the re-boot sees the new caps as consented and mounts without
      // re-prompting. (a) An initial dialog that outlived the asset token.
      // (b) A swap candidate that shell's giveUp() tore down mid-dialog
      // (load error/timeout) — f is detached-but-non-null and current.ver
      // was rolled back, so a direct h.mount(d) would desync it.
      if (
        (isInitial && Date.now() - bootAt > h.staleMs) ||
        (f && !f.isConnected)
      ) {
        h.onStale();
        return;
      }
      h.markShown(); // after the early-returns — only when content actually wires
      wire(f ?? h.mount(d), askServers);
      if (mcp) maybePromptReauth(h, tokenSlot, servers);
    },
    onCancel: () => {
      // swap: the new caps are declined, the swapped-in content stays,
      // and the previously-consented subset wired above stays wired. An
      // uncovered (server,tool) call gets `not_in_manifest` from the broker.
      // No re-auth check on this path: the viewer just declined a dialog —
      // immediately raising another would be hostile, and the next boot
      // (or the frame's own call-time errors) covers the lapsed case.
      // initial: the iframe was never mounted (prompt-before-mount), so
      // there's no kernel to settle — the error pane paints below.
      if (!isInitial) return;
      // Paint the decline in-chunk (not via shell's paintError/ERROR_COPY —
      // that map is in the shell entry) so the copy stays lazy.
      byId("loading")?.remove();
      const err = byId("err");
      if (err) {
        err.hidden = false;
        err.replaceChildren(
          el(
            "b",
            undefined,
            // Name what was actually declined: self-only, connectors-only,
            // or (when the ask spanned both) the generic form.
            selfCap && servers.length === 0
              ? "Update access declined"
              : selfCap
                ? "Access declined"
                : "Connector access declined",
          ),
          el("span", undefined, "Reload to review the request again."),
        );
        err.tabIndex = -1;
        err.focus();
      }
    },
  });
}

/** Test-only surface (STYLE_GUIDE: prefer _test over exporting for tests). */
export const _test = { projectSelfErr, wireSelf, selfSentinel, resolveMcp };
