import { useEffect, useReducer, useRef } from 'react';
import { destroySocket, getSocket } from '@/lib/socket';
import {
  MAX_COMMAND_MESSAGE_CHARS,
  MAX_COMMAND_RESULTS,
  MAX_EXECUTED_APPROVAL_IDS,
  MAX_MANIFEST_ITEMS,
  MAX_MESSAGES,
  MAX_STREAM_CHARS,
  MAX_TOOLS,
} from './limits';
import {
  asConversationId,
  asFiniteNumber,
  asHarnessSelection,
  asString,
  asStringArray,
  isRecord,
} from './runtime-guards';
import type {
  AgentEndPayload,
  AgentStartPayload,
  AgentTextPayload,
  AgentThinkingPayload,
  ChatSendCapabilityPayload,
  ChatSendProtocol,
  CommandDef,
  CommandManifest,
  CommandManifestPayload,
  ErrorPayload,
  HarnessSelection,
  HarnessTurnAckPayload,
  MessageAckPayload,
  SessionInfoPayload,
  SessionUsagePayload,
  SkillCommandDef,
  SlashCommandApprovalResultPayload,
  SlashCommandResultPayload,
  SystemReloadPayload,
  ToolEndPayload,
  ToolStartPayload,
} from '@/lib/chat-contract';

export interface ToolCallState {
  toolCallId: string;
  toolName: string;
  status: 'running' | 'success' | 'error' | 'anomaly';
}

/** Formats the visible marker prepended to a capped stream buffer once any
 * original characters have been dropped from it. Reconstructing the prior
 * tail (see `capAppendStream` below) always slices this exact computed
 * length off the front of the previous displayed value — it never scans
 * buffer content for marker-shaped text, so real streamed content that
 * happens to look like a marker can never be mistaken for one. */
function formatTruncationMarker(dropped: number): string {
  return `…[truncated ${dropped} characters]…`;
}

interface CappedStream {
  /** The full value to store/display — a plain tail when nothing has been
   * dropped yet, otherwise the marker followed by the retained tail. Always
   * bounded to at most `max` characters in total. */
  displayed: string;
  /** Total original stream characters dropped so far, cumulative across
   * every append — never reset while the buffer is still accumulating. */
  dropped: number;
}

/** Appends `addition` to a stream buffer capped at `max` displayed
 * characters (marker included), honestly disclosing how many original
 * characters have been dropped so far rather than silently retaining only
 * the tail. `priorDisplayed`/`priorDropped` come from state; the marker (if
 * any) already present in `priorDisplayed` is stripped by the exact length
 * `formatTruncationMarker(priorDropped)` computes, not by pattern-matching. */
function capAppendStream(
  priorDisplayed: string,
  priorDropped: number,
  addition: string,
  max: number,
): CappedStream {
  const priorTail =
    priorDropped > 0
      ? priorDisplayed.slice(formatTruncationMarker(priorDropped).length)
      : priorDisplayed;
  const combinedTail = priorTail + addition;

  if (priorDropped === 0 && combinedTail.length <= max) {
    return { displayed: combinedTail, dropped: 0 };
  }

  // The marker's own text grows (rarely) as `dropped`'s digit count grows,
  // which shrinks the budget left for the tail, which can in turn increase
  // `dropped` further — a handful of iterations is always enough to reach a
  // fixed point for any realistic character count.
  let dropped = priorDropped;
  for (let i = 0; i < 8; i += 1) {
    const budget = Math.max(0, max - formatTruncationMarker(dropped).length);
    const nextDropped = priorDropped + Math.max(0, combinedTail.length - budget);
    if (nextDropped === dropped) break;
    dropped = nextDropped;
  }

  const marker = formatTruncationMarker(dropped);
  const budget = Math.max(0, max - marker.length);
  const tail = combinedTail.slice(Math.max(0, combinedTail.length - budget));
  return { displayed: marker + tail, dropped };
}

/** Pushes `item` onto `arr`, dropping the oldest entries once `max` is exceeded. */
function capPush<T>(arr: T[], item: T, max: number): T[] {
  const next = [...arr, item];
  return next.length > max ? next.slice(next.length - max) : next;
}

/** A valid `toolCallId` from the wire is a non-empty string — a malformed or
 * absent one (non-string, or empty string) must never be treated as if it
 * named a real tool call. */
function isValidToolCallId(value: unknown): value is string {
  return typeof value === 'string' && value.length > 0;
}

/** Shown when a scoped `error`/`agent:end` cannot be attributed to any
 * conversation (a malformed/missing conversationId) while a send is still
 * pending and no conversation has ever been established. There is no valid
 * identity left to recover the turn under, so it is surfaced as a terminal
 * startup failure instead of leaving `sending`/`pendingSend` stuck forever. */
const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please try again.';

/** Shown when the non-evicting executed-approval dedup cache is already at
 * MAX_EXECUTED_APPROVAL_IDS and a distinct, never-before-seen approval is
 * denied as a result (see `runApprovedCommand`'s saturation branch below).
 * The approval is still consumed (the UI lock is released) but the user
 * must see why the command did not run rather than have it silently
 * dropped. */
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';

/** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn
 * (`turn:ack` with `ok:false`). It is deliberately generic: the raw server
 * `code`/`message`/`error` can carry adapter internals or entropy-source detail,
 * so no rejection ever leaks its cause into the UI — every distinct rejection
 * shows this same string. */
const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.';

/** Fixed, browser-safe notice surfaced when a turn is refused because the
 * idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing).
 * Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */
const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.';

/** The single fixed, browser-safe notice surfaced (with safe code
 * `send_protocol_unavailable`) when a send is attempted on a connection whose
 * negotiated send protocol is `unavailable` — the server never advertised a
 * usable `chat:send-capability`, advertised `unavailable` (e.g. a pi-rpc runtime
 * in this slice), or the advertisement was rejected (wrong connection id, replay,
 * or an unknown protocol). It carries no dynamic detail. */
const SEND_PROTOCOL_UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';

/** Mints a single idempotency key for one accepted `turn:send`, fail-closed.
 * Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that
 * source is absent (not a function) or throws — the caller then refuses the turn
 * rather than falling back to any non-cryptographic source (Math.random, a
 * clock, or a counter would all be forgeable/collision-prone). Never throws. */
function mintIdempotencyKey(): string | null {
  try {
    const c: unknown = globalThis.crypto;
    if (!isRecord(c) || typeof c.randomUUID !== 'function') return null;
    const key = (c.randomUUID as () => unknown)();
    return typeof key === 'string' && key.length > 0 ? key : null;
  } catch {
    return null;
  }
}

/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
 * must be treated as a terminal startup failure: no conversation has ever been
 * established yet, and a send is still pending one. Once a conversation is
 * active, or when no send is pending, an unattributable event stays a no-op —
 * dropping it (rather than guessing which turn it belongs to) is what lets a
 * later genuinely valid event still recover the turn. */
function isUnrecoverableStartupFailure(state: ChatConnectionState): boolean {
  return state.conversationId === null && state.pendingSend;
}

/** Deterministic, unique-per-event fallback id for a malformed `toolCallId`.
 * Sourced from a monotonically increasing counter carried in state (`toolSeq`)
 * rather than `tools.length`, so it stays collision-free even once `tools` is
 * capped at MAX_TOOLS and its length stops changing. */
function nextToolFallbackId(toolSeq: number): string {
  return `unknown-tool-call-${toolSeq}`;
}

/** Truncates a server-provided collection to at most `max` entries. `arr` is
 * `unknown` because it comes straight from a raw socket payload — a
 * malformed/compromised gateway can send `null` or any non-array value here,
 * which must normalize to an empty list rather than throw. */
function capList<T>(arr: unknown, max: number): T[] {
  if (!Array.isArray(arr)) return [];
  return (arr.length > max ? arr.slice(0, max) : arr) as T[];
}

/** Like `capList`, but for a server-provided string collection (e.g.
 * `system:reload.providers`): a non-string entry is dropped rather than
 * invalidating the whole list, and the result is capped at `max`. */
function capStringList(arr: unknown, max: number): string[] {
  if (!Array.isArray(arr)) return [];
  const strings = arr.filter((item): item is string => typeof item === 'string');
  return strings.length > max ? strings.slice(0, max) : strings;
}

/** Normalizes a raw message:ack payload before it is stored — `messageId` is
 * given a stable, visible "unknown" fallback rather than storing a raw
 * non-string value (which would either render as "[object Object]" or,
 * with an unguarded render site, throw). */
function sanitizeAck(payload: MessageAckPayload, conversationId: string): MessageAckPayload {
  return { conversationId, messageId: asString(payload.messageId, 'unknown') };
}

/** Normalizes a raw command:approval payload before it is stored. A command
 * string matching `pendingApproval.command` alone is not sufficient proof of
 * a genuine, executable approval: `success` must be the literal boolean
 * `true`, and a successful approval requires a non-empty string
 * `approvalId`. Anything else is normalized to a denial (`success: false`,
 * no `approvalId`) so the pending request resolves and the approve lock is
 * released instead of leaving the UI hung on a response it can never trust
 * enough to enable "Run approved command" for. `expiresAt`/`message` are
 * retained only when they are strings (message is never rendered as-is). */
function sanitizeApproval(
  payload: SlashCommandApprovalResultPayload,
  conversationId: string,
  command: string,
): SlashCommandApprovalResultPayload {
  const approvalId =
    typeof payload.approvalId === 'string' && payload.approvalId.length > 0
      ? payload.approvalId
      : undefined;
  const success = payload.success === true && approvalId !== undefined;
  return {
    conversationId,
    command,
    success,
    approvalId: success ? approvalId : undefined,
    expiresAt: typeof payload.expiresAt === 'string' ? payload.expiresAt : undefined,
    message: typeof payload.message === 'string' ? payload.message : undefined,
  };
}

/** Normalizes a raw command:result payload before it is stored — `success`
 * is coerced to a literal boolean so a truthy non-boolean value (e.g. an
 * object) can never be displayed as a successful result, and a curated
 * `message` is bounded to MAX_COMMAND_MESSAGE_CHARS so a hostile/malformed
 * gateway cannot push an unbounded string into state (the render site in
 * commands-panel.tsx applies the same bound again as defense-in-depth). */
function sanitizeCommandResult(
  payload: SlashCommandResultPayload,
  conversationId: string,
): SlashCommandResultPayload {
  return {
    conversationId,
    command: asString(payload.command, 'unknown'),
    success: payload.success === true,
    message:
      typeof payload.message === 'string'
        ? payload.message.slice(0, MAX_COMMAND_MESSAGE_CHARS)
        : undefined,
  };
}

export interface ChatTranscriptMessage {
  id: string;
  role: 'user' | 'assistant';
  text: string;
  thinking?: string;
}

export interface PendingApproval {
  command: string;
  args?: string;
}

/** Receipt captured from an accepted harness `turn:ack` — the minimal record proving the
 * server accepted this exact turn under its minted idempotency key and selection tuple. */
export interface HarnessTurnReceipt {
  idempotencyKey: string;
  receiptId: string;
  selection: HarnessSelection;
}

export interface ChatConnectionState {
  conversationId: string | null;
  /** True once a message has been sent while no conversation is active yet, so the
   * first scoped server event naming a conversation may establish it (the gateway
   * does not guarantee message:ack is the first event for a new conversation). */
  pendingSend: boolean;
  /** True from the moment a message is sent until its turn ends (agent:end),
   * errors, or the connection drops — guards against a second turn starting
   * while one is already in flight. */
  sending: boolean;
  ack: MessageAckPayload | null;
  streaming: boolean;
  text: string;
  /** Total original `agent:text` characters dropped so far by the
   * MAX_STREAM_CHARS cap on `text` — tracked separately from `text` itself
   * so the honest cumulative count survives across multiple appends without
   * re-parsing any marker embedded in the displayed string. */
  textDroppedChars: number;
  thinking: string;
  /** Same accounting as `textDroppedChars`, for `thinking`. */
  thinkingDroppedChars: number;
  tools: ToolCallState[];
  usage: SessionUsagePayload | null;
  sessionInfo: SessionInfoPayload | null;
  manifest: CommandManifest | null;
  commandResults: SlashCommandResultPayload[];
  approval: SlashCommandApprovalResultPayload | null;
  pendingApproval: PendingApproval | null;
  /** True while an approval request has been sent and no response has arrived yet. */
  approvalRequestPending: boolean;
  systemReload: SystemReloadPayload | null;
  error: string | null;
  /** How this connection is currently permitted to send, negotiated via the
   * server-to-client-only `chat:send-capability` advertisement. Starts and resets
   * to `'unavailable'` on every (re)connect and disconnect — a fresh or dropped
   * connection has no usable protocol until the server (re-)advertises. This is
   * the reactive/UI mirror of the synchronous `protocolRef` that `sendMessage`
   * actually reads; the ref is authoritative because an advertisement and a send
   * can occur in the same tick before React re-renders. */
  sendProtocol: ChatSendProtocol;
  /** Receipt from the most recently accepted harness `turn:ack`, or null before any
   * turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this
   * untouched (a prior accepted receipt is not erased by a later rejection). */
  turnReceipt: HarnessTurnReceipt | null;
  messages: ChatTranscriptMessage[];
  /** Monotonically increasing counter used to mint transcript message ids —
   * never reset while retained messages remain, so ids stay unique across the
   * MAX_MESSAGES cap boundary (unlike a `messages.length`-derived id, which
   * plateaus once the array is capped). */
  messageSeq: number;
  /** Monotonically increasing counter used to mint fallback tool-call ids for
   * malformed (non-string/empty) `toolCallId`s — never reset while retained
   * tools remain, so fallback ids stay unique across the MAX_TOOLS cap
   * boundary. */
  toolSeq: number;
  /** Explicit per-turn phase for the CURRENT (most recently sent) turn. Wire
   * payloads (agent:end/error) carry only a conversationId, not a
   * turn-scoped correlation id — two different turns on the SAME
   * conversation are indistinguishable on the wire by conversationId alone,
   * so this local phase is what actually tells them apart:
   *
   * - 'pending': minted by `local/send`; the turn has been sent but has not
   *   yet received its own `agent:start`. A same-conversation `agent:end`
   *   arriving in this phase cannot be trusted as this turn's own — Socket.IO
   *   delivers in order and the Gateway always emits `agent:start` before a
   *   legitimate `agent:end`, so this can only be a stale/duplicate end left
   *   over from an earlier turn (a `message:ack` alone does NOT advance this
   *   phase, precisely to close that hole). A same-conversation `error`,
   *   however, IS trusted in this phase: the Gateway can legitimately emit a
   *   session-creation error before ack/start, and once an earlier turn on
   *   this conversation has already fully settled (via its own terminal
   *   event) ordered delivery guarantees nothing further remains in flight
   *   for it — so any later scoped error can only belong to this turn.
   * - 'active': set only by this turn's own accepted `agent:start`. Only
   *   from this phase may a scoped `agent:end` settle the turn.
   * - 'settled': set once this turn's own terminal (`agent:end` or `error`)
   *   has been processed. A further scoped `agent:end`/`error` in this phase
   *   is a true no-op — it must not re-finalize or re-release anything.
   *
   * Fails closed: `agent:end` can only ever settle from 'active'. */
  turnPhase: 'pending' | 'active' | 'settled';
}

export interface ChatConnectionActions {
  sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean;
  abort: () => void;
  setThinking: (level: string) => void;
  executeCommand: (input: { command: string; args?: string }) => void;
  approveCommand: (input: { command: string; args?: string }) => void;
  runApprovedCommand: () => void;
}

export interface ChatConnectionValue {
  state: ChatConnectionState;
  actions: ChatConnectionActions;
}

const initialState: ChatConnectionState = {
  conversationId: null,
  pendingSend: false,
  sending: false,
  ack: null,
  streaming: false,
  text: '',
  textDroppedChars: 0,
  thinking: '',
  thinkingDroppedChars: 0,
  tools: [],
  usage: null,
  sessionInfo: null,
  manifest: null,
  commandResults: [],
  approval: null,
  pendingApproval: null,
  approvalRequestPending: false,
  systemReload: null,
  error: null,
  sendProtocol: 'unavailable',
  turnReceipt: null,
  messages: [],
  messageSeq: 0,
  toolSeq: 0,
  turnPhase: 'settled',
};

type Action =
  | { type: 'server/message:ack'; payload: MessageAckPayload }
  | { type: 'server/agent:start'; payload: AgentStartPayload }
  | { type: 'server/agent:text'; payload: AgentTextPayload }
  | { type: 'server/agent:thinking'; payload: AgentThinkingPayload }
  | { type: 'server/agent:tool:start'; payload: ToolStartPayload }
  | { type: 'server/agent:tool:end'; payload: ToolEndPayload }
  | { type: 'server/agent:end'; payload: AgentEndPayload }
  | { type: 'server/session:info'; payload: SessionInfoPayload }
  | { type: 'server/commands:manifest'; payload: CommandManifestPayload }
  | { type: 'server/command:result'; payload: SlashCommandResultPayload }
  | { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
  | { type: 'server/system:reload'; payload: SystemReloadPayload }
  | { type: 'server/error'; payload: ErrorPayload }
  | { type: 'server/turn:ack'; payload: HarnessTurnAckPayload }
  | { type: 'local/send'; content: string }
  | { type: 'local/capability'; protocol: ChatSendProtocol }
  | { type: 'local/reset-protocol' }
  | { type: 'local/send-unavailable' }
  | { type: 'local/turn-idempotency-unavailable' }
  | { type: 'local/approve-request'; command: string; args?: string }
  | { type: 'local/consume-approval' }
  | { type: 'local/approval-saturated' }
  | { type: 'local/disconnect' };

/**
 * Resolves whether a scoped server event (one carrying a conversationId) belongs to
 * the active conversation. The gateway does not guarantee message:ack is the first
 * event for a new conversation (session:info, and error on auth/session-creation
 * failure, can both arrive first) — so while a message is pending and no conversation
 * is active yet, the first scoped event names the conversation instead of being
 * dropped. Once a conversation is active, only its own events pass.
 *
 * `rawConversationId` is `unknown`, not `string` — every payload is runtime-untrusted
 * regardless of its compile-time contract, and this is the single place that may
 * establish `state.conversationId`. A malformed value (non-string/empty) is rejected
 * via the central `asConversationId` guard and never adopted: the event is treated as
 * inactive/dropped, leaving state (including `pendingSend`) untouched so a later
 * genuinely valid event can still establish or recover the turn.
 */
function resolveScopedConversation(
  state: ChatConnectionState,
  rawConversationId: unknown,
): { active: true; state: ChatConnectionState } | { active: false; state: null } {
  const conversationId = asConversationId(rawConversationId);
  if (conversationId === null) {
    return { active: false, state: null };
  }
  if (state.conversationId === conversationId) {
    return { active: true, state };
  }
  if (state.conversationId === null && state.pendingSend) {
    // The very first scoped event for a brand-new conversation can only belong
    // to the currently in-flight turn — no prior same-conversation turn exists to
    // be confused with. This only establishes conversation identity; it does not
    // advance `turnPhase` — only this turn's own `agent:start` may do that (see
    // the 'server/agent:start' case), and only this turn's own `error` may settle
    // it directly from 'pending' (see the 'server/error' case).
    return {
      active: true,
      state: { ...state, conversationId, pendingSend: false },
    };
  }
  return { active: false, state: null };
}

type ServerAction = Extract<Action, { type: `server/${string}` }>;

function isServerAction(action: Action): action is ServerAction {
  return action.type.startsWith('server/');
}

function reduce(state: ChatConnectionState, action: Action): ChatConnectionState {
  // A malformed packet (the whole payload is null/undefined/a primitive, not
  // an object) is ignored outright rather than crashing the reducer on the
  // first field dereference in the case below — every server/* action shape
  // declares a `payload` field, so this guard covers all of them uniformly.
  if (isServerAction(action) && !isRecord(action.payload)) {
    return state;
  }

  switch (action.type) {
    case 'server/message:ack': {
      const { payload } = action;
      if (state.conversationId === null) {
        const conversationId = asConversationId(payload.conversationId);
        // A malformed first frame (non-string/empty conversationId) is ignored
        // outright rather than adopted — adopting it would permanently
        // desynchronize every later scoped event's strict-equality check
        // against a value that can never again match. Leaving state untouched
        // (conversationId stays null) lets a later genuinely valid ack/start
        // still establish the turn.
        if (conversationId === null) return state;
        return {
          ...state,
          conversationId,
          pendingSend: false,
          ack: sanitizeAck(payload, conversationId),
          // Deliberately does NOT advance `turnPhase` — an ack alone must
          // never be enough to make a later `agent:end` settle this turn.
          // Only this turn's own `agent:start` may do that.
        };
      }
      if (payload.conversationId !== state.conversationId) return state;
      return {
        ...state,
        ack: sanitizeAck(payload, state.conversationId),
      };
    }

    case 'server/agent:start': {
      const resolved = resolveScopedConversation(state, action.payload.conversationId);
      if (!resolved.active) return state;
      return {
        ...resolved.state,
        streaming: true,
        text: '',
        thinking: '',
        textDroppedChars: 0,
        thinkingDroppedChars: 0,
        tools: [],
        error: null,
        // Only this turn's own accepted agent:start may move it into the
        // 'active' phase that a real agent:end may later settle.
        turnPhase: 'active',
      };
    }

    case 'server/agent:text': {
      const resolved = resolveScopedConversation(state, action.payload.conversationId);
      if (!resolved.active) return state;
      const capped = capAppendStream(
        resolved.state.text,
        resolved.state.textDroppedChars,
        asString(action.payload.text),
        MAX_STREAM_CHARS,
      );
      return { ...resolved.state, text: capped.displayed, textDroppedChars: capped.dropped };
    }

    case 'server/agent:thinking': {
      const resolved = resolveScopedConversation(state, action.payload.conversationId);
      if (!resolved.active) return state;
      const capped = capAppendStream(
        resolved.state.thinking,
        resolved.state.thinkingDroppedChars,
        asString(action.payload.text),
        MAX_STREAM_CHARS,
      );
      return {
        ...resolved.state,
        thinking: capped.displayed,
        thinkingDroppedChars: capped.dropped,
      };
    }

    case 'server/agent:tool:start': {
      const { payload } = action;
      const resolved = resolveScopedConversation(state, payload.conversationId);
      if (!resolved.active) return state;
      const valid = isValidToolCallId(payload.toolCallId);
      const toolSeq = resolved.state.toolSeq;
      const tool: ToolCallState = {
        // Each malformed toolCallId gets its own fresh fallback id (never the
        // fixed 'unknown-tool-call' constant) so distinct malformed starts
        // never collide with each other.
        toolCallId: valid ? payload.toolCallId : nextToolFallbackId(toolSeq),
        toolName: asString(payload.toolName, 'Unknown tool'),
        status: 'running',
      };
      return {
        ...resolved.state,
        tools: capPush(resolved.state.tools, tool, MAX_TOOLS),
        toolSeq: valid ? toolSeq : toolSeq + 1,
      };
    }

    case 'server/agent:tool:end': {
      const { payload } = action;
      const resolved = resolveScopedConversation(state, payload.conversationId);
      if (!resolved.active) return state;
      const valid = isValidToolCallId(payload.toolCallId);
      const toolSeq = resolved.state.toolSeq;
      const toolCallId = valid ? payload.toolCallId : nextToolFallbackId(toolSeq);
      const toolName = asString(payload.toolName, 'Unknown tool');
      const nextToolSeq = valid ? toolSeq : toolSeq + 1;
      // A fresh fallback id (malformed toolCallId) can never match an
      // existing entry, so this only ever finds a genuine prior tool:start.
      const matchIndex = resolved.state.tools.findIndex((tool) => tool.toolCallId === toolCallId);
      if (matchIndex === -1) {
        // A tool:end for an ID we never saw a tool:start for is a protocol
        // anomaly, not a no-op — surface it visibly instead of silently
        // dropping it.
        const anomaly: ToolCallState = { toolCallId, toolName, status: 'anomaly' };
        return {
          ...resolved.state,
          tools: capPush(resolved.state.tools, anomaly, MAX_TOOLS),
          toolSeq: nextToolSeq,
        };
      }
      // Update at most the first matching entry — if `toolCallId` is
      // (unexpectedly) shared by more than one retained tool, updating every
      // match would cross-contaminate unrelated statuses (and could silently
      // overwrite a distinct entry's 'anomaly' status).
      return {
        ...resolved.state,
        tools: resolved.state.tools.map((tool, index) =>
          index === matchIndex ? { ...tool, status: payload.isError ? 'error' : 'success' } : tool,
        ),
        toolSeq: nextToolSeq,
      };
    }

    case 'server/agent:end': {
      const { payload } = action;
      const resolved = resolveScopedConversation(state, payload.conversationId);
      if (!resolved.active) {
        if (isUnrecoverableStartupFailure(state)) {
          return {
            ...state,
            pendingSend: false,
            sending: false,
            streaming: false,
            approvalRequestPending: false,
            turnPhase: 'settled',
            error: CONVERSATION_START_FAILURE,
          };
        }
        return state;
      }
      const next = resolved.state;
      if (next.turnPhase !== 'active') {
        // Only an 'active' turn (one that has received its own agent:start)
        // may be settled by an agent:end. A 'pending' phase means this
        // turn has not started yet — Socket.IO delivers in order and the
        // Gateway always emits agent:start before a legitimate agent:end, so
        // this can only be a stale/duplicate end left over from an earlier
        // turn. A 'settled' phase means this turn's own end already fired —
        // this is a redelivered duplicate. Either way this must be a true
        // no-op: it must not finalize leftover text/thinking into a message
        // or reset streaming, both of which legitimately belong to the
        // still in-flight (or already-finalized) current turn.
        return next;
      }
      // Reaching here means turnPhase is 'active' — but AgentEndPayload
      // carries only a conversationId, no turn/message identity. In-order
      // delivery proves the boundary enforced above (a same-conversation end
      // arriving before this turn's own agent:start is provably stale); once
      // 'active', a genuine end for this turn and a same-conversation
      // stale/duplicate end are indistinguishable here. This residual is
      // accepted — full correlation requires a wire turnId, deferred to P5.
      const hasContent = next.text.length > 0 || next.thinking.length > 0;
      const messages = hasContent
        ? capPush(
            next.messages,
            {
              // Sourced from the reducer-owned `messageSeq` counter, not
              // `messages.length` — the latter plateaus once MAX_MESSAGES is
              // reached under a flood, producing duplicate ids/React keys.
              id: `assistant-${payload.conversationId}-${next.messageSeq}`,
              role: 'assistant' as const,
              text: next.text,
              thinking: next.thinking || undefined,
            },
            MAX_MESSAGES,
          )
        : next.messages;
      return {
        ...next,
        streaming: false,
        sending: false,
        turnPhase: 'settled',
        text: '',
        thinking: '',
        textDroppedChars: 0,
        thinkingDroppedChars: 0,
        usage: payload.usage ?? next.usage,
        messages,
        messageSeq: hasContent ? next.messageSeq + 1 : next.messageSeq,
      };
    }

    case 'server/session:info': {
      const { payload } = action;
      const resolved = resolveScopedConversation(state, payload.conversationId);
      if (!resolved.active) return state;
      return {
        ...resolved.state,
        sessionInfo: {
          ...payload,
          // A hostile/malfunctioning gateway can flood this list; cap it
          // before it ever reaches state so a render site (e.g. the
          // thinking-level <select>) can never be forced to lay out an
          // unbounded number of options.
          availableThinkingLevels: asStringArray(payload.availableThinkingLevels).slice(
            0,
            MAX_MANIFEST_ITEMS,
          ),
        },
      };
    }

    case 'server/commands:manifest': {
      // `manifest` itself, and its `commands`/`skills` fields, are raw
      // socket payload values — any of them can be null/non-object at
      // runtime regardless of the compile-time contract.
      const manifest: Record<string, unknown> = isRecord(action.payload.manifest)
        ? action.payload.manifest
        : {};
      return {
        ...state,
        manifest: {
          commands: capList<CommandDef>(manifest.commands, MAX_MANIFEST_ITEMS),
          skills: capList<SkillCommandDef>(manifest.skills, MAX_MANIFEST_ITEMS),
          version: asFiniteNumber(manifest.version, state.manifest?.version ?? 0),
        },
      };
    }

    case 'server/command:result': {
      const { payload } = action;
      const resolved = resolveScopedConversation(state, payload.conversationId);
      if (!resolved.active) return state;
      const next = resolved.state;
      const conversationId = next.conversationId;
      if (conversationId === null) return next;
      return {
        ...next,
        commandResults: capPush(
          next.commandResults,
          sanitizeCommandResult(payload, conversationId),
          MAX_COMMAND_RESULTS,
        ),
      };
    }

    case 'server/command:approval': {
      const { payload } = action;
      const resolved = resolveScopedConversation(state, payload.conversationId);
      if (!resolved.active) return state;
      const next = resolved.state;
      const conversationId = next.conversationId;
      if (
        conversationId === null ||
        !next.pendingApproval ||
        typeof payload.command !== 'string' ||
        payload.command !== next.pendingApproval.command
      ) {
        // Stale or mismatched response for a request that is no longer (or never was)
        // the one outstanding approval — do not let it replace active approval state.
        return next;
      }
      return {
        ...next,
        approval: sanitizeApproval(payload, conversationId, payload.command),
        approvalRequestPending: false,
      };
    }

    case 'server/system:reload': {
      const { payload } = action;
      // `systemReload` is built from exactly the SystemReloadPayload
      // contract fields (commands, skills, providers, message) after
      // runtime normalization — the raw payload is never spread into state,
      // so a hostile/malfunctioning gateway cannot smuggle extra fields in.
      const commands = capList<CommandDef>(payload.commands, MAX_MANIFEST_ITEMS);
      const skills = capList<SkillCommandDef>(payload.skills, MAX_MANIFEST_ITEMS);
      const providers = capStringList(payload.providers, MAX_MANIFEST_ITEMS);
      return {
        ...state,
        systemReload: {
          commands,
          skills,
          providers,
          message: asString(payload.message, 'Commands reloaded.'),
        },
        manifest: {
          commands,
          skills,
          version: state.manifest?.version ?? 0,
        },
      };
    }

    case 'server/error': {
      const { payload } = action;
      const resolved = resolveScopedConversation(state, payload.conversationId);
      if (!resolved.active) {
        if (isUnrecoverableStartupFailure(state)) {
          return {
            ...state,
            pendingSend: false,
            sending: false,
            streaming: false,
            approvalRequestPending: false,
            turnPhase: 'settled',
            error: CONVERSATION_START_FAILURE,
          };
        }
        return state;
      }
      const next = resolved.state;
      if (next.turnPhase === 'settled') {
        // No in-flight current turn: the error is still visibly surfaced,
        // but there is no turn/approval lock left to mutate.
        return { ...next, error: asString(payload.error, 'An error occurred.') };
      }
      // A current turn-scoped error is terminal from either 'pending'
      // (pre-ack — the Gateway can emit a session-creation error before
      // ack) or 'active' (a prompt error after ack/start) state. Once an
      // earlier turn on this conversation has already fully settled (its
      // own terminal event processed, moving turnPhase to 'settled' before
      // this turn was even sent), ordered Socket.IO delivery guarantees
      // nothing further remains in flight for it — so a scoped error seen
      // while turnPhase is 'pending' or 'active' can only belong to THIS
      // turn, and must settle and release the lock.
      //
      // Same wire limitation as agent:end above: ErrorPayload also carries
      // only a conversationId, no turn identity. A pre-ack ('pending') error
      // is accepted as current by design, per the reasoning above. Once
      // 'active', a genuine error for this turn and a same-conversation
      // stale/duplicate error are equally indistinguishable — accepted
      // residual; full correlation needs a wire turnId, deferred to P5.
      return {
        ...next,
        error: asString(payload.error, 'An error occurred.'),
        streaming: false,
        sending: false,
        turnPhase: 'settled',
        // A turn-scoped error also invalidates any approval request awaiting
        // a response — the gateway that just errored is unlikely to still
        // answer it, and the synchronous approveLockRef mirrors this field.
        approvalRequestPending: false,
      };
    }

    case 'server/turn:ack': {
      // The harness runtime's turn acknowledgement. The success shape carries a
      // receipt id + minted idempotencyKey + echoed selection; the failure shape
      // is discriminated on `ok === false`. Every field is runtime-untrusted (the
      // top-of-reducer guard already rejected a non-object payload).
      const record = action.payload as Record<string, unknown>;
      if (record.ok === false) {
        // A rejected turn surfaces a FIXED browser-safe notice — never the raw
        // server `message`/`error`/`code`, which can carry adapter internals — and
        // does not disturb any previously accepted receipt.
        return { ...state, error: TURN_REJECTED_NOTICE };
      }
      const idempotencyKey = asString(record.idempotencyKey);
      // The web ack uses `receiptId`; fall back to the frozen contract's `turnId`.
      const receiptId = asString(record.receiptId) || asString(record.turnId);
      const selection = asHarnessSelection(record.selection);
      if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) {
        // A malformed success frame is ignored outright rather than recorded as a
        // half-populated receipt.
        return state;
      }
      return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } };
    }

    case 'local/send': {
      const message: ChatTranscriptMessage = {
        // Sourced from the reducer-owned `messageSeq` counter — see the
        // `server/agent:end` case for why `messages.length`/`Date.now()` are
        // not collision-safe once MAX_MESSAGES is reached under a flood.
        id: `user-${state.messageSeq}`,
        role: 'user',
        text: action.content,
      };
      return {
        ...state,
        messages: capPush(state.messages, message, MAX_MESSAGES),
        messageSeq: state.messageSeq + 1,
        error: null,
        sending: true,
        // Sending a new turn begins a fresh 'pending' phase — only THIS
        // turn's own accepted agent:start may move it to 'active', so a
        // same-conversation agent:end left over from the turn that just
        // finished can never be mistaken for this one's.
        turnPhase: 'pending',
        pendingSend: state.conversationId === null ? true : state.pendingSend,
      };
    }

    case 'local/capability': {
      // The FIRST valid `chat:send-capability` for this connection generation has
      // been accepted (connection-id gating + first-wins enforced in the handler);
      // record how this connection may now send. This is the reactive mirror of
      // the synchronous `protocolRef` the send path reads.
      return { ...state, sendProtocol: action.protocol };
    }

    case 'local/reset-protocol': {
      // A (re)connect or disconnect voids any negotiated protocol: a fresh or
      // dropped connection has no usable send capability until the server
      // (re-)advertises. Reset to `unavailable` so no stale advertisement can
      // authorize a send across a connection boundary.
      if (state.sendProtocol === 'unavailable') return state;
      return { ...state, sendProtocol: 'unavailable' };
    }

    case 'local/send-unavailable': {
      // A send was attempted while the negotiated protocol is `unavailable`
      // (never advertised / advertised unavailable / rejected advertisement).
      // Surface the single FIXED safe notice — nothing was emitted, minted,
      // appended, or locked.
      return { ...state, error: SEND_PROTOCOL_UNAVAILABLE_NOTICE };
    }

    case 'local/turn-idempotency-unavailable': {
      // The idempotency-key mint failed closed (crypto.randomUUID absent or
      // throwing), so the turn was refused before emit. Surface a FIXED notice —
      // never the underlying thrown message, which can leak entropy-source
      // internals.
      return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE };
    }

    case 'local/disconnect': {
      // A transient socket disconnect must not leave the UI stuck waiting on
      // a turn/approval/send that will never resolve on this connection.
      return {
        ...state,
        streaming: false,
        sending: false,
        pendingSend: false,
        approvalRequestPending: false,
        turnPhase: 'settled',
      };
    }

    case 'local/approve-request': {
      // Only one approval request may be outstanding at a time; a second request
      // before the first resolves is ignored so it cannot overwrite the original
      // command+args pair with a mismatched one.
      if (state.approvalRequestPending) return state;
      return {
        ...state,
        pendingApproval: { command: action.command, args: action.args },
        approvalRequestPending: true,
        approval: null,
      };
    }

    case 'local/consume-approval': {
      return { ...state, approval: null, pendingApproval: null };
    }

    case 'local/approval-saturated': {
      // The non-evicting executed-approval cache is full and this is a
      // distinct, never-before-seen approval — it is denied (fail closed),
      // but unlike a plain replay of an already-executed ID, this must be
      // visibly surfaced rather than silently dropped.
      return { ...state, approval: null, pendingApproval: null, error: APPROVAL_LIMIT_MESSAGE };
    }

    default:
      return state;
  }
}

/**
 * Owns the `/chat` socket for the lifetime of the consuming page: subscribes
 * once, narrows the socket to the typed contract, and tears the socket down
 * on cleanup so the next visit gets a fresh authenticated connection.
 */
export function useChatConnection(): ChatConnectionValue {
  const [state, dispatch] = useReducer(reduce, initialState);
  // Reducer state updates are batched/async; a ref lets a double-click on "Run
  // approved command" be rejected synchronously, before React ever re-renders.
  const executedApprovalIds = useRef<Set<string>>(new Set());
  // Synchronous send lock: `state.sending` (reducer) drives the reactive UI
  // disabled state, but reducer updates are batched/async, so two sendMessage
  // calls issued in the same tick would both read the same stale `state`. This
  // ref is the source of truth the guard checks against.
  //
  // It is intentionally never cleared directly by an event handler — a stale
  // agent:end/error for a conversation other than the active one must not be
  // able to re-arm the lock for a turn that is still genuinely in flight.
  // Instead it is synchronized from `state.sending` below, which the reducer
  // only flips on an *accepted* (conversation-scoped, or unconditional
  // disconnect) transition — resolveScopedConversation already rejects a
  // foreign-conversation agent:end/error by returning the unchanged state, so
  // `state.sending` (and therefore this ref) cannot be released by one.
  const sendLockRef = useRef(false);
  useEffect(() => {
    sendLockRef.current = state.sending;
  }, [state.sending]);

  // Same design as sendLockRef, mirroring `state.approvalRequestPending`:
  // closes the same-tick double-dispatch race for approveCommand, and is
  // synchronized from (not cleared by) the reducer's own accepted
  // command:approval/error/disconnect transitions.
  const approveLockRef = useRef(false);
  useEffect(() => {
    approveLockRef.current = state.approvalRequestPending;
  }, [state.approvalRequestPending]);

  // Synchronous, generation-bound send protocol. `state.sendProtocol` drives the
  // reactive UI, but reducer updates are batched/async — a `chat:send-capability`
  // advertisement and a `sendMessage` can land in the same tick before React
  // re-renders — so this ref is the source of truth the send path reads. Unlike
  // sendLockRef/approveLockRef (synchronized FROM the reducer), this ref is
  // written directly by the socket lifecycle/capability handlers below, which
  // also dispatch the reducer mirror. It is NOT synchronized from state, because
  // its whole purpose is to be correct BEFORE the reducer has re-rendered.
  const protocolRef = useRef<ChatSendProtocol>('unavailable');
  // True once the first valid advertisement for the CURRENT connection generation
  // has been accepted; every later advertisement (a conflicting or replayed one)
  // is ignored until the next (re)connect/disconnect resets the generation.
  const protocolLockedRef = useRef(false);

  useEffect(() => {
    const socket = getSocket();

    const onMessageAck = (payload: MessageAckPayload): void =>
      dispatch({ type: 'server/message:ack', payload });
    const onAgentStart = (payload: AgentStartPayload): void =>
      dispatch({ type: 'server/agent:start', payload });
    const onAgentText = (payload: AgentTextPayload): void =>
      dispatch({ type: 'server/agent:text', payload });
    const onAgentThinking = (payload: AgentThinkingPayload): void =>
      dispatch({ type: 'server/agent:thinking', payload });
    const onToolStart = (payload: ToolStartPayload): void =>
      dispatch({ type: 'server/agent:tool:start', payload });
    const onToolEnd = (payload: ToolEndPayload): void =>
      dispatch({ type: 'server/agent:tool:end', payload });
    const onAgentEnd = (payload: AgentEndPayload): void => {
      dispatch({ type: 'server/agent:end', payload });
    };
    const onSessionInfo = (payload: SessionInfoPayload): void =>
      dispatch({ type: 'server/session:info', payload });
    const onCommandsManifest = (payload: CommandManifestPayload): void =>
      dispatch({ type: 'server/commands:manifest', payload });
    const onCommandResult = (payload: SlashCommandResultPayload): void =>
      dispatch({ type: 'server/command:result', payload });
    const onCommandApproval = (payload: SlashCommandApprovalResultPayload): void =>
      dispatch({ type: 'server/command:approval', payload });
    const onSystemReload = (payload: SystemReloadPayload): void =>
      dispatch({ type: 'server/system:reload', payload });
    const onError = (payload: ErrorPayload): void => {
      dispatch({ type: 'server/error', payload });
    };
    const onTurnAck = (payload: HarnessTurnAckPayload): void =>
      dispatch({ type: 'server/turn:ack', payload });

    // Void the negotiated send protocol at every connection-lifecycle boundary.
    // A fresh or dropped connection has no usable capability until the server
    // (re-)advertises, so no advertisement bound to a prior connection may carry
    // across the boundary and authorize a send. Both write the synchronous ref
    // AND unlock first-wins, then dispatch the reducer mirror.
    const resetSendProtocol = (): void => {
      protocolRef.current = 'unavailable';
      protocolLockedRef.current = false;
      dispatch({ type: 'local/reset-protocol' });
    };
    const onConnect = (): void => {
      resetSendProtocol();
    };
    const onCapability = (payload: ChatSendCapabilityPayload): void => {
      // Server-to-client-only advertisement of how THIS connection may send.
      // Accept only the FIRST valid one per generation, and only when it names
      // this exact connection (`connectionId === socket.id`): a capability minted
      // for another or stale connection must never arm this one. The payload is
      // runtime-untrusted despite its compile-time type, so every field is
      // guard-checked and an unknown protocol is dropped (leaving `unavailable`).
      if (protocolLockedRef.current) return;
      if (!isRecord(payload)) return;
      const { protocol, connectionId } = payload as {
        protocol?: unknown;
        connectionId?: unknown;
      };
      if (typeof connectionId !== 'string' || connectionId !== socket.id) return;
      if (protocol !== 'legacy-message' && protocol !== 'turn-send' && protocol !== 'unavailable') {
        return;
      }
      protocolLockedRef.current = true;
      protocolRef.current = protocol;
      dispatch({ type: 'local/capability', protocol });
    };
    const onDisconnect = (): void => {
      resetSendProtocol();
      dispatch({ type: 'local/disconnect' });
    };

    socket.on('message:ack', onMessageAck);
    socket.on('agent:start', onAgentStart);
    socket.on('agent:text', onAgentText);
    socket.on('agent:thinking', onAgentThinking);
    socket.on('agent:tool:start', onToolStart);
    socket.on('agent:tool:end', onToolEnd);
    socket.on('agent:end', onAgentEnd);
    socket.on('session:info', onSessionInfo);
    socket.on('commands:manifest', onCommandsManifest);
    socket.on('command:result', onCommandResult);
    socket.on('command:approval', onCommandApproval);
    socket.on('system:reload', onSystemReload);
    socket.on('error', onError);
    socket.on('turn:ack', onTurnAck);
    // Registered BEFORE connect so the initial post-auth advertisement (and any
    // reconnect) can never race ahead of its listener.
    socket.on('connect', onConnect);
    socket.on('chat:send-capability', onCapability);
    socket.on('disconnect', onDisconnect);

    if (!socket.connected) {
      socket.connect();
    }

    return () => {
      socket.off('message:ack', onMessageAck);
      socket.off('agent:start', onAgentStart);
      socket.off('agent:text', onAgentText);
      socket.off('agent:thinking', onAgentThinking);
      socket.off('agent:tool:start', onToolStart);
      socket.off('agent:tool:end', onToolEnd);
      socket.off('agent:end', onAgentEnd);
      socket.off('session:info', onSessionInfo);
      socket.off('commands:manifest', onCommandsManifest);
      socket.off('command:result', onCommandResult);
      socket.off('command:approval', onCommandApproval);
      socket.off('system:reload', onSystemReload);
      socket.off('error', onError);
      socket.off('turn:ack', onTurnAck);
      socket.off('connect', onConnect);
      socket.off('chat:send-capability', onCapability);
      socket.off('disconnect', onDisconnect);
      destroySocket();
    };
  }, []);

  const actions: ChatConnectionActions = {
    sendMessage: ({ content, selection }) => {
      // Routing is PROTOCOL-driven, never inferred from conversation/selection/
      // provider/local mode: the server advertised, once per connection, exactly
      // how this connection may send, and that advertisement is authoritative.
      // The exhaustive switch maps each protocol to its ONE event; the send path
      // never reconnects the socket (a dropped connection has already reset the
      // protocol to `unavailable`, so no emit branch is reachable while offline).
      switch (protocolRef.current) {
        case 'legacy-message': {
          // Embedded/legacy runtime: EVERY browser turn — the first (which
          // creates the conversation) and every later one — is the `message`
          // event. provider/model are sourced ONLY from the confirmed persisted
          // selection tuple, never from separate flat caller inputs.
          if (sendLockRef.current || state.streaming || state.sending) return false;
          sendLockRef.current = true;
          const socket = getSocket();
          dispatch({ type: 'local/send', content });
          socket.emit('message', {
            conversationId: state.conversationId ?? undefined,
            content,
            provider: selection?.providerId,
            modelId: selection?.modelId,
          });
          return true;
        }
        case 'turn-send': {
          // Pi turn-runtime: the exclusive `turn:send` contract. Requires an
          // already-established conversation AND a confirmed persisted selection
          // tuple; it is lock-independent (no send lock, no optimistic append),
          // and mints exactly one idempotency key per accepted turn, failing the
          // turn closed if the mint fails. A premature send (no conversation yet,
          // or no selection) is refused with no emit and no notice.
          if (selection == null || state.conversationId === null) return false;
          const idempotencyKey = mintIdempotencyKey();
          if (idempotencyKey === null) {
            dispatch({ type: 'local/turn-idempotency-unavailable' });
            return false;
          }
          const socket = getSocket();
          socket.emit('turn:send', {
            conversationId: state.conversationId,
            content,
            selection,
            idempotencyKey,
          });
          return true;
        }
        case 'unavailable': {
          // No usable protocol negotiated for this connection: refuse without
          // emitting, minting, appending, or acquiring the lock, and surface the
          // one fixed safe notice (code `send_protocol_unavailable`).
          dispatch({ type: 'local/send-unavailable' });
          return false;
        }
        default: {
          // Exhaustiveness guard: every ChatSendProtocol member is handled above.
          // An unknown value can never arm a send — refuse exactly as
          // `unavailable` rather than falling through to any emit.
          const _exhaustive: never = protocolRef.current;
          void _exhaustive;
          dispatch({ type: 'local/send-unavailable' });
          return false;
        }
      }
    },

    abort: () => {
      if (state.conversationId === null) return;
      const socket = getSocket();
      socket.emit('abort', { conversationId: state.conversationId });
    },

    setThinking: (level) => {
      if (state.conversationId === null) return;
      const socket = getSocket();
      socket.emit('set:thinking', { conversationId: state.conversationId, level });
    },

    executeCommand: ({ command, args }) => {
      if (state.conversationId === null) return;
      const socket = getSocket();
      socket.emit('command:execute', { conversationId: state.conversationId, command, args });
    },

    approveCommand: ({ command, args }) => {
      if (state.conversationId === null) return;
      if (approveLockRef.current || state.approvalRequestPending) return;
      approveLockRef.current = true;
      dispatch({ type: 'local/approve-request', command, args });
      const socket = getSocket();
      socket.emit('command:approve', { conversationId: state.conversationId, command, args });
    },

    runApprovedCommand: () => {
      const { conversationId, approval, pendingApproval } = state;
      if (conversationId === null) return;
      // Defense-in-depth: the reducer already normalizes `success`/`approvalId`
      // before storing `approval` (a matching command string alone is not
      // proof of a genuine approval), but this action must never rely on that
      // alone — a literal-`true` and non-empty-string check here too.
      if (approval?.success !== true) return;
      if (typeof approval.approvalId !== 'string' || approval.approvalId.length === 0) return;
      if (!pendingApproval || pendingApproval.command !== approval.command) return;
      if (executedApprovalIds.current.has(approval.approvalId)) {
        dispatch({ type: 'local/consume-approval' });
        return;
      }
      if (executedApprovalIds.current.size >= MAX_EXECUTED_APPROVAL_IDS) {
        // Security tradeoff, chosen deliberately: this dedup set never
        // evicts. Evicting the oldest entry to make room (the old behavior)
        // would let a replay of that forgotten ID execute again once it
        // scrolled out of the set — a false negative that lets a privileged
        // command run twice. Once the set is full, every *unseen* approval
        // is denied instead. The mounted hook remains fail-closed until
        // unmounted (lifecycle reset), not recoverable by re-approving — a
        // false positive/availability cost but a genuine replay of any ID
        // ever seen by this hook can never execute a second time. Consuming
        // (rather than silently no-op'ing) releases the UI lock, and unlike
        // the plain-replay branch above, sets a visible error notice so the
        // denial is surfaced rather than silently dropped.
        dispatch({ type: 'local/approval-saturated' });
        return;
      }
      executedApprovalIds.current.add(approval.approvalId);
      const socket = getSocket();
      socket.emit('command:execute', {
        conversationId,
        // Sourced entirely from the frozen local pendingApproval, not the
        // server-echoed approval payload — the equality guard above is
        // defense-in-depth, not the source of truth for what gets executed.
        command: pendingApproval.command,
        args: pendingApproval.args,
        approvalId: approval.approvalId,
      });
      dispatch({ type: 'local/consume-approval' });
    },
  };

  return { state, actions };
}
