/**
 * 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 (four copies:
 * render.ts, shell.ts, this file, downloads.ts — keep in lock-step);
 * `report()` and the shell's mount/iframe state arrive via
 * the BrokerHost callbacks.
 */

import {
  capConsentKey,
  needsPrompt,
  persistCapConsent,
  readCapConsent,
  stringSetCodec,
} from "./capConsent";
// 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";
// downloads.ts bundles into this same broker chunk (this file is its only
// importer), so the zero-imports gate in frameShellEntry still holds.
import { hasDownloadsGrant, wireDownloads } from "./downloads";
import {
  MCP_CACHE_GC_TIME_DEFAULT_MS,
  mcpCacheClearAll,
  mcpCacheDropMemory,
  mcpCacheGet,
  mcpCacheIdentity,
  mcpCacheBumpInvalidationGeneration,
  mcpCacheInvalidate,
  mcpCacheInvalidationGeneration,
  mcpCacheSweepExpired,
  mcpCachePutResult,
  mcpCacheWipeGeneration,
  parseCacheOptions,
  sha256Hex,
  sortKeysDeep,
} from "./mcpCache";
// Result cache for opted-in callTool calls — runtime-imported ONLY here so
// it bundles into this chunk and the zero-imports gate still holds (also
// why it is raw IndexedDB: a node_modules import would split a vendor
// chunk and fail the gate).
import {
  type CapView,
  createPermissionsController,
  type DialogVerdict,
  type PermissionsController,
} from "./permissionsState";
// Wire shapes — protocol.ts is the canonical source of truth. This is the
// ONE frame-shell entry (chunk) 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 {
  frameCapAck,
  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 };
    /** Tokenless grant — presence is the signal, EXCEPT optional:true
     * (declared-but-not-entitled), which hasDownloadsGrant refuses. */
    downloads?: { config?: unknown; optional?: unknown };
  };
}

/**
 * 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). The downloads save-confirm
   * (downloads.ts showSaveConfirm) stamps the same flag: it is equally a
   * decision surface whose claim must survive sibling-dialog closes. */
  setDecisionSurfaceOpen: (v: boolean) => void;
  /** Read the decision-surface-open flag. The pre-open guards (reauth
   * reveal-poll, picker re-show, downloads' yield probe) defer on it —
   * the stamp replaced the deleted vanilla consent dialog's DOM-id probe
   * as the "is another decision surface open" signal. */
  decisionSurfaceOpen: () => boolean;
  /** 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;
}

/** Per-tool annotation hints recorded from /mcp/servers responses —
 * wire-explicit booleans ONLY (no MCP-spec defaulting, ever: the policy
 * floor keys on declared-false, so absent must stay distinguishable).
 * Keyed (server display name → tool → hints). Module-scoped because two
 * parse sites feed it (the page listTools path and the chrome status
 * path); cleared with the cache wipes — hints recorded under one
 * account must not steer another account's cache policy. An unpopulated
 * map means "unannotated" and therefore uncached: fail-safe. */
interface ToolHints {
  readOnly?: boolean;
  destructive?: boolean;
}
/** Per server name: the hints AND the resolved connector id they were
 * recorded from, one object so they cannot desync (display names are
 * not unique — the selection machinery exists because of that — and a
 * name re-resolving to a DIFFERENT connector must drop the old
 * connector's hints rather than let them govern the new one's policy).
 * One structure, not a parallel owner map: coupled state cleared "in
 * lockstep by convention" is the drift class wipeViewerState and
 * wireOwns were built to kill. */
const mcpToolHints = new Map<
  string,
  { resolvedId?: string; byTool: Map<string, ToolHints> }
>();

export function mcpToolHintsClear(): void {
  mcpToolHints.clear();
}

// ── watchTool registry ──────────────────────────────────────────────
// Module-scoped DELIBERATELY (not closure-scoped like `inflight`): a
// consent-scope expansion re-wires the mcp bridge on a live iframe
// within one generation, and live watches must survive it — the
// descriptors carry no executable state, and the executor slot below is
// re-bound by each new wire (the TokenSlot pattern). Watches die in
// exactly two ways: their own unsubscribe, and the retraction paths
// (account/org switch wipe; scope fallout after a re-wire; generation
// change) — each of which fires a handler-visible error event first,
// never silent death.

interface WatchDescriptor {
  /** The composite registry key this descriptor lives under. */
  key: string;
  /** The frame's own id for this watch — the push-channel address. */
  watchId: string;
  /** brokerGeneration at registration — delivery and execution are
   * refused across generations. */
  gen: number;
  /** Viewer + frame binding: delivery REQUIRES equality with the
   * settling wire's identity. Two frames (or two accounts) watching the
   * same (server, tool, input) share a cacheBody; without this binding
   * one wire's settle would leak its viewer's result into the other's
   * handler. */
  frameUuid: string;
  account: string;
  org: string | undefined;
  server: string;
  tool: string;
  /** Canonicalized input (sortKeysDeep, `input ?? {}`). */
  input: unknown;
  /** Call identity — matches the callTool pipeline's coalescing key. */
  cacheBody: string;
  staleTimeMs: number;
  gcTimeMs: number;
  refetchIntervalMs?: number;
  pollTimer?: ReturnType<typeof setTimeout>;
  /** A poll elapsed while the document was hidden — run one catch-up
   * refetch on visibility return. */
  catchUp?: boolean;
  /** storedAt of the last cache-served result delivered — a poll that
   * hit-serves the SAME entry again is suppressed (not "newer"). */
  lastServedStoredAt?: number;
  /** Wall-clock of the last FRESH delivery — a cached serve whose entry
   * was created at-or-before it cannot be newer than what the handler
   * already holds, so it is suppressed too. */
  lastFreshAt?: number;
  /** A viewer-recoverable authz denial (WATCH_AUTHZ_RECOVERABLE) has
   * settled for this watcher and no data delivery has occurred since —
   * the standing-intent flag the auth-recovery poller keys on. Set on
   * settle (even one gated out of delivery by a teardown window);
   * cleared only by an actually-delivered data event. Deliberately NOT
   * cleared by non-authz error settles: a transport blip after a
   * needs_reauth is not evidence the connector healed. */
  authzFailed?: boolean;
}

/** Registry key: watchIds are runtime-generated PER FRAME ("w1","w2",…)
 * and collide across frames, generations, and accounts by construction —
 * the composite key keeps one wire's "w1" from clobbering (or blocking)
 * another's. Within one (frame, gen, account) a duplicate watchId is a
 * genuine caller bug and is rejected. */
const watchKey = (
  frameUuid: string,
  gen: number,
  account: string,
  org: string | undefined,
  watchId: string,
  // Full wire-identity tuple, org included — the key is itself an
  // identity comparison (dup rejection, unwatch scoping), and wireOwns
  // mandates all four fields: an org-less key would let one org's
  // unwatch silently delete another org's live watch under a colliding
  // per-frame watchId.
): string => JSON.stringify([frameUuid, gen, account, org ?? null, watchId]);
const watchRegistry = new Map<string, WatchDescriptor>();
/** Per-frame live-watch bound. Overflow rejects bad_request (an
 * author bug — leaked subscriptions or runaway fan-out; the fix is a
 * code change, not a retry). */
const WATCH_CAP_PER_FRAME = 64;
/** Declared-polling floor — N watchers of one identity coalesce via the
 * in-flight map, so the floor bounds per-identity upstream rate. */
const WATCH_POLL_FLOOR_MS = 30_000;

/** The live wire's watch executor — re-bound by every wireMcp and
 * generation-guarded like TokenSlot: a surviving descriptor executes
 * against the CURRENT wire's consent scope, token, and iframe, and a
 * stale wire's late registration attempt is refused. */
interface WatchExecutor {
  readonly gen: number;
  readonly frameUuid: string;
  readonly account: string;
  readonly org: string | undefined;
  /** Consent re-check under the CURRENT wire's scope. */
  inScope: (server: string, tool: string) => boolean;
  /** Drive one execution of the descriptor's identity through the
   * callTool pipeline (coalescing, gates, stores, denial enforcement —
   * deliveries reach watchers via notifyWatchers, never a page reply). */
  execute: (d: WatchDescriptor) => void;
  /** Post one watch event into the frame (same targetOrigin discipline
   * as frameCapReply). */
  postEv: (watchId: string, ev: unknown) => void;
}
let activeWatchExecutor: WatchExecutor | null = null;

function setActiveWatchExecutor(ex: WatchExecutor | null): void {
  activeWatchExecutor = ex;
}

/** Settle codes a viewer's own connector action can clear (reconnect for
 * needs_reauth, connect for server_not_connected) — the codes that make a
 * watch eligible for auth-recovery re-execution. Deliberately narrower
 * than SERVER_SCOPED_DENIALS: server_not_found is a naming/publish
 * problem no connector action fixes, and selection_required has its own
 * re-arm (sync.onSelectionRequired). */
const WATCH_AUTHZ_RECOVERABLE: ReadonlySet<string> = new Set([
  "needs_reauth",
  "server_not_connected",
]);

/** The live wire's auth-recovery poller kick — re-bound by every wireMcp
 * like activeWatchExecutor. Called with the failing server on an authz
 * settle (seeds the edge detector + arms the poll); with (server, true)
 * when a FLAGGED watch settles a transient non-authz error (lets a
 * spent edge be re-earned — see kick); and with null to re-evaluate
 * (a delivered heal reconciling episode state, an unwatch/retract
 * dropping descriptors, or visibility return resuming a paused poll). */
let kickWatchAuthRecovery:
  | ((server: string | null, transientRetry?: boolean) => void)
  | null = null;

/** Deliver one settled outcome to every live watcher of `cacheBody`
 * REGISTERED BY THE SAME WIRE IDENTITY — registry-keyed at delivery
 * time, so a descriptor unsubscribed or retracted mid-flight is simply
 * not found, and a stale wire's late settle still reaches surviving
 * descriptors through the CURRENT executor. */
function notifyWatchers(
  from: {
    gen: number;
    frameUuid: string;
    account: string;
    org: string | undefined;
  },
  cacheBody: string,
  out: { result: unknown } | { error: McpErr },
): void {
  // Auth-recovery MARKING runs BEFORE the executor gate below: a settle
  // landing in the teardown→re-wire executor-null window must still flag
  // its descriptors (they survive the re-wire, and the next wire's
  // arm-time scan recovers them) — an executor-gated mark would leave
  // such a watch idle forever. The CLEAR deliberately does NOT live
  // here: it rides the actual data delivery in the gated loop below.
  // The asymmetry is the point — set on conservative evidence, clear
  // only on proof the handler heard data (a suppressed hit-serve of a
  // pre-failure entry, or a data settle gated out by a dead executor,
  // must not clear a flag nothing was delivered for).
  for (const d of watchRegistry.values()) {
    if (d.cacheBody !== cacheBody || !wireOwns(from, d)) continue;
    if ("error" in out) {
      if (WATCH_AUTHZ_RECOVERABLE.has(out.error.code)) {
        d.authzFailed = true;
        kickWatchAuthRecovery?.(d.server);
      } else if (d.authzFailed === true) {
        // A transient NON-authz error on a flagged watch — possibly
        // this poller's own edge re-execution dying short of the
        // connector-auth question (timeout, 5xx). Tell the poller so
        // a spent edge can be re-earned; without this the watch
        // strands flagged while the status stays healthy, and the
        // poll tail runs forever with no action it can ever take.
        kickWatchAuthRecovery?.(d.server, true);
      }
    }
  }
  const ex = activeWatchExecutor;
  // Full identity vs the SETTLING wire: a dead wire's late settle at
  // the same gen (account/org switch shape) must not deliver through
  // the current wire into the current frame.
  if (!ex || !wireOwns(ex, from)) return;
  const storedAt =
    !("error" in out) &&
    out.result !== null &&
    typeof out.result === "object" &&
    !Array.isArray(out.result)
      ? ((out.result as { cache?: { storedAt?: unknown } }).cache?.storedAt as
          | number
          | undefined)
      : undefined;
  for (const d of watchRegistry.values()) {
    if (d.cacheBody !== cacheBody || !wireOwns(from, d)) continue;
    if (storedAt !== undefined) {
      if (d.lastServedStoredAt === storedAt) continue; // not newer
      if (d.lastFreshAt !== undefined && storedAt <= d.lastFreshAt) {
        continue; // the handler already heard this execution fresh
      }
      d.lastServedStoredAt = storedAt;
    } else if (!("error" in out)) {
      d.lastFreshAt = Date.now();
    }
    // The auth-recovery CLEAR: only an actually-delivered data event —
    // past the suppression checks above and the executor gate — proves
    // the handler heard fresh data. The poller reconciles immediately
    // so edge state dies with the episode (a relapse landing before the
    // next tick must meet a fresh seed, not a stale spent one).
    if (!("error" in out) && d.authzFailed === true) {
      d.authzFailed = false;
      kickWatchAuthRecovery?.(null);
    }
    ex.postEv(
      d.watchId,
      "error" in out
        ? { type: "error", error: out.error }
        : { type: "data", server: d.server, result: out.result },
    );
  }
}

/** Retract every watch matching `pred`: handler-visible error event
 * first (a replayed value must not outlive the denial that follows it),
 * then removal. Used by the account/org-switch wipes, scope fallout,
 * and generation teardown. */
/** The ONE identity question every watch site asks: are these two wire
 * identities the same wire? gen alone is transitively sufficient today
 * (one TokenSlot identity per broker generation) but ONLY transitively —
 * account-switch cleanup is detection-based, not structural, so a
 * foreign-identity descriptor can survive in the registry. EVERY
 * identity comparison in this file goes through this helper — executor
 * vs descriptor, executor vs settling wire, settling wire vs
 * descriptor; grep for `.gen ===`/`.gen !==` outside this function to
 * verify. A partial-tuple or gen-only comparison anywhere in this file
 * is a bug (this class produced six findings before the invariant
 * became grep-verifiable). */
interface WireIdentity {
  gen: number;
  frameUuid: string;
  account: string;
  org: string | undefined;
}
function wireOwns(a: WireIdentity, b: WireIdentity): boolean {
  return (
    a.gen === b.gen &&
    a.frameUuid === b.frameUuid &&
    a.account === b.account &&
    a.org === b.org
  );
}

function watchRetract(
  pred: (d: WatchDescriptor) => boolean,
  code: string,
  message: string,
): void {
  const ex = activeWatchExecutor;
  let droppedFlagged = false;
  for (const [key, d] of watchRegistry) {
    if (!pred(d)) continue;
    if (d.pollTimer !== undefined) clearTimeout(d.pollTimer);
    watchRegistry.delete(key);
    if (d.authzFailed === true) droppedFlagged = true;
    // Addressed by the FRAME's id (d.watchId) — the registry key is a
    // broker-internal composite the runtime has never seen. Full wire
    // identity, not just gen: same as notifyWatchers — one identity per
    // generation makes gen-only transitively safe today, but a colliding
    // per-frame watchId must never let one wire's retraction post into
    // another wire's frame if that structural assumption ever relaxes.
    if (ex && wireOwns(ex, d)) {
      ex.postEv(d.watchId, {
        type: "error",
        error: mcpErr(code, message, d.server),
      });
    }
  }
  // Episode boundary, same as handleUnwatch: a retraction that dropped
  // a flagged descriptor may have ended its server's failure episode —
  // reconcile so later watches meet fresh edge state.
  if (droppedFlagged) kickWatchAuthRecovery?.(null);
}

export function mcpWatchRetractAll(code: string, message: string): void {
  watchRetract(() => true, code, message);
}

/** The ONE viewer-state wipe: cache entries, recorded tool hints, and
 * live watches (retracted with the authz event) drop together. These
 * three are coupled — a wipe that forgets one leaves another viewer's
 * policy or a silently-dead watch behind — so call sites use this
 * helper, never the pieces. */
function wipeViewerState(): void {
  void mcpCacheClearAll();
  mcpToolHintsClear();
  mcpWatchRetractAll("needs_reauth", "viewer session changed");
}

// One module-level visibility listener drives catch-up refetches for
// every descriptor (polls themselves check hidden-ness at fire time).
if (typeof document !== "undefined") {
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState !== "visible") return;
    // Hidden-paused auth-recovery polls resume here (null = no seed).
    kickWatchAuthRecovery?.(null);
    const ex = activeWatchExecutor;
    if (!ex) return;
    for (const d of watchRegistry.values()) {
      // Full identity, not gen: executing a foreign-identity descriptor
      // would run the OTHER account's (server, tool, input) with the
      // CURRENT viewer's token.
      if (!d.catchUp || !wireOwns(ex, d)) continue;
      d.catchUp = false;
      ex.execute(d);
    }
  });
}

/** (Re)arm one descriptor's declared poll. The timer re-checks
 * visibility at fire time: hidden polls convert to a catch-up mark. */
function scheduleWatchPoll(d: WatchDescriptor): void {
  if (d.refetchIntervalMs === undefined) return;
  if (d.pollTimer !== undefined) clearTimeout(d.pollTimer);
  // Belt to the registration clamp's braces: never hand setTimeout a
  // delay past its signed-32-bit range, whatever the descriptor holds.
  const interval = Math.min(
    Math.max(d.refetchIntervalMs, WATCH_POLL_FLOOR_MS),
    0x7fffffff,
  );
  d.pollTimer = setTimeout(() => {
    d.pollTimer = undefined;
    // Descriptor IDENTITY, not key presence: if this key was ever
    // re-registered, the old descriptor's timer must die with it —
    // key-presence liveness would let an orphan timer poll forever.
    if (watchRegistry.get(d.key) !== d) return;
    const ex = activeWatchExecutor;
    // Full identity, not gen — see wireOwns.
    if (!ex || !wireOwns(ex, d)) return;
    if (typeof document !== "undefined" && document.hidden) {
      d.catchUp = true;
    } else {
      ex.execute(d);
    }
    scheduleWatchPoll(d);
  }, interval);
}

/** Record hints from one parsed /mcp/servers body. Accepts the raw
 * parsed JSON (never a projection): only typeof-boolean values are
 * recorded — never via spread, so no future wire field can widen this
 * surface by accident. */
function recordToolHints(
  parsed: {
    servers?: {
      server?: unknown;
      resolvedId?: unknown;
      candidates?: unknown;
      tools?: {
        name?: unknown;
        annotations?: {
          readOnlyHint?: unknown;
          destructiveHint?: unknown;
        } | null;
      }[];
    }[];
  },
  consentedScope: Map<string, Set<string>>,
): void {
  for (const s of parsed.servers ?? []) {
    if (typeof s.server !== "string" || s.server === "") continue;
    // Hints drive shell-side POLICY (cache defaults, the write floor,
    // watch retraction), so they are recorded only from rows the policy
    // can trust:
    // 1. Consented scope only — an unconsented same-named connector in
    //    the raw body must not forge hints for a consented one.
    const allowed = consentedScope.get(s.server);
    if (!allowed) continue;
    // 2. Unambiguous identity only — a duplicate set pre-selection has
    //    no single connector behind the name; record nothing until the
    //    CP resolves it (resolvedId present, or no competing set).
    const rid =
      typeof s.resolvedId === "string" && s.resolvedId !== ""
        ? s.resolvedId
        : undefined;
    const dup = Array.isArray(s.candidates) && s.candidates.length > 1;
    if (dup && rid === undefined) continue;
    // 3. Owner reconciliation — a re-selection resolving this name to a
    //    DIFFERENT connector drops the old connector's hints wholesale
    //    (they described another backend's tools).
    let entry = mcpToolHints.get(s.server);
    if (rid !== undefined && entry !== undefined && entry.resolvedId !== rid) {
      entry = undefined;
      mcpToolHints.delete(s.server);
    }
    for (const t of s.tools ?? []) {
      if (typeof t.name !== "string" || t.name === "") continue;
      if (!allowed.has(t.name)) continue;
      const a = t.annotations;
      if (!a || typeof a !== "object") continue;
      const hints: ToolHints = {};
      if (typeof a.readOnlyHint === "boolean") hints.readOnly = a.readOnlyHint;
      if (typeof a.destructiveHint === "boolean") {
        hints.destructive = a.destructiveHint;
      }
      if (hints.readOnly === undefined && hints.destructive === undefined) {
        continue;
      }
      if (!entry) {
        entry = { byTool: new Map() };
        mcpToolHints.set(s.server, entry);
      }
      if (rid !== undefined) entry.resolvedId = rid;
      entry.byTool.set(t.name, hints);
      // A declared-write hint ARRIVING for an already-watched tool
      // would silently null the watch's cache identity (uncached
      // pipeline → no watcher delivery, polls burning network).
      // Registration already rejects declared writes; late hints
      // retract with the same verdict.
      if (hints.readOnly === false) {
        const serverName = s.server;
        const toolName = t.name;
        // Scoped to the RECORDING wire's identity: hints arrive on the
        // current viewer's listTools, and an unscoped (server, tool)
        // match would silently delete another identity's surviving
        // registry entries (their postEv is identity-gated, so the
        // death would be invisible to that page).
        const rec = activeWatchExecutor;
        watchRetract(
          (d) =>
            d.server === serverName &&
            d.tool === toolName &&
            rec !== null &&
            wireOwns(rec, d),
          "bad_request",
          "declared-write tools cannot be watched",
        );
      }
    }
  }
}

// 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;
  // Executor released FIRST so the retraction below is locally
  // event-free by construction (not by caller ordering): the frame
  // content is being replaced, so there is no page context to notify.
  activeWatchExecutor = null;
  watchRetract(() => true, "capability_disabled", "frame re-booted");
}

/** 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;
  if (
    activeTokenSlot &&
    (activeTokenSlot.org !== bootOrg || activeTokenSlot.account !== acct)
  ) {
    // The viewer changed under a live slot (shared-browser switch): the
    // slot stays stale by design (fail-closed 403 — see TokenSlot), and
    // the previous viewer's cached results must not outlive detection.
    // Not gated on the envelope resolving mcp: the account parse alone is
    // the evidence, and a capless divergent boot proves the switch too.
    wipeViewerState();
  }
  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;
  /** CP marked this result no-store (e.g. an approval-gated execution):
   * server-authoritative, per call. The broker must not cache the
   * result. Absent header = storable as usual. */
  noStore: boolean;
}

/** Compose the per-leg timeout with an optional caller-cancellation
 * signal. AbortSignal.any where available; manual composition otherwise
 * (same compat posture as safeTimeout). */
const composeAbort = (
  timeout: AbortSignal,
  cancel?: AbortSignal,
): AbortSignal => {
  if (!cancel) return timeout;
  if ("any" in AbortSignal) return AbortSignal.any([timeout, cancel]);
  const ac = new AbortController();
  if (timeout.aborted || cancel.aborted) {
    ac.abort();
    return ac.signal;
  }
  const on = (): void => ac.abort();
  timeout.addEventListener("abort", on, { once: true });
  cancel.addEventListener("abort", on, { once: true });
  return ac.signal;
};

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,
  /** Page-cancellation signal (callTool options.signal, relayed as
   * cancelCall). Composed with each leg's timeout; also gates the
   * 400-retry — a cancelled call must never re-execute. */
  cancelSignal?: AbortSignal,
): 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: composeAbort(safeTimeout(budgetMs), cancelSignal),
    }).then(async (r) => ({
      status: r.status,
      ok: r.ok,
      text: await r.text(),
      noStore: r.headers.get("X-Frame-Mcp-No-Store") === "1",
    }));
  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;
    // A cancelled call must never fire the retry leg: the page already
    // saw `cancelled`, and a late retry could re-execute the tool.
    if (cancelSignal?.aborted) 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 attached, else wireSelf, else wireDownloads, else
 * wirePermissions) so multi-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 === "downloads" ||
        cap === "permissions"
        ? 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,
  brokerLive: () => boolean,
): () => void {
  const { frameOrigin, frameUuid } = h;
  /* brokerLive: generation-scoped liveness (epoch === brokerGeneration,
   * bound at mountBrokered). Checked at every send — a pending reply's
   * .then survives teardown, reply ids are bare per-module counters, and
   * a STALE GENERATION's late send would resolve the next generation's
   * same-index call with the old payload. Deliberately NOT a per-wire
   * teardown flag: wire() re-wires within a generation on scope gains,
   * and same-generation late replies (concurrent held asks, in-flight
   * calls across a re-wire) are legitimate and must deliver. Also
   * checked at the listener head: a new generation claims the epoch at
   * mountBrokered ENTRY before its wire() replaces these listeners —
   * a dispatch in that gap must drop crisply (no ack, module timeouts
   * stay armed) rather than start doomed work. */

  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 (!brokerLive()) return; // claim→wire gap: drop, keep timeouts armed
    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 => {
        if (!brokerLive()) return;
        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);
  };
}

/** sync: bootstrap.ts CHROME_ONLY_CAPS — duplicated for chunk isolation,
 * the same reason deferred.ts twins hasBrokerCap: bootstrap.ts bundles
 * into the self-contained frame-shell entry, and a cross-chunk import
 * from this (broker) chunk would hoist it into a shared chunk and fail
 * the entry's no-external-imports gate. The shell.test.ts custody test
 * asserts the copies agree. */
const PERM_CHROME_ONLY_CAPS: ReadonlySet<string> = new Set(["remote_control"]);

/** Bound on one request()'s name list — far above any real capability
 * count, low enough that a hostile page can't make the shell churn. */
const PERM_REQUEST_NAMES_MAX = 32;
/** The decision-surface busy core — the FOUR terms every surface-
 * coordination site shares: an open consent claim, a stamped decision
 * surface (downloads save-confirm etc.), a nav confirm, a delete
 * confirm. showConsentDialog's claimDecisionSurface force-closes the
 * latter three, so a page-timeable ask reaching it would hand hostile
 * frame JS a "dismiss the viewer's pending decision" primitive —
 * permShowDialog busy-rejects on this set (fail-closed: denied-
 * without-memory + cooldown), the permissions controller's
 * surfaceBusy deps hook consults it for the held-ack (fresh asks
 * only — joins ack regardless; see PermissionsDeps.surfaceBusy), and
 * the reauth poll defers on it (with its own additional reveal/chrome
 * terms). ONE definition so the sites cannot drift. */
function decisionSurfaceBusy(h: BrokerHost): boolean {
  return (
    consentClaim !== null ||
    h.decisionSurfaceOpen() ||
    h.slot.querySelector(".consent.nav") !== null ||
    document.querySelector(h.confirmOpenSelector) !== null
  );
}

/** Resolve a scoped mcp name ("mcp:<declared server name>" — parse at
 * the FIRST colon; the remainder is the raw declared server name, byte-
 * identical to the callTool join key; connector grammar admits no colon,
 * pinned by test) to its manifest entry, or null when the name isn't
 * scoped or the server isn't declared. Undeclared servers read null →
 * "unavailable", indistinguishable from undeclared names, so scoped keys
 * disclose nothing beyond the frame's own manifest. */
export const mcpScopedServer = (
  name: string,
  servers: readonly ManifestServer[],
): ManifestServer | null => {
  if (!name.startsWith("mcp:")) return null;
  const server = name.slice(4);
  return servers.find((s) => s.server === server) ?? null;
};

/** Per-key denial/grant semantics for bare aggregates: an ask (or
 * verdict) naming bare "mcp" covers every declared scoped key — the
 * viewer was asked about the whole manifest, so the decision binds each
 * server. Scoped names pass through unchanged; the bare aggregate's own
 * state derives from store coverage, so a scoped-only verdict never
 * needs to synthesize it. (Known asymmetry, accepted: on blocked
 * storage, scoped-only grants covering every server leave the bare
 * aggregate at "prompt" — a later bare ask over-asks rather than
 * under-asks, which is the safe direction.) */
export const expandMcpNames = (
  names: readonly string[],
  servers: readonly ManifestServer[],
): Set<string> => {
  const out = new Set(names);
  if (out.has("mcp")) {
    for (const s of servers) out.add(`mcp:${s.server}`);
  }
  return out;
};

/** The post-held-request re-wire, generation-guarded. A held dialog's
 * settle fires afterHeld from ITS OWN generation's bridge even after a
 * same-iframe swap has wired a NEWER generation (the settle is a pending
 * promise .then, not a message — teardown doesn't cancel it, and the
 * iframe element survives swaps, so DOM-liveness alone passes). Without
 * the epoch check, the STALE generation's wire() would re-register its
 * listeners and replace the new generation's teardown — resurrecting a
 * pre-swap scope and token slot over the live one. epoch is the
 * brokerGeneration claimed at this generation's mountBrokered entry;
 * any mismatch means a newer generation owns the iframe — do nothing. */
export const makePermAfterHeld =
  (
    epoch: number,
    currentGen: () => number,
    getFrame: () => HTMLIFrameElement | null,
    getWired: () => readonly ManifestServer[],
    scopeNow: () => ManifestServer[],
    wire: (f: HTMLIFrameElement, s: readonly ManifestServer[]) => void,
  ) =>
  (): void => {
    if (epoch !== currentGen()) return;
    const f = getFrame();
    if (!f?.isConnected) return;
    const scope = scopeNow();
    const have = new Set(getWired().map((s) => s.server));
    if (scope.some((s) => !have.has(s.server))) wire(f, scope);
  };

/** Bound on one capability name in a state/request arg. Sized for
 * scoped names ("mcp:" + a raw declared connector name, which the CP
 * grammar lets exceed 128 chars). sync: the permissions module's
 * NAME_MAX (frame-runtime permissions/index.ts). */
const PERM_NAME_MAX = 512;

/**
 * Wire the iframe → shell `window.claude.permissions` bridge. Same
 * boundary as wireMcp/wireSelf: only messages from the iframe's exact
 * origin AND contentWindow are accepted; replies go back to that origin
 * only. Read-mostly — answers come from this generation's
 * PermissionsController; no capability token is read or spent here.
 *
 * The held-call ack: a request that will open (or join) a consent dialog
 * is acked with `__frame_cap_ack` BEFORE the await, so the page's module
 * swaps its reply timer for the long dialog backstop (protocol.ts
 * FrameCapAck). `afterHeld` runs after any held request settles — the
 * mount path re-wires bridges there when the verdict granted new scope.
 *
 * `relayTelemetry` rides exactly one listener per generation (the same
 * rule wireMcp/wireSelf/wireDownloads share) — passed true only when
 * none of those attached.
 */
function wirePermissions(
  h: BrokerHost,
  iframe: HTMLIFrameElement,
  ctl: PermissionsController,
  allNames: () => readonly string[],
  afterHeld: () => void,
  relayTelemetry: boolean,
  brokerLive: () => boolean,
): () => void {
  const { frameOrigin } = h;
  /* brokerLive: generation-scoped liveness (epoch === brokerGeneration,
   * bound at mountBrokered). Checked at every send — a pending reply's
   * .then survives teardown, reply ids are bare per-module counters, and
   * a STALE GENERATION's late send would resolve the next generation's
   * same-index call with the old payload. Deliberately NOT a per-wire
   * teardown flag: wire() re-wires within a generation on scope gains,
   * and same-generation late replies (concurrent held asks, in-flight
   * calls across a re-wire) are legitimate and must deliver. Also
   * checked at the listener head: a new generation claims the epoch at
   * mountBrokered ENTRY before its wire() replaces these listeners —
   * a dispatch in that gap must drop crisply (no ack, module timeouts
   * stay armed) rather than start doomed work. */

  const onMessage = (e: MessageEvent): void => {
    // Same connection gate as wireMcp/wireSelf: never answer an iframe
    // the viewer has already seen replaced.
    if (!iframe.isConnected) return;
    if (!brokerLive()) return; // claim→wire gap: drop, keep timeouts armed
    if (e.origin !== frameOrigin || e.source !== iframe.contentWindow) return;
    const cap = parseFrameCap(e.data);
    if (cap && cap.cap === "permissions") {
      const send = (b: { result: unknown } | { error: McpErr }): void => {
        if (!brokerLive()) return;
        iframe.contentWindow?.postMessage(
          frameCapReply(cap.id, b),
          frameOrigin,
        );
      };
      // Two verbs, arg-presence routed (the module sends [] for the
      // zero-arg spellings): `state` reads without prompting — point or
      // full map; `request` asks — subset or everything.
      if (cap.method === "state") {
        if (cap.args.length === 0) {
          return send({ result: ctl.stateAll() });
        }
        const [name] = cap.args;
        if (typeof name !== "string" || name.length > PERM_NAME_MAX) {
          return send({
            error: {
              code: "bad_request",
              message: "state takes no arguments or one capability name",
            },
          });
        }
        return send({ result: ctl.query(name) });
      }
      if (cap.method !== "request") {
        return send({
          error: {
            code: "upstream_error",
            message: `unknown method ${cap.method}`,
          },
        });
      }
      const everything = cap.args.length === 0;
      let names: readonly string[];
      if (everything) {
        // Ask-everything: the controller restricts to the
        // non-"unavailable" subset itself, and wouldPrompt's
        // prompt-state filter makes the superset harmless.
        names = allNames();
      } else {
        const [raw] = cap.args;
        if (
          !Array.isArray(raw) ||
          raw.length > PERM_REQUEST_NAMES_MAX ||
          !raw.every(
            (n): n is string =>
              typeof n === "string" && n.length <= PERM_NAME_MAX,
          )
        ) {
          return send({
            error: {
              code: "bad_request",
              message:
                "request takes no arguments or an array of capability names",
            },
          });
        }
        names = raw;
      }
      // Ack BEFORE the await when the call will hold on a human decision
      // — wouldPrompt is exactly this predicate (permissionsState.ts);
      // it owns the fresh-vs-join distinction (deps.surfaceBusy refines
      // fresh asks only — see its doc). SAME-TASK INVARIANT, load-
      // bearing: this held computation, the ack post, ctl.request's
      // synchronous body, and showDialog's busy decision all run in ONE
      // task — sibling surfaces open/close only from other tasks, so
      // the busy answer cannot change between the held-check and the
      // dialog's own check. No await may ever be introduced between
      // this dispatch and showDialog entry, or both directions reopen
      // (an un-acked real dialog one way; the ack+instant-reply
      // surface-open timing leak the other).
      const held = ctl.wouldPrompt(names);
      if (held) {
        iframe.contentWindow?.postMessage(frameCapAck(cap.id), frameOrigin);
      }
      const run = everything ? ctl.requestAll() : ctl.request(names);
      // Neither request form ever rejects (permissionsState contract),
      // so a bare .then settles every held call the ack promised.
      void run.then((states) => {
        send({ result: states });
        if (held) afterHeld();
      });
      return;
    }
    if (relayTelemetry) relayCapTelemetry(h, e.data);
  };
  window.addEventListener("message", onMessage);
  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,
  brokerLive: () => boolean,
): () => void {
  const { frameOrigin, frameUuid } = h;
  /* brokerLive: generation-scoped liveness (epoch === brokerGeneration,
   * bound at mountBrokered). Checked at every send — a pending reply's
   * .then survives teardown, reply ids are bare per-module counters, and
   * a STALE GENERATION's late send would resolve the next generation's
   * same-index call with the old payload. Deliberately NOT a per-wire
   * teardown flag: wire() re-wires within a generation on scope gains,
   * and same-generation late replies (concurrent held asks, in-flight
   * calls across a re-wire) are legitimate and must deliver. Also
   * checked at the listener head: a new generation claims the epoch at
   * mountBrokered ENTRY before its wire() replaces these listeners —
   * a dispatch in that gap must drop crisply (no ack, module timeouts
   * stay armed) rather than start doomed work. */
  // 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.
  /** Validated retry-metadata projection: the documented-fields-only
   * invariant is preserved by copying each field through its own check —
   * retryable only when literally `true`, retryAfterMs only when a
   * finite non-negative number, clamped to 60 s. Never a spread. */
  const stampRetry = (
    e: McpErr,
    retryable: boolean,
    retryAfterMs?: unknown,
  ): McpErr => {
    if (retryable) e.retryable = true;
    if (
      e.retryable === true &&
      typeof retryAfterMs === "number" &&
      Number.isFinite(retryAfterMs) &&
      retryAfterMs >= 0
    ) {
      e.retryAfterMs = Math.min(retryAfterMs, 60_000);
    }
    return e;
  };
  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 stampRetry(
          mcpErr(e.code, String(e.message ?? ""), srv),
          // Denial-class codes are non-retryable BY CONSTRUCTION —
          // repeating the call cannot succeed without viewer action —
          // so neither a CP-stamped retryable nor a retry-shaped status
          // (a needs_reauth riding a 503) may stamp them.
          (e.retryable === true || status === 429 || status >= 500) &&
            denialScope(e.code) === "none",
          e.retryAfterMs,
        );
      }
    } 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 stampRetry(
      mcpErr(code, `request failed (${status})`, sid),
      status === 429 || status >= 500,
    );
  };
  const netErr = (err: unknown, sid?: string): McpErr => {
    const timeout = err instanceof DOMException && err.name === "TimeoutError";
    return stampRetry(
      mcpErr(
        timeout ? "server_unavailable" : "upstream_error",
        "request failed",
        sid,
      ),
      timeout,
    );
  };

  // 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;

  /** Error classification for CACHED DATA only (page replies unaltered).
   * The allowlist holds codes affirmatively known to be availability or
   * caller trouble; every OTHER code — including ones this shell does not
   * recognize — is a denial whose verdict is enforced on stored entries.
   * Unknown-means-evict deliberately inverts the contract's page-UX
   * "treat unknown as upstream_error" rule: for continuing to SERVE
   * cached data, eviction is fail-closed and costs only a miss. (A bare
   * unstructured 401/403 projects to keep-class upstream_error, but a
   * FINAL one — post reboot-and-replay — triggers the session wipe at
   * the delivery site instead.) */
  const CACHE_KEEP_ERROR_CODES = new Set([
    "server_unavailable",
    "upstream_error",
    "tool_error",
    "bad_request",
    // Reserved (never emitted today): a local shell-budget refusal is
    // availability trouble, not a data-trust verdict — without this pin
    // the unknown-means-evict classifier would turn future throttling
    // into cache-evicting load amplification. Deliberately diverges from
    // `self`, whose rate_limited covers upstream 429.
    "rate_limited",
  ]);
  /** Connector-wide denials evict the whole server's entries; all other
   * denial codes (and unknowns) evict the narrower (server, tool) pair. */
  const SERVER_SCOPED_DENIALS = new Set([
    "needs_reauth",
    "server_not_connected",
    "server_not_found",
    "selection_required",
  ]);
  type DenialScope = "none" | "server" | "tool";
  const denialScope = (code: string): DenialScope =>
    CACHE_KEEP_ERROR_CODES.has(code)
      ? "none"
      : SERVER_SCOPED_DENIALS.has(code)
        ? "server"
        : "tool";

  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;
              annotations?: {
                readOnlyHint?: unknown;
                destructiveHint?: unknown;
              } | null;
            }[];
          }[];
        };
        // 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.
        // The hints recording reads the RAW parse for the same reason:
        // ServersEntry carries selection bookkeeping only.
        sync.onEntries(parseServersEntries(parsed));
        recordToolHints(parsed, consentedScope);
        // 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 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 [];
                    const a = t.annotations;
                    const hints: ToolHints = {};
                    if (a && typeof a === "object") {
                      if (typeof a.readOnlyHint === "boolean") {
                        hints.readOnly = a.readOnlyHint;
                      }
                      if (typeof a.destructiveHint === "boolean") {
                        hints.destructive = a.destructiveHint;
                      }
                    }
                    const annotations =
                      hints.readOnly === undefined &&
                      hints.destructive === undefined
                        ? undefined
                        : {
                            ...(hints.readOnly !== undefined && {
                              readOnlyHint: hints.readOnly,
                            }),
                            ...(hints.destructive !== undefined && {
                              destructiveHint: hints.destructive,
                            }),
                          };
                    return [
                      {
                        name,
                        description: String(t.description ?? ""),
                        ...(annotations && { annotations }),
                      },
                    ];
                  }),
                },
              ];
            }),
          },
        });
      })
      .catch((err: unknown) => send({ error: netErr(err) }));
  };

  // One delivery pipeline for every callTool execution. `cacheable` is
  // true only on the standard-envelope success path — the one shape the
  // cache may store. Background revalidations MUST run through here too:
  // a bare-fetch revalidation would never reach the requestReboot guard,
  // and that background call is the account-switch detection event while
  // hits defer detection.
  const executeCall = (
    deliver: (
      b: { result: unknown } | { error: McpErr },
      cacheable: boolean,
    ) => void,
    payload: string,
    sel: string | undefined,
    server: string,
    tool: string,
    started: number,
    /** Page-cancellation handle. Absent on revalidations — the cache
     * wants those results regardless of page interest, so they are
     * never abortable. */
    exec?: { signal: AbortSignal; cancelled: () => boolean },
  ): void => {
    // Single denial-enforcement choke point for EVERY execution path
    // (foreground, revalidation, opted-in or not): eviction at the
    // verdict's breadth converges the cache at the FIRST denial instead
    // of one entry per revalidation. The error flows to the page
    // unchanged.
    const enforceDenial = (e: McpErr): McpErr => {
      const scope = denialScope(e.code);
      if (scope !== "none") {
        const identity = mcpCacheIdentity(h.org, tokenSlot.account, frameUuid);
        if (identity !== null) {
          void mcpCacheInvalidate(
            identity,
            scope === "server" ? { server } : { server, tool },
          );
        }
      }
      return e;
    };
    void postMcp(
      h,
      "call",
      payload,
      sel !== undefined ? "serverId" : null,
      sync.dropSelections,
      server,
      undefined,
      exec?.signal,
    )
      .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) &&
          // A cancelled call must never replay: the page already saw
          // `cancelled`, and a replay would re-execute upstream.
          !exec?.cancelled()
        ) {
          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) {
            const identityMatches =
              fresh.org === tokenSlot.org &&
              assetTokenAccount(fresh.assetToken) === tokenSlot.account;
            if (!identityMatches) {
              // The reboot proved the browser's viewer diverged from the
              // slot's mount-time binding (shared-browser account switch);
              // structurally no replay runs below. Cached tool results
              // bound to the previous viewer must not outlive the
              // detection moment — see refreshBrokerToken's wipe for the
              // full rationale.
              wipeViewerState();
            } else if (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,
                exec?.signal,
              );
            }
          }
        }
        const text = r.text;
        if (!r.ok) {
          if (
            (r.status === 401 || r.status === 403) &&
            !structuredErrBody(text)
          ) {
            // A bare unstructured 401/403 that SURVIVED the
            // reboot-and-replay machinery means the viewer's session
            // itself cannot be verified (expired, or revoked remotely —
            // requestReboot returns null for a dead session). Code-wise
            // it projects to keep-class upstream_error, so without this
            // wipe the cache would keep serving until gcTime.
            wipeViewerState();
          }
          return deliver(
            { error: enforceDenial(projectErr(r.status, text, server)) },
            false,
          );
        }
        // 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 deliver({ result: text }, false);
        }
        if (!j || typeof j !== "object" || Array.isArray(j)) {
          return deliver({ result: j }, false);
        }
        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 deliver(
            {
              error: enforceDenial(
                stampRetry(
                  mcpErr(code, String(o.error.message ?? ""), srv),
                  // Same denial guard as projectErr: a denial code is
                  // never retryable, whatever the CP stamped.
                  o.error.retryable === true && denialScope(code) === "none",
                  o.error.retryAfterMs,
                ),
              ),
            },
            false,
          );
        }
        if (!("result" in o)) {
          // Routed through enforceDenial like every other error delivery —
          // a no-op while the code is keep-class, but the uniformity is
          // structural: reclassifying any code must never silently skip
          // enforcement because one site was left unwrapped.
          return deliver(
            {
              error: enforceDenial(
                mcpErr("upstream_error", "malformed response", server),
              ),
            },
            false,
          );
        }
        // Cache-provenance attestation: the `cache` field on delivered
        // results is shell-minted ONLY. Any inbound top-level `cache` key
        // from an upstream execution result is deleted here — before
        // delivery and therefore before any store — so presence is
        // shell-attested by construction (strip-inbound and
        // strip-before-store are both pinned by tests).
        let result = o.result;
        if (
          result !== null &&
          typeof result === "object" &&
          !Array.isArray(result) &&
          "cache" in result
        ) {
          const { cache: _inbound, ...rest } = result as Record<
            string,
            unknown
          >;
          result = rest;
        }
        // noStore: the CP marked this execution's result uncacheable
        // (server-authoritative, per call — e.g. approval-gated results).
        deliver({ result }, !r.noStore);
      })
      .catch((err: unknown) =>
        deliver({ error: enforceDenial(netErr(err, server)) }, false),
      );
  };

  // Wire mount time — compared against the persisted wipe tombstone at
  // store time: a wipe landing after this wire mounted makes every store
  // from it suspect (its calls race the wiped session's death), while a
  // fresh post-wipe mount stores immediately.
  const mountedAt = Date.now();
  // At-rest hygiene: reclaim expired rows' bytes (expiry is otherwise
  // lazy, and an idle browser would retain them indefinitely).
  void mcpCacheSweepExpired();
  // In-flight coalescing for opted-in calls, keyed on the raw cache-body
  // string. Registration must be SYNCHRONOUS — keying on the async digest
  // would let two same-burst identical calls both miss the map and
  // double-fire the tool. (If lazy consent ever holds calls behind an
  // ack, the sends list is the per-joiner propagation point.)
  interface FlightRecord {
    sends: Send[];
    /** Set when ANY participant asked for refresh. The hit branch reads
     * it AFTER the lookup await, so a refresh joining mid-lookup discards
     * the hit and everyone gets the fresh execution — fresher-than-cached
     * is always acceptable for joiners; cached is never acceptable for a
     * refresh. (Joining an already-EXECUTING flight needs nothing: that
     * execution is upstream contact.) */
    refresh: boolean;
    /** Minimum staleTime across participants — staleTime is per-observer,
     * so a served hit must revalidate if it is stale for ANY caller it
     * answers, not just the leader. Same post-await read as refresh. */
    minStaleTimeMs: number;
    /** Wipe-clock capture, taken synchronously at record creation
     * (before any await) — every gate judges against the flight's own
     * captures. */
    wipeGen: number;
    /** Invalidation-epoch capture — same discipline as wipeGen. */
    invGen: number;
    /** Live page callers (leader + joiners) minus cancellations. At 0 the
     * execution has no page audience: cancelCall detaches the key and
     * aborts. Revalidations START at 0 — no registry entry ever points
     * at them, so they can never be aborted (the cache wants their
     * result regardless of page interest). */
    participants: number;
    /** Aborts this flight's upstream execution — armed on leader flights
     * only; never on revalidations. */
    controller?: AbortController;
  }
  const inflight = new Map<string, FlightRecord>();

  /** Live call registrations for THIS wire, keyed by the frame's own
   * envelope id. Cancellation authority = presence in this map, never id
   * shape: origin+contentWindow gating plus closure scoping mean a frame
   * can only ever cancel its own wire's calls, and an unknown id no-ops.
   * The page's promise was already rejected locally at abort (the
   * runtime tombstones the reply), so the broker's duties are only:
   * (a) skip the 400-retry and stale-token-replay legs,
   * (b) abort the upstream fetch when no other caller wants the result,
   * (c) SYNCHRONOUSLY detach the inflight key when the last participant
   *     leaves, so a cancel-then-retry becomes a NEW leader instead of
   *     joining the doomed flight (mirrors handleInvalidate's detach). */
  interface CallRegistration {
    cancelled: boolean;
    /** Uncached path only: this call's own execution controller. */
    controller?: AbortController;
    /** Cached path only: the coalesced flight this call participates in. */
    flight?: FlightRecord;
    cacheBody?: string;
  }
  const callRegistry = new Map<string, CallRegistration>();

  // GATE MATRIX — two decision sites, one gate function per site; each
  // function's body is its matrix row. The ABSENCES are deliberate; do
  // not add an axis to a gate without a failing case:
  //                 wipeGen  invGen  refresh
  //   hit-serve        ✓       —        ✓
  //   store            ✓       ✓        —
  // hit-serve skips invGen because invalidate is not a trust boundary
  // (a call serialized before it may serve — it must not force misses).
  // store skips refresh (refresh stores are the point); denial outcomes
  // are never cacheable by shape (errors carry no result). Invariants:
  // flight OBJECTS are truth, map keys are hints; settle runs exactly
  // once per flight.

  /** hit-serve row: an entry fetched across a wipe is pre-wipe data
   * (treated as a miss), and a refresh participant must never be
   * answered from the cache it asked to skip. */
  const mayServeHit = (fl: FlightRecord): boolean =>
    fl.wipeGen === mcpCacheWipeGeneration() && !fl.refresh;

  /** store row: a success settling after a WIPE ran under the previous
   * viewer's token; one settling after an INVALIDATE would re-store the
   * data the page just dropped, breaking the contract's
   * resolve-then-re-execute ordering. */
  const mayStore = (fl: FlightRecord): boolean =>
    fl.wipeGen === mcpCacheWipeGeneration() &&
    fl.invGen === mcpCacheInvalidationGeneration();

  const handleCall = (
    send: Send,
    /** The frame's envelope id — the cancellation handle. Empty string
     * disables registration (no cancel possible for that call). */
    callId: string,
    server: unknown,
    tool: unknown,
    input: unknown,
    options: 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();
    // null = uncached. gcTime bounds at-rest lifetime (default 5 min, cap
    // 24 h); staleTime bounds serving WITHOUT background revalidation
    // (default 0 — every hit revalidates — clamped to the 5-minute
    // security cap); refresh skips the read and overwrites.
    let cacheOpts = parseCacheOptions(options);
    // Annotation-gated effective policy. Live-but-dormant until the
    // deployed CP emits annotations: with no recorded hints every
    // lookup is undefined and neither leg fires — byte-identical to
    // pre-annotation behavior (no key, telemetry, or watch change), so
    // the policy activates tool-by-tool as hints arrive.
    // 1. `cache: false` → always uncached (parseCacheOptions already
    //    returned null, and the default leg below requires an ABSENT
    //    cache member, so the opt-out can never be overridden).
    // 2. Wire-explicit readOnlyHint === false → uncached even when the
    //    page opted in: the policy floor — cache machinery can never
    //    re-fire a declared write. Silent toward the page.
    // 3. Cache member absent + wire-explicit readOnlyHint === true →
    //    default-cached {staleTime: 0, gcTime: default}. Unannotated
    //    tools (no recorded hint) behave exactly as before.
    if (typeof server === "string") {
      const readOnly = mcpToolHints
        .get(server)
        ?.byTool.get(tool as string)?.readOnly;
      if (readOnly === false) {
        cacheOpts = null;
      } else if (
        readOnly === true &&
        cacheOpts === null &&
        (typeof options !== "object" ||
          options === null ||
          (options as { cache?: unknown }).cache === undefined)
      ) {
        cacheOpts = {
          staleTimeMs: 0,
          gcTimeMs: MCP_CACHE_GC_TIME_DEFAULT_MS,
          refresh: false,
        };
      }
    }
    const refresh = cacheOpts?.refresh === true;
    const identity =
      cacheOpts !== null
        ? mcpCacheIdentity(h.org, tokenSlot.account, frameUuid)
        : null;
    // Uncached — not opted in, ambiguous viewer identity, or no
    // SubtleCrypto (every skip fails toward no caching): exactly the
    // pre-cache pipeline, no extra serialization, no hash, no coalescing.
    if (identity === null || !globalThis.crypto?.subtle) {
      const controller = new AbortController();
      const reg: CallRegistration = { cancelled: false, controller };
      if (callId !== "") callRegistry.set(callId, reg);
      return executeCall(sendSniffed, payload, sel, server, tool, started, {
        signal: controller.signal,
        cancelled: () => reg.cancelled,
      });
    }
    // The cache body is the wire body MINUS mcpToken — byte-identical
    // construction order, so the lookup key is stable across token
    // refreshes and the token never feeds key material. The input is
    // key-sorted so property order never splits identical calls into
    // distinct entries (hashKey convention; the wire payload itself is
    // untouched).
    const canonicalInput = sortKeysDeep(input ?? {});
    const cacheBody = JSON.stringify({
      slug: frameUuid,
      server,
      tool,
      input: canonicalInput,
      ...(sel !== undefined && { serverId: sel }),
    });
    const waiting = inflight.get(cacheBody);
    if (waiting) {
      waiting.sends.push(send);
      waiting.participants++;
      if (callId !== "") {
        callRegistry.set(callId, {
          cancelled: false,
          flight: waiting,
          cacheBody,
        });
      }
      if (refresh) waiting.refresh = true;
      waiting.minStaleTimeMs = Math.min(
        waiting.minStaleTimeMs,
        cacheOpts?.staleTimeMs ?? 0,
      );
      return;
    }
    // Settle holds the flight OBJECT, not the map key: an invalidate can
    // DETACH a flight (delete the key so later matching calls re-execute
    // as new leaders) without orphaning its already-joined callers.
    // Clock captures happen here, synchronously at dispatch — a wipe OR a
    // page invalidate landing anywhere after this line makes every gated
    // step below fail toward "no caching" / "no stale".
    const flight: FlightRecord = {
      sends: [],
      refresh,
      minStaleTimeMs: cacheOpts?.staleTimeMs ?? 0,
      wipeGen: mcpCacheWipeGeneration(),
      invGen: mcpCacheInvalidationGeneration(),
      participants: 1,
      controller: new AbortController(),
    };
    inflight.set(cacheBody, flight);
    if (callId !== "") {
      callRegistry.set(callId, { cancelled: false, flight, cacheBody });
    }
    void (async () => {
      const [digest, inputDigest] = await Promise.all([
        sha256Hex(cacheBody),
        // Entry-stored input digest (same `input ?? {}` rule as the key)
        // — what an input-scoped invalidate matches against.
        sha256Hex(JSON.stringify(canonicalInput)),
      ]);
      const key =
        digest === null || inputDigest === null ? null : identity + digest;
      // Settle one execution: reply leader (null if already served from
      // cache) then joiners; store only the standard-envelope success with
      // isError !== true and no wipe since dispatch. The inflight delete
      // is enumerated per path, NOT a structural finally: during a
      // revalidation the entry must OUTLIVE the async block that
      // registered it (single-flight), so only settle may drop it.
      // Every gate decision delegates to the FlightRecord gate functions
      // (mayServeHit / mayStore — the GATE MATRIX above the record
      // type), judged against `fl`'s OWN clock captures.
      const settle = (
        fl: FlightRecord,
        b: { result: unknown } | { error: McpErr },
        cacheable: boolean,
        leader: Send | null,
      ): void => {
        if (inflight.get(cacheBody) === fl) inflight.delete(cacheBody);
        const joiners = fl.sends;
        const out = b;
        if (leader) leader(out);
        for (const j of joiners) j(out);
        // Watchers of this identity hear every settle — data and errors
        // alike (transient errors keep last-good page-side; authz
        // denials retract, per the handler doctrine).
        notifyWatchers(wireIdentity, cacheBody, out);
        // Sniff the TRUE outcome (not the substituted reply) so a
        // selection_required on any path re-arms the picker.
        if ("error" in b && b.error.code === "selection_required") {
          sync.onSelectionRequired();
        }
        if (cacheable && key !== null && "result" in b && mayStore(fl)) {
          const res = b.result;
          if (
            res !== null &&
            typeof res === "object" &&
            !Array.isArray(res) &&
            (res as { isError?: unknown }).isError !== true
          ) {
            void mcpCachePutResult(key, res, {
              server,
              tool,
              // Both non-null here by the key/opt-in guards above.
              inputHash: inputDigest ?? "",
              gcTimeMs: cacheOpts?.gcTimeMs ?? 0,
              // Dispatch + mount time — the wipe tombstone's comparands.
              dispatchedAt: started,
              mountedAt,
            });
          }
        }
      };
      if (key !== null) {
        const hit = await mcpCacheGet(key).catch(() => null);
        // mayServeHit, read here AFTER the lookup awaits, as the ONLY
        // checkpoint — deliberately no pre-lookup gate: one checkpoint
        // covers the leader's own refresh AND joiners landing during any
        // earlier await, so no window exists where a refresh is answered
        // from the cache it asked to skip. (A refresh leader pays one
        // discarded lookup — cold.)
        const fresh =
          hit !== null && Date.now() - hit.createdAt <= flight.minStaleTimeMs;
        // v2 freshness (fetchQuery semantics): serve the cached entry
        // ONLY when younger than the strictest participant's staleTime;
        // older entries execute and resolve fresh. callTool never serves
        // past declared freshness and never revalidates in the
        // background — a promise resolves once, so a revalidation result
        // would have no delivery path (the one-behind trap). The
        // serve-then-revalidate leg below remains the pre-v2 behavior.
        if (hit && mayServeHit(flight) && fresh) {
          // A cached reply is never selection_required — plain send.
          // v2 provenance: shell-attested serve marker. `revalidating`
          // is structurally false on this path — callTool never runs a
          // background re-execution under v2.
          const servedResult =
            hit.result !== null &&
            typeof hit.result === "object" &&
            !Array.isArray(hit.result)
              ? {
                  ...(hit.result as Record<string, unknown>),
                  cache: { storedAt: hit.createdAt, revalidating: false },
                }
              : hit.result;
          const served = flight.sends;
          send({ result: servedResult });
          for (const j of served) j({ result: servedResult });
          notifyWatchers(wireIdentity, cacheBody, { result: servedResult });
          // minStaleTimeMs, not the leader's own: joiners are being
          // answered here too, and staleTime is per-observer.
          if (inflight.get(cacheBody) === flight) {
            inflight.delete(cacheBody);
          }
          return;
        }
      }
      // Plain send — settle sniffs the outcome itself, once per execution.
      executeCall(
        (b, c) => settle(flight, b, c, send),
        payload,
        sel,
        server,
        tool,
        started,
        flight.controller
          ? {
              signal: flight.controller.signal,
              cancelled: () => flight.participants <= 0,
            }
          : undefined,
      );
    })();
  };

  // Page-callable, so the identity scoping is load-bearing: a page may
  // only drop what its own calls could have stored. Ambiguous identity
  // never stored anything — resolve as a no-op.
  // Positional, mirroring callTool: (server?, tool?, input?), each present
  // argument narrowing the match. `undefined` at any position ≡ omitting
  // it (JS convention) — wire arity is never consulted. The no-input call
  // is targeted by VALUE: `null` or `{}`, via the key's own `input ?? {}`
  // canonicalization — a deliberate asymmetry with callTool, where
  // `undefined` input and `{}` are the same call identity.
  const handleInvalidate = (send: Send, args: readonly unknown[]): void => {
    const [s, t] = args;
    if (s !== undefined && typeof s !== "string") {
      return send({
        error: mcpErr("bad_request", "server must be a string"),
      });
    }
    if (t !== undefined && typeof t !== "string") {
      return send({
        error: mcpErr("bad_request", "tool must be a string"),
      });
    }
    const server: string | undefined = s;
    const tool: string | undefined = t;
    const hasInput = args[2] !== undefined;
    let inputJson: string | undefined;
    if (hasInput) {
      // Exact-match against the key's canonical serialization; input
      // without both coordinates is ambiguous — reject, don't guess.
      if (server === undefined || tool === undefined) {
        return send({
          error: mcpErr("bad_request", "input requires server and tool"),
        });
      }
      try {
        inputJson = JSON.stringify(sortKeysDeep(args[2] ?? {}));
      } catch {
        return send({
          error: mcpErr("bad_request", "input is not JSON-serializable"),
        });
      }
    }
    const identity = mcpCacheIdentity(h.org, tokenSlot.account, frameUuid);
    if (identity === null) return send({ result: undefined });
    // Epoch bump FIRST, synchronously: store/substitution suppression must
    // hold from the instant the invalidate is accepted — the storage
    // delete below crosses an await (input hashing), and an execution
    // settling in that gap must not store.
    mcpCacheBumpInvalidationGeneration();
    // DETACH matching in-flight executions (synchronously, before the
    // reply can resolve): their keys leave the map so a post-invalidate
    // matching callTool becomes a NEW leader and re-executes — the
    // contract's ordering guarantee. Detached flights still settle to the
    // callers they already hold (settle keeps the flight object), and
    // their stores/substitutions are suppressed by the invalidation epoch.
    // Direct iteration is safe: the body only deletes the CURRENT key,
    // which Map iteration is specified to tolerate.
    for (const k of inflight.keys()) {
      try {
        const parsed = JSON.parse(k) as {
          server?: unknown;
          tool?: unknown;
          input?: unknown;
        };
        const matches =
          (server === undefined || parsed.server === server) &&
          (tool === undefined || parsed.tool === tool) &&
          (!hasInput || JSON.stringify(parsed.input) === inputJson);
        if (matches) inflight.delete(k);
      } catch {
        // unparsable key — leave it
      }
    }
    void (async () => {
      let inputHash: string | undefined;
      if (hasInput) {
        const digest = await sha256Hex(inputJson!);
        if (digest === null) {
          // No SubtleCrypto → nothing was ever cached; no-op.
          return send({ result: undefined });
        }
        inputHash = digest;
      }
      await mcpCacheInvalidate(identity, { server, tool, inputHash }).catch(
        () => undefined,
      );
      send({ result: undefined });
      // Watched identities re-execute after an invalidate resolves — the
      // contract's resolve-then-re-execute ordering, delivered to
      // handlers via notifyWatchers when the re-execution settles.
      for (const d of watchRegistry.values()) {
        // wireOwns, not an inline triple: the previous inline form
        // omitted org — a surviving foreign-ORG descriptor (same
        // account, org switcher) would have re-executed its (server,
        // tool, input) under the current wire's org and token.
        if (!wireOwns(watchExecutor, d)) continue;
        const matches =
          (server === undefined || d.server === server) &&
          (tool === undefined || d.tool === tool) &&
          (!hasInput || JSON.stringify(d.input) === inputJson);
        if (matches) watchExecutor.execute(d);
      }
    })();
  };

  /** This wire's identity for watcher delivery — the binding
   * notifyWatchers matches descriptors against, so one wire's settle
   * can never leak its viewer's result into another frame's or
   * account's handler. */
  const wireIdentity = {
    gen: tokenSlot.gen,
    frameUuid,
    account: tokenSlot.account,
    org: h.org,
  };

  const watchExecutor: WatchExecutor = {
    gen: tokenSlot.gen,
    frameUuid,
    account: tokenSlot.account,
    org: h.org,
    inScope: (server, tool) => consentedScope.get(server)?.has(tool) === true,
    execute: (d) => {
      // The selection can change mid-watch (duplicate-connector
      // re-pick, dropSelections): recompute the identity the way
      // handleCall will derive it, so delivery matching follows the
      // CURRENT selection instead of stalling forever on the
      // registration-time serverId.
      const sel = selections.get(d.server);
      d.cacheBody = JSON.stringify({
        slug: frameUuid,
        server: d.server,
        tool: d.tool,
        input: d.input,
        ...(sel !== undefined && { serverId: sel }),
      });
      // No-op sink: watcher deliveries flow EXCLUSIVELY through
      // notifyWatchers (hit-serve + settle), so one execution yields at
      // most one delivery per watcher, coalesced with any concurrent
      // page callers of the same identity via the in-flight map.
      handleCall(() => undefined, "", d.server, d.tool, d.input, {
        cache: { staleTime: d.staleTimeMs, gcTime: d.gcTimeMs },
      });
    },
    postEv: (watchId, ev) => {
      if (!brokerLive()) return;
      iframe.contentWindow?.postMessage(
        { __frame_mcp_watch: true, watchId, ev },
        frameOrigin,
      );
    },
  };
  setActiveWatchExecutor(watchExecutor);
  // Scope fallout on a re-wire: consent can NARROW (a republish the
  // viewer re-consented to) — watches on tools no longer in scope are
  // retracted with a handler-visible event, never silently killed.
  watchRetract(
    // wireOwns, not an inline triple: the previous form omitted org, so
    // a surviving foreign-org descriptor could be silently deleted by
    // another org's narrower consent (its postEv is identity-gated, so
    // the death would be invisible to that page).
    (d) =>
      wireOwns(watchExecutor, d) && !watchExecutor.inScope(d.server, d.tool),
    "server_not_connected",
    "no longer in the consented scope",
  );
  // Auth-recovery poller for authz-settled watches (standing intents a
  // connector fix should heal in place). Armed per wire, kicked through
  // the module hook by notifyWatchers; the teardown below stops it, so
  // no recovery continuation survives a remount.
  const authRecovery = armWatchAuthRecovery({
    h,
    tokenSlot,
    scope: consented,
    owner: watchExecutor,
    brokerLive,
  });
  kickWatchAuthRecovery = authRecovery.kick;
  // Descriptors outlive wires (module registry): authz-failed watches
  // surviving a same-generation re-wire re-arm the NEW wire's poller —
  // without this, a consent re-wire would silently drop the recovery.
  for (const d of watchRegistry.values()) {
    if (d.authzFailed === true && wireOwns(watchExecutor, d)) {
      authRecovery.kick(d.server);
    }
  }

  const handleWatch = (send: Send, args: readonly unknown[]): void => {
    const [server, tool, input, bag] = args;
    if (typeof server !== "string" || typeof tool !== "string") {
      return send({
        error: mcpErr("bad_request", "server and tool must be strings"),
      });
    }
    const b = (typeof bag === "object" && bag !== null ? bag : {}) as {
      watchId?: unknown;
      cache?: unknown;
      refetchInterval?: unknown;
    };
    const watchId = typeof b.watchId === "string" ? b.watchId : "";
    if (watchId === "") {
      return send({ error: mcpErr("bad_request", "missing watch id") });
    }
    // Page-supplied id, scoped by composite key to THIS wire's identity:
    // a duplicate within one (frame, gen, account) is a caller bug and
    // is REJECTED rather than replaced — replacement would orphan the
    // old descriptor's poll timer and let one registry row hide
    // unbounded standing executions from the cap counter. Another
    // frame's or account's identical watchId is a different key and
    // coexists untouched.
    const regKey = watchKey(
      frameUuid,
      tokenSlot.gen,
      tokenSlot.account,
      h.org,
      watchId,
    );
    if (watchRegistry.has(regKey)) {
      return send({
        error: mcpErr("bad_request", "watch id already in use"),
      });
    }
    if (!consentedScope.get(server)?.has(tool)) {
      return send({
        error: mcpErr(
          "not_in_manifest",
          "outside the scope you consented to. Reload to review.",
          server,
        ),
      });
    }
    // Write floor: a wire-explicit declared write is never watchable —
    // watch reads only (unannotated tools pass; the doc rule covers
    // them until annotations are universal).
    if (mcpToolHints.get(server)?.byTool.get(tool)?.readOnly === false) {
      return send({
        error: mcpErr(
          "bad_request",
          "declared-write tools cannot be watched",
          server,
        ),
      });
    }
    const identity = mcpCacheIdentity(h.org, tokenSlot.account, frameUuid);
    if (identity === null || !globalThis.crypto?.subtle) {
      // Watching IS a cached-read primitive: with no cache identity
      // there is nothing to replay or store — fail the registration.
      return send({
        error: mcpErr("bad_request", "watching is unavailable in this session"),
      });
    }
    let live = 0;
    for (const d of watchRegistry.values()) {
      // wireOwns: the cap is per-frame-per-WIRE — counting a surviving
      // foreign identity's descriptors against this page's budget would
      // let stale registry rows starve the current viewer's quota.
      if (wireOwns(watchExecutor, d)) live++;
    }
    if (live >= WATCH_CAP_PER_FRAME) {
      // An author bug (leaked subscriptions / runaway fan-out) — the
      // caller-bug class, with the fix named. Counted broker-side via
      // the telemetry relay; deliberately NOT a new contract code.
      return send({
        error: mcpErr(
          "bad_request",
          `watch limit (${WATCH_CAP_PER_FRAME}) reached - unsubscribe unused watches`,
          server,
        ),
      });
    }
    let canonicalInput: unknown;
    try {
      canonicalInput = sortKeysDeep(input ?? {});
      JSON.stringify(canonicalInput); // serializability probe
    } catch {
      return send({
        error: mcpErr("bad_request", "input is not JSON-serializable", server),
      });
    }
    const co = parseCacheOptions({
      cache: typeof b.cache === "object" && b.cache !== null ? b.cache : {},
    }) ?? {
      staleTimeMs: 0,
      gcTimeMs: MCP_CACHE_GC_TIME_DEFAULT_MS,
      refresh: false,
    };
    const sel = selections.get(server);
    const cacheBody = JSON.stringify({
      slug: frameUuid,
      server,
      tool,
      input: canonicalInput,
      ...(sel !== undefined && { serverId: sel }),
    });
    // Clamped like staleTime/gcTime: the 24 h cap keeps the value far
    // below setTimeout's signed-32-bit limit — an unclamped interval in
    // [2^31, 2^32) would pass the 30 s floor yet overflow the timer to
    // ~0 ms, turning the poll into a tight credentialed execution loop.
    const refetch =
      typeof b.refetchInterval === "number" &&
      Number.isFinite(b.refetchInterval) &&
      b.refetchInterval > 0
        ? Math.min(b.refetchInterval, 86_400_000)
        : undefined;
    const d: WatchDescriptor = {
      key: regKey,
      watchId,
      gen: tokenSlot.gen,
      frameUuid,
      account: tokenSlot.account,
      org: h.org,
      server,
      tool,
      input: canonicalInput,
      cacheBody,
      staleTimeMs: co.staleTimeMs,
      gcTimeMs: co.gcTimeMs,
      refetchIntervalMs: refetch,
    };
    watchRegistry.set(regKey, d);
    send({ result: { watchId } });
    // Replay + initial execution, off the dispatch turn. Registry-keyed
    // at every step: an unsubscribe landing between any two awaits wins.
    void (async () => {
      const digest = await sha256Hex(cacheBody);
      if (digest !== null) {
        const hit = await mcpCacheGet(identity + digest).catch(() => null);
        if (hit && watchRegistry.get(regKey) === d) {
          const age = Date.now() - hit.createdAt;
          if (age > co.staleTimeMs) {
            // STALE replay — the one place stale serving is allowed:
            // this handler hears the refresh the execute below starts.
            // revalidating: true is the honest marker for exactly that.
            watchExecutor.postEv(watchId, {
              type: "data",
              server,
              result:
                hit.result !== null &&
                typeof hit.result === "object" &&
                !Array.isArray(hit.result)
                  ? {
                      ...(hit.result as Record<string, unknown>),
                      cache: { storedAt: hit.createdAt, revalidating: true },
                    }
                  : hit.result,
            });
          }
          // A FRESH entry is deliberately not replayed here: the execute
          // below serves it through the hit path, which already notifies
          // watchers — replaying first would double-deliver.
        }
      }
      if (watchRegistry.get(regKey) === d) {
        watchExecutor.execute(d);
        scheduleWatchPoll(d);
      }
    })();
  };

  const handleUnwatch = (args: readonly unknown[]): void => {
    const [watchId] = args;
    if (typeof watchId !== "string") return;
    // Composite lookup makes the wire scoping structural: this wire can
    // only ever address its own (frame, gen, account) keys — a foreign
    // frame's identical watchId is a different key entirely.
    const key = watchKey(
      frameUuid,
      tokenSlot.gen,
      tokenSlot.account,
      h.org,
      watchId,
    );
    const d = watchRegistry.get(key);
    if (!d) return;
    if (d.pollTimer !== undefined) clearTimeout(d.pollTimer);
    watchRegistry.delete(key);
    // Episode boundary: an unwatched flagged descriptor may have been a
    // server's last failed watch — reconcile now so a re-watch of the
    // same server meets fresh edge state, not a stale spent seed.
    if (d.authzFailed === true) kickWatchAuthRecovery?.(null);
  };

  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 (!brokerLive()) return; // claim→wire gap: drop, keep timeouts armed
    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) => {
        if (!brokerLive()) return;
        iframe.contentWindow?.postMessage(
          frameCapReply(cap.id, b),
          frameOrigin,
        );
      };
      if (cap.method === "listTools") return handleList(send);
      if (cap.method === "callTool") {
        const [s, t, i, o] = cap.args;
        // The reply retires the registration: a cancel arriving after
        // settle must no-op (the page already has its answer).
        const sendAndRetire: Send = (b) => {
          callRegistry.delete(cap.id);
          send(b);
        };
        return handleCall(sendAndRetire, cap.id, s, t, i, o);
      }
      if (cap.method === "invalidate") {
        return handleInvalidate(send, cap.args);
      }
      if (cap.method === "watchTool") {
        return handleWatch(send, cap.args);
      }
      if (cap.method === "unwatchTool") {
        // Fire-and-forget (the runtime's unsubscribe already returned);
        // authority is the same wire scoping as cancelCall.
        handleUnwatch(cap.args);
        return;
      }
      if (cap.method === "cancelCall") {
        // Fire-and-forget from the runtime (the page's promise already
        // rejected locally) — no reply is sent or expected. Authority is
        // presence in THIS wire's registry: unknown ids no-op, so a
        // frame can only ever cancel its own live calls.
        const [target] = cap.args;
        const reg =
          typeof target === "string" ? callRegistry.get(target) : undefined;
        if (reg && !reg.cancelled) {
          reg.cancelled = true;
          callRegistry.delete(target as string);
          const fl = reg.flight;
          if (fl) {
            fl.participants--;
            if (fl.participants <= 0) {
              // SYNCHRONOUS key detach (mirrors handleInvalidate): a
              // cancel-then-retry must become a NEW leader, never join
              // the doomed flight. Abort only with zero participants —
              // joiners still waiting keep the execution alive, and
              // revalidations are never registered here at all.
              if (
                reg.cacheBody !== undefined &&
                inflight.get(reg.cacheBody) === fl
              ) {
                inflight.delete(reg.cacheBody);
              }
              fl.controller?.abort();
            }
          } else {
            reg.controller?.abort();
          }
        }
        return;
      }
      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++;
    // Release the watch executor if still this wire's — a same-
    // generation re-wire re-binds it immediately after; descriptors
    // themselves survive (module-scoped registry).
    if (activeWatchExecutor === watchExecutor) setActiveWatchExecutor(null);
    authRecovery.stop();
    if (kickWatchAuthRecovery === authRecovery.kick) {
      kickWatchAuthRecovery = null;
    }
    // Memory tier only: persisted entries deliberately survive re-wires
    // (identity is in their keys; every hit re-passes the consent check).
    // The inflight map is closure-scoped and dies with the wire.
    mcpCacheDropMemory();
    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;
    /** Fired on the external-close silent teardown (clearForRemount,
     * chrome degrade, 403 revocation, sibling claim) where neither
     * onContinue nor onCancel runs. Optional; for callers whose own
     * state machine must settle on every exit. */
    onExternalClose?: () => 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 onContinue/onCancel deliberately don't fire. Callers
  // whose own state machine must still settle (the permissions
  // controller's in-flight dialog promise) pass onExternalClose — fired
  // here so a degraded chrome or a remount can never wedge them.
  h.setCloseConsent(() => {
    settle();
    dlg?.close();
    cb.onExternalClose?.();
  });

  // 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. Callers
      // that distinguish surface-failure from a viewer's decline get
      // onExternalClose instead (the viewer never decided here — the
      // permissions controller must take its cooldown-without-denial
      // path, not record denial memory for a dialog nobody saw).
      h.report("consent-prompt", "chrome-unavailable");
      settle();
      if (cb.onExternalClose) cb.onExternalClose();
      else 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/Add/Add custom 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),
            markPresent: (server) => dlg?.markPresent(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;
// Heal poll while a reauth/consent surface is open, DESKTOP HOST ONLY:
// there no event-driven recheck can arrive (the auth_done broadcast is
// same-browser only — OAuth runs in the system browser — and the pane
// never blurs or hides), so the poll is the only heal signal. Web
// keeps the event-driven rechecks alone: /mcp/servers has no CP-side
// rate limiter, so the poll must not spend it where events work.
// Cadence: fast polls while the viewer is plausibly mid-OAuth, then
// backed off to the CP's 60s status-cache TTL — polling faster than
// the cache can change buys no heal latency. Each cycle is capped at
// REAUTH_POLL_MAX polls (~5 minutes), and ANY desktop dialog press
// (Reconnect, Connect, Add — every onAction route) re-arms a fresh
// cycle — the press, not surface-open, is when OAuth actually begins.
// A cycle's verdict is settled by the first status fetch STARTED after
// its final tick, in that same cycle: a row still unhealed counts as
// "heal-poll-exhausted" (distinct from the reveal poll's
// "poll-exhausted"; at most once per surface; a last-window heal
// counts only as auto-clear, and a superseding press abandons the
// verdict uncounted).
export const REAUTH_POLL_MS = 20_000;
export const REAUTH_POLL_SLOW_MS = 60_000;
export const REAUTH_POLL_FAST_POLLS = 3;
export const REAUTH_POLL_MAX = 7;
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 ("Add" with a
   * directory match, "Add custom" without one). */
  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 fetchAuthStatusesUncached(
  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;
  }
}

/** The most recent fetchAuthStatuses flight and its settled result,
 * recorded so the auth-recovery poller can coalesce with the reauth and
 * consent dialogs' own status fetches instead of adding POSTs beside
 * them. Only probeAuthStatuses READS this — every existing caller still
 * fetches fresh, so dialog recheck UX is unchanged. tokenSlot is
 * compared by reference: slots are per-mount objects, so a stale
 * generation's record can never satisfy a newer wire's probe. `names`
 * records the parse scope — a result is only reusable for servers that
 * scope actually covered (parseAuthStatusBody filters to it). */
interface AuthStatusProbeRec {
  tokenSlot: TokenSlot;
  names: ReadonlySet<string>;
  inflight: Promise<AuthStatusResult | null> | null;
  result: AuthStatusResult | null;
  at: number;
}
let authStatusProbe: AuthStatusProbeRec | null = null;

function fetchAuthStatuses(
  h: BrokerHost,
  tokenSlot: TokenSlot,
  scope: readonly ManifestServer[],
): Promise<AuthStatusResult | null> {
  const p = fetchAuthStatusesUncached(h, tokenSlot, scope);
  const rec: AuthStatusProbeRec = {
    tokenSlot,
    names: new Set(scope.map((s) => s.server)),
    inflight: p,
    result: null,
    at: 0,
  };
  authStatusProbe = rec;
  void p.then((r) => {
    // Guarded: a later fetch may have replaced the record mid-flight.
    if (authStatusProbe === rec) {
      rec.inflight = null;
      rec.result = r;
      rec.at = Date.now();
    }
  });
  return p;
}

/** Auth-status read for the recovery poller: reuse an in-flight
 * fetchAuthStatuses POST, or a settled result younger than
 * RECHECK_MIN_MS whose parse scope covered every `needed` server;
 * otherwise fetch fresh. A null (failed) settled result is reusable
 * too — a POST that failed 2 s ago is not worth repeating. */
function probeAuthStatuses(
  h: BrokerHost,
  tokenSlot: TokenSlot,
  scope: readonly ManifestServer[],
  needed: readonly string[],
): Promise<AuthStatusResult | null> {
  const rec = authStatusProbe;
  if (
    rec &&
    rec.tokenSlot === tokenSlot &&
    needed.every((s) => rec.names.has(s))
  ) {
    if (rec.inflight) return rec.inflight;
    if (Date.now() - rec.at < RECHECK_MIN_MS) {
      return Promise.resolve(rec.result);
    }
  }
  return fetchAuthStatuses(h, tokenSlot, scope);
}

/** 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>;
}

// ── Watch auth-recovery poller ───────────────────────────────────────────
// An authz-settled watch is a standing intent with no heal signal: after
// one needs_reauth / server_not_connected event the watch idles forever,
// and a viewer who fixes the connector in another tab only gets data by
// re-visiting (the reauth dialog machinery heals the DIALOG, and only
// while it is open). While ≥1 owned watch is authz-failed and the page
// is visible, this poller re-reads connector auth statuses (coalesced
// with the dialogs' own fetches via probeAuthStatuses) and re-executes
// exactly the failed watches on an unhealthy→healthy transition for
// their server. No watch failed or page hidden ⇒ no timer, no network.
export const WATCH_AUTH_RECOVERY_MIN_MS = 30_000;
export const WATCH_AUTH_RECOVERY_MAX_MS = 300_000;

function armWatchAuthRecovery(deps: {
  h: BrokerHost;
  tokenSlot: TokenSlot;
  scope: readonly ManifestServer[];
  owner: WatchExecutor;
  brokerLive: () => boolean;
}): { kick: (server: string | null) => void; stop: () => void } {
  let stopped = false;
  let timer: ReturnType<typeof setTimeout> | undefined;
  let delay = WATCH_AUTH_RECOVERY_MIN_MS;
  let pausedHidden = false;
  // Edge-detector state, scoped to the FAILURE EPISODE by tick-time
  // reconciliation (below), never maintained event-wise — derived wipes
  // cannot drift from descriptor state the way per-event wipes can.
  //
  // lastClass: last observed (or settle-seeded) status class per server.
  // authStatus "" and unvetted enum members update NOTHING — a degraded
  // probe proves nothing changed (the REAUTH_LAPSED "" doctrine), and
  // re-executing on it would spend credentialed executions on a flap.
  //
  // seedSpent: once a server's watches re-executed off a settle seed,
  // later settles cannot re-seed it — only a POLL-OBSERVED lapse
  // re-earns the edge. A server whose status reads healthy while calls
  // keep failing authz (out-of-band divergence) therefore costs exactly
  // one re-execution, never an execution per poll.
  const lastClass = new Map<string, "healthy" | "unhealthy">();
  const seedSpent = new Set<string>();

  const live = (): boolean =>
    !stopped && deps.brokerLive() && activeWatchExecutor === deps.owner;

  const failedServers = (): Set<string> => {
    const out = new Set<string>();
    for (const d of watchRegistry.values()) {
      if (d.authzFailed === true && wireOwns(deps.owner, d)) {
        out.add(d.server);
      }
    }
    return out;
  };

  /** Drop edge state for servers whose failure episode is over — no
   * failed descriptor left (healed, unwatched, or retracted). This is
   * what re-earns a FRESH seed for the next genuine relapse: without
   * it, a server that ever spent its seed could never heal off a
   * settle again, and a fix landing between two polls would strand its
   * watch forever. Sound only because the edge path below does NOT
   * clear authzFailed eagerly — a server stays in the failed set until
   * its re-execution actually settles, so an in-flight re-execution
   * can never read as "episode over" here. */
  const reconcile = (failed: ReadonlySet<string>): void => {
    for (const server of new Set([...lastClass.keys(), ...seedSpent])) {
      if (failed.has(server)) continue;
      lastClass.delete(server);
      seedSpent.delete(server);
    }
  };

  const disarm = (): void => {
    if (timer !== undefined) {
      clearTimeout(timer);
      timer = undefined;
    }
    pausedHidden = false;
    delay = WATCH_AUTH_RECOVERY_MIN_MS;
  };

  const schedule = (ms: number): void => {
    if (timer !== undefined) clearTimeout(timer);
    timer = setTimeout(tick, ms);
  };

  const tick = (): void => {
    timer = undefined;
    if (!live()) return;
    const failed = failedServers();
    reconcile(failed);
    if (failed.size === 0) return disarm();
    if (typeof document !== "undefined" && document.hidden) {
      // Paused, not rescheduled: no timer burns while hidden. The
      // module visibilitychange listener kicks (null) on return.
      pausedHidden = true;
      return;
    }
    void probeAuthStatuses(deps.h, deps.tokenSlot, deps.scope, [
      ...failed,
    ]).then((result) => {
      if (!live()) return;
      if (result) {
        for (const server of failed) {
          const st = result.statuses.get(server);
          // Missing from the response = no callable connector match
          // (the backend omits absent/ambiguous) = not connected — an
          // unhealthy observation, which is what lets the CONNECT case
          // (viewer adds the pinned connector for the first time)
          // produce an edge when the server later appears healthy.
          const cls =
            st === undefined
              ? "unhealthy"
              : REAUTH_HEALTHY.has(st.status)
                ? "healthy"
                : REAUTH_LAPSED.has(st.status)
                  ? "unhealthy"
                  : null;
          if (cls === null) continue;
          const prior = lastClass.get(server);
          lastClass.set(server, cls);
          if (cls === "unhealthy") {
            seedSpent.delete(server);
            continue;
          }
          if (prior !== "unhealthy") continue;
          // unhealthy→healthy edge: re-execute exactly this server's
          // failed watches through the full callTool pipeline — fresh
          // deliveries reach handlers via notifyWatchers, selection is
          // recomputed by execute(). authzFailed is deliberately NOT
          // cleared here: the re-execution's own settle resolves it
          // (data clears, an authz re-failure keeps it set and cannot
          // re-seed past seedSpent) — and keeping the server in the
          // failed set while the settle is in flight is what makes
          // reconcile() above safe with no settle-grace clock.
          seedSpent.add(server);
          for (const d of watchRegistry.values()) {
            if (
              d.authzFailed === true &&
              d.server === server &&
              wireOwns(deps.owner, d)
            ) {
              deps.owner.execute(d);
            }
          }
        }
      }
      if (failedServers().size === 0) return disarm();
      delay = Math.min(delay * 2, WATCH_AUTH_RECOVERY_MAX_MS);
      schedule(delay);
    });
  };

  const kick = (server: string | null, transientRetry?: boolean): void => {
    if (!live()) return;
    if (server !== null) {
      if (transientRetry) {
        // A flagged watch settled a NON-authz error. If this server's
        // edge credit is spent, the execution that spent it died short
        // of the connector-auth question — the healthy observation
        // proved nothing about this watch, so re-earn the edge and let
        // the next poll retry. This catches every non-authz code, the
        // permanent ones (server_not_found, bad_request) included:
        // retry cadence is bounded by the poll backoff (30s→5min)
        // regardless, at or below the WATCH_POLL_FLOOR_MS a page's own
        // declared poll may already run at.
        // An unspent seed needs nothing: the original edge still arms.
        // Deliberately NOT applied to authz re-failures — that is the
        // divergence loop guard, and unspending there would turn a
        // status-healthy-but-denied connector into an execution per
        // poll.
        if (seedSpent.has(server)) {
          seedSpent.delete(server);
          lastClass.set(server, "unhealthy");
          if (timer === undefined && !pausedHidden) schedule(delay);
        }
        return;
      }
      if (!seedSpent.has(server) && lastClass.get(server) !== "unhealthy") {
        // Fresh failure: the settle itself witnessed the unhealthy
        // state, so it seeds the edge — a first poll that reads healthy
        // IS the recovery (the viewer fixed it before we ever polled).
        // Reset to the fast cadence: a viewer who just saw the error is
        // plausibly fixing it right now. Non-seeding kicks deliberately
        // never touch `delay`, so page-driven execution spam against a
        // still-broken connector cannot pin the poll at the floor.
        lastClass.set(server, "unhealthy");
        delay = WATCH_AUTH_RECOVERY_MIN_MS;
        if (!pausedHidden) schedule(delay);
        return;
      }
      if (timer === undefined && !pausedHidden) schedule(delay);
      return;
    }
    // null = re-evaluate: a data settle just healed a watch (reconcile
    // immediately so a server's edge state dies with its episode) and/or
    // visibility returned (resume a hidden-paused poll — the probe cache
    // floors the actual POST rate against the dialogs' own focus-driven
    // rechecks).
    reconcile(failedServers());
    if (pausedHidden && (typeof document === "undefined" || !document.hidden)) {
      pausedHidden = false;
      schedule(0);
    }
  };

  return {
    kick,
    stop: (): void => {
      stopped = true;
      disarm();
    },
  };
}

/** 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, Add, Add custom; 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() ||
        // The shared decision-surface busy core (consent claim, stamped
        // surface, nav confirm, delete confirm) — see decisionSurfaceBusy.
        decisionSurfaceBusy(h)
      ) {
        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;
  /** A custom row's connector turned out to exist (recheck saw it
   * positively lapsed in the status response — never a degraded ""
   * entry): retire the row's "No matching connector found" claim and
   * relabel its action to Connect (never Reconnect — the viewer never
   * finished connecting). The controller flips the item to
   * the lapsed posture in the same step, so the press routes to the
   * bare settings page — add-custom would mint a duplicate of the
   * connector that now exists. */
  markPresent(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]));
  // The desktop app's preview pane opens popup/new-tab URLs in the system
  // browser and denies the window — there window.open returns null with
  // nothing blocked. BEHAVIOR keys on the embedder's own UA token, never
  // the link-controllable ?platform= override that feeds the analytics
  // header (cpHeaders.ts: "never an authz input") — a shared viewer link
  // must not be able to suppress the popup-blocked fallback or start the
  // heal poll on web. sync: cpHeaders.ts detectPlatform.
  const desktopHost = navigator.userAgent.includes(" Electron/");
  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 pollTimer = 0;
  let polls = 0;
  // The heal poll's exhaustion verdict is settled by the first status
  // fetch STARTED after the final tick (flag set by pollTick, consumed
  // in recheck's continuation), and is counted at most once per surface
  // however many press-re-armed poll cycles run. A fetch may only
  // settle the verdict when its START postdates the flag (captured at
  // fetch start): a brownout fetch from an earlier poll — the 65s fetch
  // timeout (a literal beside MCP_TIMEOUT_MS.servers) exceeds the 60s
  // slow tick — carries a stale snapshot and must not consume. A fetch
  // CAN cross a cycle boundary (any press mid-fetch bumps pollCycle),
  // but the cycle-equality gate only changes an outcome if such a fetch
  // completes after the NEXT cycle re-sets the pending flag — needing a
  // ≤65s fetch to span a ≥300s cycle, unreachable today. It is a
  // structural drift-guard: nothing ties those constants together, and
  // shrinking the schedule below the fetch timeout makes it load-bearing.
  let pollCycle = 0; // bumped per armHealPoll; stale fetches carry an older value
  let pollExhaustPending = false; // set by the final tick, cleared on settle/re-arm
  let exhaustCounted = false; // surface-lifetime once-only latch, never reset
  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(pollTimer);
    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. On the desktop
   * host a null window means the OAuth URL opened in the system browser,
   * not that it was blocked — that returns true, with no popup to track.
   * 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) {
      if (desktopHost) {
        // The press succeeded externally — a settings fallback here
        // would open a second external tab over a live OAuth flow.
        // (The press re-probe + poll re-arm run for every desktop
        // press, in onAction's tail.)
        h.report(opts.tel, "desktop-external");
        return true;
      }
      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.
  // A never-connected name with NO directory match instead lands on the
  // add-custom-connector dialog with the name prefilled (ConnectorsPageClient
  // consumes these params): the manifest deliberately carries no server URL,
  // so a custom connector completed with a URL the viewer knows out-of-band
  // is the only remaining path — the bare directory could never match.
  // Connected (lapsed) rows keep the bare page: the viewer already has a
  // connector there to reconnect, and add-custom would mint a duplicate.
  const settingsPath = (item: ReauthItem): string => {
    const base = "/customize/connectors";
    if (item.directoryId !== undefined) {
      return `${base}?directory-open=true&directory-uuid=${encodeURIComponent(item.directoryId)}`;
    }
    if (!item.connected) {
      // Defensive URL bound only — the shell validates manifest names as
      // non-empty strings without a length cap (resolveMcp), so an
      // author-sized name must not become an unbounded query string. 128
      // matches the display cap (capName); an oversized name just opens
      // the dialog with the field unfilled.
      const prefill =
        [...item.server].length <= 128
          ? `&connectorName=${encodeURIComponent(item.server)}`
          : "";
      return `${base}?modal=add-custom-connector${prefill}`;
    }
    return 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);
    }
    // Every desktop press is a fresh OAuth/setup attempt, whatever the
    // route or outcome: the settings routes complete out-of-band in the
    // system browser exactly like the inline external-open, so each
    // press re-probes now (floored by RECHECK_MIN_MS) and re-arms a
    // fresh poll cycle. Runs synchronously at press — never from
    // inlineAdd's create continuation, where a timeout would skip it.
    // The unknown-server early return above is not a press.
    if (desktopHost) {
      recheck();
      armHealPoll();
    }
  };

  // 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;
    // Captured at ACTUAL fetch start (a floor-deferred recheck re-enters
    // and re-captures here): only a fetch that began with the verdict
    // pending, in the cycle that set it, may settle exhaustion. The
    // `checking` dedup makes the captor unique — it covers every status
    // fetch this controller issues (the pre-mount classify fetch
    // completes before any controller exists).
    const settlesExhaust = pollExhaustPending;
    const exhaustCycle = pollCycle;
    void fetchAuthStatuses(h, tokenSlot, scope).then((result) => {
      checking = false;
      if (ctrlStopped) return;
      if (!ui.isOpen()) {
        opts.onGone();
        return;
      }
      const settles = settlesExhaust && exhaustCycle === pollCycle;
      if (!result) {
        // The final poll's fetch failed: the surface is open and
        // unverified — that IS the exhausted tail.
        reportPollExhaust(false, settles);
        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);
        else if (
          !a.connected &&
          a.directoryId === undefined &&
          REAUTH_LAPSED.has(result.statuses.get(a.server)?.status ?? "")
        ) {
          // A custom row's connector now exists LAPSED (the viewer added
          // it elsewhere; OAuth pending/failed/expired): the "No matching
          // connector found" claim is stale, and its Add custom press
          // would mint a duplicate — the exact thing the lapsed route
          // avoids. Flip the item to the lapsed posture (connected ⇒
          // settingsPath returns the bare page) and let the dialog retire
          // the claim + relabel to Connect. Positively lapsed only —
          // NOT mere map presence: a degraded "" entry proves nothing was
          // added (the "" doctrine at REAUTH_LAPSED), and an unvetted new
          // enum status must fail safe (no flip), matching how "" never
          // prompts. Item mutation mirrors the inline-add idiom, so later
          // rechecks skip the branch and stay idempotent.
          a.connected = true;
          ui.markPresent(a.server);
        }
      }
      const allListed = items.every(healthy);
      reportPollExhaust(allListed, settles);
      opts.onHealth?.(
        allListed,
        allListed && classifyReauth(scope, result).length === 0,
      );
    });
  };

  /** Settle the final poll's exhaustion verdict. `settles` carries the
   * caller's start-time capture (began with the verdict pending, in the
   * cycle that set it); only such a fetch whose outcome leaves a listed
   * row unhealed counts — a last-window heal registers as auto-clear
   * alone, never both. Once per surface. */
  const reportPollExhaust = (healedAll: boolean, settles: boolean): void => {
    if (!settles || !pollExhaustPending) return;
    pollExhaustPending = false;
    if (healedAll || exhaustCounted) return;
    exhaustCounted = true;
    h.report(opts.tel, "heal-poll-exhausted");
  };

  const pollTick = (): void => {
    pollTimer = 0;
    if (ctrlStopped) return;
    polls++;
    if (polls >= REAUTH_POLL_MAX) {
      // Last poll of this cycle: the exhaustion verdict waits on this
      // recheck's own fetch (reportPollExhaust) — the OAuth may have
      // completed during the final back-off window.
      pollExhaustPending = true;
      recheck();
      return;
    }
    recheck();
    // recheck() can tear the controller down synchronously
    // (!ui.isOpen() → onGone → stop()) — re-check before re-parking
    // so no timer outlives stop().
    if (ctrlStopped) return;
    pollTimer = window.setTimeout(
      pollTick,
      polls < REAUTH_POLL_FAST_POLLS ? REAUTH_POLL_MS : REAUTH_POLL_SLOW_MS,
    );
  };

  /** Arm (or re-arm) the desktop heal poll with a fresh fast phase.
   * Runs at surface-open AND on every desktop dialog press, whatever
   * the route — the press is the moment OAuth can actually begin, so a
   * press after a dead poll must restart the schedule, not inherit its
   * spent cap. Clearing the pending flag here is deliberate: a press
   * supersedes any pending final verdict — the abandoned cycle counts
   * nothing (the new cycle gets its own verdict). */
  const armHealPoll = (): void => {
    if (!desktopHost || ctrlStopped) return;
    window.clearTimeout(pollTimer);
    polls = 0;
    pollCycle++;
    pollExhaustPending = false;
    pollTimer = window.setTimeout(pollTick, REAUTH_POLL_MS);
  };

  const start = (): void => {
    if (ctrlStopped) return;
    document.addEventListener("visibilitychange", recheck, {
      signal: watch.signal,
    });
    window.addEventListener("focus", recheck, { signal: watch.signal });
    // Bounded heal poll (see REAUTH_POLL_MS): the one recheck trigger
    // that needs no browser event, for the pane where the completion
    // signal cannot arrive. recheck() floors the fetch rate and no-ops
    // while hidden. Mirrors the reveal poll's tick-counter bound.
    armHealPoll();
    // 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) => {
      const action = a.connected
        ? ("reconnect" as const)
        : a.directoryId !== undefined
          ? ("add" as const)
          : ("custom" as const);
      return {
        server: a.server,
        action,
        inline: isInline(a) || canInlineAdd(a),
        iconUrl: a.iconUrl,
        // "custom" rows have no catalog one-liner by construction (no
        // directory entry is what routed them here) — the slot instead
        // tells the viewer WHY there's nothing one-click: sync with the
        // Add custom action (ACTION_LABEL), which is the remaining path.
        description:
          action === "custom" ? "No matching connector found" : 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),
      markPresent: (server) => dlg?.markPresent(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.
 */
/** Fast-fail listener factory for unserviceable mcp dispatches (built
 * only for generations whose boot carries NO mcp grant — a static
 * per-generation fact, never transient wire state, so it cannot race a
 * live wire for the reply). Answers on the EXISTING reply channel so
 * every pinned runtime generation understands it; capability_disabled
 * is the documented code for granted-but-not-usable-in-this-view. The
 * pre-wire gap on a GRANTED boot deliberately gets no listener (the
 * wire is coming — today's drop posture holds), and other caps are
 * never answered (their wires are not instrumented; replying could
 * double-settle). Single-owner: a stale generation's listener detaches
 * without replying, so at most one listener ever answers. Exported for
 * tests via _test. */
function makeMcpFastFail(deps: {
  live: () => boolean;
  frame: () => HTMLIFrameElement | null;
  frameOrigin: string;
  detach: () => void;
}): (e: MessageEvent) => void {
  return (e: MessageEvent): void => {
    if (!deps.live()) {
      deps.detach();
      return;
    }
    const f = deps.frame();
    if (!f?.isConnected || !f.contentWindow) return;
    // Authenticate BEFORE acting — a reply to an unauthenticated source
    // would be an oracle (same gate as every wire listener).
    if (e.origin !== deps.frameOrigin || e.source !== f.contentWindow) return;
    const cap = parseFrameCap(e.data);
    if (!cap || cap.cap !== "mcp") return;
    f.contentWindow.postMessage(
      frameCapReply(cap.id, {
        error: mcpErr(
          "capability_disabled",
          "no active connector bridge for this view",
        ),
      }),
      deps.frameOrigin,
    );
  };
}

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;
  // Generation-scoped liveness for every bridge send/listener — see the
  // brokerLive doc on the wire* functions. True exactly while THIS
  // mountBrokered invocation is the newest generation.
  const brokerLive = (): boolean => 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++;
  // The mcp bridge is active iff the CP sent BOTH a manifest AND a token
  // (the CP only mints the token when the frame has a manifest AND the gate
  // is on). The downloads bridge is tokenless — envelope presence is the
  // whole grant.
  const mcp = resolveMcp(d);
  const selfCap = resolveSelf(d);
  // Declared ABOVE every early exit deliberately: the perm* closures that
  // wire([]) installs on the downloads-only and no-grant boots read these
  // bindings, and hoisting them out of the temporal dead zone retires a
  // crash class (three page-triggerable TDZ ReferenceErrors were caught
  // here in review) by construction instead of per-site guards. The
  // remaining mcp/selfCap guards in those closures carry semantic meaning
  // only. Both initializers are throw-free: servers is null-tolerant, and
  // the assetToken typeof mirrors permAccount's defensive read — a
  // malformed boot degrades to a placeholder key whose store reads as
  // indeterminate (prompt-only), never a throw on the serve path.
  const servers = mcp?.servers ?? [];
  const key = consentStoreKey(
    h,
    typeof d.assetToken === "string" ? d.assetToken : "",
  );
  // The downloads bridge is tokenless — envelope presence is the whole
  // grant (hasDownloadsGrant refuses the optional:true
  // declared-but-not-entitled emission).
  const dl = hasDownloadsGrant(d);

  // 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;

  // Fast-fail for unserviceable mcp dispatches, on the EXISTING reply
  // channel so every runtime generation ever pinned understands it.
  // Keyed on a STATIC per-generation fact — "this boot carries no mcp
  // grant" — never on transient wire state: a re-boot that dropped the
  // grant leaves the page's already-real mcp methods posting envelopes
  // that nothing will ever serve, and without this reply each call dies
  // only at the runtime's long timeout. capability_disabled is the
  // documented code for exactly that state ("granted but not usable in
  // this view"). Deliberate non-cases: the pre-wire gap on a granted
  // boot is transient (the wire is coming — no reply, today's posture);
  // other caps' dispatches are not this listener's to answer (their
  // wires are not instrumented, and replying could double-settle).
  // Single-owner across generations by the brokerLive gate: a stale
  // generation's listener self-removes without replying, so exactly one
  // listener can ever answer a dispatch. The grantless fact is STATIC
  // for the generation's whole life: an mcp grant can only arrive on a
  // boot envelope, and every boot path routes through a new
  // mountBrokered generation (wire() and the held-permission re-wire
  // both operate on the consented scope of an mcp-GRANTED boot — with
  // mcp === null there is no token and no manifest to wire, so no flow
  // can materialize an mcp wire mid-generation).
  if (mcp === null) {
    const fastFail = makeMcpFastFail({
      live: brokerLive,
      frame: () => mountedFrame,
      frameOrigin: h.frameOrigin,
      detach: () => window.removeEventListener("message", fastFail),
    });
    window.addEventListener("message", fastFail);
  }
  // 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[] = [];

  // ── permissions meta-capability (runtime permissions API) ─────────────
  // One controller per broker generation: denial memory and the dialog
  // cooldown reset on remount by construction (permissionsState.ts).
  // Declared BEFORE wire() so the downloads-only call path has a live
  // controller; everything here that touches grant-present declarations
  // (`key`, `servers`, `tokenSlot`, `commitPicks`, `pref`, `fetchEntries`,
  // `applyEntries`) does so only behind `mcp`/`selfCap` guards or at
  // call time on paths those guards gate — the same pre-declaration
  // discipline wire() documents below.
  /** The consent-record account segment — independent of tokenSlot so the
   * downloads-only path never evaluates it early. */
  const permAccount = assetTokenAccount(d.assetToken);
  /** Boot capability entries as the permissions layer reads them —
   * structurally loose (BrokerBoot types only a subset; the CP emits
   * `optional`/`policy` on every entry) and minus this module's own
   * synthetic init entry, which is not a queryable capability. Unlike
   * capabilitiesForInit (which SKIPS optional entries for the kernel),
   * this view keeps them: the controller's concealment rule answers
   * "unavailable" for them, identical to undeclared. */
  const bootCapEntries = (): ReadonlyArray<
    [string, { optional?: unknown; policy?: unknown; config?: unknown }]
  > =>
    Object.entries(
      (d as { capabilities?: Record<string, unknown> }).capabilities ?? {},
    ).filter(
      (
        p,
      ): p is [
        string,
        { optional?: unknown; policy?: unknown; config?: unknown },
      ] =>
        p[0] !== "permissions" &&
        // Chrome-only capabilities must answer "unavailable" like any
        // undeclared name: their presence is concealed from the page
        // everywhere else (capabilitiesForInit, hasBrokerCap), and a
        // "prompt" answer here would disclose e.g. an agent-driven
        // session across the iframe boundary — and poison requestAll's
        // prompt set with an ask no dialog can render.
        !PERM_CHROME_ONLY_CAPS.has(p[0]) &&
        !!p[1] &&
        typeof p[1] === "object",
    );
  /** network (and any future scoped capability): codec + declared grant
   * value from the boot entry's config. Null = no scope-aware codec —
   * such a capability gets NO durable standing grant (a durable presence
   * grant would survive a later scope change un-re-prompted), so its
   * consent stays session-only until a codec is added here. */
  const scopedGrant = (
    name: string,
    entry: { config?: unknown },
  ): { codec: typeof stringSetCodec; declared: ReadonlySet<string> } | null => {
    if (name !== "network") return null;
    const cfg = entry.config as { origins?: unknown } | null | undefined;
    const raw = cfg && Array.isArray(cfg.origins) ? cfg.origins : null;
    if (!raw || !raw.every((o): o is string => typeof o === "string")) {
      return null;
    }
    return { codec: stringSetCodec, declared: new Set(raw) };
  };
  const grantFor = (
    name: string,
    entry: { config?: unknown },
  ): boolean | null => {
    // mcp and self answer from the legacy consent store; an entry whose
    // envelope didn't resolve (no redeemable token) reads indeterminate —
    // its Optional bit usually makes the answer "unavailable" anyway.
    // downloads (and every per-use-confirm capability) never consults
    // hasGrant — the policy row answers "granted" without it.
    // Cross-layer pin: the CP emits policy "prompt-at-first-use" for
    // BOTH mcp and self (the authoritative table is pinned by
    // TestCapabilityConsentPolicy in api-go/frame/internal/service), so
    // treating them as standing-grant family here matches the wire.
    // permissionsState's "self: policy none" doc examples are
    // illustrative fiction, not the emitted value — if the CP ever
    // changed self's row, grantedByDecision would read constant-false
    // for it and the sentinel would stop entering permScopeNow, which
    // fails CLOSED (never a silent grant).
    if (name === "mcp") {
      if (!mcp) return null;
      const st = readConsented(key);
      return st === null ? null : !hasNewCaps(servers, st);
    }
    // Scoped mcp names ("mcp:<declared server name>" — byte-identical to
    // the callTool join key): per-server coverage from the same store.
    // GUARD BEFORE EVALUATION: on the downloads-only / no-grant exits
    // `servers`/`key` sit in their TDZ, so the string check and the !mcp
    // return must both run before anything reads them (the class that
    // produced the round-1 permScopeNow guard — same discipline).
    if (name.startsWith("mcp:")) {
      if (!mcp) return null;
      const scopedServer = mcpScopedServer(name, servers);
      if (scopedServer === null) return null;
      const st = readConsented(key);
      if (st === null) return null;
      const ok = st.get(scopedServer.server)?.tools;
      return scopedServer.tools.every((t) => ok?.has(t) === true);
    }
    if (name === "self") {
      if (!selfCap) return null;
      const st = readConsented(key);
      return st === null
        ? null
        : st.get(SELF_SENTINEL)?.tools.has("publish") === true;
    }
    const sg = scopedGrant(name, entry);
    if (!sg) return null;
    const read = readCapConsent(
      capConsentKey(name, h.org, permAccount, h.frameUuid),
      sg.codec,
    );
    return read.state === "ok"
      ? !needsPrompt(sg.declared, read, sg.codec)
      : null;
  };
  const permCaps = (): ReadonlyMap<string, CapView> => {
    const m = new Map<string, CapView>();
    for (const [name, entry] of bootCapEntries()) {
      const view: CapView = {
        optional: entry.optional === true,
        policy:
          typeof entry.policy === "string"
            ? entry.policy
            : "prompt-at-first-use",
        hasGrant: grantFor(name, entry),
      };
      m.set(name, view);
      // Per-server entries for the declared mcp manifest: same policy
      // and optional bit as the capability; per-server grant. `servers`
      // is resolveMcp's output, so scoped entries exist only when the
      // envelope RESOLVED — an Optional/unresolved mcp entry yields no
      // scoped views at all, and every scoped name then answers
      // "unavailable" exactly like an undeclared one (concealment).
      if (name === "mcp" && mcp !== null) {
        // The mcp !== null guard keeps `servers` unread on the early-exit
        // boots where it is never initialized (TDZ) — an unresolved mcp
        // entry can still appear in bootCapEntries there.
        for (const s of servers) {
          m.set(`mcp:${s.server}`, {
            optional: view.optional,
            policy: view.policy,
            hasGrant: grantFor(`mcp:${s.server}`, entry),
          });
        }
      }
    }
    return m;
  };
  const permPersist = (name: string): void => {
    // mcp and self are persisted by the dialog's own commit (commitPicks →
    // persistConsent: consent and selection are one gesture) — a second
    // write here would race the picks. Scoped capabilities persist via
    // their codec; anything else stays session-only (grantedThisLoad).
    if (name === "mcp" || name === "self") return;
    const entry = bootCapEntries().find(([n]) => n === name)?.[1];
    const sg = entry && scopedGrant(name, entry);
    if (sg) {
      persistCapConsent(
        capConsentKey(name, h.org, permAccount, h.frameUuid),
        sg.declared,
        sg.codec,
      );
    }
  };
  /** The ONE definition of "this capability has a dialog surface": the
   * mcp manifest rows and the fixed self row. Consumed by BOTH the
   * controller's canAsk (prompt-set filter) and permShowDialog's
   * renderable check — a single predicate so the two can never drift
   * (a canAsk/renderable split would silently re-poison batched asks,
   * the exact bug canAsk closed). per-use-confirm capabilities
   * (downloads, navigation) never reach either: they are never
   * prompt-state. */
  const permAskable = (n: string): boolean =>
    (n === "mcp" && mcp !== null) ||
    (n === "self" && selfCap !== null) ||
    (mcp !== null && mcpScopedServer(n, servers) !== null);
  const permSurfaceBusy = (): boolean => decisionSurfaceBusy(h);
  const permShowDialog = (names: readonly string[]): Promise<DialogVerdict> => {
    // Defense-in-depth behind canAsk's prompt-set filter: an ask
    // containing anything without a dialog surface fails closed
    // (reject → denied-without-memory + cooldown, permissionsState.ts).
    if (!names.every(permAskable)) {
      return Promise.reject(new Error("no dialog surface for this ask"));
    }
    if (permSurfaceBusy()) {
      return Promise.reject(new Error("decision surface busy"));
    }
    // The ask defines the rows: bare "mcp" → the whole declared
    // manifest; scoped names → exactly those servers' rows.
    const wantMcp = names.includes("mcp");
    const askedServers = wantMcp
      ? servers
      : servers.filter((s) => names.includes(`mcp:${s.server}`));
    const anyMcp = askedServers.length > 0;
    // The dialog opens over this generation's live content — same
    // shown-ness rule as the picker re-show.
    h.markShown();
    return new Promise<DialogVerdict>((resolve, reject) => {
      showConsentDialog(h, askedServers, anyMcp ? tokenSlot : null, {
        selfAsk: names.includes("self"),
        entries: anyMcp
          ? fetchEntries().then((res) => {
              if (res === null) return null;
              applyEntries(res.entries);
              return res;
            })
          : Promise.resolve(null),
        pref,
        onContinue: (picks) => {
          // Continue grants the whole ask (the dialog has no per-row
          // decline — pickers only route duplicate connectors): commit
          // consent + selections in one gesture, like the mount flow.
          // Partial (scoped) asks persist only the asked servers —
          // persistConsent unions into the store.
          commitPicks(picks, [
            ...askedServers,
            ...(names.includes("self") ? [selfSentinel()] : []),
          ]);
          resolve({
            granted: expandMcpNames(names, servers),
            denied: new Set<string>(),
          });
        },
        onCancel: () =>
          resolve({
            granted: new Set<string>(),
            denied: expandMcpNames(names, servers),
          }),
        // Silent teardown (remount, chrome degrade, revocation, sibling
        // claim): the viewer never decided — reject so the controller
        // takes its fail-closed surface-failure path (cooldown, no
        // denial memory) instead of wedging inFlight for the load.
        onExternalClose: () => reject(new Error("consent surface closed")),
      });
    });
  };
  const permCtl = createPermissionsController({
    caps: permCaps,
    showDialog: permShowDialog,
    persist: permPersist,
    now: Date.now,
    // The shared permAskable predicate: a prompt-state name outside it
    // (a capability whose consent policy the boot didn't carry, a scoped
    // cap with no dialog surface yet) stays "prompt" in results instead
    // of poisoning the whole batched ask into rejection.
    canAsk: permAskable,
    surfaceBusy: permSurfaceBusy,
    // Bare "mcp" in an ask binds every declared scoped key (the dialog
    // shows the whole manifest). Guard before evaluation: on the early-
    // exit boots `servers` is TDZ — but no ask is ever askable there, so
    // the mcp check must short-circuit first.
    expandAsk: (ns) => (mcp ? expandMcpNames(ns, servers) : new Set(ns)),
  });
  const permAllNames = (): readonly string[] => [...permCaps().keys()];
  /** The scope the bridges should carry given consent state NOW — read
   * after a permissions-dialog grant. mcp "granted" = the whole manifest
   * (the dialog asks for all of it); otherwise the store-covered subset
   * stays wired (the swap flow's posture). Only reachable on
   * grant-present paths (a held request needs a dialog, which needs
   * mcp/self), so the later declarations it reads are initialized. */
  const permScopeNow = (): ManifestServer[] => {
    // Pre-declaration guard (same discipline as wire()'s other bridges):
    // on the downloads-only boot `key`/`servers` are never initialized —
    // touching them here would be a page-triggerable TDZ throw inside
    // wirePermissions' settle path. No mcp and no self means no scope to
    // recompute, ever.
    if (!mcp && !selfCap) return [];
    // WIRING keys on grantedByDecision, never on query(): query's
    // "granted" is the page-visible UX vocabulary and is answered for
    // policy "none"/"per-use-confirm" capabilities with no viewer
    // decision at all — keying the live bridges on it would turn a
    // policy-value change into an unconsented wireSelf(granted=true).
    const st = readConsented(key) ?? new Map<string, StoredServer>();
    const out =
      mcp === null
        ? []
        : permCtl.grantedByDecision("mcp")
          ? [...servers]
          : servers.filter(
              (s) =>
                s.tools.every((t) => st.get(s.server)?.tools.has(t) === true) ||
                // Session-only scoped grants (blocked storage) wire too.
                permCtl.grantedByDecision(`mcp:${s.server}`),
            );
    if (selfCap && permCtl.grantedByDecision("self")) {
      out.push(selfSentinel());
    }
    return out;
  };
  /** After a held (dialog-bearing) request settles: when consent now
   * covers more than what's wired, re-wire against the mounted iframe —
   * wire()'s setUnwire replacement tears the old listeners down first
   * (deferred.ts setUnwire). */
  const permAfterHeld = makePermAfterHeld(
    epoch,
    () => brokerGeneration,
    () => mountedFrame,
    () => wiredScope,
    permScopeNow,
    (f, s) => wire(f, s),
  );

  // Wire every active bridge against `iframe` with exactly `scope` as the
  // consented capability set, replacing any prior broker. setUnwire holds
  // exactly ONE teardown, so the bridges compose here rather than each
  // claiming it. A wiring exception leaves EVERYTHING unwired (deny) and
  // surfaces a counter — never a half-wired bridge. `selections`/`sync`/
  // `tokenSlot`/`selfSlot`/`bootVer` are declared later, in the
  // grant-present flow; the `mcp && mcpScope` / `selfCap` guards mean the
  // one pre-declaration call path (the downloads-only boot) never
  // evaluates them.
  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);
    // Telemetry relay rides exactly one listener per generation: wireMcp
    // carries it when ATTACHED (not merely resolved — see the scope gate
    // below), else wireSelf, else wireDownloads (the downloads-only boot
    // still reports its own chunk-load failures).
    const mcpAttached = !!mcp && mcpScope.length > 0;
    // 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 (mcpAttached) {
        downs.push(
          wireMcp(h, iframe, tokenSlot, mcpScope, selections, sync, brokerLive),
        );
      }
      if (selfCap) {
        downs.push(
          wireSelf(
            h,
            iframe,
            selfSlot,
            bootVer,
            selfGranted,
            !mcpAttached,
            brokerLive,
          ),
        );
      }
      if (dl) {
        downs.push(
          wireDownloads(
            h,
            iframe,
            !mcpAttached && !selfCap
              ? (data: unknown) => relayCapTelemetry(h, data)
              : null,
            brokerLive,
          ),
        );
      }
      // The permissions bridge attaches on EVERY brokered wire — it
      // spends no tokens and answers consent state, so even an
      // all-declined frame gets honest query/request answers instead of
      // the kernel's reply timeout. Last in the telemetry-relay chain.
      downs.push(
        wirePermissions(
          h,
          iframe,
          permCtl,
          permAllNames,
          permAfterHeld,
          !mcpAttached && !selfCap && !dl,
          brokerLive,
        ),
      );
      h.setUnwire(() => {
        for (const down of downs) down();
      });
    } 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");
    }
  };

  // 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 {}); for
  // downloads the tokenless envelope entry.
  if (!mcp && !selfCap) {
    // No token-carrying bridge this generation, whatever else boots: the
    // refresh path must not reuse a previous boot's slot.
    activeTokenSlot = null;
    activeSelfTokenSlot = null;
    if (!dl) {
      // 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.
      // A forwarded-but-unresolved entry (e.g. an mcp key whose token the
      // CP omitted) still makes the kernel take the BRIDGE path in the
      // permissions module (realCaps ≥ 1), so a permissions listener must
      // exist here or every query dangles to the module's reply timeout —
      // wire() with an empty scope attaches exactly that one bridge, and
      // the controller answers "unavailable"/"prompt" honestly.
      // ORDER IS LOAD-BEARING: wire()'s setUnwire(non-null) makes the
      // deferred wrapper set brokered=true (positive wiring truth), so
      // the structural posture-clear must run AFTER the wire or the
      // gains-only remount disjunct reads this exit as brokered and a
      // later wireable boot refreshes instead of remounting (no rewire).
      wire(h.mount(d), []);
      h.setBrokered?.(false);
      return;
    }
    // downloads-only boot: no pre-mount consent — the per-save confirm
    // dialog IS the viewer's decision surface (downloads.ts), so this is a
    // plain mount with the save handler wired.
    wire(h.mount(d), []);
    return;
  }
  // 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 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);
  };

  // In-flight selection-LESS /mcp/servers memo for this mount generation.
  // On a cached-consent mount, recheck() fires right after wire() and the
  // page's first listTools typically arrives while that POST is still
  // outstanding; with no selections yet seeded both send the identical
  // `{slug, mcpToken}` body, so the second caller joins the first's promise.
  // Selection-carrying requests are NOT memoed: postMcp's 400-retry can
  // resolve them with the selection-less retry's response, and a joining
  // caller's bookkeeping (echo recording, the ≥2 reconciliation) must see
  // the result of the body it would itself have sent. Cleared on settle.
  // Sharing topology: the memo is this closure's own variable, so only
  // callers holding THIS generation's `sync` can join — a re-mount gets a
  // fresh null memo, and every joined consumer lands in this closure's
  // applyEntries under this closure's `epoch`, where a stale generation
  // is already a no-op.
  let serversInFlight: Promise<McpPost> | null = null;

  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);
      if (!withSel && serversInFlight !== null) return serversInFlight;
      const promise = postMcp(
        h,
        "servers",
        body,
        withSel ? "selections" : null,
        sync.dropSelections,
      );
      if (!withSel) {
        serversInFlight = promise;
        void promise
          .finally(() => {
            if (serversInFlight === promise) serversInFlight = null;
          })
          .catch(() => undefined);
      }
      return promise;
    },
    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;
        // Deliberately NOT a hints-recording site: this fetch runs on
        // the selection/recheck machinery, which can execute before (or
        // without) consent — and hints drive policy, so they record only
        // on the consent-gated listTools path. ServersEntry stays
        // selection bookkeeping only.
        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;
        // An open downloads save-confirm defers the re-show the same way —
        // it stamps the decision-surface flag (no consent-id DOM marker).
        if (h.decisionSurfaceOpen()) 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. Deliberately feeds NO permissions-controller
               state: this is a selection ask, not a consent ask, so
               noteExternalDenial would wrongly flip mcp to denied. The
               residual nag window (a page-triggered consent dialog can
               follow this dismissal with no cooldown) is bounded — the
               re-show fires at most once per generation — and grants
               nothing; arming only the cooldown here is tracked with the
               permission-rails follow-on. */
          },
        });
      })
      .finally(() => {
        recheckPending = false;
        lastRecheckDone = Date.now();
      });
  }

  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);
    });
    // Always wire: 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; downloads' own
    // per-save confirm never waits on the mcp consent decision; and the
    // permissions bridge must answer even on an mcp-only swap with
    // nothing covered (wire() attaches only it on an empty scope — the
    // mcp listener stays scope-gated, so no live mcp listener exists to
    // drive authenticated POSTs).
    wire(f, covered);
  }
  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
      // The symmetric twin of onCancel's noteExternalDenial: record the
      // grant in the controller too. On an indeterminate store the
      // commitPicks persist is skipped, and without this the viewer's
      // Allow is forgotten within the load that granted it — query()
      // answers "prompt" and the next request() re-opens the dialog for
      // the just-granted scope.
      permCtl.noteExternalGrant([
        ...expandMcpNames(mcp ? ["mcp"] : [], servers),
        ...(selfCap ? (["self"] as const) : []),
      ]);
      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.
      // The decline also feeds the permissions controller: without this,
      // page JS could call request(["mcp"]) and immediately re-summon
      // the dialog the viewer just dismissed — the denial memory and
      // anti-nag cooldown must not depend on WHICH surface asked.
      // Known coarseness: showConsentDialog's chrome-failure paths also
      // land here, recording denial for a decision the viewer never saw
      // (fail-closed for this load — the module's surface-failure path
      // would arm only the cooldown). Distinguishing decline from
      // surface-failure needs a reason on the shared dialog callbacks;
      // tracked with the permission-rails follow-on.
      permCtl.noteExternalDenial([
        ...expandMcpNames(mcp ? ["mcp"] : [], servers),
        ...(selfCap ? (["self"] as const) : []),
      ]);
      // 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 = {
  mcpScopedServer,
  expandMcpNames,
  makePermAfterHeld,
  projectSelfErr,
  wireSelf,
  wirePermissions,
  PERM_CHROME_ONLY_CAPS,
  selfSentinel,
  resolveMcp,
  wireMcp,
  /** Lets tests exercise refreshBrokerToken's divergence wipe without the
   * full shell harness (only mountBrokered sets the slot). */
  setActiveTokenSlot: (s: TokenSlot | null): void => {
    activeTokenSlot = s;
  },
  makeMcpFastFail,
  /** Lets the auth-recovery dedupe test record a dialog-originated fetch
   * into the probe cache without the full reauth-prompt harness. */
  fetchAuthStatuses,
  /** Lets the suppressed-serve test deliver a crafted settle without
   * staging the full eviction race (the one path that reaches a
   * dedup-suppressed data settle deterministically). */
  notifyWatchers,
};
