import { resolveInstallId } from "./install-identity.ts";
import { installationStatus, isInstallationSuperseded, recalledSuperseded, rememberSuperseded, waitForReclaim } from "./install-conflict.ts";
import { notifySupersededOwner } from "./owner-sibling-notify.ts";
import { randomUUID } from "node:crypto";
import { applyDmRecallDefault } from "./dm-recall-default.ts";
import { HOST_FLOOR, HOST_TOOL_SEARCH_DEFAULT_ON, hostAtLeast, hostToolSearchEnabled } from "./host-version.ts";
import {
  AckTimeoutError,
  AuthError,
  ProtocolError,
  StateError,
  TransportError,
  EVENT,
  recallTargetMessageId,
  type Envelope,
  type Transport,
} from "./protocol-types.ts";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.ts";
import {
  acquireRefreshLock, childOf, claimClientLease, tipOf, ownerChangedSince, recordRotation, snapshotClientOwner, isAncestor, type ClientLease, type RefreshLock,
} from "./client-ownership.ts";
import { createOpenclawClawlingApiClient } from "./api-client.ts";
import { buildActivationBootstrapText } from "./activation-greeting.ts";
import { buildFriendGreetingEnvelope } from "./friend-greeting.ts";
import { resolveOwnerLanguageIfKnown, type OwnerLanguage } from "./owner-language.ts";
import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
import { readOnboardingReport } from "./onboarding-report.ts";
import path from "node:path";
import {
  ensureLivewareCli,
  livewareCliHomeDir,
  livewareSampleRootDir,
  resolveLivewarePath,
} from "./liveware-cli.ts";
import {
  LivewareSampleSupervisor,
  type LivewareSampleStoreLike,
  type LivewareSampleSupervisorDeps,
} from "./liveware-sample.ts";
import { ClawlingApiError } from "./api-types.ts";
import { RefreshManager, fingerprintRefreshToken, type RevokedRefreshMarker } from "./refresh-manager.ts";
import { RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
import {
  runOpenclawClawlingLogin,
  type LoginParams,
  type OpenclawClawchatMutateConfigFile,
} from "./login.runtime.ts";
import {
  CHANNEL_ID,
  effectiveOutputVisibility,
  effectiveGroupCommandMode,
  effectiveGroupMode,
  hasOpenclawClawlingConnectCredentials,
  normalizeOpenclawClawlingAccountId,
  readOpenclawClawlingAccountSection,
  resolveOpenclawClawlingAccount,
  type ResolvedOpenclawClawlingAccount,
  CLAWCHAT_REFRESH_TOKEN_ENV,
} from "./config.ts";
import { patchOpenclawClawlingAccountSection } from "./account-config-writes.ts";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
import { CLOSE_CODE_SUPERSEDED_BY_OTHER_INSTALL, isValidChatId, type ClawlingChatClient } from "./ws-client.ts";
import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
import {
  PendingConsentStore,
  cleanupTombstonedManagedSkills,
  handleOwnerConsentReply,
  managedSkillExists,
  readLocalSkillState,
  resolveBundledSkillsDir,
  resolveManagedSkillsDir,
  runSkillUpdateCheck,
} from "./skill-update.ts";
import { CLAWCHAT_MEDIA_MAX_BYTES, fetchInboundMedia } from "./media-runtime.ts";
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
import { runWithTerminalClawChatSendScope } from "./terminal-send.ts";
import {
  flushAlignedOutboundQueue,
  getAlignedOutboundQueueSize,
  sendOpenclawClawlingText,
  setAlignedOutboundLogContext,
} from "./outbound.ts";
import { formatWsLog } from "./ws-log.ts";
import { createNotifySignalObserver, createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
import {
  clawChatDbPathForStateDir,
  getClawChatStore,
  makeOwnerProfilePersister,
  type ClawChatStore,
  type SupersededMarker,
  type SupersededLookup,
  type MessageRow,
} from "./storage.ts";
import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
import { classifyChat, isOwnerSpeakerBatch, parseClawChatSessionKey, clawChatSessionAccountId, nonOwnerToolAllowlist } from "./turn-boundary.ts";
import { pushTurnGrants, waitForSessionTurnsReleased, type TurnGrantDetails } from "./turn-grants.ts";
import { openTurnArtifacts } from "./turn-artifacts.ts";
import { buildRequestDenyReceipt, onRequestDecided, buildRequestContinuation, buildRequestNotice, type ToolRequest, type RequestWakeError } from "./tool-requests.ts";
import { capabilityAvailability, selectorDisplay, type CapabilityId } from "./tool-capabilities.ts";
import {
  resolveOriginGrantContext, resolveTurnGrants, emptyTurnGrants, grantDispatchPolicy, buildNonOwnerGrantsHint, buildGrantReceipt,
  GRANT_INVALID_CODE_MESSAGE, GRANT_ASK_UNAVAILABLE_RECEIPT, GRANT_WRITE_FAILED_MESSAGE, type TurnGrants, type GrantRow,
} from "./tool-grants.ts";
import { resolveOwnerIdentity } from "./owner-identity.ts";
import {
  canAskEachTime,
  createApprovalsWriteGuard,
  listActiveAccountIds,
  reconcileApprovalsPlugin,
  resolveApprovalRoute,
  storedIdentityLookup,
} from "./approval-route.ts";
import {
  loadClawChatPromptMetadata,
  type ClawChatGroupParticipantPrompt,
  type ClawChatPromptMetadata,
  renderClawChatProfilePrompt,
  resolveSenderRelation,
} from "./profile-prompt.ts";
import { buildNoteMemorySection } from "./note-injection.ts";
import { buildGroupCatchUp, CATCH_UP_FETCH_ROWS, DeliveredGroupMessages } from "./group-catch-up.ts";
import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.ts";
import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.ts";
import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.ts";
import { registerMentionRosterResolver } from "./mention-autolink.ts";
import {
  clearClawChatPromptInjectionForSession,
  stageClawChatPromptInjection,
} from "./prompt-injection.ts";
import { createGroupMessageCoalescer, relabelCoalescedGroupBody } from "./group-message-coalescer.ts";
import { openclawLlmContextDebug } from "./llm-context-debug.ts";
import { GroupSettingsCache } from "./group-settings.ts";
import { PermissionCache } from "./permissions.ts";
import { buildAwarenessNoteEnvelope, buildMomentCommentNoteEnvelope } from "./awareness-note-turn.ts";
import { handlePermissionResult } from "./permission-result.ts";

type Log = { debug?: (m: string) => void; info?: (m: string) => void;
  warn?: (m: string) => void; error?: (m: string) => void };
type ChannelContextBuilder = (params: Record<string, unknown>) => unknown;
type CompatibleChannelRuntime = PluginRuntime["channel"] & {
  inbound?: { buildContext?: ChannelContextBuilder };
  turn?: { buildContext?: ChannelContextBuilder };
};
type OpenClawReplyContext = Parameters<
  PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"]
>[0]["ctx"];
type MutableOpenClawReplyContext = OpenClawReplyContext & Record<string, unknown>;
type RuntimeConnectionStore = Pick<
  ClawChatStore,
  "startConnection" | "markConnectSent" | "markConnectionReady" | "finishConnection"
> &
  Partial<
    Pick<
      ClawChatStore,
      | "getOrCreateInstallUuid"
      | "lookupSuperseded"
      | "setSuperseded"
      | "clearSuperseded"
      | "claimSupersededNotification"
      | "getActivationCredentials"
      | "upsertActivation"
      | "rotateActivationTokens"
      | "clearActivationCredentials"
      | "getRevokedRefreshMarker"
      | "setRevokedRefreshMarker"
      | "clearRevokedRefreshMarker"
      | "insertMessage"
      | "claimMessageOnce"
      | "markMessageAcknowledged"
      | "updateMessageByIdentity"
      | "claimPendingActivationBootstrap"
      | "releaseActivationBootstrapClaim"
      | "markActivationBootstrapSent"
      | "getActivationConversation"
      | "lookupActivationIdentity"
      | "getLastResolvedDeviceId"
      | "listRecentGroupMessages"
      | "recallMessage"
      | "upsertOwnerProfile"
      | "getOwnerProfile"
      | "listToolGrants"
      | "confirmPendingToolGrant"
      | "confirmToolRequest"
      | "claimToolRequestWake"
      | "finishToolRequestWake"
      | "recoverToolRequestWakes"
      | "takeToolRequestNotices"
      | "hasActiveAskGrant"
      | "hasPendingAskGrant"
    >
  >;

const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } =
  createPluginRuntimeStore<PluginRuntime>("clawchat-plugin-openclaw runtime not initialized");

export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };

const activeClients = new Map<string, ClawlingChatClient>();
// Live liveware-sample supervisors, keyed by the gateway's abortSignal and then by
// `platform:accountId`. startOpenclawClawlingGateway re-enters ITSELF (token-refresh
// reconnect, transport backoff, post-auth-failed re-activation) carrying the same
// `...params` — so the same abortSignal — and a per-call `new LivewareSampleSupervisor`
// left the previous one running: both then raced the bootstrap "account has zero apps"
// gate and each minted its own liveware app, leaving a duplicate app tile that no
// SQLite row tracks (the single-row PK means the loser is orphaned forever). Keyed by
// signal so an entry cannot outlive the channel it belongs to.
const livewareSampleSupervisors = new WeakMap<
  AbortSignal,
  Map<string, LivewareSampleSupervisor>
>();
// Abort listener per supervisor, so an installation-conflict stop can detach it.
const livewareSampleAbortListeners = new WeakMap<LivewareSampleSupervisor, () => void>();
const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output", "clawchat-reclaim", "clawchat-status"]);
const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
// Activation-bootstrap retry on the live connection. The bootstrap greeting is
// otherwise only (re)dispatched on a fresh `connected` edge, so if its send is
// cancelled mid-turn (e.g. a proactive token-refresh reconnect closed the
// transport) or a reconnect's claim no-op'd while a stale in-flight dispatch
// still held the claim, the greeting would stay pending forever on a now-stable
// connection. These bound an in-connection retry that re-attempts until the
// greeting lands or the per-connection budget is spent (then it falls back to
// the next reconnect, as before).
const ACTIVATION_BOOTSTRAP_RETRY_DELAY_MS = 5_000;
const ACTIVATION_BOOTSTRAP_MAX_RETRIES = 6;
// Count of in-flight activation-bootstrap dispatches per account, shared across
// gateway re-enters so a fresh connection can tell "nothing pending" (don't
// retry) apart from "a prior dispatch still holds the claim" (retry once it
// releases).
const activationBootstrapInFlight = new Map<string, number>();
function incrementActivationBootstrapInFlight(accountId: string): void {
  activationBootstrapInFlight.set(accountId, (activationBootstrapInFlight.get(accountId) ?? 0) + 1);
}
function decrementActivationBootstrapInFlight(accountId: string): void {
  const next = (activationBootstrapInFlight.get(accountId) ?? 0) - 1;
  if (next <= 0) activationBootstrapInFlight.delete(accountId);
  else activationBootstrapInFlight.set(accountId, next);
}
function activationBootstrapInFlightCount(accountId: string): number {
  return activationBootstrapInFlight.get(accountId) ?? 0;
}
// Upper bound (ms) on how long group ingest waits for the FIRST group-settings
// pull to settle before proceeding anyway. Best-effort governance must never
// permanently disable group traffic, so a stalled initial GET (endpoint accepts
// but never responds) can hold the gate at most this long. Matches the sibling
// hermes plugin's GROUP_SETTINGS_READY_TIMEOUT_SECONDS (5s).
const GROUP_SETTINGS_READY_TIMEOUT_MS = 5_000;
/** Number of recent group messages to prepend as context when the agent is @-mentioned. */
const CLAWCHAT_MEMORY_ROOT_UNAVAILABLE =
  "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved";
const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
  "approve",
  "deny",
  "always",
  "cancel",
  "yes",
  "no",
  "ok",
  "confirm",
  "remember",
  "nevermind",
]);
const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
/** Group turns under `non-owner-host-tools: approve` (route live). */
const GROUP_APPROVAL_GUIDANCE =
  "Host tools here (commands, files, browser, sub-agents, scheduling and the like) run only if your owner approves each call: " +
  "the request goes to your owner's direct chat and the call waits up to 5 minutes. " +
  "Do not ask anyone in this group to approve it. If it is denied or times out, do not retry; say your owner did not approve it.";
export const OWNER_GROUP_MEMORY_GUIDANCE =
  "Where a remembered fact goes: a fact about your owner goes into `owner.md` (`targetType=owner`, `targetId=owner`), not into this group's note; `owner.md` holds what your owner told you themselves. A fact about the group itself goes into the group's note.";
// §C.1 — user-visible message emitted on permanent token expiry. Kept
// byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
// at the reconnect prompt (a code bound to this identity) rather than at a
// slash command: a fresh create code spent here would mint a second agent.
export const CLAWCHAT_TOKEN_EXPIRED_MESSAGE =
  "ClawChat token expired and could not be refreshed. Ask your owner to send you the reconnect prompt from the ClawChat app, then follow " +
  RECONNECT_GUIDE_URL +
  ".";
const CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR = "token expired — re-pair required";

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
}

/**
 * §A.2 — classify a WS `hello-fail` reason for refresh gating.
 *  - "token-rejected": the ONE terminal reason → refresh.
 *  - "auth-unavailable": 5xx auth-backend outage → backoff, DO NOT refresh.
 *  - "generic": anything else → transient; backoff with the current token.
 *
 * msghub Protocol v2 §3.5 pins the matching contract: `"authentication failed"`
 * is matched by exact equality (case / surrounding whitespace tolerant, like the
 * hermes plugin's `_is_token_rejected`), never by substring or prefix, and no
 * speculative matchers (`invalid token`, `token expired`, `unauthorized`, …) may
 * be added — msghub does not emit them, and matching them would turn a reason
 * added later into a forced refresh and, on a rotated refresh token, a re-pair.
 * The ws-client already routes every non-terminal reason as a TransportError
 * (backoff), so only the exact terminal string normally reaches this classifier;
 * it stays defensive for the live-session path.
 */
export function classifyHelloFailReason(
  reason: string,
): "token-rejected" | "auth-unavailable" | "generic" {
  const r = (reason || "").trim().toLowerCase();
  if (/auth service unavailable|temporarily unavailable/.test(r)) return "auth-unavailable";
  if (r === "authentication failed") return "token-rejected";
  return "generic";
}

/**
 * The refresh token paired with the account's access token. The account
 * section's `refreshToken` wins. `CLAWCHAT_REFRESH_TOKEN` is process-wide, so
 * it is honoured ONLY for the default account and ONLY when that section
 * carries no `token` of its own (i.e. the access token itself came from
 * `CLAWCHAT_TOKEN`) — an env-provisioned pair can rotate, while a login-written
 * token is never paired with a stray refresh token left in the environment.
 * A revoked marker also excludes a matching refresh fingerprint from either source.
 */
function readConfigRefreshToken(
  cfg: OpenClawConfig,
  accountId: string,
  revokedMarker?: RevokedRefreshMarker | null,
  env: NodeJS.ProcessEnv = process.env,
): string | null {
  const section = readOpenclawClawlingAccountSection(cfg, accountId);
  const refreshToken = section.refreshToken;
  const usable = (token: unknown): string | null => {
    const value = typeof token === "string" ? token.trim() : "";
    return value && (!revokedMarker || fingerprintRefreshToken(value) !== revokedMarker.fingerprint)
      ? value : null;
  };
  if (typeof refreshToken === "string" && refreshToken.trim()) return usable(refreshToken);
  if (normalizeOpenclawClawlingAccountId(accountId) !== DEFAULT_ACCOUNT_ID) return null;
  const configToken = section.token;
  if (typeof configToken === "string" && configToken.trim()) return null;
  const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
  return usable(envRefreshToken);
}

function withFullVerboseDispatchConfig(cfg: OpenClawConfig, agentId: string): OpenClawConfig {
  const cfgRecord = cfg as Record<string, unknown>;
  const agents = isRecord(cfgRecord.agents) ? cfgRecord.agents : {};
  const defaults = isRecord(agents.defaults) ? agents.defaults : {};
  const nextAgents: Record<string, unknown> = {
    ...agents,
    defaults: {
      ...defaults,
      verboseDefault: "full",
    },
  };
  const agentCfg = agents[agentId];
  if (agentId && agentId !== "defaults" && isRecord(agentCfg)) {
    nextAgents[agentId] = {
      ...agentCfg,
      verboseDefault: "full",
    };
  }
  return {
    ...cfgRecord,
    agents: nextAgents,
  } as OpenClawConfig;
}

function resolveDispatchConfigForOutputVisibility(params: {
  cfg: OpenClawConfig;
  account: ResolvedOpenclawClawlingAccount;
  chatId: string;
  chatType: "direct" | "group";
  agentId: string;
}): OpenClawConfig {
  const visibility = effectiveOutputVisibility(
    params.account,
    params.chatId,
    params.chatType,
  );
  return visibility === "full"
    ? withFullVerboseDispatchConfig(params.cfg, params.agentId)
    : params.cfg;
}

function resolveChannelContextBuilder(rt: PluginRuntime["channel"]): ChannelContextBuilder {
  const channel = rt as CompatibleChannelRuntime;
  const buildContext = channel.inbound?.buildContext ?? channel.turn?.buildContext;
  if (!buildContext) {
    throw new Error("OpenClaw channel runtime missing inbound/turn buildContext");
  }
  return buildContext;
}

function parseSlashCommandName(rawBody: string): string | null {
  const stripped = rawBody.trimStart();
  if (!stripped.startsWith("/")) return null;
  const token = stripped.split(/\s+/, 1)[0] ?? "";
  const name = token.slice(1).replace(/_/g, "-").toLowerCase();
  if (!name || name.includes("/")) return null;
  return name;
}

function isKnownOpenClawGroupSlashCommand(rawBody: string, cfg: OpenClawConfig): boolean {
  const name = parseSlashCommandName(rawBody);
  if (!name) return false;
  return hasControlCommand(rawBody, cfg)
    || CLAWCHAT_PLUGIN_SLASH_COMMANDS.has(name)
    || OPENCLAW_CONFIRM_SLASH_COMMANDS.has(name);
}

function isOpenClawConfirmSlashCommand(rawBody: string): boolean {
  const name = parseSlashCommandName(rawBody);
  return Boolean(name && OPENCLAW_CONFIRM_SLASH_COMMANDS.has(name));
}

// Group commands that start an agent turn (OpenClaw 2026.9.8 command
// handlers): their output is the conversation itself, so it stays in the group
// with no ✅. `/new <text>` and `/reset <text>` run the text as a prompt.
const AGENT_TURN_GROUP_COMMANDS = new Set(["btw", "dashboard", "goal", "learn", "loop", "steer", "tell"]);

function opensAgentTurn(commandText: string): boolean {
  const name = parseSlashCommandName(commandText);
  if (!name) return false;
  if (AGENT_TURN_GROUP_COMMANDS.has(name)) return true;
  const hasArgs = /\s\S/.test(commandText.trim());
  return (name === "new" || name === "reset") && hasArgs;
}

/** First line of the owner's direct-chat receipt for a group command. */
export function ownerCommandReceiptHeader(groupTitle: string, commandText: string): string {
  return `From group "${groupTitle}": ${commandText.trim()}`;
}

function isOpenClawTextSlashCommand(rawBody: string, cfg: OpenClawConfig): boolean {
  return Boolean(parseSlashCommandName(rawBody) && (
    hasControlCommand(rawBody, cfg)
    || isKnownOpenClawGroupSlashCommand(rawBody, cfg)
  ));
}

function groupOwnerAttentionText(groupId: string, fallbackText: string): string {
  const body = fallbackText.trim();
  return `ClawChat group ${groupId} ${GROUP_OWNER_ATTENTION_TITLE}.${body ? `\n\n${body}` : ""}`;
}

export function getOpenclawClawlingClient(accountId: string): ClawlingChatClient | undefined {
  return activeClients.get(accountId);
}

export function resolveClawChatMemoryRoot(
  runtime: PluginRuntime,
  cfg: OpenClawConfig,
  agentId: string,
): string {
  const resolver = runtime.agent?.resolveAgentWorkspaceDir;
  if (typeof resolver !== "function") {
    throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
  }

  let workspaceDir: string;
  try {
    workspaceDir = resolver(cfg, agentId);
  } catch {
    throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
  }

  const memoryRoot = typeof workspaceDir === "string" ? workspaceDir.trim() : "";
  if (!memoryRoot) {
    throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
  }
  return memoryRoot;
}

export async function waitForOpenclawClawlingClient(
  accountId: string,
  options: { timeoutMs?: number; pollMs?: number } = {},
): Promise<ClawlingChatClient> {
  const timeoutMs = options.timeoutMs ?? 15_000;
  const pollMs = options.pollMs ?? 100;
  const deadline = Date.now() + timeoutMs;

  for (;;) {
    const client = activeClients.get(accountId);
    if (client && (client as { state?: string }).state === "connected") {
      return client;
    }
    if (Date.now() >= deadline) {
      throw new Error(
        `clawchat-plugin-openclaw client did not activate within ${timeoutMs}ms for account ${accountId}`,
      );
    }
    await new Promise((resolve) => setTimeout(resolve, pollMs));
  }
}

export type ClawlingState =
  | "idle"
  | "connecting"
  | "challenging"
  | "authenticating"
  | "connected"
  | "reconnecting"
  | "disconnected";

export function mapClawlingStateToStatus(state: ClawlingState): {
  connected: boolean;
  running: boolean;
  lastStartAt?: number;
  lastStopAt?: number;
} {
  const now = Date.now();
  switch (state) {
    case "connected":
      return { connected: true, running: true, lastStartAt: now };
    case "reconnecting":
      return { connected: false, running: true };
    case "disconnected":
      return { connected: false, running: false, lastStopAt: now };
    default:
      return { connected: false, running: true };
  }
}

export function classifyClawlingClientError(err: unknown): {
  kind: "auth" | "transport" | "protocol" | "ack-timeout" | "state" | "unknown";
  retry: boolean;
  message: string;
} {
  if (err instanceof AuthError) return { kind: "auth", retry: false, message: err.message };
  if (err instanceof TransportError)
    return { kind: "transport", retry: true, message: err.message };
  if (err instanceof AckTimeoutError)
    return { kind: "ack-timeout", retry: false, message: err.message };
  if (err instanceof ProtocolError) return { kind: "protocol", retry: false, message: err.message };
  if (err instanceof StateError) return { kind: "state", retry: false, message: err.message };
  return {
    kind: "unknown",
    retry: false,
    message: err instanceof Error ? err.message : String(err),
  };
}

function formatConversationSubject(peer: { kind: "direct" | "group"; id: string }): string {
  return peer.kind === "group" ? `group:${peer.id}` : peer.id;
}

function asRecord(value: unknown): Record<string, unknown> | null {
  return value && typeof value === "object" ? value as Record<string, unknown> : null;
}

function optionalString(value: unknown): string | undefined {
  return typeof value === "string" ? value : undefined;
}

function hasOwn(record: Record<string, unknown>, key: string): boolean {
  return Object.prototype.hasOwnProperty.call(record, key);
}

function optionalNullableString(record: Record<string, unknown>, key: string): string | null | undefined {
  if (!hasOwn(record, key)) return undefined;
  const value = record[key];
  if (value === null) return null;
  return optionalString(value);
}

function isConversationNotFoundError(err: unknown): boolean {
  if (!(err instanceof ClawlingApiError)) return false;
  return err.meta?.status === 404 || err.meta?.status === 410 ||
    err.meta?.code === 404 || err.meta?.code === 410 || err.meta?.code === 40401 ||
    err.message.toLowerCase().includes("conversation not found");
}

function metadataVersionFromEnvelope(env: Envelope): number | undefined {
  const payload = asRecord(env.payload);
  const version = payload?.version;
  return typeof version === "number" && Number.isFinite(version) ? version : undefined;
}

function metadataScopesFromEnvelope(env: Envelope): string[] {
  const scope = asRecord(env.payload)?.scope;
  return Array.isArray(scope) ? scope.filter((item): item is string => typeof item === "string") : [];
}

function shouldRefreshBehaviorForScopes(scopes: string[]): boolean {
  // §9.3: empty/absent scope ⇒ "refetch everything"; unknown scope strings must
  // also trigger a refresh. Only the known non-behavior scopes (title,
  // description) leave agent behavior untouched.
  if (scopes.length === 0) return true;
  return scopes.some((scope) => scope !== "title" && scope !== "description");
}

function shouldRefreshConversationForScopes(scopes: string[]): boolean {
  if (scopes.length === 0) return true;
  return scopes.some((scope) => scope === "title" || scope === "description" || scope !== "behavior");
}

function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
  return {
    ...cfg,
    session: {
      ...(cfg.session ?? {}),
      dmScope: "per-account-channel-peer",
    },
  };
}

/**
 * Synthetic envelope builder for the post-activation greeting turn.
 *
 * The fourth synthetic INBOUND envelope carrying a chat_id, alongside
 * `buildPermissionResultEnvelope`, `buildAwarenessNoteEnvelope` and the
 * reply-dispatcher's owner-direct path — and it carries the same invariant.
 *
 * `conversationId` MUST be a conversation idcode (`cnv_…`, `isValidChatId`),
 * the one recorded at activation from `agents/connect`'s `conversation.id`.
 * The agent's in-turn reply INHERITS this envelope's chat_id
 * (src/inbound.ts → runtime.dispatch → src/reply-dispatcher.ts), and msghub
 * resolves a chat_id only through member-backend — anything else comes back
 * "invalid conversation id", so the greeting is refused at the outbound
 * boundary after a full LLM turn has already been spent. Callers must check at
 * the source and skip rather than hand an unroutable id down here.
 */
function buildActivationBootstrapEnvelope(params: {
  account: ResolvedOpenclawClawlingAccount;
  conversationId: string;
  /** `null` when the owner's locale is not known — then no language line is emitted. */
  language: OwnerLanguage | null;
}): Envelope {
  const text = buildActivationBootstrapText(undefined, params.language);
  const now = Date.now();
  return {
    version: "2",
    event: EVENT.MESSAGE_SEND,
    trace_id: `clawchat-plugin-openclaw-bootstrap-${now}`,
    emitted_at: now,
    chat_id: params.conversationId,
    chat_type: "direct",
    to: { id: params.account.userId, type: "direct" },
    sender: {
      id: "clawchat-bootstrap",
      type: "direct",
      nick_name: "ClawChat Activation",
    },
    payload: {
      message_id: `clawchat-plugin-openclaw-bootstrap-${params.conversationId}-${now}`,
      message_mode: "normal",
      message: {
        body: { fragments: [{ kind: "text", text }] },
        context: { mentions: [], reply: null },
        streaming: {
          status: "static",
          sequence: 0,
          mutation_policy: "sealed",
          started_at: null,
          completed_at: null,
        },
      },
    },
  };
}

export interface StartGatewayParams {
  cfg: OpenClawConfig;
  account: ResolvedOpenclawClawlingAccount;
  abortSignal: AbortSignal;
  setStatus: (next: ChannelAccountSnapshot) => void;
  getStatus: () => ChannelAccountSnapshot;
  log?: Log;
  /** Internal — set on recursive re-entry so the approve-route check at start runs once per lifecycle. */
  boundaryWarningIssued?: boolean;
  /** Test observer called after a durable decision and the continuation attempt. */
  onRequestDecided?: (requestId: string) => void | Promise<void>;
  /** Test hook only. */
  store?: RuntimeConnectionStore | null;
  /** Test hook only. */
  transport?: Transport;
  /** Test hook only. */
  activationPollIntervalMs?: number;
  /** Test hook only. */
  rejectedActivationToken?: string;
  /** Test hook only — fetch impl used by the refresh manager (`/v1/auth/refresh`). */
  refreshFetchImpl?: typeof fetch;
  /** Test hook only — setTimeout override for the refresh manager's proactive timer. */
  refreshSetTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout> | number;
  /** Test hook only — clearTimeout override for the refresh manager's proactive timer. */
  refreshClearTimer?: (handle: ReturnType<typeof setTimeout> | number) => void;
  /** Test hook only — jitter override (ms) for the proactive timer. */
  refreshJitter?: () => number;
  /** Test hook only — config-file mutator used to persist rotated/blanked creds. */
  mutateConfigFile?: OpenclawClawchatMutateConfigFile;
  /** Internal — set when the current attempt is a refresh-driven reconnect. */
  refreshReconnectDepth?: number;
  /**
   * Internal — epoch-ms at which the current refresh-driven reconnect streak
   * began. Used with `refreshReconnectDepth` to bound a rotate-then-reject loop
   * (§A.3/§A.4): once depth exceeds the cap inside the window we stop re-entering
   * via refresh and fall back to plain transport backoff with the current token.
   */
  refreshReconnectWindowStartedAt?: number;
  /**
   * Internal — the refresh manager's single-flight latch + min-interval state,
   * carried across gateway re-enters so the §A.3 guards (rejected-token latch,
   * min-interval floor) actually constrain a cross-reconnect refresh loop instead
   * of resetting on every fresh `RefreshManager`.
   */
  refreshManagerState?: Parameters<RefreshManager["restoreState"]>[0];
  /** Internal guard when durable marker writes fail; never contains credentials. */
  revokedRefreshMarker?: RevokedRefreshMarker;
  /**
   * Internal — the live (possibly rotated) refresh token, carried across a
   * §D/§B gateway re-enter. A rotation persists config with `afterWrite:{mode:
   * "none"}`, so the in-memory `cfg` handed to the re-enter is NOT reloaded and
   * still holds the pre-rotation refresh token. For a "configured" (config-
   * sourced) agent the re-enter would otherwise re-read that stale token and
   * submit an already-consumed refresh token on the next refresh → a spurious
   * `10003` auto-logout. Threading the live token forward closes that gap.
   * An override can advance authoritative SQLite/config credentials only
   * along their recorded lineage; unrelated activation credentials win.
   */
  refreshTokenOverride?: string;
  /** Test hook only — clock override for the refresh manager (min-interval / proactive timer). */
  refreshNow?: () => number;
  /**
   * Internal — set when the current attempt is a plain transport-backoff
   * re-enter (transient/skipped reactive refresh). Carries the current (unchanged)
   * token so creds stay untouched; the auth-failure teardown is suppressed.
   */
  transportBackoffReconnect?: boolean;
  /** Test hook only — setTimeout override for the transport-backoff delay. */
  backoffTimer?: (cb: () => void, ms: number) => void;
  /** Test hook only — fixed transport-backoff delay (ms) for re-enter. */
  transportBackoffDelayMs?: number;
  /** Test hook only — retry delay (ms) for re-dispatching a pending activation bootstrap. */
  activationBootstrapRetryDelayMs?: number;
}

/**
 * §A.3/§A.4 — max consecutive refresh-driven reconnects within
 * `REFRESH_RECONNECT_WINDOW_MS` before we abandon the refresh loop and fall back
 * to plain transport backoff (or auto-logout on a permanent reject). Bounds a
 * server that keeps rotating-then-rejecting fresh tokens.
 */
export const MAX_REFRESH_RECONNECTS = 3;
export const REFRESH_RECONNECT_WINDOW_MS = 5 * 60_000;
/** §B — default transport-backoff delay before a reactive re-enter (capped 30s). */
export const TRANSPORT_BACKOFF_BASE_MS = 1_000;
export const TRANSPORT_BACKOFF_MAX_MS = 30_000;

function resolveConnectionStore(
  params: StartGatewayParams,
  runtime: PluginRuntime,
): RuntimeConnectionStore | null {
  if (params.store !== undefined) return params.store;
  if (params.transport) return null;
  try {
    const stateDir = runtime.state?.resolveStateDir?.();
    return getClawChatStore({
      ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
      log: { error: (message) => params.log?.error?.(message) },
    });
  } catch {
    params.log?.error?.("clawchat-plugin-openclaw sqlite connection persistence unavailable; continuing.");
    return null;
  }
}

function accountHasConnectCredentials(account: ResolvedOpenclawClawlingAccount): boolean {
  return hasOpenclawClawlingConnectCredentials(account);
}

/**
 * §B self-heal — env var carrying a one-shot connect code for non-interactive
 * (re)activation. Containers / orchestrators set this so a plugin that lost its
 * token (e.g. a consumed-rotation auto-logout, or a wiped/rotated env) can
 * recover WITHOUT the openclaw CLI channel registry (which cannot reach this
 * plugin pre-config) and without an in-chat `/clawchat-activate` (which needs the
 * agent already online — a chicken/egg). Declared in `openclaw.plugin.json`
 * `channelEnvVars` so the host forwards it.
 */
export const CONNECT_CODE_ENV = "CLAWCHAT_CONNECT_CODE";

/**
 * §B self-heal — process-scoped record of connect codes we have already tried to
 * redeem, so a repeated `startAccount` (reconnect / re-enter) does not hammer the
 * server with the same (often single-use) code. The real guard against
 * re-redeeming a SUCCESSFUL code is `accountHasConnectCredentials`; this set only
 * bounds retries of a code that has not yet produced credentials this process.
 */
const consumedAutoActivationCodes = new Set<string>();

/** Test seam — clear the process-scoped consumed-code set between cases. */
export function resetAutoActivationStateForTests(): void {
  consumedAutoActivationCodes.clear();
}

export interface AutoActivateFromEnvParams {
  cfg: OpenClawConfig;
  account: ResolvedOpenclawClawlingAccount;
  accountId: string | null;
  /** The connect code (production: `process.env[CONNECT_CODE_ENV]`). */
  connectCode: string | undefined;
  /**
   * Plugin SQLite store. A complete activation row is the durable credential
   * source of truth (`waitForActivationCredentials` resolves SQLite-first), so
   * when one exists the env code is skipped — NOT consumed — rather than
   * re-redeemed on every restart of a container that still carries the code.
   */
  store?: Pick<RuntimeConnectionStore, "getActivationCredentials"> | null;
  log?: Log;
  /** Test seam — login implementation. */
  runLogin?: (params: LoginParams) => Promise<void>;
  /** Test seam — account resolver applied to the (mutated) cfg after login. */
  resolveAccount?: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedOpenclawClawlingAccount;
}

/**
 * §B self-heal — if a connect code is present in the environment and the account
 * is NOT already credentialed, redeem it via the shared invite-code exchange
 * (`runOpenclawClawlingLogin`, which also persists the activation row to SQLite),
 * then return the re-resolved account. Credentials are written into the in-memory
 * `cfg` (no host restart); durability comes from the SQLite activation row. All
 * failures are swallowed — a bad/expired code must not crash startup; the gateway
 * falls through to the normal wait-for-activation loop.
 */
export async function maybeAutoActivateFromEnv(
  params: AutoActivateFromEnvParams,
): Promise<ResolvedOpenclawClawlingAccount> {
  const code = (params.connectCode ?? "").trim();
  if (!code) return params.account;
  // The env var is process-wide: it can only ever describe ONE identity, and
  // that is the default account. A named account never redeems it.
  if (normalizeOpenclawClawlingAccountId(params.account.accountId) !== DEFAULT_ACCOUNT_ID) {
    params.log?.info?.(
      `[${params.account.accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but ignored: env activation is for the default account only`,
    );
    return params.account;
  }
  if (accountHasConnectCredentials(params.account)) return params.account;
  const accountId = params.account.accountId;
  const stored = params.store?.getActivationCredentials?.({ platform: "openclaw", accountId });
  if (stored) {
    params.log?.info?.(
      `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but a sqlite activation row already holds credentials; not redeeming`,
    );
    return params.account;
  }
  if (consumedAutoActivationCodes.has(code)) return params.account;
  consumedAutoActivationCodes.add(code);

  const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
  const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
  params.log?.info?.(
    `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`,
  );
  try {
    await runLogin({
      cfg: params.cfg,
      accountId: params.accountId,
      runtime: { log: (m: string) => params.log?.info?.(m) },
      readInviteCode: async () => code,
      // Update the IN-MEMORY config so this process re-resolves to a configured
      // account immediately (no host restart). Durable persistence is the SQLite
      // activations row that runOpenclawClawlingLogin writes alongside this.
      persistConfig: (next) => {
        Object.assign(params.cfg, next);
      },
    });
  } catch (err) {
    params.log?.error?.(
      `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation failed: ${err instanceof Error ? err.message : String(err)}`,
    );
    return params.account;
  }
  const next = resolveAccount(params.cfg, accountId);
  params.log?.info?.(
    `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation succeeded hasToken=${Boolean(next.token)}`,
  );
  return next;
}

// Re-armable "settings pull pending" gate — the openclaw analogue of the hermes
// adapter's `asyncio.Event` (`_group_settings_ready`). It holds a single "ready"
// promise that group dispatch awaits (bounded by GROUP_SETTINGS_READY_TIMEOUT_MS
// at the call site). `arm()` re-closes the gate when a NEW settings pull starts
// (startup, every reconnect after hello-ok, and on `agent.config.changed`) so a
// later pull's in-flight cache write is never raced by a held group turn reading
// the stale cache.
//
// Open condition (Finding 2 fix). The gate must NOT open just because the LATEST
// pull settled: a newer pull can FAIL or return a non-authoritative result and
// settle FIRST, while an earlier pull is still in flight and about to apply an
// authoritative (e.g. just-muted) snapshot. Opening on the newer pull's settle
// would let a held group turn proceed against the stale cache. So the gate opens
// only when EITHER:
//   (a) any in-flight pull APPLIED an authoritative snapshot (the cache is now
//       fresh for this arm epoch — no need to wait for slower pulls), OR
//   (b) there are no longer any in-flight pulls (every pull settled
//       non-authoritatively / failed — nothing better is coming).
// The bounded timeout/abort at the call site remains the ultimate backstop. The
// gate is created already-OPEN so direct traffic and pre-pull harness paths are
// never blocked; group dispatch only ever waits while a pull is actually pending.
// Opaque token handed back by `arm()` and round-tripped to `release()`. Carries
// both the pull's generation (latest-pull tracking) and its epoch (the re-close
// counter used to retire stale older-epoch pulls). Callers treat it as opaque.
interface SettingsReadyToken {
  generation: number;
  epoch: number;
}

interface SettingsReadyGate {
  /** Re-close the gate for a starting pull; returns that pull's opaque token. */
  arm(): SettingsReadyToken;
  /**
   * Mark a pull as settled. `authoritative` is true when the pull applied an
   * authoritative snapshot (HTTP 200). A token from a SUPERSEDED epoch (the gate
   * re-closed for a newer pull since this one was armed) is ignored entirely — it
   * neither decrements the current epoch's in-flight count nor opens the gate.
   * Otherwise the gate opens when an authoritative pull has landed for the current
   * arm epoch, or when no in-flight pulls remain for that epoch.
   */
  release(token: SettingsReadyToken, authoritative: boolean): void;
  /** Force the gate open regardless of in-flight pulls (abort/shutdown backstop). */
  forceOpen(): void;
  /** The current ready promise — resolved while open, pending while a pull runs. */
  wait(): Promise<void>;
}

export function createSettingsReadyGate(): SettingsReadyGate {
  let generation = 0;
  // Epoch counter: incremented every time the gate RE-CLOSES (an arm() while the
  // gate was open). `pending` is epoch-scoped — a pull armed in a prior epoch is
  // retired and can neither keep nor reopen the current epoch's gate.
  let epoch = 0;
  // Number of CURRENT-epoch dispatched pulls that have not yet settled (released).
  let pending = 0;
  // Has an authoritative snapshot been applied since the gate last closed? Reset
  // whenever the gate re-closes (the first arm() after an open).
  let authoritativeApplied = false;
  // Starts OPEN: a resolved promise so nothing blocks until the first arm().
  let ready: Promise<void> = Promise.resolve();
  let resolveReady: (() => void) | null = null;
  const open = () => {
    const resolve = resolveReady;
    resolveReady = null;
    resolve?.();
  };
  return {
    arm(): SettingsReadyToken {
      generation += 1;
      // Only create a fresh pending promise when the gate is currently open; if a
      // pull is already in flight (resolveReady set) keep the SAME promise so its
      // existing waiters stay parked. When re-closing an open gate, start a NEW
      // epoch: discard any stale pending count left over from pulls of the prior
      // (now-closed) period — a startup pull that STALLED and never settled must
      // not keep the new epoch's `pending` above zero forever — and clear the
      // authoritative flag so the new epoch must observe a fresh authoritative
      // pull (or drain to zero in-flight) before opening again.
      if (resolveReady === null) {
        epoch += 1;
        pending = 0;
        authoritativeApplied = false;
        ready = new Promise<void>((resolve) => {
          resolveReady = resolve;
        });
      }
      pending += 1;
      return { generation, epoch };
    },
    release(token: SettingsReadyToken, authoritative: boolean): void {
      // Retire stale older-epoch pulls. The gate re-closed (a newer pull started)
      // since this token was armed, so this release belongs to a superseded
      // period. Ignore it entirely: a stalled startup pull settling LATE must
      // neither decrement the current epoch's `pending` (which would spuriously
      // open via pending===0) nor mark the current epoch authoritative.
      if (token.epoch !== epoch) return;
      // Count this current-epoch pull out of the in-flight set.
      if (pending > 0) pending -= 1;
      // Only the LATEST armed pull's authoritative snapshot may open the gate.
      // An OLDER pull (e.g. an on-ready/reconnect pull armed before a config
      // change) can return an authoritative HTTP-200 snapshot while a NEWER
      // `agent.config.changed` pull — carrying the just-muted / mention-only
      // state — is still in flight. Opening on that stale authoritative release
      // would let held group traffic proceed against settings the newer pull is
      // about to replace (Finding #3). So only set `authoritativeApplied` when
      // this release is for the latest armed generation. An older-generation
      // authoritative release merely decrements `pending`.
      if (authoritative && token.generation === generation) authoritativeApplied = true;
      // Open iff the cache is now authoritative for the LATEST epoch, OR no pull
      // is still in flight (a slower, earlier pull could still be applying an
      // authoritative snapshot, so a non-authoritative settle must NOT open the
      // gate while `pending > 0`). The `pending === 0` backstop still fires when
      // every pull has settled — including the older-authoritative-then-newer-
      // failure case, where the older snapshot is the freshest available.
      if (authoritativeApplied || pending === 0) open();
    },
    forceOpen(): void {
      pending = 0;
      open();
    },
    wait(): Promise<void> {
      return ready;
    },
  };
}

// Resolve when `gate` settles, OR after `ms`, OR on abort — whichever comes
// first. The timer is always cleared so it can't leak under fake timers. Used to
// bound the first-group-settings-pull gate so a stalled pull can never hang group
// traffic permanently (best-effort governance must always release).
function waitWithTimeout(gate: Promise<void>, ms: number, abortSignal: AbortSignal): Promise<void> {
  if (abortSignal.aborted) return Promise.resolve();
  return new Promise<void>((resolve) => {
    let settled = false;
    const finish = () => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      abortSignal.removeEventListener("abort", finish);
      resolve();
    };
    const timer = setTimeout(finish, Math.max(1, ms));
    abortSignal.addEventListener("abort", finish, { once: true });
    void gate.then(finish, finish);
  });
}

function waitForActivationPoll(abortSignal: AbortSignal, ms: number): Promise<boolean> {
  if (abortSignal.aborted) return Promise.resolve(false);
  return new Promise((resolve) => {
    const timer = setTimeout(() => {
      abortSignal.removeEventListener("abort", onAbort);
      resolve(true);
    }, Math.max(1, ms));
    const onAbort = () => {
      clearTimeout(timer);
      resolve(false);
    };
    abortSignal.addEventListener("abort", onAbort, { once: true });
  });
}

async function waitForActivationCredentials(params: {
  cfg: OpenClawConfig;
  account: ResolvedOpenclawClawlingAccount;
  abortSignal: AbortSignal;
  getStatus: () => ChannelAccountSnapshot;
  setStatus: (next: ChannelAccountSnapshot) => void;
  store: RuntimeConnectionStore | null;
  log?: Log;
  pollIntervalMs: number;
  rejectedActivationToken?: string;
  revokedRefreshMarker?: RevokedRefreshMarker | null;
}): Promise<{ account: ResolvedOpenclawClawlingAccount; source: "configured" | "sqlite" } | null> {
  const { abortSignal, getStatus, setStatus, store, log } = params;
  const accountId = params.account.accountId;
  const revoked = params.revokedRefreshMarker;
  const usable = (credentials: { accessToken: string; refreshToken: string | null }): boolean =>
    credentials.accessToken !== params.rejectedActivationToken &&
    (!revoked || (!!credentials.refreshToken &&
      fingerprintRefreshToken(credentials.refreshToken) !== revoked.fingerprint));
  // SQLite-first: activation and token rotation persist to the activations
  // row, so a complete row is the durable credential source of truth even
  // when channel config/env also carries credentials. Config/env credentials
  // bootstrap the no-row case, or replace a revoked pair with a different
  // refresh fingerprint (the caller seeds a row from the adopted pair).
  if (store?.getActivationCredentials) {
    const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
    if (stored && usable(stored)) {
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
      );
      return {
        source: "sqlite",
        account: {
          ...params.account,
          configured: true,
          token: stored.accessToken,
          userId: stored.userId,
          ownerUserId: stored.ownerUserId,
        },
      };
    }
  }
  const configuredCredentials = (account: ResolvedOpenclawClawlingAccount) => {
    const refreshToken = readConfigRefreshToken(params.cfg, accountId, revoked);
    return accountHasConnectCredentials(account) && usable({ accessToken: account.token, refreshToken })
      ? { account, source: "configured" as const } : null;
  };
  const configured = configuredCredentials(params.account);
  if (configured) return configured;
  if (revoked) {
    log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CLAWCHAT_TOKEN_EXPIRED_MESSAGE}`);
    setStatus({ ...getStatus(), connected: false, configured: false, running: false,
      lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR });
  }
  if (!revoked && !store?.getActivationCredentials) {
    const message = "Clawling Chat token is missing and activation database is unavailable";
    log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${message}`);
    setStatus({
      ...getStatus(),
      connected: false,
      configured: true,
      running: false,
      lastError: message,
    });
    return null;
  }

  log?.info?.(
    `[${accountId}] clawchat-plugin-openclaw waiting for activation credentials in sqlite`,
  );
  if (!revoked) setStatus({
    ...getStatus(),
    connected: false,
    configured: true,
    running: true,
    lastError: "waiting for ClawChat activation",
  });

  while (!abortSignal.aborted) {
    const credentials = store?.getActivationCredentials?.({ platform: "openclaw", accountId });
    if (credentials && usable(credentials)) {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
      return {
        source: "sqlite",
        account: {
          ...params.account,
          configured: true,
          token: credentials.accessToken,
          userId: credentials.userId,
          ownerUserId: credentials.ownerUserId,
        },
      };
    }
    if (revoked) {
      // Re-read env credentials while parked so a new pair can recover without
      // another gateway invocation. Config credentials use the supplied snapshot.
      const replacement = configuredCredentials(resolveOpenclawClawlingAccount(params.cfg, accountId));
      if (replacement) return replacement;
    }
    log?.debug?.(
      `[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=${credentials ? "rejected_token_unchanged" : "waiting"} intervalMs=${params.pollIntervalMs}`,
    );
    const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
    if (!shouldContinue) break;
  }

  setStatus({
    ...getStatus(),
    connected: false,
    running: false,
    lastStopAt: Date.now(),
  });
  return null;
}

let warnedBelowHostFloor = false;
let loggedHostToolSearch = false;

/** Re-pair lifecycle owned by one gateway invocation (see performAutoLogout). */
type GatewayLifecycle = { rePairWait?: Promise<void>; conflictWait?: Promise<boolean>; conflictCleanup?: Promise<void> };

export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
  // The host restarts a settled account task. Keep it alive until explicit
  // reclaim, including when a conflict arrives before hello-ok.
  do {
    const lifecycle: GatewayLifecycle = {};
    try {
      await runOpenclawClawlingGateway(params, lifecycle);
    } finally {
      await lifecycle.rePairWait;
      await lifecycle.conflictCleanup;
    }
    if (!lifecycle.conflictWait || !await lifecycle.conflictWait) return;
    params = { ...params, refreshManagerState: undefined, refreshTokenOverride: undefined };
  } while (!params.abortSignal.aborted);
}

async function runOpenclawClawlingGateway(
  params: StartGatewayParams,
  lifecycle: GatewayLifecycle,
): Promise<void> {
  const { cfg, abortSignal, setStatus, getStatus, log } = params;
  let account = params.account;
  const accountId = account.accountId;
  const initialOwner = snapshotClientOwner(accountId);
  // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
  const runtime = getOpenclawClawlingRuntime();
  const currentConfig = (): OpenClawConfig => {
    const current = (runtime.config as { current?: () => unknown } | undefined)?.current;
    return (typeof current === "function" ? current.call(runtime.config) : cfg) as OpenClawConfig;
  };
  const store = resolveConnectionStore(params, runtime);
  if (!warnedBelowHostFloor && !hostAtLeast(runtime.version, HOST_FLOOR)) {
    warnedBelowHostFloor = true;
    log?.warn?.(`clawchat-plugin-openclaw: host ${runtime.version ?? "unknown"} is below ${HOST_FLOOR}; turns your owner did not start keep the host tools in their schema and rely on the before_tool_call block`);
  }
  if (
    !loggedHostToolSearch &&
    hostAtLeast(runtime.version, HOST_TOOL_SEARCH_DEFAULT_ON) &&
    hostToolSearchEnabled(cfg)
  ) {
    loggedHostToolSearch = true;
    log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools without asking (approve mode: other tools in group turns still need the owner's approval each call) (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
  }
  log?.info?.(
    `[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`,
  );
  // Freeze one report device_id for this process; reused for the paired report
  // so the backend links both to the same row. Imported from ./client.ts.
  const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
  const pluginVersion = resolvePluginVersion();
  const agentVersion = runtime.version ?? "";
  void reportPluginVersionSafe({
    baseUrl: account.baseUrl,
    mediaBaseUrl: account.mediaBaseUrl,
    token: "",
    deviceId: reportDeviceId,
    pluginVersion,
    agentVersion,
    authenticated: false,
    onboarding: readOnboardingReport(),
    log,
  });
  // Ensure the liveware CLI is installed (async, never blocks startup).
  // Keep the promise: the liveware-sample supervisor must await it, or a
  // first boot races the download, sees no CLI, and silently skips forever.
  const livewareCliReady = ensureLivewareCli({
    stateDir: runtime.state?.resolveStateDir?.(),
    log: {
      warn: (m) => log?.info?.(m),
      debug: (m) => log?.debug?.(m),
      error: (m) => log?.error?.(m),
    },
  });
  // §B self-heal — non-interactive env connect-code activation BEFORE blocking on
  // the wait-for-activation loop. Gated so the common path (no env code, or an
  // already-credentialed account) adds NO async work: on success it credentials
  // the in-memory account (and persists the SQLite activation row) so the loop
  // below returns immediately.
  const envConnectCode = process.env[CONNECT_CODE_ENV];
  if (envConnectCode && envConnectCode.trim() && !accountHasConnectCredentials(account)) {
    account = await maybeAutoActivateFromEnv({
      cfg,
      account,
      accountId,
      connectCode: envConnectCode,
      store,
      log,
    });
  }
  const revokedRefreshMarker = params.revokedRefreshMarker ??
    store?.getRevokedRefreshMarker?.({ platform: "openclaw", accountId });
  const activationAccount = await waitForActivationCredentials({
    cfg,
    account,
    abortSignal,
    getStatus,
    setStatus,
    store,
    log,
    pollIntervalMs: params.activationPollIntervalMs ?? ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS,
    rejectedActivationToken: params.rejectedActivationToken,
    revokedRefreshMarker,
  });
  if (!activationAccount) return;
  account = activationAccount.account;
  const installId = resolveInstallId(store, { warn: (message) => log?.warn?.(`[${accountId}] ${message}`) });
  const showSuperseded = (marker: SupersededMarker) => {
    rememberSuperseded(accountId, marker);
    setStatus({
      ...getStatus(), ...installationStatus(marker, installId), connected: false, running: true,
      lastError: "Superseded by another installation; use /clawchat-reclaim",
    });
  };
  let installationSuperseded = false;
  const conflictStop = new AbortController();
  let stopForConflict: () => void = () => {};
  const installationIsSuperseded = (): boolean => {
    if (installationSuperseded) return true;
    if (!isInstallationSuperseded(accountId, account.userId)) return false;
    const marker = recalledSuperseded(accountId, account.userId)!;
    installationSuperseded = true;
    showSuperseded(marker);
    lifecycle.conflictWait ??= waitForReclaim(accountId, abortSignal);
    stopForConflict();
    conflictStop.abort();
    return true;
  };
  // Lightweight test transports may omit persistence; production always reads
  // the strict lookup and fails closed when the database is unavailable.
  const lookupConflict = (): SupersededLookup => {
    try {
      return store?.lookupSuperseded?.(accountId) ??
        (params.transport ? { status: "absent" } : { status: "unavailable" });
    } catch { return { status: "unavailable" }; }
  };
  const waitForStorageRetry = () => new Promise<void>((resolve) => {
    const finish = () => {
      clearTimeout(timer);
      abortSignal.removeEventListener("abort", finish);
      resolve();
    };
    const timer = setTimeout(finish, 60_000);
    abortSignal.addEventListener("abort", finish, { once: true });
    if (abortSignal.aborted) finish();
  });
  while (!abortSignal.aborted) {
    if (installationIsSuperseded()) return;
    const conflict = lookupConflict();
    if (conflict.status === "unavailable") {
      setStatus({ ...getStatus(), connected: false, running: true,
        lastError: "Installation conflict storage unavailable; connection paused" });
      log?.warn?.(`[${accountId}] clawchat.install_conflict storage unavailable; retrying in 60s`);
      await waitForStorageRetry();
      continue;
    }
    if (conflict.status === "absent") break;
    if (conflict.row.userId !== account.userId) {
      if (store?.clearSuperseded?.(accountId)) break;
      // A stale marker cannot be ignored until its deletion is durable.
      setStatus({ ...getStatus(), connected: false, running: true,
        lastError: "Installation conflict storage unavailable; stale marker could not be cleared" });
      log?.warn?.(`[${accountId}] clawchat.install_conflict stale marker deletion failed; retrying in 60s`);
      await waitForStorageRetry();
      continue;
    }
    showSuperseded(conflict.row);
    log?.warn?.(`[${accountId}] clawchat.install_conflict previous installation takeover; use /clawchat-reclaim`);
    lifecycle.conflictWait = waitForReclaim(accountId, abortSignal);
    return;
  }
  if (abortSignal.aborted) return;
  // Reset in-memory refresh guards only after adopting a different pair.
  if (revokedRefreshMarker) {
    params = { ...params, revokedRefreshMarker: undefined, refreshTokenOverride: undefined,
      refreshManagerState: undefined, rejectedActivationToken: undefined };
  }
  // Config/env bootstrap seeding: converge on SQLite as the single durable
  // credential store, so token rotation always has a row to update and the
  // next startup resolves SQLite-first to the same credentials.
  if (activationAccount.source === "configured" && store?.upsertActivation) {
    try {
      store.upsertActivation({
        platform: "openclaw",
        accountId,
        userId: account.userId,
        ownerUserId: account.ownerUserId,
        accessToken: account.token,
        refreshToken: readConfigRefreshToken(cfg, accountId, revokedRefreshMarker),
        loginMethod: "config-seed",
        deviceId: CHANNEL_ID,
      });
    } catch (error) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw sqlite credential seed failed; continuing with config credentials: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  }
  if (revokedRefreshMarker && store) {
    if (store.clearRevokedRefreshMarker?.({ platform: "openclaw", accountId }) !== true) {
      log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite revoked marker after activation`);
    }
  }
  // Paired: link the report row via the authenticated endpoint, reusing the
  // SAME frozen device_id so the backend upserts the existing unpaired row.
  void reportPluginVersionSafe({
    baseUrl: account.baseUrl,
    mediaBaseUrl: account.mediaBaseUrl,
    token: account.token,
    deviceId: reportDeviceId,
    pluginVersion,
    agentVersion,
    authenticated: true,
    onboarding: readOnboardingReport(),
    log,
  });
  // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
  // configured start even though the seed block above may have just created a
  // row — this cell only ever queries `store.getActivationCredentials` when
  // `source === "sqlite"`, so a config-sourced account relies on the JWT `exp`
  // alone until its next SQLite-first resolution.
  let activatedAtMs: number | null =
    activationAccount.source === "sqlite" && store?.getActivationCredentials
      ? store.getActivationCredentials({ platform: "openclaw", accountId })?.activatedAt ?? null
      : null;
  let conversationApiClient: ReturnType<typeof createOpenclawClawlingApiClient> | undefined;
  const buildConversationApiClient = (): ReturnType<typeof createOpenclawClawlingApiClient> =>
    createOpenclawClawlingApiClient({
      baseUrl: account.baseUrl,
      mediaBaseUrl: account.mediaBaseUrl,
      token: account.token,
      userId: account.userId,
    });
  // §A.2.1 — forward reference to the single-flight REST refresh wrapper; set
  // once the refresh manager exists. Until then, calls run un-wrapped.
  let restWithRefresh: (<T>(call: () => Promise<T>) => Promise<T>) | null = null;
  // Returns a proxy whose every method call runs through `restWithRefresh`, so a
  // 401/403 transparently triggers one single-flight refresh + retry. The proxy
  // reads the cached client lazily on each call so a post-refresh rebuild is
  // picked up automatically.
  const getConversationApiClient = (): ReturnType<typeof createOpenclawClawlingApiClient> => {
    return new Proxy({} as ReturnType<typeof createOpenclawClawlingApiClient>, {
      get: (_target, prop) => {
        return (...args: unknown[]) => {
          const invoke = () => {
            conversationApiClient ??= buildConversationApiClient();
            const fn = (conversationApiClient as unknown as Record<string, unknown>)[
              prop as string
            ];
            if (typeof fn !== "function") {
              throw new TypeError(`clawchat api-client has no method ${String(prop)}`);
            }
            return (fn as (...a: unknown[]) => unknown).apply(conversationApiClient, args);
          };
          return restWithRefresh
            ? restWithRefresh(() => Promise.resolve(invoke()) as Promise<unknown>)
            : invoke();
        };
      },
    });
  };
  // Rebuilt after every in-memory token swap so REST calls use the fresh token.
  const invalidateConversationApiClient = () => {
    conversationApiClient = undefined;
  };

  // Liveware Sample auto-boot (see src/liveware-sample.ts).
  // Fire-and-forget like ensureLivewareCli above: never blocks or fails the channel.
  // `livewareSampleNotify` is reassigned once `client`/`account` are ready (see
  // `sendOwnerSkillMessage` below) — both live in this same function closure, so
  // the forward reference here just resolves to the fresh value at call time.
  let livewareSampleNotify: (text: string) => Promise<boolean> = async () => false;
  // The supervisor this gateway call shares (new or adopted). The abort cleanup
  // below awaits its stop() so the host's channel stop (which waits on this
  // task) does not return while liveware children are still being killed.
  let livewareSampleForShutdown: LivewareSampleSupervisor | null = null;
  if (store) {
    const stateDirForSample = runtime.state?.resolveStateDir?.();
    const livewareSampleDeps: LivewareSampleSupervisorDeps = {
      platform: "openclaw",
      accountId,
      enabled: account.livewareSample,
      // `store` here is the `ClawChatStore` returned by `resolveConnectionStore`;
      // `RuntimeConnectionStore` only `Pick`s a subset of its methods, so widen
      // through the structural `LivewareSampleStoreLike` contract (Task 8/4).
      store: store as unknown as LivewareSampleStoreLike,
      // Per account: two supervisors in one process otherwise install, swap and
      // delete inside the SAME `app/` tree. `default` keeps the legacy path.
      sampleRoot: livewareSampleRootDir(stateDirForSample ?? "", accountId),
      resolveToken: () => account.token ?? "",
      resolveAgentUserId: () => account.userId ?? null,
      resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
      // Per-instance AND per-account HOME for the liveware CLI: its credentials
      // otherwise live in the host-global `$HOME/.clawling`, which a second
      // profile — or a second account in this very process — clobbers.
      cliHome: livewareCliHomeDir(stateDirForSample, accountId),
      cliReady: livewareCliReady,
      listApps: async () => {
        const client = getConversationApiClient();
        return await client.listApps();
      },
      registerApp: async (p) => {
        const client = getConversationApiClient();
        return await client.registerApp(p);
      },
      notifyOwner: (text) => livewareSampleNotify(text),
      resolveOwnerLocale: () => store.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
      fetchFn: globalThis.fetch,
      log: {
        warn: (m) => log?.info?.(m),
        debug: (m) => log?.debug?.(m),
        error: (m) => log?.error?.(m),
      },
    };
    const supervisorsForSignal =
      livewareSampleSupervisors.get(abortSignal) ?? new Map<string, LivewareSampleSupervisor>();
    livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
    const supervisorKey = `openclaw:${accountId}`;
    const runningSupervisor = supervisorsForSignal.get(supervisorKey);
    livewareSampleForShutdown = runningSupervisor ?? null;
    if (runningSupervisor) {
      // A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
      // hand the live supervisor this closure's token + REST client + notify path
      // instead of constructing a second one that would register a duplicate app.
      log?.debug?.(
        `[${accountId}] clawchat-plugin-openclaw liveware-sample supervisor already live; re-pointing it at the re-entered gateway`,
      );
      runningSupervisor.adoptDeps(livewareSampleDeps);
    } else {
      const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
      livewareSampleForShutdown = livewareSampleSupervisor;
      supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
      const stopOnAbort = () => {
        // Starts the SIGTERM → SIGKILL teardown at once; the gateway's own
        // abort cleanup awaits the same (idempotent) stop() promise.
        void livewareSampleSupervisor.stop();
        if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
          supervisorsForSignal.delete(supervisorKey);
        }
      };
      livewareSampleAbortListeners.set(livewareSampleSupervisor, stopOnAbort);
      abortSignal.addEventListener("abort", stopOnAbort, { once: true });
      void livewareSampleSupervisor.start();
    }
  }

  const stopLivewareSample = async (): Promise<void> => {
    if (!livewareSampleForShutdown) return;
    const supervisors = livewareSampleSupervisors.get(abortSignal);
    const key = `openclaw:${accountId}`;
    if (supervisors?.get(key) === livewareSampleForShutdown) supervisors.delete(key);
    // An installation-conflict stop keeps the abort signal alive for reclaim;
    // drop this supervisor's listener so repeated reclaims do not accumulate.
    const stopOnAbort = livewareSampleAbortListeners.get(livewareSampleForShutdown);
    if (stopOnAbort) abortSignal.removeEventListener("abort", stopOnAbort);
    try {
      await livewareSampleForShutdown.stop();
    } catch (err) {
      log?.error?.(`[${accountId}] clawchat-plugin-openclaw liveware-sample stop failed: ${err instanceof Error ? err.message : String(err)}`);
    }
  };

  const resolveMutateConfigFile = (): OpenclawClawchatMutateConfigFile | undefined => {
    if (params.mutateConfigFile) return params.mutateConfigFile;
    const runtimeConfig = runtime.config as unknown as {
      mutateConfigFile?: OpenclawClawchatMutateConfigFile;
    };
    return typeof runtimeConfig?.mutateConfigFile === "function"
      ? runtimeConfig.mutateConfigFile
      : undefined;
  };

  // OpenClaw >= 2026.8.1 recalls across a single agent's DMs unless DM isolation
  // is visible in the gateway config; ours is in-memory only. Write the agent's
  // `rememberAcrossConversations: false` once, unless the owner set it
  // (dm-recall-default.ts). Fire-and-forget: never blocks or fails startup.
  void (async () => {
    const stateDir = runtime.state?.resolveStateDir?.();
    const mutateConfigFile = resolveMutateConfigFile();
    const readConfig = (runtime.config as { current?: () => unknown } | undefined)?.current;
    if (!stateDir || !mutateConfigFile || typeof readConfig !== "function") return;
    let agentId: string;
    try {
      agentId = runtime.channel.routing.resolveAgentRoute({
        cfg: withClawChatSessionScope(cfg),
        channel: CHANNEL_ID,
        accountId,
        peer: { kind: "direct", id: account.ownerUserId || "owner" },
      }).agentId;
    } catch (err) {
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw rememberAcrossConversations default skipped: agent route unavailable (${err instanceof Error ? err.message : String(err)})`,
      );
      return;
    }
    await applyDmRecallDefault({
      agentId,
      hostVersion: runtime.version,
      stateDir,
      readConfig: () => readConfig.call(runtime.config),
      mutateConfigFile: mutateConfigFile as never,
      log: {
        info: (m) => log?.info?.(`[${accountId}] ${m}`),
        warn: (m) => {
          const warn = (log as { warn?: (msg: string) => void } | undefined)?.warn;
          if (warn) warn(`[${accountId}] ${m}`);
          else log?.info?.(`[${accountId}] WARNING ${m}`);
        },
        error: (m) => log?.error?.(`[${accountId}] ${m}`),
      },
    });
  })();

  // §0/§C.1 — write the account slot's `token`/`refreshToken` keys. `tokens=null`
  // blanks them (auto-logout); otherwise persists the rotated pair.
  const persistConfigTokens = async (
    tokens: { accessToken: string; refreshToken: string } | null,
    rejectedTokens?: { accessToken: string; refreshToken: string | null },
    rotation?: { parentRefreshToken: string | null; assertLockHeld(): void },
  ): Promise<"persisted" | "conflict" | "unavailable"> => {
    const mutateConfigFile = resolveMutateConfigFile();
    if (!mutateConfigFile) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw config persistence unavailable; cannot ${tokens ? "rotate" : "clear"} tokens in config`,
      );
      return "unavailable";
    }
    let result: "persisted" | "conflict" = "conflict";
    await mutateConfigFile({
      afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw token refresh" },
      mutate(draft) {
        if (tokens === null) {
          const current = readOpenclawClawlingAccountSection(draft, accountId);
          // The host may queue this mutation while another invocation adopts
          // fresh credentials. Compare against the rejected pair at write time.
          if (clientWasSuperseded() || !rejectedTokens ||
            current.token !== rejectedTokens.accessToken ||
            (current.refreshToken ?? null) !== rejectedTokens.refreshToken) {
            log?.info?.(`[${accountId}] clawchat-plugin-openclaw config credential clear skipped; owner or credentials changed`);
            return;
          }
        }
        if (tokens) {
          const current = readOpenclawClawlingAccountSection(draft, accountId);
          rotation?.assertLockHeld();
          if (current.refreshToken !== rotation?.parentRefreshToken && current.refreshToken !== tokens.refreshToken &&
            !(typeof current.refreshToken === "string" && isAncestor(accountId, current.refreshToken, rotation?.parentRefreshToken))) {
            log?.info?.(`[${accountId}] clawchat-plugin-openclaw config token rotation skipped; credential lineage changed`);
            return;
          }
        }
        if (tokens) rotation?.assertLockHeld();
        Object.assign(
          draft,
          patchOpenclawClawlingAccountSection(draft, accountId, {
            token: tokens ? tokens.accessToken : "",
            refreshToken: tokens ? tokens.refreshToken : "",
          }),
        );
        result = "persisted";
      },
    });
    return result;
  };

  let lastHelloFailTraceId = "-";
  let lastHelloFailReason = "";
  let lastConnectTraceId = "-";
  let lastHelloOkDeviceId: string | undefined;
  let lastHelloOkDeliveryMode: string | undefined;
  let currentAttemptStartedAt = 0;
  let authFailureLogged = false;
  let closingForAbort = false;
  let wsReady = false;
  // True once this gateway attempt reached "connected" at least once — used to
  // route a later auth-fail through the live-session reactive refresh path
  // rather than the initial-connect catch.
  let wsReadyEverThisAttempt = false;
  // §D — set when a refresh succeeded and we are closing the live WS to
  // reconnect with the new token; suppresses the auth-failed teardown path and
  // drives a clean re-enter into the gateway with the rotated account.
  let reconnectWithRefreshedToken = false;
  // §C — set once auto-logout has fired so we don't double-emit or reconnect.
  let autoLoggedOut = false;
  let refreshClient: ClawlingChatClient | undefined;
  let clientLease: ClientLease | undefined;
  const clientWasSuperseded = () => installationIsSuperseded() || (clientLease
    ? !clientLease.isCurrent()
    : ownerChangedSince(accountId, initialOwner));
  let latestInitialClientError: unknown;
  let autoLogoutCleanup: Promise<void> | undefined;

  // §E — connect-time device id for `X-Device-Id` on refresh. Prefer the value
  // recorded in SQLite at connect; backfill legacy rows (no column) to the
  // deterministic constant `CHANNEL_ID` actually sent by `authHeaders`.
  const refreshDeviceId =
    (activationAccount.source === "sqlite" && store?.getActivationCredentials
      ? store.getActivationCredentials({ platform: "openclaw", accountId })?.deviceId
      : null) || CHANNEL_ID;

  // §C — auto-logout on permanent refresh failure. Blank creds in BOTH stores
  // (KEEP identity), flip not-configured via the auth-failure status path, and
  // emit the user-visible message. Idempotent.
  const performAutoLogout = (
    info: { code: number; message: string }, marker: RevokedRefreshMarker,
  ): Promise<void> => {
    if (clientWasSuperseded()) {
      syncRotatedTokens();
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw permanent refresh result ignored; ws-client superseded`);
      if (!installationIsSuperseded()) lifecycle.rePairWait ??= waitUntilAbort(abortSignal, () => {});
      return Promise.resolve();
    }
    if (childOf(accountId, refreshRequestToken)) {
      syncRotatedTokens();
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw permanent refresh failure ignored; consumed token has a recorded successor`);
      return Promise.resolve();
    }
    if (autoLogoutCleanup) return autoLogoutCleanup;
    const rejectedTokens = { accessToken: account.token, refreshToken: latestRefreshToken };
    const stopSupersededLogout = () => {
      if (!clientWasSuperseded()) return false;
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw auto-logout cleanup stopped; ws-client superseded`);
      return true;
    };
    autoLoggedOut = true;
    log?.error?.(
      `[${accountId}] clawchat-plugin-openclaw auto-logout (token permanently expired) code=${info.code}: ${info.message}`,
    );
    refreshManager.stop();
    if (activeClients.get(accountId) === refreshClient) activeClients.delete(accountId);
    try { refreshClient?.close(); } catch { /* Best effort shutdown. */ }
    clientLease?.release();
    // Write the marker independently BEFORE clearing, so a failed clear cannot
    // resurrect this rejected credential at the next process start.
    autoLogoutCleanup = (async () => {
      if (stopSupersededLogout()) return;
      const markerInput = { platform: "openclaw", accountId, ...marker };
      try {
        if (store?.setRevokedRefreshMarker?.(markerInput) !== true) {
          log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
        }
      } catch {
        log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
      }
      if (stopSupersededLogout()) return;
      try {
        const current = store?.getActivationCredentials?.({ platform: "openclaw", accountId });
        if (stopSupersededLogout()) return;
        // The store has no conditional-clear API. These synchronous reads and
        // the owner check must immediately precede the synchronous clear.
        if (current && (current.accessToken !== rejectedTokens.accessToken ||
          current.refreshToken !== rejectedTokens.refreshToken)) {
          log?.info?.(`[${accountId}] clawchat-plugin-openclaw sqlite credential clear skipped; credentials changed`);
        } else if (store?.clearActivationCredentials?.({ platform: "openclaw", accountId }) !== true) {
          log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
        }
      } catch {
        log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
      }
      if (stopSupersededLogout()) return;
      try {
        const persisted = await persistConfigTokens(null, rejectedTokens);
        if (stopSupersededLogout()) return;
        if (persisted === "unavailable") throw new Error("config mutation unavailable");
      } catch {
        if (stopSupersededLogout()) return;
        log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout`);
      }
      if (stopSupersededLogout()) return;
      // Flip not-configured (existing auth-failure status path) with the re-pair
      // hint as `lastError`.
      setStatus({
        ...getStatus(),
        connected: false,
        configured: false,
        running: false,
        lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
      });
      // User-visible notification (in addition to logs). Best-effort; never throws.
      if (stopSupersededLogout()) return;
      emitUserVisibleAuthLogout();
    })();
    // Assign the lifetime before yielding, including when config clearing is
    // still pending and closing the old socket interrupts client.connect().
    lifecycle.rePairWait = autoLogoutCleanup.then(async () => {
      if (stopSupersededLogout()) {
        await waitUntilAbort(abortSignal, () => {});
        return;
      }
      await startOpenclawClawlingGateway({
        ...params, boundaryWarningIssued: true,
        account: { ...account, configured: false, token: "" },
        revokedRefreshMarker: marker, refreshTokenOverride: undefined,
        refreshManagerState: undefined, rejectedActivationToken: undefined,
        refreshReconnectDepth: undefined, refreshReconnectWindowStartedAt: undefined,
      });
    });
    return autoLogoutCleanup;
  };

  // §C.1.4 — surface the permanent-expiry message to the user/operator. The
  // plugin has no guaranteed live chat target after creds are cleared, so we
  // route through the runtime notification surface when present and always log.
  const emitUserVisibleAuthLogout = (): void => {
    log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CLAWCHAT_TOKEN_EXPIRED_MESSAGE}`);
    try {
      const notify = (runtime as unknown as {
        notifications?: { notify?: (input: { level?: string; message: string }) => void };
      }).notifications?.notify;
      if (typeof notify === "function") {
        notify({ level: "error", message: CLAWCHAT_TOKEN_EXPIRED_MESSAGE });
      }
    } catch {
      // Best effort only.
    }
  };

  // Re-entry can carry an old pair while activation has already persisted a
  // new one. Choose the authoritative lineage before following any successors.
  // Only a SQLite-sourced account treats the activation row as authoritative;
  // a config-sourced account keeps the config section as its source of truth.
  const storedCredentials = activationAccount.source === "sqlite"
    ? store?.getActivationCredentials?.({ platform: "openclaw", accountId })
    : undefined;
  // Read both fields from one snapshot: the host replaces config objects
  // when activation publishes new credentials instead of updating this cfg.
  const credentialConfig = storedCredentials ? cfg : currentConfig();
  const initialRefreshToken = readConfigRefreshToken(cfg, accountId, revokedRefreshMarker);
  let storedRefreshToken = storedCredentials ? storedCredentials.refreshToken : initialRefreshToken;
  if (storedCredentials) {
    account = { ...account, token: storedCredentials.accessToken, configured: true,
      userId: storedCredentials.userId, ownerUserId: storedCredentials.ownerUserId };
  } else {
    const configured = resolveOpenclawClawlingAccount(credentialConfig, accountId);
    const configuredRefreshToken = credentialConfig === cfg ? initialRefreshToken
      : readConfigRefreshToken(credentialConfig, accountId, revokedRefreshMarker);
    // Keep the accepted pair when the host supplies resolved credentials but
    // no complete config pair, or this snapshot still carries revoked tokens.
    if (accountHasConnectCredentials(configured) && (!revokedRefreshMarker || configuredRefreshToken)) {
      if (configured.token !== account.token || configured.configured !== account.configured ||
        configured.userId !== account.userId || configured.ownerUserId !== account.ownerUserId ||
        configuredRefreshToken !== initialRefreshToken) {
        account = { ...account, token: configured.token, configured: configured.configured,
          userId: configured.userId, ownerUserId: configured.ownerUserId };
        invalidateConversationApiClient();
      }
      storedRefreshToken = configuredRefreshToken;
    }
  }
  const refreshTokenOverride = params.refreshTokenOverride?.trim() || null;
  let latestRefreshToken: string | null = refreshTokenOverride ?? storedRefreshToken;
  if (refreshTokenOverride && refreshTokenOverride !== storedRefreshToken && (storedCredentials || storedRefreshToken)) {
    if (isAncestor(accountId, storedRefreshToken, refreshTokenOverride)) {
      // Activation resolution may have replaced params.account.token with the
      // parent's access token. Take both fields from the persisted descendant.
      const tokens = tipOf(accountId, storedRefreshToken)!;
      account = { ...account, token: tokens.accessToken, configured: true };
      latestRefreshToken = tokens.refreshToken;
    } else {
      latestRefreshToken = storedRefreshToken;
      const changedLineage = !isAncestor(accountId, refreshTokenOverride, storedRefreshToken);
      params = { ...params, refreshTokenOverride: undefined,
        ...(changedLineage ? {
          refreshManagerState: undefined,
          refreshReconnectDepth: undefined,
          refreshReconnectWindowStartedAt: undefined,
          rejectedActivationToken: undefined,
          transportBackoffReconnect: undefined,
        } : {}),
      };
      invalidateConversationApiClient();
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw refresh token override ignored; using authoritative ${storedCredentials ? "sqlite" : "config"} credentials${changedLineage ? "; reset inherited refresh guards" : ""}`);
    }
  }

  function syncRotatedTokens(): boolean {
    const tokens = tipOf(accountId, latestRefreshToken);
    if (!tokens) return false;
    account = { ...account, token: tokens.accessToken, configured: true };
    latestRefreshToken = tokens.refreshToken;
    invalidateConversationApiClient();
    log?.info?.(`[${accountId}] clawchat-plugin-openclaw synchronized credentials from process token rotation record`);
    return true;
  }
  syncRotatedTokens();
  let refreshRequestToken: string | null = null;
  let refreshLock: RefreshLock | undefined;
  const refreshManager = new RefreshManager({
    baseUrl: account.baseUrl,
    deviceId: refreshDeviceId,
    getAccessToken: () => { syncRotatedTokens(); return account.token; },
    getRefreshToken: () => { syncRotatedTokens(); return latestRefreshToken; },
    persistRotatedTokens: async (tokens) => {
      const lock = refreshLock;
      const assertLockHeld = () => {
        if (lock?.isHeld()) return;
        const message = `[${accountId}] clawchat-plugin-openclaw token persistence blocked; refresh lock no longer held`;
        if (log?.warn) log.warn(message);
        else log?.info?.(message);
        throw new Error("clawchat-plugin-openclaw refresh lock no longer held");
      };
      const rotation = { parentRefreshToken: refreshRequestToken, assertLockHeld };
      try {
        assertLockHeld();
        // SQLite is authoritative. Publish its durable result before waiting
        // for the mirror so a later holder can converge a lagging config file.
        if (store?.rotateActivationTokens) {
          const current = store.getActivationCredentials?.({ platform: "openclaw", accountId });
          if (!current || current.refreshToken === refreshRequestToken) {
            assertLockHeld();
            const rotateResult = store.rotateActivationTokens({
              platform: "openclaw", accountId,
              accessToken: tokens.accessToken, refreshToken: tokens.refreshToken,
            });
            if (rotateResult === false) {
              if (!store.upsertActivation) {
                throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
              }
              assertLockHeld();
              store.upsertActivation({
                platform: "openclaw", accountId,
                userId: account.userId, ownerUserId: account.ownerUserId,
                accessToken: tokens.accessToken, refreshToken: tokens.refreshToken,
                loginMethod: "config-seed", deviceId: refreshDeviceId,
              });
            } else if (rotateResult === null) {
              throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
            }
          } else if (current.refreshToken !== tokens.refreshToken) {
            log?.info?.(`[${accountId}] clawchat-plugin-openclaw sqlite token rotation skipped; credential lineage changed`);
            throw new Error("clawchat-plugin-openclaw credential lineage changed");
          }
          assertLockHeld();
          if (refreshRequestToken) recordRotation(accountId, refreshRequestToken, tokens);
          // Config is a best-effort mirror once SQLite holds the child. Its
          // callback still needs the fence: a timed-out writer must not resume.
          try {
            await persistConfigTokens(tokens, undefined, rotation);
          } catch (error) {
            log?.error?.(
              `[${accountId}] clawchat-plugin-openclaw config token mirror failed (sqlite already rotated): ${error instanceof Error ? error.message : String(error)}`,
            );
          }
        } else {
          const persisted = await persistConfigTokens(tokens, undefined, rotation);
          if (persisted !== "persisted") {
            throw new Error(persisted === "conflict"
              ? "clawchat-plugin-openclaw credential lineage changed"
              : "clawchat-plugin-openclaw config persistence unavailable");
          }
          // The host may commit after the lock expired; the write is durable
          // either way, so record it before the fence decides about swapping.
          if (refreshRequestToken) recordRotation(accountId, refreshRequestToken, tokens);
        }
        // An expired holder does not swap its response into memory: the next
        // getter follows the recorded chain to whichever child is newest.
        assertLockHeld();
      } finally {
        lock?.();
        if (refreshLock === lock) refreshLock = undefined;
      }
    },
    swapInMemoryTokens: (tokens) => {
      account = { ...account, token: tokens.accessToken, configured: true };
      latestRefreshToken = tokens.refreshToken;
      invalidateConversationApiClient();
      activatedAtMs = Date.now();
    },
    onPermanentFailure: performAutoLogout,
    // §A.1/§D — proactive-timer success closes the live WS and re-enters with the
    // rotated token. The running ws-client captured the OLD token at `connect`
    // time, so the in-memory swap alone never reaches a `connect` envelope.
    onProactiveRefreshed: async () => {
      await runRefreshReconnect("proactive-timer");
    },
    fetchImpl: async (input, init) => {
      // The lock covers HTTP and durable persistence, across module generations.
      // Read the body rather than a mutable getter: queued callers may still
      // carry a parent that another invocation has already refreshed.
      if (installationIsSuperseded()) throw new Error("installation superseded before refresh lock");
      const parent = JSON.parse(String(init?.body)).refresh_token as string;
      refreshRequestToken = parent;
      const release = await acquireRefreshLock(accountId, (message) => {
        if (log?.warn) log.warn(message);
        else log?.info?.(message);
      });
      try {
        if (installationIsSuperseded() || init?.signal?.aborted) throw new Error("refresh request stopped while waiting for lock");
        const child = childOf(accountId, parent);
        const response = child
          ? new Response(JSON.stringify({ code: 0, data: {
            access_token: child.accessToken, refresh_token: child.refreshToken,
          } }), { status: 200, headers: { "content-type": "application/json" } })
          : await (params.refreshFetchImpl ?? globalThis.fetch.bind(globalThis))(input, init);
        const body = await response.clone().json().catch(() => null);
        if (response.status === 200 && body?.code === 0 &&
          typeof body.data?.access_token === "string" && body.data.access_token &&
          typeof body.data?.refresh_token === "string" && body.data.refresh_token &&
          !init?.signal?.aborted) {
          refreshLock = release;
        } else {
          release();
        }
        return response;
      } catch (error) {
        release();
        throw error;
      }
    },
    ...(params.refreshSetTimer ? { setTimer: params.refreshSetTimer } : {}),
    ...(params.refreshClearTimer ? { clearTimer: params.refreshClearTimer } : {}),
    ...(params.refreshJitter ? { jitter: params.refreshJitter } : {}),
    ...(params.refreshNow ? { now: params.refreshNow } : {}),
    log,
  });
  stopForConflict = () => {
    refreshManager.stop();
    lifecycle.conflictCleanup ??= stopLivewareSample();
  };
  // §A.3/§A.4 — carry the single-flight latch + min-interval across re-enters so
  // the guards bound a rotate-then-reject loop instead of resetting each time.
  if (params.refreshManagerState) {
    refreshManager.restoreState(params.refreshManagerState);
  }
  let currentConnectionId: number | null = null;
  let currentConnectionFinished = false;
  const reconnectTracker = createReconnectTracker({
    accountId,
    log: (msg) => log?.info?.(msg),
    maxDelayMs: account.reconnect.maxDelay,
  });
  const wsLogContext = () => {
    const snapshot = reconnectTracker.snapshot();
    return {
      attempt: snapshot.attempt || 1,
      reconnectCount: snapshot.reconnectCount,
      state: snapshot.state === "connected" ? "ready" : snapshot.state,
    };
  };
  const recordConnection = <T>(action: string, fn: () => T): T | undefined => {
    try {
      return fn();
    } catch {
      log?.error?.(`[${accountId}] clawchat-plugin-openclaw sqlite ${action} failed; continuing`);
      return undefined;
    }
  };
  const finishCurrentConnection = (input: {
    state: string;
    disconnectedAt?: number;
    closeCode?: number | null;
    closeReason?: string | null;
    error?: string | null;
  }) => {
    if (!store || currentConnectionId == null || currentConnectionFinished) return;
    const connectionId = currentConnectionId;
    recordConnection("finish", () => store.finishConnection(connectionId, input));
    currentConnectionFinished = true;
    currentConnectionId = null;
  };
  const refreshConversationDetails = async (
    conversationId: string,
    options: { metadataVersion?: number; source: string; memoryRoot?: string },
  ): Promise<void> => {
    try {
      const memoryRoot = options.memoryRoot;
      if (!memoryRoot) {
        await getConversationApiClient().getConversation(conversationId);
        return;
      }
      const result = await pullGroupMetadata({
        memoryRoot,
        groupId: conversationId,
        api: getConversationApiClient(),
        skipUserIds: [account.userId, account.ownerUserId],
      });
      if (result.failures.length > 0) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw group participant metadata refresh partially failed: ${result.failures.map((failure) => `${failure.targetId}: ${failure.error}`).join("; ")}`,
        );
      }
      if (!result.conversation) throw new Error("ClawChat conversation metadata response is missing conversation");
    } catch (err) {
      if (isConversationNotFoundError(err)) {
        if (options.memoryRoot) {
          await deleteClawChatMemoryFile(options.memoryRoot, {
            targetType: "group",
            targetId: conversationId,
          }).catch((deleteError) => {
            log?.error?.(
              `[${accountId}] clawchat-plugin-openclaw group metadata file delete failed conversation=${conversationId}: ${
                deleteError instanceof Error ? deleteError.message : String(deleteError)
              }`,
            );
          });
        }
        return;
      }
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw metadata refresh failed source=${options.source} conversation=${conversationId}: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
  };
  const upsertOwnerProfile = store?.upsertOwnerProfile?.bind(store);
  const persistOwnerProfile = upsertOwnerProfile
    ? makeOwnerProfilePersister({ upsertOwnerProfile }, { accountId })
    : undefined;
  // The owner_profile row is where owner-language copy reads its locale
  // (framework errors, greetings, liveware intros). It was only refreshed on
  // owner metadata pulls, so an owner who switched app language kept getting
  // the old one. friend.profile_updated also reaches the agent when the
  // owner edits their profile; refetch just the owner row then.
  const refreshOwnerProfileOnUpdate = (entityId: unknown): void => {
    const subject = typeof entityId === "string" ? entityId.trim() : "";
    if (!persistOwnerProfile || !subject || subject !== account.ownerUserId) return;
    void (async () => {
      try {
        const owner = await getConversationApiClient().getAgentOwner();
        persistOwnerProfile(owner as unknown as Record<string, unknown>);
      } catch (err) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw owner profile refresh failed source=friend.profile_updated: ${err instanceof Error ? err.message : String(err)}`,
        );
      }
    })();
  };
  const refreshAgentBehavior = async (options: {
    metadataVersion?: number;
    source: string;
    memoryRoot: string;
  }): Promise<void> => {
    try {
      await pullOwnerMetadata({
        memoryRoot: options.memoryRoot,
        agentId: account.agentId,
        accountUserId: account.userId,
        accountOwnerUserId: account.ownerUserId,
        api: getConversationApiClient(),
        persistOwnerProfile,
      });
    } catch (err) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw behavior refresh failed source=${options.source} agent=${account.userId}: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
  };
  const refreshConversationCacheAfterReady = async (): Promise<void> => {
    void store;
  };
  const resolveMemoryRootForPeer = (peer: { kind: "direct" | "group"; id: string }): string | null => {
    try {
      const route = runtime.channel.routing.resolveAgentRoute({
        cfg: withClawChatSessionScope(cfg),
        channel: CHANNEL_ID,
        accountId,
        peer,
      });
      return resolveClawChatMemoryRoot(runtime, cfg, route.agentId);
    } catch (err) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw metadata refresh memory root unavailable: ${err instanceof Error ? err.message : String(err)}`,
      );
      return null;
    }
  };
  const handleMetadataInvalidation = async (env: Envelope): Promise<void> => {
    const conversationId = typeof env.chat_id === "string" && env.chat_id.trim()
      ? env.chat_id
      : "";
    if (!conversationId) {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw metadata invalidation missing chat_id trace=${env.trace_id}`);
      return;
    }

    const version = metadataVersionFromEnvelope(env);
    const scopes = metadataScopesFromEnvelope(env);
    const refreshBehavior = shouldRefreshBehaviorForScopes(scopes);
    const refreshConversation = shouldRefreshConversationForScopes(scopes);
    if (!refreshBehavior && !refreshConversation) return;
    const peer = {
      kind: env.chat_type === "group" ? "group" as const : "direct" as const,
      id: conversationId,
    };
    const memoryRoot = resolveMemoryRootForPeer(peer);
    if (!memoryRoot) return;

    // §9.3: agent behavior is per-agent metadata that lives only on the agent's
    // DIRECT conversation — never refetch it for a group invalidation, even on a
    // "refetch everything" (empty/unknown) scope.
    if (refreshBehavior && peer.kind === "direct") {
      await refreshAgentBehavior({
        source: "metadata_invalidation",
        ...(version !== undefined ? { metadataVersion: version } : {}),
        memoryRoot,
      });
    }

    if (!refreshConversation) return;

    await refreshConversationDetails(conversationId, {
      source: "metadata_invalidation",
      ...(version !== undefined ? { metadataVersion: version } : {}),
      memoryRoot,
    });
  };
  const syncMessagePathProfiles = async (turn: IngestTurnParams, memoryRoot: string): Promise<void> => {
    if (turn.senderId === "clawchat-bootstrap") {
      await pullOwnerMetadata({
        memoryRoot,
        agentId: account.agentId,
        accountUserId: account.userId,
        accountOwnerUserId: account.ownerUserId,
        api: getConversationApiClient(),
        persistOwnerProfile,
      });
      return;
    }
    if (turn.peer.kind === "direct" && (turn.senderId === account.ownerUserId || turn.senderId === account.userId)) {
      await pullOwnerMetadata({
        memoryRoot,
        agentId: account.agentId,
        accountUserId: account.userId,
        accountOwnerUserId: account.ownerUserId,
        api: getConversationApiClient(),
        persistOwnerProfile,
      });
      return;
    }
    if (turn.peer.kind === "group") {
      await refreshGroupProfile({
        platform: "openclaw",
        accountId,
        accountUserId: account.userId,
        accountOwnerUserId: account.ownerUserId,
        conversationId: turn.peer.id,
        api: getConversationApiClient(),
        store: {},
        memoryRoot,
        log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
      });
      return;
    }
    await syncFirstSeenClawChatProfiles({
      platform: "openclaw",
      accountId,
      accountUserId: account.userId,
      accountOwnerUserId: account.ownerUserId,
      chat: {
        id: turn.peer.id,
        type: "direct",
        lastSeenAt: turn.timestamp,
      },
      sender: {
        id: turn.senderId,
        ...(turn.senderNickName ? { nickname: turn.senderNickName } : {}),
      },
      api: getConversationApiClient(),
      store: {},
      memoryRoot,
      log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
    });
  };
  // §A.4 — startup refresh-if-near-expiry, BEFORE the first WS connect. Recovers
  // a long-stopped pod with no manual re-pair. On a permanent refresh failure
  // auto-logout immediately and skip the doomed connect.
  syncRotatedTokens();
  if (installationIsSuperseded()) return;
  if (
    !abortSignal.aborted &&
    latestRefreshToken &&
    refreshManager.isNearExpiry(activatedAtMs)
  ) {
    log?.info?.(
      `[${accountId}] clawchat-plugin-openclaw access token near expiry at startup; refreshing before connect`,
    );
    const startupOutcome = await refreshManager.refresh("startup-near-expiry");
    // Another invocation may have connected while startup refresh was in flight.
    // Its owner can follow persisted rotations; this invocation must not claim it.
    if (installationIsSuperseded()) return;
    if (clientWasSuperseded()) {
      refreshManager.stop();
      await waitUntilAbort(abortSignal, () => {});
      return;
    }
    if (abortSignal.aborted) return;
    if (startupOutcome.kind === "permanent" && autoLoggedOut) {
      // Wait for re-pair or abort rather than settling the host channel task.
      await lifecycle.rePairWait;
      return;
    }
    // success swaps the in-memory token in place; transient/skipped just connect
    // with the current token (the WS handshake will then drive reactive refresh).
  }
  // Reuse the device id the server resolved on a previous connection so a pod
  // restart (fresh hostname → fresh hostname-derived id) does not present a
  // brand-new device, which would force a full inbox replay and orphan the
  // prior device's cursor. Persisted from `hello-ok` via markConnectionReady.
  const persistedDeviceId = store?.getLastResolvedDeviceId
    ? store.getLastResolvedDeviceId({ platform: "openclaw", accountId })
    : null;
  if (persistedDeviceId) {
    log?.info?.(
      `[${accountId}] clawchat-plugin-openclaw reusing persisted resolved_device_id`,
    );
  }
  syncRotatedTokens();
  if (installationIsSuperseded()) return;
  const client = createOpenclawClawlingClient(account, {
    ...(installId ? { installId } : {}),
    ...(params.transport ? { transport: params.transport } : {}),
    ...(persistedDeviceId ? { deviceIdOverride: persistedDeviceId } : {}),
    wsLifecycle: {
      onConnectFrameSent: (env) => {
        lastConnectTraceId = typeof env.trace_id === "string" ? env.trace_id : "-";
        if (store && currentConnectionId != null) {
          const connectionId = currentConnectionId;
          recordConnection("connect-sent", () => store.markConnectSent(connectionId));
        }
        const deviceId =
          typeof env.payload?.device_id === "string" ? env.payload.device_id : "-";
        const current = wsLogContext();
        log?.info?.(
          formatWsLog({
            event: "connect_sent",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            state: "handshaking",
            action: "await_hello",
            fields: [
              ["trace_id", lastConnectTraceId],
              ["device_id", deviceId],
            ],
          }),
        );
      },
    },
  });
  const superseded = new AbortController();
  let abortCleanup: Promise<void> | undefined;
  function closeClient() {
    abortSignal.removeEventListener("abort", closeClientOnAbort);
    if (activeClients.get(accountId) === client) activeClients.delete(accountId);
    wsReady = false;
    refreshManager.stop();
    try {
      client.close();
    } finally {
      clientLease?.release();
    }
  }
  function closeClientOnAbort() {
    closingForAbort = true;
    finishCurrentConnection({ state: "disconnected", closeCode: 1000, closeReason: "client close" });
    closeClient();
  }
  function finishAbortCleanup(): Promise<void> {
    return abortCleanup ??= (async () => {
      await (lifecycle.conflictCleanup ?? stopLivewareSample());
      if (!installationIsSuperseded() && clientLease?.isCurrent()) setStatus({
        ...getStatus(), connected: false, running: false, lastStopAt: Date.now(),
      });
    })();
  }
  stopForConflict = () => {
    refreshManager.stop();
    lifecycle.conflictCleanup ??= stopLivewareSample();
    closeClient();
  };
  if (installationIsSuperseded()) { closeClient(); return; }
  const lease = claimClientLease(accountId, () => {
    finishCurrentConnection({
      state: "disconnected", closeCode: 1000, closeReason: "replaced by a newer client",
    });
    try {
      closeClient();
    } finally {
      // A transport may report close asynchronously (or throw during close).
      // Wake the initial-reconnect waiter without depending on that event.
      superseded.abort();
    }
  });
  clientLease = lease;
  if (lease.replacedPrevious) {
    (log?.warn ?? log?.info)?.(
      `[${accountId}] clawchat-plugin-openclaw existing ws-client for this account; closed previous instance`,
    );
  }
  if (abortSignal.aborted) {
    closeClientOnAbort();
    await finishAbortCleanup();
    return;
  }
  if (installationIsSuperseded() || !lease.isCurrent()) {
    closeClient();
    return;
  }
  abortSignal.addEventListener("abort", closeClientOnAbort, { once: true });
  setStatus({ ...getStatus(), ...installationStatus(null, installId), lastError: null });
  refreshClient = client;
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client created`);

  // §A.3/§A.4 — recompute the refresh-driven reconnect streak (depth + window).
  // Returns the next depth/window to thread into the re-enter, and whether the
  // cap is exceeded so the caller must NOT re-enter via refresh again.
  const nextRefreshReconnectStreak = (): {
    depth: number;
    windowStartedAt: number;
    capped: boolean;
  } => {
    const now = Date.now();
    const priorWindowStart = params.refreshReconnectWindowStartedAt ?? 0;
    const withinWindow =
      priorWindowStart !== 0 && now - priorWindowStart < REFRESH_RECONNECT_WINDOW_MS;
    const windowStartedAt = withinWindow ? priorWindowStart : now;
    const depth = (withinWindow ? params.refreshReconnectDepth ?? 0 : 0) + 1;
    return { depth, windowStartedAt, capped: depth > MAX_REFRESH_RECONNECTS };
  };

  // §B/§D — re-enter the gateway after a plain transport-backoff delay, with the
  // CURRENT (unchanged) token and creds left untouched. Used when a reactive
  // refresh is transient/skipped (§B: a transient refresh failure NEVER
  // auto-logs-out and NEVER stops — keep retrying with the current token) and
  // when the refresh-reconnect loop is capped (§A.4). Carries the refresh
  // manager's latch + min-interval state so the guards keep bounding the loop.
  const scheduleTransportBackoffReconnect = (reason: string): void => {
    if (installationIsSuperseded() || !lease.isCurrent()) {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw backoff reconnect skipped; ws-client superseded`);
      return;
    }
    if (abortSignal.aborted || autoLoggedOut || reconnectWithRefreshedToken) return;
    reconnectWithRefreshedToken = true; // suppress the auth-failed teardown path.
    refreshManager.stop();
    if (activeClients.get(accountId) === client) activeClients.delete(accountId);
    finishCurrentConnection({
      state: "disconnected",
      closeCode: 1000,
      closeReason: "transport backoff reconnect",
    });
    try {
      closeClient();
    } catch {
      // best effort
    }
    const attempt = (params.refreshReconnectDepth ?? 0) + 1;
    const delayMs =
      params.transportBackoffDelayMs ??
      Math.min(TRANSPORT_BACKOFF_MAX_MS, TRANSPORT_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1));
    log?.info?.(
      `[${accountId}] clawchat-plugin-openclaw reactive refresh ${reason}; backoff-reconnect with current token delayMs=${delayMs}`,
    );
    const managerState = refreshManager.exportState();
    const streak = nextRefreshReconnectStreak();
    const reEnter = () => {
      if (installationIsSuperseded() || abortSignal.aborted) return;
      if (!lease.isCurrent()) {
        log?.info?.(`[${accountId}] clawchat-plugin-openclaw backoff reconnect skipped; ws-client replaced by a newer instance`);
        return;
      }
      syncRotatedTokens();
      void startOpenclawClawlingGateway({
        ...params,
        cfg: currentConfig(),
        boundaryWarningIssued: true,
        account: { ...account },
        // Carry the live refresh token forward (a rotation earlier this lifecycle
        // leaves the in-memory `cfg` stale; see refreshTokenOverride).
        ...(latestRefreshToken ? { refreshTokenOverride: latestRefreshToken } : {}),
        transportBackoffReconnect: true,
        refreshReconnectDepth: streak.depth,
        refreshReconnectWindowStartedAt: streak.windowStartedAt,
        refreshManagerState: managerState,
      });
    };
    const timer = params.backoffTimer ?? ((cb, ms) => void setTimeout(cb, ms));
    timer(reEnter, delayMs);
  };

  // §A/§D — close the live WS and re-enter the gateway with the rotated token
  // (a token only enters via a fresh `connect` envelope; it cannot be hot-swapped
  // onto a live socket). Assumes a refresh ALREADY succeeded and swapped the
  // in-memory token (proactive path), or is called by `runRefreshAndReconnect`
  // after its own successful refresh (reactive path). Carries the refresh
  // manager's latch + min-interval + reconnect-streak state across the re-enter.
  const closeAndReconnectWithRefreshedToken = async (reason: string): Promise<void> => {
    if (installationIsSuperseded() || !lease.isCurrent()) {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw token-refresh reconnect skipped; ws-client superseded`);
      return;
    }
    if (abortSignal.aborted || autoLoggedOut || reconnectWithRefreshedToken) return;
    reconnectWithRefreshedToken = true;
    const managerState = refreshManager.exportState();
    refreshManager.stop();
    if (activeClients.get(accountId) === client) activeClients.delete(accountId);
    log?.info?.(
      `[${accountId}] clawchat-plugin-openclaw token refreshed (${reason}); closing WS to reconnect with new token`,
    );
    finishCurrentConnection({
      state: "disconnected",
      closeCode: 1000,
      closeReason: "token refresh",
    });
    try {
      closeClient();
    } catch {
      // best effort
    }
    if (installationIsSuperseded() || abortSignal.aborted) return;
    syncRotatedTokens();
    const streak = nextRefreshReconnectStreak();
    // Pass the current host snapshot, which may still predate this rotation
    // after afterWrite:none. The override advances that snapshot only along
    // its persisted lineage; a newly activated lineage remains authoritative.
    await startOpenclawClawlingGateway({
      ...params,
      cfg: currentConfig(),
      boundaryWarningIssued: true,
      account: {
        ...params.account,
        configured: true,
        token: account.token,
        userId: account.userId,
        ownerUserId: account.ownerUserId,
      },
      ...(latestRefreshToken ? { refreshTokenOverride: latestRefreshToken } : {}),
      refreshReconnectDepth: streak.depth,
      refreshReconnectWindowStartedAt: streak.windowStartedAt,
      refreshManagerState: managerState,
    });
  };
  // Alias used by the proactive port (refresh already succeeded + swapped).
  const runRefreshReconnect = closeAndReconnectWithRefreshedToken;

  // §A/§B/§D — run a single-flight refresh and act on the outcome:
  //  - success  → close the live WS + re-enter with the rotated token (§D).
  //  - permanent→ the manager already auto-logged-out (§C); nothing more here.
  //  - transient/skipped → §B: NEVER teardown. Backoff-reconnect with the CURRENT
  //    token, creds + configured untouched, and keep retrying.
  // Returns "handled" when it took ownership of the next connection lifecycle
  // (reconnect scheduled / auto-logout), "fallthrough" when the caller should run
  // its own path (only when aborted mid-flight).
  const runRefreshAndReconnect = async (reason: string): Promise<"handled" | "fallthrough"> => {
    if (installationIsSuperseded() || !lease.isCurrent()) return "handled";
    if (abortSignal.aborted || autoLoggedOut || reconnectWithRefreshedToken) return "handled";
    // §A.4 — if the refresh-driven reconnect loop is already capped, do not run
    // another refresh; fall back to plain transport backoff with the current
    // token so a rotate-then-reject server cannot loop forever with no backoff.
    if ((params.refreshReconnectDepth ?? 0) >= MAX_REFRESH_RECONNECTS) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw refresh-reconnect loop capped (depth=${params.refreshReconnectDepth}); backoff-reconnect with current token`,
      );
      scheduleTransportBackoffReconnect("refresh-reconnect-capped");
      return "handled";
    }
    const outcome = await refreshManager.refresh(reason);
    if (abortSignal.aborted) return "fallthrough";
    if (installationIsSuperseded() || !lease.isCurrent()) {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw refresh result ignored; ws-client superseded`);
      return "handled";
    }
    if (autoLoggedOut) {
      await lifecycle.rePairWait;
      return "handled";
    }
    if (outcome.kind === "success") {
      await closeAndReconnectWithRefreshedToken(reason);
      return "handled";
    }
    // §B — transient / skipped (in-flight / min-interval / rejected-latch /
    // no-refresh-token): keep the WS in backoff with the CURRENT token; do NOT
    // teardown. (no-refresh-token has no path to recover, but tearing down is
    // wrong per §B; backoff keeps the supervisor alive without a refresh storm.)
    scheduleTransportBackoffReconnect(`refresh-${outcome.kind}`);
    return "handled";
  };

  // §A.2.1 — run an authenticated REST call; on a 401/403 (`ClawlingApiError`
  // kind "auth") run the single-flight refresh and retry the call ONCE with a
  // rebuilt api-client. Any other error propagates. Used to wrap the REST
  // api-client so metadata/profile calls survive an expired access token
  // without waiting for the WS handshake.
  const isRestAuthError = (err: unknown): boolean =>
    err instanceof ClawlingApiError && err.kind === "auth";
  const withRefresh = async <T>(call: () => Promise<T>): Promise<T> => {
    try {
      return await call();
    } catch (err) {
      if (installationIsSuperseded() || !isRestAuthError(err) || abortSignal.aborted) throw err;
      const outcome = await refreshManager.refresh("rest-401");
      if (installationIsSuperseded() || outcome.kind !== "success" || (clientLease && !clientLease.isCurrent())) throw err;
      // The in-memory swap already invalidated the cached api-client; the next
      // `call()` rebuilds it with the fresh token.
      return await call();
    }
  };
  // Activate the REST proxy's refresh wrapper now that the manager exists.
  restWithRefresh = withRefresh;

  // §A.2/§B — handle a WS hello-fail(auth) by gating a reactive refresh on the
  // reason classification:
  //  - token-rejected → refresh. Success reconnects with the fresh token;
  //    permanent auto-logs-out; transient/skipped backoff-reconnects with the
  //    CURRENT token (§B: a transient refresh failure NEVER auto-logs-out and
  //    NEVER stops — `runRefreshAndReconnect` owns all three).
  //  - generic + token near expiry → same refresh path.
  //  - generic + token NOT near expiry → §A.2: transient backoff with the current
  //    token (NO refresh, NO teardown). A backend outage emitting a generic
  //    reason must not trigger a refresh storm OR a spurious logout.
  //  - auth-unavailable never reaches here (the ws-client routes it as a
  //    TransportError so its own backoff loop handles it).
  const handleWsAuthFailure = async (reason: string): Promise<void> => {
    if (installationIsSuperseded() || abortSignal.aborted || reconnectWithRefreshedToken || autoLoggedOut) return;
    const klass = classifyHelloFailReason(reason);
    const eligible =
      klass === "token-rejected" ||
      (klass === "generic" && refreshManager.isNearExpiry(activatedAtMs));
    if (eligible) {
      // `runRefreshAndReconnect` is total: it either reconnects (success),
      // auto-logs-out (permanent), or backoff-reconnects with the current token
      // (transient/skipped). No teardown path remains for an eligible hello-fail.
      await runRefreshAndReconnect("ws-hello-fail");
      return;
    }
    // §A.2 / Finding 5 — generic + token NOT near expiry: keep the WS in
    // transport backoff with the current token. Do NOT refresh and do NOT tear
    // the account down (the old teardown wrongly flipped configured:false).
    scheduleTransportBackoffReconnect("hello-fail-generic-not-near");
  };

  setAlignedOutboundLogContext(client, wsLogContext);
  client.on("hello:ok", (env: Envelope) => {
    const payload = env.payload && typeof env.payload === "object"
      ? env.payload as { device_id?: unknown; delivery_mode?: unknown }
      : {};
    lastHelloOkDeviceId = typeof payload.device_id === "string" ? payload.device_id : undefined;
    lastHelloOkDeliveryMode = typeof payload.delivery_mode === "string" ? payload.delivery_mode : undefined;
  });
  const protocolControlLogger = createProtocolControlHandler({
    accountId,
    log: (msg) => log?.info?.(msg),
    context: wsLogContext,
  });
  const notifySignalObserver = createNotifySignalObserver({
    accountId,
    log: (msg) => log?.info?.(msg),
    context: wsLogContext,
  });
  const groupSettingsCache = new GroupSettingsCache();
  const permissionCache = new PermissionCache();
  // Findings 2 & 3 — gate group dispatch on the IN-FLIGHT settings pull, not just
  // the first one. The pull is fire-and-forget, so replayed/live group messages
  // could otherwise enter the governance gate against a stale/empty cache
  // (treating a just-muted/mention-only chat as default "all"). A RE-ARMABLE gate
  // (`settingsReadyGate`) re-closes whenever a new pull starts — at startup, on
  // every WS reconnect (after hello-ok), and on `agent.config.changed` — so a
  // reconnect/config-change pull holds group traffic until the fresh settings
  // land, mirroring the hermes adapter's `_group_settings_ready` event. Group
  // ingest awaits it through a bounded race (`waitWithTimeout`,
  // GROUP_SETTINGS_READY_TIMEOUT_MS) so even a stalled pull — endpoint accepts but
  // never responds — can hold group traffic at most that long before the gate
  // releases. Non-group traffic never awaits this.
  const settingsReadyGate = createSettingsReadyGate();
  // Best-effort re-pull of per-group agent settings from the backend.
  // Called at startup, on every successful WS (re)connect, and on
  // `notify.signal` with type "agent.config.changed".
  //
  // Finding 4 — `bypassRefresh` is set for the PRE-connect startup pull only. The
  // normal pull runs through the REST `withRefresh` wrapper; a 401/403 there
  // rotates `account.token`. But the WS client captured the token at construction
  // (`createOpenclawClawlingClient`, `token: account.token`), so a pre-connect
  // rotation would leave the client connecting with a stale token. The startup
  // pull therefore calls the api-client WITHOUT the refresh wrapper: on auth
  // failure it simply fails (empty cache + gate released), leaving the WS
  // handshake — which has its own auth/refresh path — to own token rotation. The
  // on-ready pull (post hello-ok) keeps the refresh wrapper.
  // Monotonic fetch sequence: incremented once per dispatched pull and passed
  // into `applyFetched` so the cache can order results across overlapping
  // requests (including empty pulls, which have no row version to compare). A
  // late result with a lower sequence is ignored, so a stale snapshot — empty
  // or not — can never roll back newer state.
  let groupSettingsFetchSequence = 0;
  const pullGroupSettings = (opts?: { bypassRefresh?: boolean }): void => {
    const sequence = ++groupSettingsFetchSequence;
    // Re-close the gate SYNCHRONOUSLY, before the async pull is scheduled, so any
    // group frame the read loop dispatches concurrently observes the closed gate
    // (rather than slipping past against the pre-pull cache). The opaque token
    // scopes the release by generation AND epoch, so a superseded pull (from a
    // prior re-closed epoch) never re-opens or counts against a newer pull's gate.
    const gateToken = settingsReadyGate.arm();
    void (async () => {
      // Whether THIS pull applied an authoritative (HTTP 200) snapshot. Only an
      // authoritative apply opens the gate immediately; a non-authoritative /
      // failed pull must not open the gate while an earlier pull is still in
      // flight (Finding 2) — it only counts down the in-flight set.
      let authoritative = false;
      try {
        const result = opts?.bypassRefresh
          ? await (conversationApiClient ??= buildConversationApiClient()).getMyGroupSettings()
          : await getConversationApiClient().getMyGroupSettings();
        if (!result.authoritative) {
          // Non-authoritative outcome (404 / endpoint-absent / malformed body):
          // preserve the cache. This is NOT "cleared".
          log?.info?.(
            `[${accountId}] clawchat-plugin-openclaw group-settings pull non-authoritative seq=${sequence} (cache preserved)`,
          );
          return;
        }
        groupSettingsCache.applyFetched(result.rows, sequence);
        authoritative = true;
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw group-settings pull ok seq=${sequence} rows=${result.rows.length}`,
        );
      } catch (err) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw group-settings pull failed: ${err instanceof Error ? err.message : String(err)}`,
        );
      } finally {
        // Pull settled. Open the gate iff this pull applied an authoritative
        // snapshot OR no in-flight pull remains. A non-authoritative/failed pull
        // settling ahead of an earlier still-pending authoritative pull does NOT
        // open the gate — the earlier pull's authoritative apply (or the call-site
        // timeout/abort backstop) does.
        settingsReadyGate.release(gateToken, authoritative);
      }
    })();
  };
  // Best-effort pull of the agent's permission policy from the backend.
  // Called at startup (bypassRefresh: true, mirrors pullGroupSettings startup)
  // and on every successful (re)connect after hello-ok. On failure, logs and
  // keeps the stale cache — never throws, never blocks message handling.
  const pullPermissions = (opts?: { bypassRefresh?: boolean }): void => {
    void (async () => {
      try {
        const policy = opts?.bypassRefresh
          ? await (conversationApiClient ??= buildConversationApiClient()).getMyPermissions()
          : await getConversationApiClient().getMyPermissions();
        permissionCache.set(policy);
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw permissions pull ok ops=${Object.keys(policy.byOperation).length}`,
        );
      } catch (err) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw permissions pull failed: ${err instanceof Error ? err.message : String(err)}`,
        );
        // Keep stale cache; never throw.
      }
    })();
  };
  const logAuthFailure = (reason: string) => {
    if (authFailureLogged) return;
    authFailureLogged = true;
    const current = wsLogContext();
    log?.error?.(
      formatWsLog({
        event: "auth_failed",
        accountId,
        attempt: current.attempt,
        reconnectCount: current.reconnectCount,
        state: "auth_failed",
        action: "stop_reconnect",
        fields: [
          ["trace_id", lastHelloFailTraceId],
          ["reason", reason || lastHelloFailReason],
        ],
      }),
    );
  };
  let dispatchActivationBootstrap: () => Promise<void> = async () => {};
  // Set by dispatchTurnToAgent when it dispatches the synthetic
  // `clawchat-bootstrap` turn: true only if the greeting was actually delivered
  // (a real successful outbound send — block or final). The bootstrap dispatcher
  // reads it to decide whether to mark the bootstrap sent or leave it pending for
  // a later retry.
  let bootstrapGreetingDelivered = false;
  // Per-connection retry for a pending activation bootstrap (see the module-level
  // constants). Re-attempts on the live connection while `wsReady`, bounded by
  // ACTIVATION_BOOTSTRAP_MAX_RETRIES; the budget resets on each fresh connect.
  let bootstrapRetryTimer: ReturnType<typeof setTimeout> | undefined;
  let bootstrapRetryAttempts = 0;
  const bootstrapRetryDelayMs = Math.max(
    1,
    params.activationBootstrapRetryDelayMs ?? ACTIVATION_BOOTSTRAP_RETRY_DELAY_MS,
  );
  const clearBootstrapRetry = () => {
    if (bootstrapRetryTimer !== undefined) {
      clearTimeout(bootstrapRetryTimer);
      bootstrapRetryTimer = undefined;
    }
  };
  const armBootstrapRetry = () => {
    if (bootstrapRetryTimer !== undefined) return;
    if (!wsReady) return;
    if (bootstrapRetryAttempts >= ACTIVATION_BOOTSTRAP_MAX_RETRIES) return;
    bootstrapRetryTimer = setTimeout(() => {
      bootstrapRetryTimer = undefined;
      if (!wsReady) return;
      bootstrapRetryAttempts += 1;
      void dispatchActivationBootstrap();
    }, bootstrapRetryDelayMs);
  };

  client.on("superseded", ({ byClientVersion }: { byClientVersion?: string }) => {
    if (installationIsSuperseded() || abortSignal.aborted || !lease.isCurrent()) return;
    installationSuperseded = true;
    refreshManager.stop();
    clearBootstrapRetry();
    groupCoalescer.cancelAll();
    const marker: SupersededMarker = {
      accountId, userId: account.userId,
      deviceId: lastHelloOkDeviceId || persistedDeviceId || resolveOpenclawClawlingDeviceId(account),
      installId: installId ?? "", supersededAt: Date.now(),
      byClientVersion: byClientVersion ?? null, ownerNotifiedAt: null,
    };
    let saved = false;
    try { saved = store?.setSuperseded?.(marker) === true; } catch { /* Keep the in-memory stop. */ }
    if (!saved) log?.error?.(`[${accountId}] clawchat.install_conflict persistence failed; stopped in this process only (a gateway restart reconnects); use /clawchat-reclaim to resume`);
    showSuperseded(marker);
    log?.error?.(`[${accountId}] clawchat.superseded device_id=${marker.deviceId} client_version=${pluginVersion} by_client_version=${byClientVersion ?? "unknown"} install_id=${installId?.slice(0, 8) ?? "unavailable"}; use /clawchat-reclaim`);
    lifecycle.conflictWait = waitForReclaim(accountId, abortSignal);
    const noticeStop = new AbortController();
    void lifecycle.conflictWait.then(() => noticeStop.abort());
    // Register the waiter before sending; reclaim can arrive while the short
    // notification connection is still waiting for its acknowledgement.
    if (saved) void notifySupersededOwner({ store, marker, url: account.websocketUrl,
      signal: AbortSignal.any([abortSignal, noticeStop.signal]), warn: (message) => log?.warn?.(`[${accountId}] ${message}`),
    }).then(() => {
      if (noticeStop.signal.aborted || abortSignal.aborted || !lease.isCurrent()) return;
      const current = lookupConflict();
      if (current.status === "row") showSuperseded(current.row);
    });
    try { stopForConflict(); } finally { conflictStop.abort(); }
  });

  client.on("state", ({ from, to }) => {
    if (installationIsSuperseded() || !lease.isCurrent()) return;
    log?.info?.(`[${accountId}] clawchat-plugin-openclaw state ${from} -> ${to}`);
    wsReady = to === "connected";
    if (to === "connected") wsReadyEverThisAttempt = true;
    if (to === "connecting") {
      reconnectTracker.connectStart();
      currentAttemptStartedAt = Date.now();
      const current = wsLogContext();
      if (store) {
        recordConnection("start", () => {
          currentConnectionId = store.startConnection({
            platform: "openclaw",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            connectStartedAt: currentAttemptStartedAt,
          });
          currentConnectionFinished = false;
        });
      }
      log?.info?.(
        formatWsLog({
          event: "connect_start",
          accountId,
          attempt: current.attempt,
          reconnectCount: current.reconnectCount,
          state: "connecting",
          action: "connect",
          fields: [
            ["url", account.websocketUrl],
            ["queue_size", getAlignedOutboundQueueSize(client)],
          ],
        }),
      );
    } else if (to === "connected") {
      const elapsedMs = Math.max(0, Date.now() - currentAttemptStartedAt);
      const queueSize = getAlignedOutboundQueueSize(client);
      reconnectTracker.markReady();
      const current = wsLogContext();
      if (store && currentConnectionId != null) {
        const connectionId = currentConnectionId;
        recordConnection("ready", () => {
          if (lastHelloOkDeviceId === undefined && lastHelloOkDeliveryMode === undefined) {
            store.markConnectionReady(connectionId);
            return;
          }
          store.markConnectionReady(connectionId, {
            ...(lastHelloOkDeviceId !== undefined ? { resolvedDeviceId: lastHelloOkDeviceId } : {}),
            ...(lastHelloOkDeliveryMode !== undefined ? { deliveryMode: lastHelloOkDeliveryMode } : {}),
          });
        });
      }
      log?.info?.(
        formatWsLog({
          event: "handshake_ok",
          accountId,
          attempt: current.attempt,
          reconnectCount: current.reconnectCount,
          state: "ready",
          action: "flush_queue",
          fields: [
            ["trace_id", lastConnectTraceId],
            ["elapsed_ms", elapsedMs],
            ["queue_size", queueSize],
          ],
        }),
      );
      try {
        flushAlignedOutboundQueue(client);
      } catch {
        // The queue keeps the failed frame at the head and will retry after the next reconnect.
      }
      void refreshConversationCacheAfterReady();
      // Fresh connection → fresh retry budget; cancel any stale timer first.
      bootstrapRetryAttempts = 0;
      clearBootstrapRetry();
      void dispatchActivationBootstrap();
      // Re-pull per-group agent settings on every successful (re)connect so the
      // cache stays current after a network interruption or token refresh.
      pullGroupSettings();
      // Re-pull permission policy alongside group settings on every (re)connect.
      pullPermissions();
      // §A.1 — arm the proactive refresh timer from the live token's `exp`
      // every time a connection becomes ready (re-armed after every refresh via
      // the gateway re-enter).
      refreshManager.armProactiveTimer(activatedAtMs);
    } else if (to === "disconnected") {
      reconnectTracker.markClosed();
      // Stop the bootstrap retry while down; it re-arms on the next ready.
      clearBootstrapRetry();
      // §A.1 — clear the proactive timer on disconnect; it re-arms on the next
      // ready, or the gateway re-enter arms a fresh one.
      if (!reconnectWithRefreshedToken) refreshManager.disarmProactiveTimer();
    }
    const next = { ...getStatus(), ...mapClawlingStateToStatus(to as ClawlingState) };
    setStatus(next);
  });

  client.on("close", ({ code, reason }: { code?: number; reason?: string }) => {
    if (closingForAbort || (code === 1000 && reason === "client close")) return;
    finishCurrentConnection({
      state: "disconnected",
      closeCode: code ?? null,
      closeReason: reason ?? null,
    });
    const current = wsLogContext();
    log?.info?.(
      formatWsLog({
        event: "connection_lost",
        accountId,
        attempt: current.attempt,
        reconnectCount: current.reconnectCount,
        state: current.state,
        action: code === CLOSE_CODE_SUPERSEDED_BY_OTHER_INSTALL ? "stop_superseded" : "reconnect",
        fields: [
          ["code", code],
          ["reason", reason],
        ],
      }),
    );
  });

  client.on("reconnect:scheduled", ({ delay }: { delay?: number }) => {
    reconnectTracker.scheduleReconnect("connection_lost", {
      delayMs: delay,
      maxDelayMs: account.reconnect.maxDelay,
    });
  });

  client.on("raw", (env: Envelope) => {
    if (env.event === "connect.challenge") {
      const payload = env.payload as { nonce?: unknown } | undefined;
      const current = wsLogContext();
      log?.info?.(
        formatWsLog({
          event: "challenge_received",
          accountId,
          attempt: current.attempt,
          reconnectCount: current.reconnectCount,
          state: "handshaking",
          action: "send_connect",
          fields: [
            ["challenge_trace_id", env.trace_id],
            ["has_nonce", typeof payload?.nonce === "string" && payload.nonce.length > 0],
          ],
        }),
      );
    }
    if (env.event === "ping" || env.event === "pong") {
      protocolControlLogger.handleInbound(env);
    }
    if (wsReady) {
      const sender = env.sender as { id?: unknown } | undefined;
      const senderId = typeof sender?.id === "string" ? sender.id : "-";
      if (env.event === "message.send" || env.event === "message.reply") {
        const current = wsLogContext();
        log?.info?.(
          formatWsLog({
            event: "inbound_dispatch",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            state: "ready",
            action: "dispatch",
            fields: [
              ["event_name", env.event],
              ["trace_id", env.trace_id],
              ["chat_id", env.chat_id],
              ["sender_id", senderId],
            ],
          }),
        );
      } else if (env.event === "message.ack") {
        const current = wsLogContext();
        log?.info?.(
          formatWsLog({
            event: "inbound_control",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            state: "ready",
            action: "ack",
            fields: [
              ["event_name", env.event],
              ["trace_id", env.trace_id],
            ],
          }),
        );
      } else if (
        env.event === "offline.batch" ||
        env.event === "offline.ack" ||
        env.event === "offline.done"
      ) {
        const current = wsLogContext();
        log?.info?.(
          formatWsLog({
            event: "inbound_control",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            state: "ready",
            action: "ignore_legacy",
            fields: [
              ["event_name", env.event],
              ["trace_id", env.trace_id],
            ],
          }),
        );
      } else if (env.event === "message.error") {
        // Never fold this into `inbound_ignored`: `code` + `chat_id` are the
        // only in-plugin evidence that an uplink was refused, and the
        // 2026-07-19 incident needed a server-log dig purely because this line
        // did not exist. `action=message_error` marks it as semantically seen.
        const current = wsLogContext();
        const errorPayload = env.payload && typeof env.payload === "object"
          ? (env.payload as { code?: unknown })
          : undefined;
        log?.info?.(
          formatWsLog({
            event: "inbound_control",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            state: "ready",
            action: "message_error",
            fields: [
              ["event_name", env.event],
              ["trace_id", env.trace_id],
              ["chat_id", env.chat_id],
              ["code", typeof errorPayload?.code === "string" ? errorPayload.code : "-"],
            ],
          }),
        );
      } else if (env.event === "history.truncated") {
        // Replay-start boundary frame (docs/client-integration.md §11.7): part
        // of the retained history will not be replayed to this device. This
        // plugin advertises no reliable-delivery flag, so the server does not
        // send it today; if one arrives it is logged explicitly. Never acked (no
        // seq/dseq), never dispatched, no user-visible action. An absent or
        // unrecognised `reason` means "pruned"; `oldest_seq` is an opaque token.
        const current = wsLogContext();
        const truncatedPayload = env.payload && typeof env.payload === "object"
          ? (env.payload as { oldest_seq?: unknown; reason?: unknown })
          : undefined;
        const rawReason = truncatedPayload?.reason;
        const knownReason = rawReason === "pruned" || rawReason === "cursor_started_above_zero";
        const fields: Array<[string, string | number]> = [
          ["event_name", env.event],
          ["trace_id", env.trace_id],
          ["oldest_seq", typeof truncatedPayload?.oldest_seq === "number" ? truncatedPayload.oldest_seq : "-"],
          ["reason", knownReason ? (rawReason as string) : "pruned"],
        ];
        if (!knownReason && typeof rawReason === "string" && rawReason) {
          fields.push(["raw_reason", rawReason]);
        }
        log?.info?.(
          formatWsLog({
            event: "inbound_control",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            state: "ready",
            action: "history_truncated",
            fields,
          }),
        );
      } else if (env.event !== "ping" && env.event !== "pong") {
        const current = wsLogContext();
        log?.info?.(
          formatWsLog({
            event: "inbound_ignored",
            accountId,
            attempt: current.attempt,
            reconnectCount: current.reconnectCount,
            state: "ready",
            action: "ignore",
            fields: [
              ["event_name", env.event],
              ["trace_id", env.trace_id],
            ],
          }),
        );
      }
    }
    if (env.event !== "hello-fail") return;
    lastHelloFailTraceId = env.trace_id;
    const payload = env.payload as { reason?: unknown } | undefined;
    lastHelloFailReason = typeof payload?.reason === "string" ? payload.reason : "";
  });

  client.on("metadata:invalidated", (env: Envelope) => {
    void handleMetadataInvalidation(env);
  });

  // --- Conversational skill hot-update (adapter-driven; see src/skill-update.ts) ---
  // One pending-consent record per runtime.
  // WRITE target = the OpenClaw-managed skills dir (`<stateDir>/skills`): it is
  // chokidar-watched (host src/skills/runtime/refresh.ts:107) so a write bumps
  // the snapshot + rebuilds the prompt next turn, and it loads at HIGHER
  // precedence than plugin-bundled skills (managed=3 > bundled=2, host
  // src/skills/loading/workspace.ts:1220-1239), overriding the bundled copy.
  // The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
  // "current installed version" only — never written (it lives in node_modules
  // → may be read-only and is clobbered on `openclaw plugins update`).
  // Update detection converges on raw sha256 (readLocalSkillState), not the
  // frontmatter version, so a same-version content fix still applies; a
  // manifest `removed` id tombstones the LOCAL managed copy (never bundled)
  // via removeManagedSkill, deleting only ids the official manifest names.
  const skillUpdateConsent = new PendingConsentStore();
  const managedSkillsDir = resolveManagedSkillsDir();
  let bundledSkillsDir: string | null = null;
  try {
    bundledSkillsDir = resolveBundledSkillsDir();
  } catch (err) {
    log?.info?.(
      `[${accountId}] clawchat-plugin-openclaw skill-update: bundled skills dir not resolvable (using managed-only current): ${
        err instanceof Error ? err.message : String(err)
      }`,
    );
  }
  // Load-time tombstone sweep: delete any managed skill copy the bundled
  // manifest snapshot marks as removed, so a stale id from an old install
  // stops shadowing/loading. Fire-and-forget — must never block or fail boot.
  void cleanupTombstonedManagedSkills({
    managedDir: managedSkillsDir,
    bundledDir: bundledSkillsDir,
    log,
  }).catch(() => {});
  // A usr_... id is not a valid chat_id — member-backend rejects it with
  // "invalid conversation id" and msghub never acks the send. Proactive owner
  // messages must be addressed to the direct conversation recorded at
  // activation (parity with the hermes adapter's _owner_direct_chat_id).
  // One warning per failure class for this lifecycle; never logs ids or SQL.
  const ownerIdentityWarnings = new Set<string>();
  const warnOwnerIdentity = (failure: string) => {
    if (ownerIdentityWarnings.has(failure)) return;
    ownerIdentityWarnings.add(failure);
    try {
      log?.warn?.(`clawchat-plugin-openclaw: ${failure}; owner identity unavailable`);
    } catch {
      // Logging cannot change the (fail-closed) answer.
    }
  };
  const resolveOwnerDirectConversationId = (): string | null =>
    // Shared strict resolver (src/owner-identity.ts): the SQLite activation
    // row decides; the config `ownerUserId` mirror may be stale or absent and
    // can neither hide nor invent the owner's direct chat. Unavailable storage
    // or a cleared/rejected token yields no owner DM (fail closed).
    resolveOwnerIdentity(store, accountId, account.ownerUserId, warnOwnerIdentity,
      params.rejectedActivationToken).conversationId;
  // R3: the owner's direct conversation is never gated by the dead-chat send
  // guard. If it were, the very message explaining why the agent went dark
  // (skill-update notices, auth/logout notices, group owner-attention
  // forwards) would be unreachable. Registered lazily — the activation
  // conversation may be recorded after the client is constructed.
  client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
  // `non-owner-host-tools: approve` routes group host-tool approvals to the
  // owner's direct chat through the host's `approvals.plugin`
  // (approval-route.ts). Checked once at start and again from a group turn
  // that finds the route missing (the owner chat may be recorded later). One
  // attempt per owner chat per lifecycle, so a failing write cannot loop.
  const readLiveConfig = (): OpenClawConfig => {
    const current = (runtime.config as { current?: () => unknown } | undefined)?.current;
    return (typeof current === "function" ? current.call(runtime.config) : cfg) as OpenClawConfig;
  };
  const approvalReconcileAttempts = new Set<string>();
  const hasStoredIdentity = storedIdentityLookup(store);
  const approvalsStateDir = (() => {
    try {
      return runtime.state?.resolveStateDir?.();
    } catch {
      return undefined;
    }
  })();
  const hasActiveAskGrant = (id: string): boolean => {
    try {
      return resolveOpenclawClawlingAccount(readLiveConfig(), id).toolGrants === "on"
        && (store?.hasActiveAskGrant?.(id, Date.now()) === true || store?.hasPendingAskGrant?.(id, Date.now()) === true);
    } catch { return false; }
  };
  const reconcileApprovalRoute = (): void => {
    const mutateConfigFile = resolveMutateConfigFile();
    if (!mutateConfigFile) {
      if (approvalReconcileAttempts.has("no-config-writes")) return;
      approvalReconcileAttempts.add("no-config-writes");
      try {
        const live = readLiveConfig();
        if (resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools !== "approve"
          && !hasActiveAskGrant(accountId)) return;
        const route = resolveApprovalRoute({
          liveConfig: live,
          accountId,
          activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
          approve: true,
          ownerConversationId: resolveOwnerDirectConversationId(),
        });
        if (route.ready) return;
      } catch {
        // Fall through: say why approve is not routing.
      }
      log?.warn?.(
        `[${accountId}] clawchat-plugin-openclaw: non-owner-host-tools approve cannot write approvals.plugin (host config writes unavailable); group host-tool calls stay blocked`,
      );
      return;
    }
    let ownerChat: string | null = null;
    try {
      ownerChat = resolveOwnerDirectConversationId();
    } catch {
      ownerChat = null;
    }
    const attemptKey = `${ownerChat ?? ""}:${hasActiveAskGrant(accountId)}`;
    if (approvalReconcileAttempts.has(attemptKey)) return;
    approvalReconcileAttempts.add(attemptKey);
    void reconcileApprovalsPlugin({
      readConfig: readLiveConfig,
      mutateConfigFile: mutateConfigFile as never,
      ownerConversation: (id) => (id === accountId ? ownerChat : null),
      knownActive: accountId,
      hasStoredIdentity,
      hasActiveAskGrant,
      ...(approvalsStateDir ? { writeGuard: createApprovalsWriteGuard(approvalsStateDir) } : {}),
      log: {
        info: (m) => log?.info?.(`[${accountId}] ${m}`),
        warn: (m) => {
          const warn = (log as { warn?: (msg: string) => void } | undefined)?.warn;
          if (warn) warn(`[${accountId}] ${m}`);
          else log?.info?.(`[${accountId}] WARNING ${m}`);
        },
        error: (m) => log?.error?.(`[${accountId}] ${m}`),
      },
    }).catch(() => {
      // Logged inside; the route simply stays not ready (calls block).
    });
  };
  const groupApprovalRouteReady = (ownerChat: string | null): boolean => {
    try {
      const live = readLiveConfig();
      const approve = resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools === "approve";
      if (!approve) return false;
      const route = resolveApprovalRoute({
        liveConfig: live,
        accountId,
        activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
        approve,
        ownerConversationId: ownerChat,
      });
      if (!route.ready && (route.reason === "not-applied" || route.reason === "no-owner-chat")) {
        reconcileApprovalRoute();
      }
      return route.ready;
    } catch {
      return false;
    }
  };
  if (!params.boundaryWarningIssued) reconcileApprovalRoute();
  const sendOwnerSkillMessage = async (text: string): Promise<void> => {
    const conversationId = resolveOwnerDirectConversationId();
    if (!conversationId) {
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw skill-update owner message skipped: no activation conversation recorded`,
      );
      return;
    }
    await sendOpenclawClawlingText({
      client,
      account,
      to: { chatId: conversationId, chatType: "direct" },
      text,
      log,
    });
  };
  // Wire the liveware-sample intro notifier now that `client`/`account` are in
  // scope (both declared earlier in this same function body — see the
  // `livewareSampleNotify` forward declaration above the supervisor construction).
  livewareSampleNotify = async (text: string): Promise<boolean> => {
    const conversationId = resolveOwnerDirectConversationId();
    if (!conversationId) return false;
    const result = await sendOpenclawClawlingText({
      client,
      account,
      to: { chatId: conversationId, chatType: "direct" },
      text,
      log,
    });
    return result != null;
  };
  const triggerSkillUpdateCheck = async (): Promise<void> => {
    const ownerUserId = account.ownerUserId?.trim();
    if (!ownerUserId) {
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw skill-update check skipped: no owner user id`,
      );
      return;
    }
    // Effective current = managed copy if present, else bundled (host precedence).
    await runSkillUpdateCheck({
      ownerUserId,
      readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
      hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
      fetchFn: globalThis.fetch,
      store: skillUpdateConsent,
      sendOwnerMessage: sendOwnerSkillMessage,
      log: {
        info: (m) => log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${m}`),
        error: (m) => log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${m}`),
      },
    });
  };
  const consumeOwnerSkillConsent = async (args: {
    senderId: string;
    text: string;
    chatId: string;
  }): Promise<boolean> => {
    const ownerUserId = account.ownerUserId?.trim();
    if (!ownerUserId) return false;
    return handleOwnerConsentReply({
      ownerUserId,
      senderId: args.senderId,
      text: args.text,
      store: skillUpdateConsent,
      fetchFn: globalThis.fetch,
      skillsDir: managedSkillsDir,
      sendOwnerMessage: sendOwnerSkillMessage,
      log: {
        info: (m) => log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${m}`),
        error: (m) => log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${m}`),
      },
    });
  };

  // Debounce flag for lighter-awareness consolidated notes.
  // A single queueMicrotask coalesces all awareness signals that arrive in
  // the same synchronous turn into exactly one handleInboundEnvelope call.
  let pendingAwarenessNote = false;
  /**
   * First message to a newly added NON-owner friend (`friend.added`).
   *
   * The signal only names the counterparty; the direct conversation the
   * server created inside the accept transaction is resolved through
   * `POST /v1/conversations/direct` and ONE synthetic greeting turn runs in
   * it. Dedupe is persisted through the message ledger keyed on the signal's
   * `event_id` (`message_id` is `notify:friend.added:<userId>` and would
   * collide on remove + re-add, so it is only the fallback key) — a
   * reconnect replay of the same signal cannot greet twice. The claim uses
   * kind "message" because the ledger's once-only unique index is partial on
   * kind = 'message'; chat_id stays null so the row never feeds a transcript.
   * The owner's own `friend.added` is skipped: the activation bootstrap greets
   * the owner.
   */
  const scheduleFriendGreeting = (payload: Record<string, unknown> | undefined): void => {
    if (!account.friendGreeting) return;
    const friendUserId = typeof payload?.entity_id === "string" ? payload.entity_id.trim() : "";
    if (!friendUserId) return;
    const ownerUserId = account.ownerUserId?.trim();
    if (ownerUserId && friendUserId === ownerUserId) return;
    const eventId = typeof payload?.event_id === "string" ? payload.event_id.trim() : "";
    const messageId = typeof payload?.message_id === "string" ? payload.message_id.trim() : "";
    const dedupeKey = eventId || messageId;
    if (!dedupeKey) {
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_dedupe_key`,
      );
      return;
    }
    if (store?.claimMessageOnce) {
      const claimed = recordConnection("friend greeting claim", () =>
        store.claimMessageOnce?.({
          platform: "openclaw",
          accountId,
          kind: "message",
          direction: "inbound",
          eventType: "friend.greeting",
          traceId: null,
          chatId: null,
          messageId: `friend.greeting:${dedupeKey}`,
          text: null,
          raw: { friend_user_id: friendUserId },
        }),
      );
      if (claimed !== true) {
        // false = already greeted (replay); null = ledger undecided — fail
        // closed rather than risk a duplicate unsolicited message.
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} key=${dedupeKey} claimed=${String(claimed)}`,
        );
        return;
      }
    }
    void (async () => {
      let conversationId = "";
      try {
        const result = await getConversationApiClient().getDirectConversation(friendUserId);
        conversationId = result?.conversation?.id?.trim() ?? "";
      } catch (err) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw friend greeting conversation lookup failed friend=${friendUserId}: ${
            err instanceof Error ? err.message : String(err)
          }`,
        );
        return;
      }
      if (!isValidChatId(conversationId)) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_conversation_id`,
        );
        return;
      }
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw friend greeting dispatch friend=${friendUserId} chat_id=${conversationId}`,
      );
      // The recipient here is the new friend, NOT the owner — but we
      // deliberately use the OWNER's language anyway: friends are usually in
      // the same language circle, and the backend only exposes `locale` on
      // the owner profile endpoint, so no third-party locale is obtainable.
      //
      // `…IfKnown`, not `resolveOwnerLanguage`: this is a synchronous read of a
      // cache that may never have been filled, and it must NOT be awaited here.
      // A cold cache yields null and the greeting simply carries no language
      // line — see `buildActivationBootstrapText`.
      const language = resolveOwnerLanguageIfKnown(
        store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
      );
      await handleInboundEnvelope(
        buildFriendGreetingEnvelope({ account, conversationId, friendUserId, language }),
      );
    })().catch((err) => {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw friend greeting failed friend=${friendUserId}: ${
          err instanceof Error ? err.message : String(err)
        }`,
      );
    });
  };

  client.on("notify:signal", (env: Envelope) => {
    // §9.4 reliable system notification. The plugin holds no friend/roster
    // cache (friends are fetched on demand via REST tools), so there is nothing
    // to invalidate — observe + dedup only. The live frame and its reliable
    // inbox replay carry the same event_id and collapse to one observation.
    const outcome = notifySignalObserver.observe(env);
    if (outcome === "observed") {
      const payload = env.payload && typeof env.payload === "object"
        ? (env.payload as Record<string, unknown>)
        : undefined;
      const type = typeof payload?.type === "string" ? payload.type : "";
      if (type === "conversation.dissolved") {
        // The conversation is gone. Evict it so no further uplink is emitted
        // for it. Falls through to the awareness branch so the owner is told.
        const entityId =
          typeof payload?.entity_id === "string" ? payload.entity_id : "";
        // "signal" tier: this gates typing only. The protocol forbids gating
        // message.send on a dissolved signal — only a server rejection
        // (message.error, see src/outbound.ts) may do that, and unlike this
        // one that rejection expires.
        const evicted = client.markChatDead(entityId, "signal");
        log?.info?.(
          evicted
            ? `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received; evicting ${entityId}`
            : `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received with missing/invalid entity_id; no eviction performed`,
        );
      }
      if (type === "agent.config.changed") {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`,
        );
        pullGroupSettings();
      } else if (type === "agent.permission.changed") {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw agent.permission.changed signal received; re-pulling permissions`,
        );
        pullPermissions();
      } else if (type === "clawchat.skill.update.check") {
        // Content-free trigger: check the official skill source in code and, if
        // there is an update, ask the owner in chat for consent. The atomic
        // overwrite happens later when the owner affirms (consent gate).
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw clawchat.skill.update.check signal received; checking official skill source`,
        );
        void triggerSkillUpdateCheck().catch((err) => {
          log?.error?.(
            `[${accountId}] clawchat-plugin-openclaw skill-update check failed: ${
              err instanceof Error ? err.message : String(err)
            }`,
          );
        });
      } else if (type === "moment.comment.created" || type === "moment.comment.replied") {
        // Content-free trigger: the note carries only the scenario + moment id
        // and points the agent at the get_moment tool — never the comment
        // text. Not gated behind account.awarenessNote and not debounced:
        // each moment-comment signal yields its own note (msghub already
        // coalesces multiple comments on one moment by entity_id).
        const momentId =
          typeof payload?.entity_id === "string" ? payload.entity_id : "";
        // No activation conversation ⇒ no routable chat_id. Skip the note
        // rather than address it to the owner's usr_… id, which
        // member-backend rejects as "invalid conversation id" (the agent's
        // in-turn replies are then dropped with no negative ack). Resolve
        // this FIRST, mirroring the friend.*/conversation.* awareness
        // branch's guard below.
        const ownerConversationId = resolveOwnerDirectConversationId();
        if (!ownerConversationId) {
          log?.info?.(
            `[${accountId}] clawchat-plugin-openclaw ${type} signal received but no owner conversation resolved; skipping moment awareness note (moment ${momentId})`,
          );
          return;
        }
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw ${type} signal received; synthesizing moment awareness note (moment ${momentId})`,
        );
        void handleInboundEnvelope(
          buildMomentCommentNoteEnvelope({
            account,
            ownerConversationId,
            momentId,
            replied: type === "moment.comment.replied",
          }),
        );
      } else if (
        type === "friend.added" ||
        type === "friend.removed" ||
        type === "friend.profile_updated" ||
        type.startsWith("conversation.")
      ) {
        // Lighter-awareness: log + optional single consolidated note.
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw ${type} signal received; lighter-awareness handling`,
        );
        if (account.awarenessNote && !pendingAwarenessNote) {
          pendingAwarenessNote = true;
          queueMicrotask(() => {
            pendingAwarenessNote = false;
            // No activation conversation ⇒ no routable chat_id. Skip the note
            // rather than address it to the owner's usr_… id, which
            // member-backend rejects as "invalid conversation id" (the agent's
            // in-turn replies are then dropped with no negative ack).
            const ownerConversationId = resolveOwnerDirectConversationId();
            if (!ownerConversationId) {
              log?.info?.(
                `[${accountId}] clawchat-plugin-openclaw awareness note skipped: no activation conversation recorded`,
              );
              return;
            }
            void handleInboundEnvelope(
              buildAwarenessNoteEnvelope({ account, ownerConversationId }),
            );
          });
        }
        if (type === "friend.added") {
          scheduleFriendGreeting(payload as Record<string, unknown> | undefined);
        }
        if (type === "friend.profile_updated") {
          refreshOwnerProfileOnUpdate(payload?.entity_id);
        }
      }
    }
  });

  client.on("replay:done", (env: Envelope) => {
    // §11.5 terminal control frame: device replay drained, live delivery begins.
    // Fires on every reconnect (even zero-backlog). Replayed messages are
    // processed inline, so this is a logged boundary marker, not a gate.
    log?.info?.(`[${accountId}] clawchat-plugin-openclaw replay.done trace=${env.trace_id}`);
  });

  client.on("error", (err: unknown) => {
    if (installationIsSuperseded() || !lease.isCurrent() || closingForAbort) return;
    if (!wsReadyEverThisAttempt) latestInitialClientError = err;
    const classified = classifyClawlingClientError(err);
    if (classified.kind === "auth") {
      // §A.2 — a WS hello-fail(auth) on a LIVE (already-connected) session.
      // Attempt a gated reactive refresh before tearing the account down. The
      // INITIAL-connect auth failure is owned by the `client.connect()` catch
      // below (which runs the refresh/backoff/teardown decision and the recursive
      // re-enter), so only react here once the session was previously ready —
      // otherwise we'd double-handle and the error handler's teardown would race
      // the catch's transient-backoff branch (wrongly flipping configured:false).
      if (!reconnectWithRefreshedToken && !autoLoggedOut && wsReadyEverThisAttempt) {
        void handleWsAuthFailure(lastHelloFailReason || classified.message);
        return;
      }
      // Not-ready (initial connect): the `client.connect()` catch below owns the
      // refresh/backoff/teardown DECISION and the status flip. Record the
      // connection as auth_failed here (bookkeeping — the ws-client's own 4001
      // close would otherwise finish it as a plain "disconnected") and log the
      // auth failure, but do NOT flip status here: a transient refresh / generic
      // backoff must leave configured untouched, and that decision lives in the
      // catch.
      if (!reconnectWithRefreshedToken && !autoLoggedOut) {
        finishCurrentConnection({
          state: "auth_failed",
          error: lastHelloFailReason || classified.message,
        });
        logAuthFailure(classified.message);
      }
      return;
    } else if (classified.kind === "transport") {
      finishCurrentConnection({ state: "transport_error", error: classified.message });
      const current = wsLogContext();
      log?.info?.(
        formatWsLog({
          event: "connection_lost",
          accountId,
          attempt: current.attempt,
          reconnectCount: current.reconnectCount,
          state: current.state,
          action: "reconnect",
          fields: [
            ["code", "-"],
            ["reason", classified.message],
          ],
        }),
      );
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw transport error (reconnecting): ${classified.message}`,
      );
      setStatus({ ...getStatus(), connected: false, running: true });
    } else if (classified.kind === "ack-timeout") {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw ack timeout: ${classified.message}`);
    } else if (classified.kind === "protocol") {
      log?.error?.(`[${accountId}] clawchat-plugin-openclaw protocol error: ${classified.message}`);
    } else if (classified.kind === "state") {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw state error: ${classified.message}`);
    } else {
      log?.error?.(`[${accountId}] clawchat-plugin-openclaw client error: ${classified.message}`);
    }
  });

  type IngestTurnResult = "submitted" | "skipped" | "failed" | "stored_muted" | "stored_skipped";
  type GroupIngestTurnParams = IngestTurnParams & { peer: { kind: "group"; id: string } };

  const loadGroupParticipantsForPrompt = async (params: {
    memoryRoot: string;
    groupId: string;
    ownerMetadata: ClawChatPromptMetadata | null;
  }): Promise<ClawChatGroupParticipantPrompt[]> => {
    const groupFile = await readClawChatMemoryFile(params.memoryRoot, {
      targetType: "group",
      targetId: params.groupId,
    });
    const participantIds = (groupFile.metadata.participant_ids ?? "")
      .split(",")
      .map((value) => value.trim())
      .filter((value) => value.length > 0);
    const agentOwnerId = params.ownerMetadata?.agent_owner_id ?? account.ownerUserId;
    const groupOwnerId = groupFile.metadata.group_owner_id;
    const participants: ClawChatGroupParticipantPrompt[] = [];
    for (const userId of participantIds) {
      let metadata: ClawChatPromptMetadata | null = null;
      try {
        const file = await readClawChatMemoryFile(params.memoryRoot, {
          targetType: "user",
          targetId: userId,
        });
        metadata = file.exists ? file.metadata : null;
      } catch {
        metadata = null;
      }
      const isAgentOwner = Boolean(agentOwnerId && userId === agentOwnerId);
      const isGroupOwner = Boolean(groupOwnerId && userId === groupOwnerId);
      const name = isAgentOwner
        ? params.ownerMetadata?.agent_owner_nickname ?? metadata?.nickname ?? userId
        : userId === account.userId
          ? params.ownerMetadata?.agent_nickname ?? metadata?.nickname ?? userId
          : isGroupOwner
            ? groupFile.metadata.group_owner_nickname ?? metadata?.nickname ?? userId
            : metadata?.nickname ?? userId;
      const profileType = metadata?.profile_type ?? (userId === account.userId ? "agent" : "user");
      if (name !== userId) knownNicknames.set(userId, name);
      participants.push({ id: userId, name, profileType, isAgentOwner, isGroupOwner });
    }
    return participants;
  };

  const buildPromptForTurn = async (turn: IngestTurnParams, memoryRoot: string): Promise<string> => {
    const senderRelation = resolveSenderRelation({
      senderId: turn.senderId,
      accountUserId: account.userId,
      accountOwnerUserId: account.ownerUserId,
      senderProfileType: turn.senderProfileType,
    });
    const promptChatType = turn.peer.kind === "group" ? "group" : "dm";
    const promptMetadata = await loadClawChatPromptMetadata({
      memoryRoot,
      turn: {
        chatType: promptChatType,
        senderId: turn.senderId,
        senderIsOwner: senderRelation === "owner",
        groupId: turn.peer.kind === "group" ? turn.peer.id : null,
      },
      log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
    });
    const metadataSenderProfileType = promptMetadata.userMetadata?.profile_type ?? null;
    const promptSenderProfileType = metadataSenderProfileType ?? turn.senderProfileType ?? (
      senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"
    );
    const groupParticipants = turn.peer.kind === "group"
      ? await loadGroupParticipantsForPrompt({
          memoryRoot,
          groupId: turn.peer.id,
          ownerMetadata: promptMetadata.ownerMetadata,
        })
      : [];
    const noteMemorySection = await buildNoteMemorySection({
      memoryRoot,
      chatType: promptChatType,
      chatId: turn.peer.id,
      senderId: turn.senderId,
      senderIsOwner: senderRelation === "owner",
      selfId: account.userId,
      ownerId: account.ownerUserId,
      speakers: turn.peer.kind === "group"
        ? (turn.groupMessages && turn.groupMessages.length > 0
            ? turn.groupMessages.map((message) => ({ id: message.senderId, name: message.senderName }))
            : [{ id: turn.senderId, name: turn.senderNickName }])
        : [],
      caps: { user: account.noteCapUser, group: account.noteCapGroup, turn: account.noteCapTurn },
    });
    return renderClawChatProfilePrompt({
      basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
      ...promptMetadata,
      ownerIdFallback: account.ownerUserId,
      currentAgentIdFallback: account.userId,
      groupParticipants,
      noteMemorySection,
      turn: {
        chatType: promptChatType,
        chatId: turn.peer.id,
        senderId: turn.senderId,
        senderName: promptMetadata.userMetadata?.nickname ?? (turn.senderNickName || turn.senderId),
        senderProfileType: promptSenderProfileType,
        senderIsOwner: senderRelation === "owner",
        groupId: turn.peer.kind === "group" ? turn.peer.id : null,
        coalescedGroupBatch: turn.coalescedGroupBatch === true,
        wasMentioned: turn.wasMentioned,
        mentionedUserIds: turn.mentionedUserIds,
        mentionedUsers: turn.mentionedUsers,
        groupMessages: turn.groupMessages,
        messageText: turn.rawBody,
        emittedAt: turn.timestamp,
      },
    });
  };

  // Group frames often carry the sender's id as nick_name. Prefer the nickname
  // already cached in ClawChat memory (owner.md for the owner and this agent,
  // users/<id>.md for everyone else, the group's group_owner_nickname for its
  // owner); keep the id when nothing is cached.
  const cachedNicknameFor = async (memoryRoot: string, userId: string): Promise<string | null> => {
    try {
      if (userId === account.ownerUserId || userId === account.userId) {
        const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
        const nickname = userId === account.userId
          ? ownerFile.metadata.agent_nickname
          : ownerFile.metadata.agent_owner_nickname;
        if (nickname) return nickname;
      }
      const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
      return userFile.exists && userFile.metadata.nickname ? userFile.metadata.nickname : null;
    } catch {
      return null;
    }
  };

  // Nicknames seen while building group prompts, so ingest can label a
  // coalesced batch's transcript ("Alice:" rather than "usr_…:") without
  // touching disk.
  const knownNicknames = new Map<string, string>();
  // Group message ids already handed to the agent, per chat (see group-catch-up.ts).
  const deliveredGroupMessages = new DeliveredGroupMessages();
  // One catch-up line: `Name (usr_…): text`, the name from the stored frame's
  // nick_name unless that is just the id, else the cached nickname.
  const formatCatchUpLine = (row: MessageRow): string => {
    const text = (row.text ?? "").trim() || "(empty message)";
    let senderId = "";
    let nickName = "";
    try {
      const frame = row.raw_json ? (JSON.parse(row.raw_json) as { sender?: { id?: unknown; nick_name?: unknown } }) : null;
      senderId = typeof frame?.sender?.id === "string" ? frame.sender.id : "";
      nickName = typeof frame?.sender?.nick_name === "string" ? frame.sender.nick_name : "";
    } catch {
      // A malformed stored frame still yields its text.
    }
    if (!senderId) return text;
    const name = (nickName && nickName !== senderId ? nickName : knownNicknames.get(senderId)) ?? "";
    return name ? `${name} (${senderId}): ${text}` : `${senderId}: ${text}`;
  };

  // The sender id of a stored row whose frame carried no real nickname (its
  // nick_name is missing or just the id), else "".
  const bareSenderIdOfRow = (row: MessageRow): string => {
    try {
      const frame = row.raw_json
        ? (JSON.parse(row.raw_json) as { sender?: { id?: unknown; nick_name?: unknown } })
        : null;
      const id = typeof frame?.sender?.id === "string" ? frame.sender.id : "";
      const nick = typeof frame?.sender?.nick_name === "string" ? frame.sender.nick_name : "";
      return id && (!nick || nick === id) ? id : "";
    } catch {
      return "";
    }
  };
  // Last group-metadata pull made only to resolve names, per chat: a sender who
  // can never be resolved (deleted account) must not refetch on every batch.
  const nicknameRefreshAt = new Map<string, number>();
  const NICKNAME_REFRESH_COOLDOWN_MS = 60_000;
  const NICKNAME_REFRESH_TIMEOUT_MS = 3_000;
  // Fill knownNicknames for `ids` from ClawChat memory; if some are still
  // missing in a group, pull the group's participants once (bounded, with a
  // cooldown) and read again. Never throws: a name is a nicety, the turn is not.
  const warmGroupNicknames = async (
    peer: { kind: "direct" | "group"; id: string },
    ids: Iterable<string>,
  ): Promise<void> => {
    if (peer.kind !== "group") return;
    try {
      const memoryRoot = resolveMemoryRootForPeer(peer);
      if (!memoryRoot) return;
      const wanted = Array.from(new Set(Array.from(ids).filter((id) => id && id !== account.userId)));
      const missing = () => wanted.filter((id) => !knownNicknames.has(id));
      const readCached = async () => {
        for (const id of missing()) {
          const nickname = await cachedNicknameFor(memoryRoot, id);
          if (nickname && nickname !== id) knownNicknames.set(id, nickname);
        }
      };
      await readCached();
      if (missing().length === 0) return;
      const now = Date.now();
      const last = nicknameRefreshAt.get(peer.id);
      if (last !== undefined && now - last < NICKNAME_REFRESH_COOLDOWN_MS) return;
      nicknameRefreshAt.set(peer.id, now);
      let timer: ReturnType<typeof setTimeout> | undefined;
      await Promise.race([
        refreshGroupProfile({
          platform: "openclaw",
          accountId,
          accountUserId: account.userId,
          accountOwnerUserId: account.ownerUserId,
          conversationId: peer.id,
          api: getConversationApiClient(),
          store: {},
          memoryRoot,
          log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
        }),
        new Promise<void>((resolve) => {
          timer = setTimeout(resolve, NICKNAME_REFRESH_TIMEOUT_MS);
        }),
      ]).finally(() => {
        if (timer) clearTimeout(timer);
      });
      await readCached();
    } catch (err) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw group sender name lookup failed: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
  };

  const withCachedGroupSenderNames = async <T extends IngestTurnParams>(
    turn: T,
    memoryRoot: string | null,
  ): Promise<T> => {
    if (turn.peer.kind !== "group" || !memoryRoot) return turn;
    const isBareId = (name: string | null | undefined, id: string): boolean => !name || name === id;
    const cache = new Map<string, string | null>();
    let groupOwner: { id?: string; nickname?: string } | null = null;
    const lookup = async (userId: string): Promise<string | null> => {
      if (!cache.has(userId)) {
        let nickname = await cachedNicknameFor(memoryRoot, userId);
        if (!nickname) {
          if (!groupOwner) {
            try {
              const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: turn.peer.id });
              groupOwner = { id: groupFile.metadata.group_owner_id, nickname: groupFile.metadata.group_owner_nickname };
            } catch {
              groupOwner = {};
            }
          }
          if (groupOwner.id === userId && groupOwner.nickname) nickname = groupOwner.nickname;
        }
        cache.set(userId, nickname);
        if (nickname) knownNicknames.set(userId, nickname);
      }
      return cache.get(userId) ?? null;
    };
    const senderNickName = isBareId(turn.senderNickName, turn.senderId)
      ? (await lookup(turn.senderId)) ?? turn.senderNickName
      : turn.senderNickName;
    // The wire carries no sender type, so a batch message from another agent
    // (Hermes, a second OpenClaw…) would read as a person. users/<id>.md holds
    // the backend's user type; this agent is always an agent.
    const typeCache = new Map<string, string | null>();
    const lookupProfileType = async (userId: string): Promise<string | null> => {
      if (userId === account.userId) return "agent";
      if (!typeCache.has(userId)) {
        let profileType: string | null = null;
        try {
          const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
          profileType = userFile.exists && userFile.metadata.profile_type ? userFile.metadata.profile_type : null;
        } catch {
          profileType = null;
        }
        typeCache.set(userId, profileType);
      }
      return typeCache.get(userId) ?? null;
    };
    const groupMessages = turn.groupMessages
      ? await Promise.all(turn.groupMessages.map(async (message) => {
          const named = isBareId(message.senderName, message.senderId)
            ? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
            : message;
          const profileType = await lookupProfileType(message.senderId);
          if (!profileType || profileType === named.senderProfileType) return named;
          const senderRelation = profileType === "agent" && named.senderRelation === "peer_user"
            ? "peer_agent" as const
            : named.senderRelation;
          return { ...named, senderProfileType: profileType, senderRelation };
        }))
      : turn.groupMessages;
    return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
  };

  const resolveSenderNickNameForTurn = (turn: IngestTurnParams): string => {
    if (turn.senderNickName && turn.senderNickName !== turn.senderId) return turn.senderNickName;
    // Synchronous on purpose: an await here would reorder same-group frames.
    const known = turn.peer.kind === "group" ? knownNicknames.get(turn.senderId) : undefined;
    if (known) return known;
    return turn.senderNickName || turn.senderId;
  };

  const resolveSenderBatchIdentityForTurn = (turn: IngestTurnParams): {
    senderRelation: NonNullable<IngestTurnParams["senderRelation"]>;
    senderProfileType: string;
    senderIsOwner: boolean;
    senderIsGroupOwner?: boolean;
  } => {
    const senderRelation = resolveSenderRelation({
      senderId: turn.senderId,
      accountUserId: account.userId,
      accountOwnerUserId: account.ownerUserId,
      senderProfileType: turn.senderProfileType,
    });
    return {
      senderRelation,
      senderProfileType: turn.senderProfileType ??
        (senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"),
      senderIsOwner: senderRelation === "owner",
      // Left undefined when the frame does not say, so the prompt falls back
      // to the group metadata's group_owner_id instead of a forced false.
      ...(turn.senderIsGroupOwner !== undefined ? { senderIsGroupOwner: turn.senderIsGroupOwner } : {}),
    };
  };

  const claimInboundTurn = (turn: IngestTurnParams): "claimed" | "skipped" => {
    const env = turn.envelope;
    if (store?.claimMessageOnce) {
      const claimed = recordConnection("message claim", () =>
        store.claimMessageOnce?.({
          platform: "openclaw",
          accountId,
          kind: "message",
          direction: "inbound",
          eventType: String(env.event),
          traceId: turn.traceId,
          chatId: turn.peer.id,
          messageId: turn.messageId,
          text: turn.rawBody,
          raw: env,
        }),
      );
      if (claimed === false) {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw skip duplicate stored msg=${turn.messageId}`,
        );
        return "skipped";
      }
      if (claimed !== true) {
        // Fail closed: a claim that could not be decided (store disabled, SQLite
        // error, a throw converted to undefined by recordConnection) must not
        // dispatch. Live delivery and device replay can carry the same message,
        // and the outbound reply path (outbound.ts / reply-dispatcher.ts) already
        // refuses to send without a claim — so dispatching here only re-ran the
        // LLM/tools without delivering, or double-replied. Logged at error level
        // with reason=claim_unavailable so a store outage is visible and is not
        // mistaken for dedup. Accepted cost: a one-off transient claim failure
        // drops that message.
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw inbound skipped msg=${turn.messageId} chat_id=${turn.peer.id} event=${String(env.event)} reason=claim_unavailable result=${String(claimed)}`,
        );
        return "skipped";
      }
    }
    // No store wired at all (injected test transport, or store construction
    // threw at startup — which is itself logged) keeps the store-less pass-through.
    return "claimed";
  };

  // The group's title from its metadata file, which the turn's profile sync
  // has just refreshed (so a group's first command already has it); the
  // group id when none is on record.
  const groupTitleForReceipt = async (groupId: string, memoryRoot: string): Promise<string> => {
    try {
      const file = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: groupId });
      const title = file.exists ? file.metadata.group_title : undefined;
      if (typeof title === "string" && title.trim()) return title.trim();
    } catch {
      // Fall back to the id below.
    }
    return groupId;
  };

  const dispatchTurnToAgent = async (
    turn: IngestTurnParams,
    options: { ownerGroupCommand?: string; grantReceipt?: ToolRequest; wakeFailure?: RequestWakeError;
      /** Set when a receipt wake was queued as a host followup; resolves with its final wake result. */
      wakeSettled?: Promise<RequestWakeError | undefined> } = {},
  ): Promise<IngestTurnResult> => {
    const rt = runtime.channel;
    const routeCfg = withClawChatSessionScope(cfg);
    const route = rt.routing.resolveAgentRoute({
      cfg: routeCfg,
      channel: CHANNEL_ID,
      accountId,
      peer: turn.peer,
    });
    // A saved route cannot silently move to another agent/session after a config change.
    if (options.grantReceipt && (route.sessionKey !== options.grantReceipt.sourceSessionKey || abortSignal.aborted)) {
      options.wakeFailure = "session-unavailable";
      return "failed";
    }
    // OpenClaw >=2026.8 (roster-only refactor, openclaw#112678) throws
    // SessionStoreAgentIdRequiredError when `session.store` is unset and no agentId is
    // supplied — that killed every inbound turn. Older hosts already accepted
    // `opts.agentId` and merely defaulted it to "main", so passing the routed id is
    // correct on the whole supported range and resolves to the same path for a
    // single-agent roster. Must stay below resolveAgentRoute: it owns `agentId`.
    const storePath = rt.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
    const memoryRoot = resolveClawChatMemoryRoot(runtime, cfg, route.agentId);
    const body = rt.reply.formatAgentEnvelope({
      channel: "Clawling Chat",
      from: formatConversationSubject(turn.peer),
      body: turn.rawBody,
      timestamp: turn.timestamp,
      ...rt.reply.resolveEnvelopeFormatOptions(cfg),
    });
    try {
      await syncMessagePathProfiles(turn, memoryRoot);
    } catch (err) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw message metadata refresh failed: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
    turn = await withCachedGroupSenderNames(turn, memoryRoot);
    const ownerCommandReceipt = options.ownerGroupCommand && turn.peer.kind === "group"
      ? {
          header: ownerCommandReceiptHeader(
            await groupTitleForReceipt(turn.peer.id, memoryRoot),
            options.ownerGroupCommand,
          ),
          commandMessageId: turn.messageId,
        }
      : undefined;
    const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
    const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
    // Owner turn <=> the owner's activation direct chat. Resolved per turn;
    // any failure leaves it null, which classifies as non-owner (fail closed).
    let ownerConversationId: string | null = null;
    try {
      ownerConversationId = resolveOwnerDirectConversationId();
    } catch {
      ownerConversationId = null;
    }
    const synthetic = Boolean(options.grantReceipt) || turn.senderId.startsWith("clawchat-");
    const originContext = resolveOriginGrantContext(options.grantReceipt ? { trustedSource: "grant-receipt",
      requesterUserId: options.grantReceipt.requesterUserId } : undefined);
    const hasTrustedSpeakers = originContext !== "unknown" || (!synthetic && Boolean(turn.senderId.trim())
      && (turn.groupMessages ?? []).every(message => Boolean(message.senderId?.trim()) && !message.senderId.startsWith("clawchat-")));
    const turnClass = synthetic || !hasTrustedSpeakers ? "non-owner" : classifyChat({
      kind: turn.peer.kind,
      chatId: turn.peer.id,
      ownerDirectConversationId: ownerConversationId,
    });
    // `non-owner-host-tools: approve`: a group turn keeps the host tools once
    // the approval route to the owner's direct chat is live; the hook then
    // asks the owner for each call. Friend direct chats always narrow.
    const realSpeakerIds = hasTrustedSpeakers
      ? (turn.groupMessages?.length ? turn.groupMessages.map(message => message.senderId) : [turn.senderId]) : [];
    const ownerSide = !synthetic && isOwnerSpeakerBatch(account.ownerUserId, realSpeakerIds);
    const nonOwnerTools = turnClass === "non-owner" && !ownerSide;
    const groupApproval = nonOwnerTools && hasTrustedSpeakers
      && turn.peer.kind === "group"
      && groupApprovalRouteReady(ownerConversationId);
    const narrowHostTools = nonOwnerTools && !groupApproval;
    const publicTools = nonOwnerToolAllowlist(account.publicTools, log);
    const grantsEnabled = nonOwnerTools && account.toolGrants !== "off";
    const speakers = !hasTrustedSpeakers ? [] : [...new Map((turn.groupMessages?.length ? turn.groupMessages.map(message => ({
      id: message.senderId || "", name: message.senderName || message.senderId || turn.senderId,
    })) : [{ id: turn.senderId, name: turn.senderNickName || turn.senderId }])
      .map(speaker => [speaker.id, speaker])).values()];
    let grants: TurnGrants = emptyTurnGrants();
    const anyGrantedTools = new Set<string>();
    if (grantsEnabled) {
      try {
        const now = Date.now();
        if (!store?.listToolGrants) throw new Error("Grant store unavailable");
        const rows = store.listToolGrants(accountId, now);
        const input = {
          ownerUserId: account.ownerUserId, rows, now, publicTools,
          unmatched: groupApproval ? "ask" as const : "none" as const,
        };
        grants = resolveTurnGrants({ ...input, speakerIds: speakers.map(speaker => speaker.id) });
        for (const speaker of speakers) {
          for (const tool of resolveTurnGrants({ ...input, speakerIds: [speaker.id] }).keys()) anyGrantedTools.add(tool);
        }
      } catch {
        // An unavailable grant list cannot add tools to this turn.
        grants = resolveTurnGrants({
          speakerIds: speakers.map(s => s.id), ownerUserId: account.ownerUserId,
          rows: [], now: Date.now(), publicTools,
        });
        anyGrantedTools.clear();
      }
    }
    const grantPolicy = grantsEnabled ? grantDispatchPolicy(grants, publicTools, {
      chatId: turn.peer.id, sessionKey: route.sessionKey, accountId,
    }) :
      { ...(narrowHostTools ? { allow: [...publicTools] } : {}) };
    const toolsAllow = grantPolicy.allow;
    const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
    const nonOwnerMediaGuidance =
      "To send an image or file here, include an http(s) URL in your final reply. Local files require direct access to every Files tool, or the exact structured media artifact declared by a successfully executed tool in this turn. Do not invent local paths or reuse artifacts from other turns.";
    let requestNotices: string[] = [];
    if (nonOwnerTools && !synthetic && hasTrustedSpeakers && account.toolGrants !== "off") {
      try {
        requestNotices = (store?.takeToolRequestNotices?.(accountId,
          speakers.filter(speaker => /^usr_[^\s]+$/.test(speaker.id) && speaker.id !== account.ownerUserId).map(speaker => speaker.id),
          route.sessionKey, turn.peer.id, turn.peer.kind, Date.now()) ?? []).map(buildRequestNotice);
      } catch { log?.error?.(`[${accountId}] clawchat-plugin-openclaw request notice unavailable`); }
    }
    const turnPrompt = [
      ...requestNotices,
      ...(nonOwnerTools ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
      ...(groupApproval ? [GROUP_APPROVAL_GUIDANCE] : []),
      ...(grantsEnabled ? [buildNonOwnerGrantsHint({ grants, config: readLiveConfig(), agentId: route.agentId })] : []),
      ...(ownerSide && turn.peer.kind === "group" ? [OWNER_GROUP_MEMORY_GUIDANCE] : []),
      baseTurnPrompt,
    ].filter(Boolean).join("\n\n");
    const ctxPayload = resolveChannelContextBuilder(rt)({
      channel: CHANNEL_ID,
      accountId: route.accountId ?? accountId,
      provider: CHANNEL_ID,
      surface: CHANNEL_ID,
      messageId: turn.messageId,
      messageIdFull: turn.messageId,
      timestamp: turn.timestamp,
      from: conversationTarget,
      sender: {
        id: turn.senderId,
        name: turn.senderNickName || turn.senderId,
        displayLabel: turn.senderNickName || turn.senderId,
      },
      conversation: {
        kind: turn.peer.kind,
        id: turn.peer.id,
        label: formatConversationSubject(turn.peer),
        routePeer: turn.peer,
      },
      route: {
        agentId: route.agentId,
        accountId: route.accountId ?? accountId,
        routeSessionKey: route.sessionKey,
      },
      reply: {
        to: `${CHANNEL_ID}:${account.userId}`,
        originatingTo: conversationTarget,
      },
      message: {
        body,
        rawBody: turn.rawBody,
        bodyForAgent: turn.rawBody,
        commandBody: turn.rawBody,
        envelopeFrom: conversationTarget,
      },
      access: {
        mentions: {
          canDetectMention: true,
          wasMentioned: turn.wasMentioned,
          hasAnyMention: turn.mentionedUserIds.length > 0,
        },
        ...(isOpenClawTextSlashCommand(turn.rawBody, cfg) && senderIsOwner
          ? { commands: { authorized: true, source: "text" } }
          : {}),
        // toolPolicy: host 2026.8.1+ (floor)
        ...(nonOwnerTools ? { toolPolicy: grantPolicy } : {}),
      },
      ...(memoryRoot ? { extra: { memoryRoot } } : {}),
      // Deliver the rendered ClawChat per-turn prompt (owner agent_behavior,
      // metadata, peer/sender profile) to the host for ALL chat types. The host
      // appends `GroupSystemPrompt` to the system prompt regardless of chat
      // kind. Direct chats previously relied only on the `before_prompt_build`
      // staging hook, which is not applied by the host for DM sessions, so the
      // owner-configured behavior never reached the LLM in 1:1 chats.
      ...(turnPrompt
        ? { supplemental: { groupSystemPrompt: turnPrompt } }
        : {}),
    }) as MutableOpenClawReplyContext;
    if (memoryRoot) {
      (ctxPayload as Record<string, unknown>).memoryRoot = memoryRoot;
    }
    if (turn.mentionedUserIds.length > 0) {
      (ctxPayload as Record<string, unknown>).MentionedUserIds = turn.mentionedUserIds;
    }
    // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
    const inboundPaths =
      turn.mediaItems.length > 0
        ? await fetchInboundMedia(turn.mediaItems, {
            runtime,
            log,
            maxBytes: CLAWCHAT_MEDIA_MAX_BYTES,
          })
        : [];
    if (inboundPaths.length > 0) {
      (ctxPayload as Record<string, unknown>).MediaPath = inboundPaths[0];
      (ctxPayload as Record<string, unknown>).MediaPaths = inboundPaths;
    }

    const resolvedSessionKey = optionalString(ctxPayload.SessionKey) ?? route.sessionKey;
    if (options.grantReceipt && resolvedSessionKey !== options.grantReceipt.sourceSessionKey) {
      options.wakeFailure = "session-unavailable";
      return "failed";
    }

    openclawLlmContextDebug.writeSnapshot({
      visibility: "injected_only",
      trace: {
        traceId: turn.traceId,
        messageId: turn.messageId,
        chatId: turn.peer.id,
        chatType: turn.peer.kind,
        senderId: turn.senderId,
        sessionKey: resolvedSessionKey,
      },
      input: {
        injectedPrompt: turnPrompt,
        eventText: turn.rawBody,
      },
    });

    clearClawChatPromptInjectionForSession(resolvedSessionKey);
    const stagedDirectPrompt = turn.peer.kind !== "group";
    if (stagedDirectPrompt) {
      stageClawChatPromptInjection({
        sessionKey: resolvedSessionKey,
        prompt: baseTurnPrompt,
      });
    }

    let releaseGrants = () => {};
    let releaseArtifacts = () => {};
    let wakeFollowup: Promise<RequestWakeError | undefined> | undefined;
    try {
      const replyCtx = turn.replyCtx;
      const terminalSendScopeId = `${account.accountId}\0${turn.peer.id}\0${turn.messageId}`;
      const dispatchCfg = resolveDispatchConfigForOutputVisibility({
        cfg,
        account,
        chatId: turn.peer.id,
        chatType: turn.peer.kind,
        agentId: route.agentId,
      });
      // Fresh dispatch identities prevent late completions from binding to a
      // later turn even if a host dispatcher reuses its suggested run id.
      const runId = turnClass === "non-owner" ? randomUUID() : undefined;
      const { dispatcher, replyOptions, markDispatchIdle, getOutboundDeliveredCount } =
        createOpenclawClawlingReplyDispatcher({
          cfg,
          runtime,
          account,
          client,
          resolveOwnerLocale: () =>
            store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
          target: { chatId: turn.peer.id, chatType: turn.peer.kind },
          ...(replyCtx ? { replyCtx } : {}),
          inboundMessageId: turn.messageId,
          terminalSendScopeId,
          inboundForFinalReply: {
            chatId: turn.peer.id,
            senderId: turn.senderId,
            senderNickName: turn.senderNickName || turn.senderId,
            bodyText: turn.rawBody,
          },
          store: store
            ? {
                insertMessage: (input) => store.insertMessage?.(input) ?? null,
                claimMessageOnce: (input) => store.claimMessageOnce?.(input) ?? null,
                markMessageAcknowledged: (input) => store.markMessageAcknowledged?.(input) ?? null,
                updateMessageByIdentity: (input) => store.updateMessageByIdentity?.(input),
              }
            : null,
          // Group owner-attention escalations address the owner's activation
          // conversation, not `ownerUserId` (a usr_… chat_id is rejected by
          // member-backend). Passed as a thunk so it is resolved at send time.
          resolveOwnerConversationId: resolveOwnerDirectConversationId,
          nonOwnerTurn: nonOwnerTools,
          ...(runId ? { replyMediaTurn: { sessionKey: resolvedSessionKey, runId } } : {}),
          ...(ownerCommandReceipt ? { ownerCommandReceipt } : {}),
          log,
        });

      const dispatchReplyOptions = turnClass === "non-owner"
        ? {
            ...replyOptions,
            runId,
            ...(toolsAllow ? { toolsAllow: [...toolsAllow] } : {}),
          }
        : replyOptions;
      if (turnClass === "non-owner") {
        const grantDetails: TurnGrantDetails = { speakers, anyGrantedTools, ownerSide, accountId, toolGrants: account.toolGrants,
          ...(originContext !== "unknown" && originContext.source === "grant-receipt"
            ? { trustedSource: originContext.source } : {}),
          nonOwnerSpeakerIds: new Set(speakers.filter(speaker => speaker.id !== account.ownerUserId).map(speaker => speaker.id)),
          ...(!synthetic && hasTrustedSpeakers && !ownerSide && !senderIsOwner && /^usr_[^\s]+$/.test(turn.senderId) ? { requestOrigin: {
            accountId, requesterUserId: turn.senderId, sourceChatId: turn.peer.id, sourceChatType: turn.peer.kind,
            sourceSessionKey: resolvedSessionKey, sourceMessageId: turn.messageId, requestSummary: turn.rawBody,
          } } : {}),
        };
        releaseGrants = pushTurnGrants(resolvedSessionKey, grants, grantDetails, runId);
        // Older hosts ignore this field and keep the original fail-closed path.
        // The local SDK predates the host's turn-adoption lifecycle surface.
        type AdoptionLifecycle = {
          admission?: "exclusive" | "cancel-only";
          onAdopted: () => void | Promise<void>;
          onDeferred?: () => boolean | void;
          onSettled?: () => void;
          onAbandoned?: () => void;
        };
        const adoptionOptions = dispatchReplyOptions as typeof dispatchReplyOptions & { turnAdoptionLifecycle?: AdoptionLifecycle };
        const original = adoptionOptions.turnAdoptionLifecycle;
        let deferred = false;
        let finished = false;
        let adoptionStarted = false;
        let releaseAdopted = () => {};
        // A queued receipt wake succeeds only once the host adopted and settled it.
        let settleWake: (error: RequestWakeError | undefined) => void = () => {};
        const wakeOutcome = options.grantReceipt
          ? new Promise<RequestWakeError | undefined>(resolve => {
            settleWake = error => { settleWake = () => {}; abortSignal.removeEventListener("abort", abortWake); resolve(error); };
          })
          : undefined;
        const abortWake = () => settleWake("dispatch-failed");
        const finishAdoption = () => {
          finished = true;
          abortSignal.removeEventListener("abort", finishAdoption);
          releaseAdopted();
        };
        adoptionOptions.turnAdoptionLifecycle = {
          ...original,
          admission: "exclusive",
          onDeferred: () => {
            if (original?.onDeferred?.() === false) return false;
            deferred = true;
            if (wakeOutcome) {
              wakeFollowup = wakeOutcome;
              if (abortSignal.aborted) abortWake();
              else abortSignal.addEventListener("abort", abortWake, { once: true });
            }
            return true;
          },
          onAdopted: async () => {
            await original?.onAdopted();
            if (!deferred || finished || adoptionStarted) return;
            adoptionStarted = true;
            await waitForSessionTurnsReleased(resolvedSessionKey);
            if (finished || abortSignal.aborted) return;
            releaseAdopted = pushTurnGrants(resolvedSessionKey, grants, grantDetails, undefined, turn.senderId);
            // A stopped gateway must not leave the adopted snapshot bindable until settlement.
            abortSignal.addEventListener("abort", finishAdoption, { once: true });
          },
          onSettled: () => {
            try {
              if (deferred) settleWake(adoptionStarted && !abortSignal.aborted ? undefined : "host-rejected");
              finishAdoption();
            } finally { original?.onSettled?.(); }
          },
          onAbandoned: () => {
            try {
              if (deferred) settleWake("host-rejected");
              finishAdoption();
            } finally { original?.onAbandoned?.(); }
          },
        };
        releaseArtifacts = openTurnArtifacts({ sessionKey: resolvedSessionKey, runId: runId!, accountId,
          target: { chatId: turn.peer.id, chatType: turn.peer.kind } });
      }

      try {
        await rt.session.recordInboundSession({
          storePath,
          sessionKey: resolvedSessionKey,
          ctx: ctxPayload,
          onRecordError: (err) => {
            log?.error?.(
              `[${accountId}] clawchat-plugin-openclaw failed to record inbound session: ${String(err)}`,
            );
          },
        });
      } catch (err) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw failed to record inbound session: ${String(err)}`,
        );
      }

      const agentsConfigured = Object.keys((cfg as { agents?: Record<string, unknown> }).agents ?? {});
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw dispatching reply msg=${turn.messageId} session=${resolvedSessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
      );

      try {
        const dispatchResult = await rt.reply.withReplyDispatcher({
          dispatcher,
          onSettled: () => markDispatchIdle(),
          run: () => {
            // Preparation and withReplyDispatcher can await after the wake was
            // claimed. Recheck at the actual host-dispatch boundary as well.
            if (options.grantReceipt && Date.now() >= options.grantReceipt.expiresAt) {
              options.wakeFailure = "dispatch-failed";
              return Promise.resolve(undefined);
            }
            return runWithTerminalClawChatSendScope({ scopeId: terminalSendScopeId, sessionKey: resolvedSessionKey }, () =>
              rt.reply.dispatchReplyFromConfig({
                ctx: ctxPayload,
                cfg: dispatchCfg,
                dispatcher,
                replyOptions: dispatchReplyOptions,
              }),
            );
          },
        });
        if (options.wakeFailure) return "failed";
        const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
        const queuedFinal = Boolean(
          (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
        );
        if (options.grantReceipt) {
          const result = dispatchResult as { sendPolicyDenied?: boolean; beforeAgentRunBlocked?: boolean; failedCounts?: Record<string, number> } | undefined;
          if (result?.sendPolicyDenied || result?.beforeAgentRunBlocked || abortSignal.aborted
            || Object.values(result?.failedCounts ?? {}).some(count => count > 0)
            || (!wakeFollowup && !queuedFinal && Object.values(counts).every(count => !count) && !(getOutboundDeliveredCount?.() ?? 0))) {
            options.wakeFailure = "host-rejected";
            return "failed";
          }
          // A queued followup has produced nothing yet; its lifecycle decides the wake.
          if (wakeFollowup) options.wakeSettled = wakeFollowup;
        }
        if (turn.senderId === "clawchat-bootstrap") {
          // Did the greeting actually reach the user? The reliable signal is a
          // real, successful outbound send (the agent may deliver the greeting
          // as a `block`, not a `final`, and a cancelled send still shows up in
          // host `counts.block`). Treat a queued/counted final as delivered too,
          // to cover deferred/human-delay finals not yet flushed at this point.
          const deliveredOutbound = getOutboundDeliveredCount?.() ?? 0;
          bootstrapGreetingDelivered =
            deliveredOutbound > 0 || queuedFinal || (counts.final ?? 0) > 0;
        }
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
        );
        if (!queuedFinal && Object.values(counts).every((n) => !n)) {
          log?.info?.(
            `[${accountId}] clawchat-plugin-openclaw NO reply was produced (no final / block / tool dispatched). ` +
              `Likely causes: agent='${route.agentId}' not configured in cfg.agents (configured: [${agentsConfigured.join(",")}]); ` +
              `or send-policy denied; or a plugin claimed the binding.`,
          );
        }
        return "submitted";
      } catch (err) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw dispatch failed msg=${turn.messageId}: ${String(err)}`,
        );
        return "failed";
      }
    } finally {
      releaseArtifacts();
      releaseGrants();
      if (stagedDirectPrompt) {
        clearClawChatPromptInjectionForSession(resolvedSessionKey);
      }
    }
  };

  // Resolve the live mute / reply-mode / batch-window for a group chat from the
  // backend settings cache, falling back to the static channel config. Shared by
  // the enqueue-time gate (ingestTurn) and the flush-time re-gate (coalescer
  // dispatch) so both decisions read the SAME source of truth.
  const resolveGroupEffectiveSettings = (chatId: string) =>
    groupSettingsCache.effective(chatId, {
      muted: false,
      replyMode: effectiveGroupMode(account, chatId),
      batchDelaySeconds: 10,
    });

  const groupCoalescer = createGroupMessageCoalescer<GroupIngestTurnParams>({
    idleMs: 10_000,
    maxWaitMs: 30_000,
    dispatch: async (turn) => {
      // Finding 3 — gate the FLUSH path too. A queued non-mention batch can flush
      // (idle/max timer) while an `agent.config.changed` settings pull is in
      // flight; resolving effective settings here without awaiting the gate reads
      // the stale cache and dispatches one reply for a chat about to be muted /
      // flipped to mention-only. Await the same re-armable gate (bounded by
      // GROUP_SETTINGS_READY_TIMEOUT_MS) so the flush-time re-check below sees the
      // fresh snapshot.
      //
      // Finding #4 — do NOT early-return on abort here. The ingest path DOES early-
      // return on abort, but only AFTER it has already taken the idempotency claim
      // (`claimInboundTurn` runs BEFORE the gate wait there, so the frame is durably
      // persisted before the interruptible wait — required by legacy advance-on-
      // write delivery, where a frame written before a disconnect is not reliably
      // re-delivered on reconnect (legacy may lose OR replay; a replay is deduped by
      // the claim); see docs/client-integration.md §11.7). So even an ingest-time
      // abort keeps the frame; it just skips dispatch. Here at FLUSH the batch was
      // likewise already claimed at ingest AND `flush()` has already removed it from
      // the coalescer's pending set. An abort early-return would therefore silently
      // DROP an already-claimed, already-dequeued batch — and a replay of the same
      // message_id is deduped by `claimMessageOnce`, permanently losing the group
      // reply. The gate wait still runs and resolves immediately on abort (forceOpen)
      // so we re-resolve against the freshest available settings, then dispatch
      // best-effort rather than dropping.
      await waitWithTimeout(settingsReadyGate.wait(), GROUP_SETTINGS_READY_TIMEOUT_MS, abortSignal);
      // Finding 1 — re-evaluate governance at FLUSH time. Settings can change
      // (via an agent.config.changed pull) while a coalesced batch sits pending,
      // so the enqueue-time decision may be stale. Drop the batch if the chat is
      // now muted, or if it flipped to mention-only and this batch carries no
      // mention.
      const eff = resolveGroupEffectiveSettings(turn.peer.id);
      if (eff.muted) {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw coalesced group batch dropped at flush (now muted) chat_id=${turn.peer.id} msg=${turn.messageId}`,
        );
        return;
      }
      if (eff.replyMode === "mention" && !turn.wasMentioned) {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw coalesced group batch dropped at flush (now mention-only, no mention) chat_id=${turn.peer.id} msg=${turn.messageId}`,
        );
        return;
      }
      const chatId = turn.peer.id;
      const batchIds = new Set(turn.allMessageIds ?? [turn.messageId]);
      const catchUpRows = turn.wasMentioned && store?.listRecentGroupMessages
        ? store.listRecentGroupMessages("openclaw", accountId, chatId, CATCH_UP_FETCH_ROWS + batchIds.size) ?? []
        : [];
      // Ingest labels a batch from the in-memory nickname map, which is empty
      // after a restart, and catch-up lines read the same map: resolve every
      // sender named here before the turn goes out.
      await warmGroupNicknames(turn.peer, [
        ...(turn.groupMessages ?? [])
          .filter((message) => !message.senderName || message.senderName === message.senderId)
          .map((message) => message.senderId),
        ...(!turn.senderNickName || turn.senderNickName === turn.senderId ? [turn.senderId] : []),
        ...catchUpRows.map(bareSenderIdOfRow),
      ]);
      if (turn.groupMessages?.length) {
        turn = {
          ...turn,
          rawBody: relabelCoalescedGroupBody(turn.rawBody, turn.groupMessages, (id) => knownNicknames.get(id)),
          groupMessages: turn.groupMessages.map((message) => {
            const known = message.senderName === message.senderId ? knownNicknames.get(message.senderId) : undefined;
            return known ? { ...message, senderName: known } : message;
          }),
        };
      }
      if (turn.senderNickName === turn.senderId && knownNicknames.has(turn.senderId)) {
        turn = { ...turn, senderNickName: knownNicknames.get(turn.senderId)! };
      }
      // An @-mention adds the group messages this session has not been given
      // since its last turn (e.g. ones a mention-only group held back), within
      // delta-budget-chars. Every batch handed to the agent is recorded as
      // delivered, so nothing it already has comes back. See group-catch-up.ts.
      let catchUp: string | null = null;
      if (turn.wasMentioned && store?.listRecentGroupMessages) {
        const fetchLimit = CATCH_UP_FETCH_ROWS + batchIds.size;
        catchUp = buildGroupCatchUp({
          chatId,
          rows: catchUpRows,
          fetchLimit,
          batchIds,
          delivered: deliveredGroupMessages,
          budgetChars: account.deltaBudgetChars,
          formatLine: formatCatchUpLine,
        });
      }
      deliveredGroupMessages.remember(chatId, batchIds);
      if (catchUp) {
        await dispatchTurnToAgent({ ...turn, rawBody: `${catchUp}\n${turn.rawBody}` });
        return;
      }
      await dispatchTurnToAgent(turn);
    },
    onError: (err) => {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw coalesced group dispatch failed: ${String(err)}`,
      );
    },
    onDrop: (chatId, count) => {
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw dropped pending group batch chat_id=${chatId} count=${count} reason=shutdown`,
      );
    },
  });

  const forwardGroupApprovalSlashToOwner = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
    const ownerConversationId = resolveOwnerDirectConversationId();
    if (!ownerConversationId) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_conversation chat_id=${turn.peer.id} msg=${turn.messageId}`,
      );
      return "skipped";
    }
    try {
      await sendOpenclawClawlingText({
        client,
        account,
        to: { chatId: ownerConversationId, chatType: "direct" },
        text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
        log,
      });
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw forwarded group approval command to owner chat_id=${turn.peer.id} msg=${turn.messageId}`,
      );
      return "submitted";
    } catch (err) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw group approval command forward failed chat_id=${turn.peer.id} msg=${turn.messageId}: ${String(err)}`,
      );
      return "failed";
    }
  };

  // Only this closure can pass grantReceipt to dispatchTurnToAgent. Wire fields are never read for it.
  const wakeRequest = async (requestId: string): Promise<void> => {
    if (!store?.claimToolRequestWake || !store.finishToolRequestWake) return;
    await onRequestDecided(requestId, { accountId,
      store: { claimToolRequestWake: store.claimToolRequestWake.bind(store), finishToolRequestWake: store.finishToolRequestWake.bind(store) },
      dispatch: async request => {
        const parsed = parseClawChatSessionKey(request.sourceSessionKey);
        if (request.accountId !== accountId || !parsed || parsed.kind !== request.sourceChatType
          || parsed.chatId !== request.sourceChatId.toLowerCase()
          || (parsed.kind === "direct" && clawChatSessionAccountId(request.sourceSessionKey) !== accountId)
          || !/^usr_[^\s]+$/.test(request.requesterUserId) || request.requesterUserId === account.ownerUserId
          || account.toolGrants === "off" || abortSignal.aborted) return "session-unavailable";
        try {
          const { conversation } = await getConversationApiClient().getConversation(request.sourceChatId);
          if (!conversation || conversation.id !== request.sourceChatId) return "session-unavailable";
        } catch { return "session-unavailable"; }
        // The card may expire while its source conversation is being fetched.
        // Failed notices already require expires_at > now in storage.
        if (Date.now() >= request.expiresAt) return "dispatch-failed";
        const now = Date.now(), messageId = `clawchat-grant-receipt-${request.wakeKey}`;
        const rawBody = buildRequestContinuation(request);
        const envelope = { version: "2", event: EVENT.MESSAGE_SEND, trace_id: randomUUID(), emitted_at: now,
          chat_id: request.sourceChatId, chat_type: request.sourceChatType,
          sender: { id: request.requesterUserId, type: "direct", nick_name: request.requesterName },
          payload: { message_id: messageId } } as Envelope;
        const turn: IngestTurnParams = { channel: CHANNEL_ID, accountId,
          peer: { kind: request.sourceChatType, id: request.sourceChatId }, senderId: request.requesterUserId,
          senderNickName: request.requesterName, senderIsOwner: false, senderRelation: "peer_user",
          rawBody, messageId, allMessageIds: [messageId], traceId: envelope.trace_id, timestamp: now,
          wasMentioned: false, mentionedUserIds: [], mentionedUsers: [], mediaItems: [], cfg, runtime, account, envelope };
        const options: { grantReceipt: ToolRequest; wakeFailure?: RequestWakeError;
          wakeSettled?: Promise<RequestWakeError | undefined> } = { grantReceipt: request };
        const result = await dispatchTurnToAgent(turn, options);
        if (result !== "submitted") return options.wakeFailure ?? "dispatch-failed";
        return options.wakeSettled ? { settled: options.wakeSettled } : undefined;
      },
    });
  };

  const consumeGrantConfirmation = async (turn: IngestTurnParams): Promise<boolean> => {
    const commandPattern = /^\/clawchat-(allow|ask|deny)\s+([0-9]{6})$/;
    const match = commandPattern.exec(turn.rawBody.trim())
      ?? (turn.peer.kind === "group" ? commandPattern.exec(turn.addressedCommandBody?.trim() ?? "") : null);
    if (!match) return false;
    // Plugin-started owner turns use synthetic sender ids, never the owner's user id.
    if (turn.peer.kind !== "direct" || turn.peer.id !== resolveOwnerDirectConversationId()
      || turn.senderId !== account.ownerUserId || turn.senderId.startsWith("clawchat-")
      || account.toolGrants === "off") {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw grant confirmation dropped`);
      return true;
    }
    let lang: OwnerLanguage = "en";
    try {
      lang = resolveOwnerLanguageIfKnown(store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale) ?? "en";
    } catch { /* A cold or unreadable language cache uses English. */ }
    const cardLang = lang === "zh" || lang === "zh_Hant" ? lang : "en";
    let text: string;
    let askConfirmed = false;
    let askUnavailable = false;
    let decidedRequestId: string | undefined;
    try {
      if (!store?.confirmPendingToolGrant) throw new Error("Grant store unavailable");
      const canApplyMode = (mode: GrantRow["mode"]): boolean => {
        if (mode !== "ask") return true;
        try {
          const live = readLiveConfig();
          askUnavailable = !canAskEachTime(accountId, {
            hostVersion: runtime.version, liveConfig: live,
            activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
            ownerConversationId: resolveOwnerDirectConversationId(), canWriteConfig: typeof resolveMutateConfigFile() === "function",
          });
        } catch { askUnavailable = true; }
        return !askUnavailable;
      };
      const now = Date.now();
      let rows = match[1] === "allow" ? store.confirmPendingToolGrant(accountId, match[2], now, canApplyMode) : null;
      const requestDecision = !rows && !askUnavailable ? store.confirmToolRequest?.(accountId, match[2],
        match[1] === "allow" ? "direct" : match[1] === "ask" ? "ask" : "deny", account.ownerUserId, now, canApplyMode) : null;
      if (requestDecision) { rows = requestDecision.rows; decidedRequestId = requestDecision.requestId; }
      const receiptTools = (items: GrantRow[]) => [...new Set(items.map(row => selectorDisplay(
        row.whatKind === "capability" ? `cap:${row.what}` : row.whatKind === "tool" ? `tool:${row.what}` : "all", lang)))];
      if (rows?.some(row => row.mode === "deny")) {
        text = buildRequestDenyReceipt(lang, [...new Set(rows.map(row => row.granteeName))], receiptTools(rows));
      } else if (rows) {
        const available: GrantRow[] = [];
        const unavailable: GrantRow[] = [];
        const unknown: GrantRow[] = [];
        for (const row of rows) {
          let availability: "available" | "unavailable" | "unknown" = row.whatKind === "capability" ? "unknown" : "available";
          if (row.whatKind === "capability") {
            try {
              const live = readLiveConfig();
              // A failed route lookup leaves availability unknown.
              const agentId = runtime.channel.routing.resolveAgentRoute({ cfg: withClawChatSessionScope(live),
                channel: CHANNEL_ID, accountId, peer: turn.peer }).agentId;
              availability = capabilityAvailability(row.what as CapabilityId, { config: live, agentId });
            } catch { /* A failed availability check cannot promise working tools. */ }
          }
          (availability === "available" ? available : availability === "unavailable" ? unavailable : unknown).push(row);
        }
        text = [available, unavailable, unknown].flatMap((items, index) => items.length ? [buildGrantReceipt({ lang,
          names: [...new Set(items.map(row => row.granteeName))], tools: receiptTools(items),
          unconfirmed: index === 1, unknown: index === 2,
        })] : []).join("\n");
      } else {
        text = askUnavailable ? GRANT_ASK_UNAVAILABLE_RECEIPT[cardLang] : GRANT_INVALID_CODE_MESSAGE[cardLang];
      }
      askConfirmed = rows?.some(row => row.mode === "ask") === true;
    } catch {
      text = GRANT_WRITE_FAILED_MESSAGE[cardLang];
    }
    if (askConfirmed) {
      approvalReconcileAttempts.clear();
      try { reconcileApprovalRoute(); }
      catch { log?.error?.(`[${accountId}] clawchat-plugin-openclaw grant approval route sync failed`); }
    }
    if (decidedRequestId) {
      try { await wakeRequest(decidedRequestId); await params.onRequestDecided?.(decidedRequestId); }
      catch { log?.error?.(`[${accountId}] clawchat-plugin-openclaw request decision callback failed`); }
    }
    await sendOwnerSkillMessage(text);
    return true;
  };

  const ingestTurn = async (rawTurn: IngestTurnParams): Promise<IngestTurnResult> => {
    const senderBatchIdentity = rawTurn.peer.kind === "group"
      ? resolveSenderBatchIdentityForTurn(rawTurn)
      : {};
    let turn: IngestTurnParams = {
      ...rawTurn,
      senderNickName: resolveSenderNickNameForTurn(rawTurn),
      ...senderBatchIdentity,
    };
    if (turn.peer.kind === "group") {
      // Finding 1 corrected (message loss) — claim the inbound idempotency row
      // BEFORE the (interruptible) settings-gate wait, so every received group
      // frame is durably persisted EXACTLY ONCE before any wait that a shutdown
      // can interrupt. This client does NOT advertise `reliable_delivery`
      // (src/ws-client.ts), so under legacy delivery the server's replay cursor
      // advances on socket-WRITE (docs/client-integration.md §11.7): a frame
      // already written before a disconnect is NOT reliably re-delivered on
      // reconnect. Legacy is not at-most-once either — a multi-replica server
      // (relayed frames do not advance the cursor) or a failed cursor write can
      // replay frames already received — and the claim below dedupes those.
      // `claimMessageOnce` is the ONLY durability an inbound has, and the store
      // never reprocesses undispatched inbounds. So if we claimed only AFTER the
      // wait, a shutdown-mid-wait would leave the frame unpersisted with no
      // guaranteed replay — lost. Claiming first makes it durable; the abort
      // check below then returns without dispatching, but the frame is kept.
      // `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
      // message_id) is still deduped here exactly once. The claim fails CLOSED:
      // only a successful claim proceeds; an undecided claim (store unavailable)
      // skips with reason=claim_unavailable, because outbound replies also refuse
      // to send without a claim, so dispatching would only waste the turn.
      const claimed = claimInboundTurn(turn);
      if (claimed === "skipped") return "skipped";
      if (await consumeGrantConfirmation(turn)) return "skipped";
      // Findings 2 & 3 — hold ALL group traffic until the in-flight settings pull
      // has settled so the mute / reply-mode gate never runs against a stale/empty
      // cache (startup, reconnect, AND config-change pulls all re-close the gate).
      // This only delays group traffic; direct messages return below. The wait is
      // bounded (GROUP_SETTINGS_READY_TIMEOUT_MS): a stalled pull must never hang
      // group traffic forever, so the gate always releases — falling back to the
      // (possibly stale) cache rather than blocking indefinitely.
      await waitWithTimeout(settingsReadyGate.wait(), GROUP_SETTINGS_READY_TIMEOUT_MS, abortSignal);
      // Finding (#2 follow-up) — the bounded wait also resolves on abort. If the
      // gateway is closing, stop here instead of enqueuing/dispatching a group turn
      // into a tearing-down gateway. The frame is already claimed (persisted), so
      // returning here drops only the dispatch, never the message.
      if (abortSignal.aborted) return "skipped";
      // Mute gate — evaluated BEFORE the slash-command branch so a backend-muted
      // group is fully store-only, consistent with muted normal messages: a
      // muted group must never forward/dispatch a `/clawchat-*` command (or any
      // group slash command) to the agent or the owner. Known commands were
      // previously handled above this gate, which let a muted group still drive
      // the agent — contradicting the mute semantics. Keep this check first.
      const eff = resolveGroupEffectiveSettings(turn.peer.id);
      if (eff.muted) {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw group message stored but muted chat_id=${turn.peer.id} msg=${turn.messageId}`,
        );
        return "stored_muted";
      }
      // `@agent /cmd` counts as `/cmd` once the leading mentions are off.
      const commandText = isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)
        ? turn.rawBody
        : turn.addressedCommandBody && isKnownOpenClawGroupSlashCommand(turn.addressedCommandBody, cfg)
          ? turn.addressedCommandBody
          : null;
      if (commandText) {
        turn = { ...turn, rawBody: commandText };
        if (isOpenClawConfirmSlashCommand(turn.rawBody)) {
          return forwardGroupApprovalSlashToOwner(turn);
        }
        const commandMode = effectiveGroupCommandMode(account, turn.peer.id);
        const commandAllowed = commandMode === "all"
          || (commandMode === "owner" && turn.senderIsOwner === true);
        if (!commandAllowed) {
          log?.info?.(
            `[${accountId}] clawchat-plugin-openclaw group command dropped chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode} owner=${turn.senderIsOwner === true}`,
          );
          return "skipped";
        }
        // The owner's command answers in the owner's direct chat; a command
        // that starts an agent turn, or anyone else's, answers in the group.
        const receiptToOwner = turn.senderIsOwner === true && !opensAgentTurn(commandText);
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode} receipt=${receiptToOwner ? "owner" : "group"}`,
        );
        return dispatchTurnToAgent(turn, receiptToOwner ? { ownerGroupCommand: commandText } : {});
      }
      if (eff.replyMode === "mention" && !turn.wasMentioned) {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw group message stored but skipped (mention mode) chat_id=${turn.peer.id} msg=${turn.messageId}`,
        );
        return "stored_skipped";
      }
      const idleMsOverride = eff.batchDelaySeconds * 1000;
      if (turn.wasMentioned) {
        groupCoalescer.enqueue(turn as GroupIngestTurnParams, { idleMsOverride });
        groupCoalescer.flushNow(turn.peer.id);
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw dispatching mentioned group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
        );
        return "submitted";
      }
      groupCoalescer.enqueue(turn as GroupIngestTurnParams, { idleMsOverride });
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw queued group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
      );
      return "submitted";
    }
    // Direct messages: no gate to await, so claim right before dispatch. (The
    // group branch claims BEFORE its gate wait so a shutdown-mid-wait cannot lose
    // the frame under this client's legacy advance-on-write delivery — see
    // Finding 1 corrected above.)
    const claimed = claimInboundTurn(turn);
    if (claimed === "skipped") return "skipped";
    if (await consumeGrantConfirmation(turn)) return "skipped";
    return dispatchTurnToAgent(turn);
  };

  const handleInboundEnvelope = async (env: Envelope): Promise<IngestTurnResult | undefined> => {
    let ingestResult: IngestTurnResult | undefined;
    try {
      await dispatchOpenclawClawlingInbound({
        envelope: env as Envelope<unknown>,
        cfg,
        runtime,
        account,
        log,
        consumeOwnerConsent: consumeOwnerSkillConsent,
        consumePermissionResult: async (metadata) => {
          // A permission_result receipt closes the loop on an earlier request.
          // Dedup by request_id; on a fresh receipt feed the synthetic context
          // note back to the agent so it can react to the outcome.
          //
          // The note is addressed to the owner's ACTIVATION CONVERSATION id
          // (cnv_…), never `account.ownerUserId`: the agent's in-turn reply
          // inherits this chat_id, and member-backend rejects a usr_… with
          // "invalid conversation id" — the reply is then dropped server-side
          // with no negative ack, so the outcome silently never reaches the
          // owner. With no conversation on record the receipt is skipped
          // outright (see handlePermissionResult — the dedup slot is preserved
          // so a replay after activation still renders).
          const ownerConversationId = resolveOwnerDirectConversationId();
          if (!ownerConversationId) {
            log?.info?.(
              `[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id} skipped: no activation conversation recorded`,
            );
            return;
          }
          const note = handlePermissionResult(metadata, account, ownerConversationId);
          if (note) {
            log?.info?.(
              `[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`,
            );
            void handleInboundEnvelope(note);
          }
        },
        ingest: async (turn) => {
          ingestResult = await ingestTurn(turn);
        },
      });
    } catch (err) {
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw message handler error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
      );
      return "failed";
    }
    return ingestResult;
  };

  dispatchActivationBootstrap = async (): Promise<void> => {
    if (!store?.claimPendingActivationBootstrap || !store.markActivationBootstrapSent) return;
    let bootstrap: { conversationId: string } | null | undefined;
    let claimedInFlight = false;
    const releaseBootstrap = () => {
      if (!bootstrap || !store.releaseActivationBootstrapClaim) return;
      const claimedBootstrap = bootstrap;
      recordConnection("activation bootstrap release", () =>
        store.releaseActivationBootstrapClaim?.({
          platform: "openclaw",
          accountId,
          conversationId: claimedBootstrap.conversationId,
        }),
      );
    };
    const markBootstrapSent = (reason: string) => {
      if (!bootstrap) return;
      const claimedBootstrap = bootstrap;
      const marked = recordConnection("activation bootstrap sent", () =>
        store.markActivationBootstrapSent?.({
          platform: "openclaw",
          accountId,
          conversationId: claimedBootstrap.conversationId,
        }),
      );
      if (marked !== true) {
        // The UPDATE is guarded on bootstrap_sent = 0 AND bootstrap_claimed_at
        // IS NOT NULL, so a stale re-claim makes this a no-op — and the row
        // then stays re-greetable on every later reconnect. Never swallow it.
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw activation bootstrap not marked sent (${reason}); the greeting may repeat on reconnect`,
        );
      }
      // Delivered — stop any pending retry.
      clearBootstrapRetry();
    };
    // Single decision point for every exit after the claim. The greeting is
    // emitted *inside* the dispatched turn, so once it has reached the user the
    // claim must never be rolled back: releasing it lets the next connection
    // claim again and greet a second time. Only a turn that delivered nothing
    // is safe to retry.
    const settleBootstrap = (reason: string) => {
      if (bootstrapGreetingDelivered) {
        markBootstrapSent(reason);
        return;
      }
      releaseBootstrap();
      armBootstrapRetry();
    };
    try {
      bootstrap = recordConnection("activation bootstrap claim", () =>
        store.claimPendingActivationBootstrap?.({ platform: "openclaw", accountId }),
      );
      if (!bootstrap) {
        // Nothing claimable right now. If another dispatch (e.g. a pre-reconnect
        // one that was about to fail) still holds the claim, keep retrying on
        // this live connection so the greeting isn't stranded once it releases.
        if (activationBootstrapInFlightCount(accountId) > 0) armBootstrapRetry();
        return;
      }
      const claimedBootstrap = bootstrap;
      // The claimed id is about to become the chat_id of a synthetic INBOUND
      // envelope, and the agent's in-turn reply inherits it. A statically
      // invalid one can never be delivered — and unlike a dead chat it can
      // never become valid either — so skip it here instead of buying a full
      // LLM turn whose answer the outbound boundary will refuse. Release the
      // claim (a later re-login can record a real conversation id) but do NOT
      // arm a retry: retrying an unroutable id would just loop.
      if (!isValidChatId(claimedBootstrap.conversationId)) {
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw activation bootstrap skipped: chat_id="${claimedBootstrap.conversationId}" reason=invalid_chat_id`,
        );
        releaseBootstrap();
        return;
      }
      claimedInFlight = true;
      incrementActivationBootstrapInFlight(accountId);
      bootstrapGreetingDelivered = false;
      // Synchronous, un-awaited read: the bootstrap is dispatched the moment
      // the connection comes up (`void dispatchActivationBootstrap()`), so on a
      // fresh install `owner_profile` has not been pulled yet and there is no
      // locale to be had. `…IfKnown` returns null there and the prompt ships
      // with no language line, instead of hard-asserting English at an owner
      // who may not speak it. Do NOT add an await here to reach the locale.
      const language = resolveOwnerLanguageIfKnown(
        store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
      );
      const result = await handleInboundEnvelope(
        buildActivationBootstrapEnvelope({
          account,
          conversationId: claimedBootstrap.conversationId,
          language,
        }),
      );
      if (result !== "submitted") {
        settleBootstrap("turn was not submitted");
        return;
      }
      // Only mark the bootstrap done once the greeting was actually delivered
      // (a real successful outbound send). If the turn was handed off but nothing
      // reached the user — e.g. the greeting send was cancelled by a reconnect, or
      // the agent went off to write a file/note instead of replying — leave the
      // bootstrap pending (release the claim) and retry on the live connection,
      // rather than silently consuming it forever or waiting for the next reconnect.
      if (!bootstrapGreetingDelivered) {
        log?.info?.(
          `[${accountId}] clawchat-plugin-openclaw activation bootstrap dispatched but greeting was not delivered; leaving pending for retry`,
        );
      }
      settleBootstrap("turn submitted");
    } catch (err) {
      settleBootstrap("dispatch threw");
      log?.error?.(
        `[${accountId}] clawchat-plugin-openclaw activation bootstrap failed: ${err instanceof Error ? err.message : String(err)}`,
      );
    } finally {
      if (claimedInFlight) decrementActivationBootstrapInFlight(accountId);
    }
  };

  client.on("message", (env: Envelope) => {
    void (async () => {
      await handleInboundEnvelope(env);
    })();
  });

  // `message.recall` — the sender withdrew a message. Erase our own ledger row
  // so the mention catch-up above (unseen group messages, no time predicate,
  // no retention) can never read it back into a turn.
  //
  // Ids only: the frame carries no content, and `payload.message_id` is the
  // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
  //
  // Synchronous and fire-and-forget on purpose: there is no ack to send (the
  // frame is BestEffort and non-ackable).
  //
  // §9.8 requires a permanent tombstone consulted on every ingest path, and
  // `store.recallMessage` writes one in the same transaction as the delete.
  // That is what makes this handler's timing irrelevant: the inbound ledger
  // write sits behind an await, so on a replay carrying the original and its
  // recall back-to-back the DELETE runs first — and the tombstone then refuses
  // the INSERT. It also closes the Kafka-redelivery resurrection this comment
  // previously accepted as a known limitation.
  client.on("message:recall", (env: Envelope) => {
    const target = recallTargetMessageId(env.payload);
    if (!target) {
      log?.info?.(
        `[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`,
      );
      return;
    }
    const removed = store?.recallMessage?.({
      platform: "openclaw",
      accountId,
      messageId: target,
    });
    const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
    const logFn = typeof removed === "number" ? log?.info : log?.error;
    logFn?.(
      `[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`,
    );
  });

  // Initial pull of per-group agent settings at startup (best-effort; errors are
  // logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
  // rotating `account.token` out from under the already-constructed WS client.
  // Settling this releases the Finding-2 group-dispatch gate.
  pullGroupSettings({ bypassRefresh: true });
  // Initial pull of permission policy at startup (best-effort; errors logged,
  // stale cache kept). Uses bypassRefresh for the same reason as pullGroupSettings.
  pullPermissions({ bypassRefresh: true });
  // Safety: never strand a held group message if the runtime is torn down while a
  // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
  // but force the gate open too so any later `wait()` resolves immediately.
  const forceSettingsReadyOnAbort = () => settingsReadyGate.forceOpen();
  abortSignal.addEventListener("abort", forceSettingsReadyOnAbort, { once: true });

  const handleInitialAuthFailure = async (err: unknown): Promise<void> => {
    if (installationIsSuperseded() || abortSignal.aborted || !lease.isCurrent()) return;
    const classified = classifyClawlingClientError(err);
    // §A.2/§B — initial-connect hello-fail(auth). Do NOT pre-flip
    // configured:false here: a transient refresh must leave creds + configured
    // untouched (§B). Branch on the refresh-eligibility classification first.
    const klass = classifyHelloFailReason(lastHelloFailReason || classified.message);
    const eligible =
      !reconnectWithRefreshedToken &&
      !autoLoggedOut &&
      !abortSignal.aborted &&
      Boolean(latestRefreshToken) &&
      (klass === "token-rejected" ||
        (klass === "generic" && refreshManager.isNearExpiry(activatedAtMs)));
    if (eligible) {
      // Total: success reconnects, permanent auto-logs-out, transient/skipped
      // backoff-reconnects with the current token (creds + configured intact).
      await runRefreshAndReconnect("ws-initial-connect-auth");
      return;
    }
    // §A.2 / Finding 5 — generic + token NOT near expiry (and we have a refresh
    // token but it isn't refresh-eligible): keep the WS in transport backoff
    // with the current token instead of tearing the account down.
    if (
      klass === "generic" &&
      Boolean(latestRefreshToken) &&
      !reconnectWithRefreshedToken &&
      !autoLoggedOut &&
      !abortSignal.aborted
    ) {
      scheduleTransportBackoffReconnect("initial-connect-generic-not-near");
      return;
    }
    // Not refresh-eligible and no usable refresh token — fall back to the
    // legacy auth-failed teardown so the gateway flips not-configured and (for
    // a sqlite-sourced account) re-enters wait-for-activation.
    setStatus({
      ...getStatus(),
      connected: false,
      configured: false,
      running: false,
      lastError: classified.message,
    });
    finishCurrentConnection({
      state: "auth_failed",
      error: lastHelloFailReason || classified.message,
    });
    logAuthFailure(classified.message);
    closeClient();
    if (activationAccount.source === "sqlite" && !abortSignal.aborted) {
      await startOpenclawClawlingGateway({
        ...params,
        boundaryWarningIssued: true,
        account: {
          ...params.account,
          configured: false,
          token: "",
          userId: "",
          ownerUserId: "",
        },
        rejectedActivationToken: account.token,
      });
    }
    return;
  };

  // A non-terminal hello-fail rejects connect() while the client keeps its own
  // backoff loop alive. Keep the host task pending until that client is ready
  // or stops; returning would let the host start another reconnecting client.
  type InitialReconnectResult = { kind: "ready" } | { kind: "stopped" } | { kind: "auth"; err: unknown };
  const waitForInitialReconnect = (): Promise<InitialReconnectResult> => new Promise((resolve) => {
    const finish = (result: InitialReconnectResult) => {
      client.off("state", check);
      abortSignal.removeEventListener("abort", check);
      superseded.signal.removeEventListener("abort", check);
      resolve(result);
    };
    const check = () => {
      if (abortSignal.aborted || superseded.signal.aborted || !lease.isCurrent()) finish({ kind: "stopped" });
      else if (client.state === "connected") finish({ kind: "ready" });
      else if (!client.canReconnect || client.state === "disconnected") {
        finish(classifyClawlingClientError(latestInitialClientError).kind === "auth"
          ? { kind: "auth", err: latestInitialClientError }
          : { kind: "stopped" });
      }
    };
    client.on("state", check);
    abortSignal.addEventListener("abort", check, { once: true });
    superseded.signal.addEventListener("abort", check, { once: true });
    check();
  });

  try {
    try {
      if (installationIsSuperseded()) return;
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime calling client.connect()`);
      await client.connect();
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client.connect() resolved`);
    } catch (err) {
      if (installationIsSuperseded() || abortSignal.aborted || !lease.isCurrent()) {
        closeClient();
        return;
      }
      if (autoLoggedOut) {
        await lifecycle.rePairWait;
        return;
      }
      const classified = classifyClawlingClientError(err);
      if (classified.kind === "auth") {
        await handleInitialAuthFailure(err);
        return;
      }
      const result = await waitForInitialReconnect();
      if (installationIsSuperseded() || abortSignal.aborted || !lease.isCurrent()) {
        closeClient();
        return;
      }
      if (result.kind === "auth") {
        await handleInitialAuthFailure(result.err);
        return;
      }
      if (result.kind === "stopped") {
        closeClient();
        const stoppedError = classifyClawlingClientError(latestInitialClientError ?? err);
        setStatus({
          ...getStatus(),
          connected: false,
          configured: true,
          running: false,
          lastError: stoppedError.message,
        });
        log?.error?.(
          `[${accountId}] clawchat-plugin-openclaw connect failed (${stoppedError.kind}): ${stoppedError.message}`,
        );
        return;
      }
    }
    if (installationIsSuperseded() || abortSignal.aborted || !lease.isCurrent()) {
      closeClient();
      return;
    }
    if (autoLoggedOut) {
      await lifecycle.rePairWait;
      return;
    }
    activeClients.set(accountId, client);
    // Text "@name" autolinking on outbound group sends reads the same cached
    // roster (participant ids + nicknames) the group prompt shows the model.
    const unregisterMentionRoster = registerMentionRosterResolver(accountId, async (groupId) => {
      const memoryRoot = resolveMemoryRootForPeer({ kind: "group", id: groupId });
      if (!memoryRoot) return [];
      let ownerMetadata: ClawChatPromptMetadata | null = null;
      try {
        const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
        ownerMetadata = ownerFile.exists ? ownerFile.metadata : null;
      } catch {
        ownerMetadata = null;
      }
      const participants = await loadGroupParticipantsForPrompt({ memoryRoot, groupId, ownerMetadata });
      return participants.map((participant) => ({ userId: participant.id, name: participant.name }));
    });
    log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime active client registered`);
    setStatus({
      ...getStatus(),
      connected: true,
      running: true,
      lastStartAt: Date.now(),
    });
    log?.info?.(`[${accountId}] clawchat-plugin-openclaw connected`);
    try {
      const pending = store?.recoverToolRequestWakes?.(accountId, Date.now()) ?? [];
      for (const requestId of pending) void wakeRequest(requestId).catch(() => {
        log?.error?.(`[${accountId}] clawchat-plugin-openclaw request wake recovery failed`);
      });
    } catch { log?.error?.(`[${accountId}] clawchat-plugin-openclaw request wake recovery unavailable`); }

    await waitUntilAbort(AbortSignal.any([abortSignal, conflictStop.signal]), async () => {
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
      if (activeClients.get(accountId) === client) activeClients.delete(accountId);
      unregisterMentionRoster();
      closingForAbort = true;
      clearBootstrapRetry();
      // §A.1 — stop the proactive refresh timer on shutdown.
      refreshManager.stop();
      groupCoalescer.cancelAll();
      finishCurrentConnection({
        state: "disconnected",
        closeCode: 1000,
        closeReason: "client close",
      });
      closeClient();
      await finishAbortCleanup();
      log?.info?.(`[${accountId}] clawchat-plugin-openclaw disconnected`);
    });
  } finally {
    abortSignal.removeEventListener("abort", forceSettingsReadyOnAbort);
    try {
      closeClient();
    } finally {
      clearBootstrapRetry();
      refreshManager.stop();
      if (abortSignal.aborted || installationIsSuperseded()) await finishAbortCleanup();
    }
  }
}
