/**
 * Nexus Connect — Service Worker
 * Handles API requests from the popup, auto-syncs cookies on change,
 * and polls for bot commands to execute via the LinkedIn content script.
 */

// ── Build marker ──────────────────────────────────────────────────────
//
// Every time we ship code-affecting changes, bump this. The marker logs on
// service-worker boot and is also returned by the "li-build-version" message
// handler so the dashboard / lihealth / content script can verify they're
// running against the latest extension. Saves the "did the reload take?"
// debugging dance.
//
// Format: YYYY-MM-DD-NN where NN is a same-day revision counter.
const NEXUS_BUILD = "2026-08-01-r93";
const NEXUS_BUILD_NOTES =
  "r93: Per-account action gate + LinkedIn soft-wall detection. (1) NEW nexus-bot/src/engine/account-gate.js + schema-v106 `account_action_gate`: at most ONE automation action in flight per social account across every engine, and a minimum randomized gap between consecutive actions on that account. Closes two silent ban-risk holes — `withLock()` keys its lock on the JOB NAME (so li-outreach and comment-campaign could drive the same account simultaneously), and linkedin-limits.js caps VOLUME per day/week but never SPACING (so a whole day's DM budget could legally fire in one 90-second burst). checkLinkedInLimits now consults the gate read-only; withAccountGate() claims/releases at execution sites; a stale-claim reaper frees accounts wedged by a dead process. (2) NEW _detectSoftWall() in content-linkedin.js: LinkedIn serves 'You've reached the weekly invitation limit' as 200 OK prose, which neither the 429 path nor _detectAccountWall could see. Matched phrases POST to the new /api/linkedin/soft-wall endpoint, which freezes ONLY that action category (connect_request) and leaves messages and likes running — unlike a hard wall's blunt 24h whole-account pause. Architecture adapted from GrowChief's per-account Temporal throttler. Reload the extension AND restart the bot to pick this up. PRIOR r91: Fix `ReferenceError: _sender is not defined` in the background service worker. The onMessage listener's parameter is `sender`, but 14 handlers in its body read `_sender` (an undeclared name) — so those paths threw the instant they ran, including 'Accept & Message' (li-start-accept-and-dm-batch-bg → `const panelTabId = _sender?.tab?.id`) and every panel-* / li-cdp-* / tw-run-action-bg / LI_BG_ACTION_MAP dispatch. All 14 `_sender` → `sender`. Reload the extension to pick this up. PRIOR r90: Circuit bridge origin lock. content-circuit.js now wires up ONLY on an explicit ALLOWED_ORIGINS allow-list (thecircuit.vercel.app + localhost:3000), the manifest match narrowed from http://localhost/* (any port) to :3000, the SW validates message senders for the privileged handlers (circuit-mutate must come from an allow-listed Circuit tab; consent-get/consent-resolve only from this extension's own consent.html), and a per-quest grant is persisted only AFTER its subscription succeeds (no approved-but-not-running TOCTOU). Reload the extension to pick this up. PRIOR r79: Account cockpit + logout flagging + system pulse. (1) ACCOUNTS page is now an honest cockpit: expired/logged-out sessions are SHOWN (amber Re-login banner with auto-resync + re-login actions — the X lockout pattern generalized to LinkedIn/Telegram/Luma via buildReloginRow), cooldowns show a 'resumes in Xh' countdown, Telegram rows show the ACTUAL effective daily caps (premium = higher caps, NOT a lock — non-premium runs at BASE caps), owner-mismatch ('active under another profile') gets a 'Reconnect under this profile' CTA, one-click 'Prune expired sessions' on LinkedIn, and a unified 'Add account:' bar up top. Backed by extended GET /api/accounts/all (session truth per X handle incl. session-only identities, stale LinkedIn rows, caps, cooldown_until). (2) LOGOUT DETECTION closes the loop: action failures with session-dead codes (http_401/session_expired) now CONFIRM via a live validate probe before flagging — probe alive = suppressed (no false alarms), probe dead = the owner-scoped session row flips to expired + a browser resync is auto-requested + ONE alert lands in the in-extension bell (#accounts deep link) AND Telegram-if-linked (action-failure-alerter.js rewrite, notifyOwner routing; LinkedIn failures hooked via liActionFail). POST /api/connections/test now stamps last_validated_at on success and expires confirmed-dead rows. (3) SYSTEM PULSE: new topbar chip 'N automations · X working · Y need you' (90s poll of /api/admin/automations/proof) — the proof-of-execution rollup ambient on every screen; click → Proof board. Semantic status tokens (--status-ok/warn/bad) added to dashboard.css — green=working, amber=needs-you, red=failing/logged-out, one language everywhere. Reload the extension AND restart the bot to pick this up. PRIOR r78: Proof-of-execution board (Monitor → Proof, #automations-proof). The receipt spine becomes VISIBLE: every automation's REAL-WORK verdict (Working/Failing/Blocked/Idle/Unproven/Never-ran) with succeeded/attempted counts, the precondition reason for blocked ones, and proof evidence refs (tweet/message/signal ids) — not just 'did the cron tick'. Verdict-rollup chips up top; per-row ▶ Run triggers the job and refreshes its fresh receipt; your active custom-workflows show their latest run outcome. Backed by new GET /api/admin/automations/proof (live-map-routes.js handleAutomationProof) which reuses the health-auditor's per-job receipt classification — the SAME data `node tools/proof-audit.js` prints. New background action 'automations-proof'. Reload the extension to pick this up. PRIOR r77: Phase-1.5 tie-together — the quest seam becomes VISIBLE on both sides. (1) Dashboard 'Earn' section (7th sidebar entry, Ctrl/Cmd+7, #earn): queued/awaiting-proof/verified-today/XP KPIs + recent quest activity + how-the-loop-works, backed by new GET /api/quests/summary (db/quest-jobs.js summarizeQuestsForOwner — ONE source of truth). (2) Today console gets a 'Community quests' tile (/api/today now returns a quests block; only when the seam was ever used). (3) NEW content-circuit.js bridge on thecircuit.vercel.app (+localhost dev): window.postMessage protocol (nx-ping / nx-api) → SW proxies ONLY the read-only allowlist {quests-summary, engine-health} to the local bot, so the hosted Circuit renders a live 'My engine' passport panel without knowing the (unpacked, per-machine) extension ID — externally_connectable can't work for sideloads. Token never crosses the bridge. (4) Verifier now records the Circuit's awarded XP on each acknowledged callback (quest_watches.awarded_xp, schema-v92). Reload the extension AND restart the bot to pick this up. PRIOR r76: Phase-0 Circuit×Engine quests. The extension now WAKES on browser startup (chrome.runtime.onStartup) and on a 1-min chrome.alarms timer ('nx-quest-drain'), so the new persistent quest_jobs queue is drained the moment the laptop starts — surviving MV3 service-worker teardown (the 3s setInterval alone dies after ~30s idle). Server: /api/commands/pending now also returns quest_jobs (no extension pickup change) and /api/commands/:id/result records quest-job results (db/quest-jobs.js, schema-v88). Reload the extension to pick this up. PRIOR r75: Fix LinkedIn DOM-snapshot uploads silently failing with 'Failed to fetch'. _uploadDomSnapshot in content-linkedin.js was the lone content→server call that fetched the bot directly; under MV3 a content-script fetch from https://www.linkedin.com to http://localhost:9091 is cross-origin and bound by the PAGE's CORS (content scripts don't inherit the extension's host-permission CORS bypass), so the preflight got no Access-Control-Allow-Origin and every snapshot upload died. Now it relays through the service worker like liLog: content sends {type:'li-dom-snapshot', payload} → background liUploadDomSnapshot() POSTs /api/linkedin/dom-snapshot (worker holds the localhost host_permission → bypasses CORS) and stamps NEXUS_BUILD. Reload the extension to pick this up. PRIOR r74: Platform IA rethink + Today operator console. Sidebar regrouped into 6 sections around the daily loop — Today · Inbox · Pipeline · Find · Automate · Monitor — collapsing the old 13-child 'Work' blob (Automate = Goals/Run now/Scheduled/Campaigns/Boost/Operations; Monitor = Activity/Performance/Approvals/Coin MM); Accounts/Workspaces/utilities moved to the footer. Pure sidebar regroup — every child points at an existing route, so deep links are unchanged. Ctrl/Cmd+1..6. NEW Today console (gamification, no XP/badges): GET /api/today (db/today-digest.js) composes momentum/streak, a 'needs you now' triage queue, setup quests, feature-adoption nudges, 30d reply-rate + tip, goal progress, and account strength — all from existing data, owner-scoped. PRIOR r73: Screenshot replay now covers LinkedIn batch runs too — li-shot handler + _liWorkerTabId tracking (set in the LI worker-tab handlers) + _liShot() per step in content-linkedin.js + a 📸 Replay button on the LinkedIn batch drawer. Shares the same run-shots store/viewer as X (runId-keyed). PRIOR r72: Per-step screenshot REPLAY. Every X (and LinkedIn) run now captures a screenshot of the hidden worker tab at each logged step via chrome.debugger Page.captureScreenshot (captureVisibleTab can't see a hidden tab), POSTed to /api/twitter/run-shot and stored owner-scoped under DATA_DIR/run-shots/<runId>; the X run drawer gets a 📸 Replay button (_openRunReplayModal) that shows the visual step-by-step. Best-effort + throttled (min 800ms, cap 30/run) — a failed capture never affects the action. PRIOR r71: Step-by-step PREVIEW on every automation. X and LinkedIn run-config modals now have a 🔎 Preview button that shows your resolved settings + a plain-English, numbered step map of exactly what will happen — nothing runs until you confirm. LinkedIn batch actions (Accept & Message, Add Connections, Like Posts, etc.) got node step-maps in linkedin-action-registry.js so they preview just like X; the detail modal shows the same step list. A node-type→plain-English gloss (_NODE_EXPLAIN/_stepGloss in dashboard.js) turns code symbols into sentences. Presets/compositions/coin_mm/boost/custom workflows already previewed via runWithPreview. PRIOR r70: Phantom (Solana) wallet connect — login or link a wallet to your profile. The connect happens in a compact popup window served by the bot (Phantom only injects into web pages) and AUTO-TRIGGERS the approval on load; the orchestration (open window → poll → store token → link) runs in the SERVICE WORKER so it completes even though the extension popup closes when the wallet window takes focus (the prior popup-side poll could never finish — that's why 'connected but didn't log in'). On success the SW stores the token + posts a wallet-result message; reopen the popup to see the linked wallet chip. PRIOR r69: Manual login (any platform) + rename detection. NEW: 'Add account' in the connection strip opens a fresh-fingerprint browser window on this machine (isolated profile + anti-detection + optional proxy, default direct) for you to log in by hand; it auto-captures + stores the session (X/LinkedIn/Luma to their tables, Gmail/anything-else to a generic browser_sessions table). Async start/status (POST /api/accounts/manual-login + GET …/status) so a multi-minute login never hits the 120s IPC timeout; generic 'I'm logged in' button works for ANY site. CLI: node tools/manual-login.js --platform x [--proxy ...] [--url ...]. RENAME FIX: X validation now uses getCurrentUser (current handle + stable numeric id) instead of the rename-fragile search(); the keeper + connection-test reconcile identity — renamed handles update in place and stale DUPLICATE session rows (same id, old handles) get expired (fixed midpricedrainer→CryptomondaysIZ). DASHBOARD_ROUTES: manual-login, manual-login-status. PRIOR r68: Centralized activity log + stats. ONE unified feed (GET /api/activity/feed → unified-activity.js) merges X/LinkedIn/Telegram/Email/Luma/outreach logs into a single time-sorted stream, each row carrying a clickable link (read-side extraction). The 'Action log → All' tab renders it with 🔗 links + a cross-platform stats header + 5s dedup-polling (DASHBOARD_ROUTES: activity-feed). Centralized stats: GET /api/stats/overview (stats-aggregator.js getActivityStats + the outreach funnel extracted into a shared computeOutcomes() in ops-routes.js, so /api/ops/outcomes stays byte-identical). New Performance → Overview tab (default): cross-platform action health + funnel + top actions + error breakdown, 7d/30d toggle (DASHBOARD_ROUTES: stats-overview). PRIOR r67: Persistent linked run history. Run-from-here X actions already persist to twitter_action_log via _twRelayLog → tw-log → POST /api/twitter/log, but the post/message LINK was dropped. Now _twRelayLog includes the url (result.url), and the X Activity feed row renderer (_renderLinkedInActivityRow, shared with LinkedIn) renders a 🔗 link when result_json.url is present. So every action is browsable later in #action-log/x with a click-through to the actual tweet/profile/message — not just in the ephemeral drawer. No server change (the POST endpoint already stored result). PRIOR r66: Connection health strip in the dashboard topbar (#conn-strip). Shows a live per-platform session dot for X / LinkedIn / Luma (green=connected, red=needs reconnect) — the #1 reason actions 'do nothing' is an expired session, now visible at a glance. Click a pill to LIVE-test (new POST /api/connections/test → worker twitter:validate-session / linkedin:session-validate / luma status); click ↻ to Reconnect (reuses the existing sync-twitter/sync-linkedin/sync-luma handlers that pull fresh cookies from your logged-in browser tab). New DASHBOARD_ROUTES entry 'connections-test'. PRIOR r65: Clickable action log. The X run drawer now renders every action's post/message LINK (the url was already relayed from content-twitter's addLog but the dashboard threw it away), shows a timestamp per line, and has a '⧉ Copy' button that copies the whole run (every action + link) to the clipboard so it can be pasted back for debugging. content-twitter now also attaches links to follow (→profile), quote, and post actions (likes/replies/retweets already linked the tweet). PRIOR r64: ROOT-CAUSE FIX for the X dashboard drawer showing only 'Starting Engage Feed…' forever. The Dashboard is an extension PAGE (dashboard.html), but background progress (stage updates + the nx-batch-progress relay of content-script addLog/setStatus) was delivered ONLY via chrome.tabs.sendMessage(panelTabId) — which reaches content scripts, NOT extension pages. So the dashboard drawer never received any progress (LinkedIn's on-page floating panel worked because it IS a content script). New _deliverToPanel(panelTabId, envelope) fans out to BOTH chrome.tabs.sendMessage (on-page panels) AND chrome.runtime.sendMessage (extension-page dashboard); stage()/setStage() and the nx-batch-progress relay now use it. Reload the extension; the drawer's first line should read 'Nexus 2026-06-04-r64 · …'. PRIOR r63: Engage-feed / X 'Run from here' diagnostics. Every run now (1) prints a 'Nexus <build> · <actionId> · run <runId>' banner in the drawer the instant it starts, so you can confirm which build is actually loaded and quote the runId back; (2) does a fast URL-based login check on the hidden worker tab — if x.com bounced it to a login/account-access/landing page, it fails in ~1s with a clear 'background X tab isn't logged in — log into x.com and retry' message instead of silently retrying for ~105s then hitting the 120s timeout; (3) shows a 'Waiting for the X tab…' stage during the dispatch so the long wait is never silent. Reload the extension for these to take effect (the banner's build string will read 2026-06-04-r63 once it does). PRIOR — Unified session keeper: added a `resync_session` poller command — the bot's session-keeper can now ask the extension to pull fresh cookies from the logged-in browser on demand (executeCommand handles {platform: twitter|linkedin|luma|cryptorank} → syncTwitter/syncLinkedIn/syncLuma/syncCryptorank). This is the universal recovery that keeps X + LinkedIn + Luma sessions alive on autopilot without headless password login (which X's Arkose/LinkedIn's captcha make unreliable). Reload the extension for the resync command handler to take effect. PRIOR (r61): X (Twitter) observability + hang fix. The 'X stuck on starting engage feed' bug had two causes: (1) the pipeline could hang forever — graphql-client.extractTwitterCookies awaited getBrowser()/page.cookies() with no timeout, so a crashed Puppeteer browser blocked the whole action; sendToContentScript awaited chrome.tabs.sendMessage with no timeout; and a missing session returned null → silent no-op. (2) X had no persisted log, so a hang left no record. Fixes: a correlation runId is generated in the dashboard (_xRunStartBatch) and threaded through tw-run-action-bg → content-twitter runDrivenTwitterAction → twitterCmd (auto-injected) → /api/twitter/command → IPC → social-autoposter engine. New twitter_action_log table (schema-v57) + twitter-action-log.js CRUD + twitter-logger.js helpers mirror the LinkedIn stack; content-twitter addLog/setStatus relay via new tw-log message → background txLog → POST /api/twitter/log. Every step also appends to nexus-bot/data/logs/x-action-trace.jsonl (bot/extension) and social-autoposter/data/x-trace.jsonl (worker, written live so a hang leaves its last step); `npm run x-trace` merges both by runId into x-debug-latest.json. Hang fixes: 15s timeout on browser cookie extraction (throws cookie_extraction_timeout instead of hanging; missing cookies → explicit no_session error, no silent success); sendToContentScript Promise.race timeout (75s default, 105s for tw:run-action) that bails immediately on timeout; runDrivenTwitterAction wraps action.start in a 95s timeout; tw-run-action-bg resets the worker tab to x.com/home after each run and clears the cached worker-tab id on timeout/unreachable so the next run spawns fresh; executeTwitterCommand fetch bumped 10s→90s; background hard backstop 90s→120s (budget ordering: fetch 90 < dispatch 105 < backstop 120). New Dashboard 'X Activity' tab (#action-log/x) polls /api/twitter/log/recent and reuses the LinkedIn row renderer. Dashboard automation surface expanded from 5 → 8 actions: added Accept Invites (no DM), Withdraw Sent Invites, Follow Suggested. All three reuse the EXISTING content-script dispatch cases (li:accept-invites / li:withdraw-invites / li:follow-connections — they were already in the panel) by extending those cases to set _NX_BACKGROUND_BATCH when params.panelTabId is provided. Three new entries in LI_BG_ACTION_MAP route the dashboard's li-start-*-bg messages to the right URL + dispatch. Legacy panel calls still work because the wrapper is conditional on panelTabId being present. Three registry entries added under a new 'Cleanup' / 'Engagement' category so the card grid groups them naturally. Skipped Connect with Commenters: depends on linkedin:http-feed which LinkedIn deprecated (400s for most accounts). Skipped accept-invites-http: redundant with the new accept-invites entry which already drives the same DOM-based handler. Dashboard-driven LinkedIn automation. New #linkedin/automations route in the dashboard exposes 5 actions (Accept & Message, Add Connections, Like Posts, Message Connections, View Profiles) via card grid + modal config form + live progress drawer. Schema lives in new linkedin-action-registry.js (loaded as a script before dashboard.js, exposes window.LI_ACTION_REGISTRY) and is the single source of truth for action UI. Worker-tab status pill in the header polls li-worker-tab-status every 3s. Each card reads /api/linkedin/budget for the action's daily remaining count + warmup multiplier; disables when blocked. Account-wall cooldown surfaces as a red banner blocking all cards. Run-now button opens a modal with schema-driven inputs (text/number/textarea/boolean + chip palette for {placeholder} variables); Submit fires the same chrome.runtime.sendMessage handlers the floating panel uses (li-start-accept-and-dm-batch-bg already existed from r44; r50 adds the parallel li-start-add-connections-bg / li-start-like-posts-bg / li-start-message-connections-bg / li-start-view-profiles-bg via one new LI_BG_ACTION_MAP helper in background.js). Worker-tab content script gets four new li:run-* dispatch cases that wrap existing helpers (handleLikeAndComment, linkedinCmd) and set _NX_BACKGROUND_BATCH so addLog/setStatus relay through the nx-batch-progress channel back to the dashboard tab id, which now also listens to {nexusPanel:true, type:log|status} messages and feeds them into the live drawer. Sidebar entry under Work>Run; route mapped via V3_ROUTE_TO_SECTION. handleRoute now calls _liAutoRunUnmount on every navigation so the 3s status poll and message listener are torn down cleanly. The floating panel still works identically; both surfaces now read from the same registry and trigger via the same SW handlers — power users keep the in-context shortcut, new users get the central dashboard. Anti-bot pass 3: per-recipient hook + active-hours guard. (1) Per-recipient context — new /api/linkedin/recipient-context endpoint fetches the recipient's headline via Voyager linkedin:http-view-profile, derives company from the 'X at Y' pattern in the headline, and optionally generates a 1-line AI hook conditioned on the user's base template (linkedin-hook-generator.js). 1h server-side cache per (owner, slug) so repeat fetches don't burn extra profile-views or AI calls. Three new template placeholders the panel batch resolves at send time: {headline} (verbatim), {company} (derived), {hook} (AI). Panel UI advertises the new chips; hint warns {hook} adds 5-10s per recipient. Variation generator's PLACEHOLDER_KEYS extended to preserve these across siblings. (2) Active-hours guard — _isWithinActiveHours runs at the top of runAcceptAndDmBatch, defaults to 08:00-22:00 local, configurable via chrome.storage.local._liActiveHours = {startHour, endHour}. Wraparound (e.g. 22:00-04:00) supported. Bails the batch entirely if outside window with a clear 'try again during the window' message, before the worker tab even spawns. Complements the server-side 23:00-06:00 quiet hours — the active-hours window is the user's preferred working surface, quiet-hours is the LinkedIn-side hard pause. Anti-bot pass 2: per-recipient cooldown + account-wall detector + danger-score canary + organic skip. (1) Per-recipient cooldown — checkLinkedInLimits now accepts recipientSlug/Urn; queries linkedin_action_log.params_json LIKE pattern for prior ok actions of the same category. Windows: DM 90d, connect 60d, comment 7d. New li-limits-check-v2 message handler routes the slug/urn from runAcceptAndDmBatch through to the server, panel batch skips matched recipients (continue) instead of stopping the whole run. (2) Account-wall detector — _detectAccountWall in content-linkedin.js runs at the top of every batch iteration, looks for checkpoint pathnames, captcha iframes (recaptcha/hcaptcha), and dialog text matching restricted/unusual-activity/verify-identity/phone-verify. On hit: POST to new /api/linkedin/incident → bot stores in new linkedin_incidents table (migration v85) → notifyUser fires Telegram alert → checkLinkedInLimits returns account_wall_cooldown blockedReason for the next 24h on every category. Server-side dedup on (owner, type) within the 24h window so a flickering selector doesn't spam Telegram. (3) Daily danger-score canary — new linkedin-danger-score.js aggregates 7d signals (429 count × 15pts + unresolved incidents × 25pts + DM ok-rate score capped 25pts + connect-acceptance score capped 20pts), caps at 100. Daily cron at 04:00 UTC (added to scheduler.js withLock 'li-danger-score') walks every active owner; level red (>=60) and no prior alert today fires a Telegram message with the signal breakdown. On-demand GET /api/linkedin/danger-score endpoint for dashboard / /lihealth integration. (4) Organic skip — after iteration 3, every recipient has a 10% chance of getting accepted without a DM. Matches the natural distribution of how real users handle invites. Build markers archived at NEXUS_BUILD_NOTES_R46 (r46) and inline below. Anti-bot pass 1: rate-limit gating + AI message variation pool + account-age warmup. (1) New /api/linkedin/limits-check endpoint reads daily + 7-day counters off linkedin_action_log and applies a fuzzy per-owner daily cap (msg 10-20/day, connect 3-5/day, like 30-50/day, comment 8-15/day) plus 7-day caps for high-risk actions. Account-age multiplier ramps caps from 0.2x (days 0-3) through 0.4x (days 4-7), 0.7x (days 8-14), to 1.0x (day 15+) so brand-new accounts can't burn through full caps. Quiet hours 23:00-06:00 hard-paused. (2) Panel runAcceptAndDmBatch now calls limits-check before every DM (hard stop on cap) and before every like-step (soft skip — likes are low-risk so we drop the warm-up and still send the DM). Starting budget surfaced via /api/linkedin/budget in the first log line so the user sees their headroom. (3) New /api/linkedin/generate-variations endpoint takes the user's template and asks the account-voice-conditioned LLM (existing voice-profile.js + askAI infra) for 12 distinct siblings — different opening words, length distribution, sentence structure. AI output passes through the same em-dash/AI-tell scrubber social-autoposter uses. Placeholders {firstName}/{lastName}/{name}/{headline} preserved across all variations. Panel start() fetches the pool before spinning the worker tab; nextVariation() inside the batch picks one without replacement so no two recipients in a batch see the same wording. AI failure of any kind falls back to the original single template (no batch regression). Module: nexus-bot/src/ai/linkedin-variation-generator.js. Limits module: nexus-bot/src/engine/linkedin-limits.js. Previous r46 notes archived as NEXUS_BUILD_NOTES_PREV.";

const NEXUS_BUILD_NOTES_R46 =
  "Post-r45 audit fixes. (1) Voyager DM body encoding: linkedin:http-message, linkedin:http-send-message, linkedin:http-comment, linkedin:http-send-connection now wrap their JSON body with Buffer.from(payload, 'utf8') instead of passing the JS string straight to fetch. The string-to-ByteString conversion fetch performs threw on any char > 0xFF, so EVERY DM/comment/connect-note containing non-ASCII (Chinese, Cyrillic, accented, emoji) silently failed — 41 fails over 7 days against zero successes in the action log. Now UTF-8 bytes flow through directly; Content-Type also bumped to 'application/json; charset=utf-8' for cleanliness. (2) Auto-snapshot upload pipeline: linkedin_dom_snapshots had zero non-puppeteer rows despite the extension calling _uploadDomSnapshot at every selector-miss failure path. Root cause: insertDomSnapshot had a silent catch ('return null') and the server route at /api/linkedin/dom-snapshot returned 200-ok regardless of whether the row landed. Extension saw 200 and logged 'snapshot uploaded — id null' as success. Fixed both: catch now console.warns the actual error, route returns 500 when id is null. (3) linkedin:http-send-connection 403 classification: previously 401 AND 403 were both classified as 'session expired', causing campaigns to retry the same contact 22+ times over a week (real case in action log: same slug + same campaign id, every 30 min). Now 401 stays session_expired, 403 returns terminal:true with a sniffed reason from the response body (already-connected / weekly-cap / generic) so the outreach engine can skip the contact. (4) Selector drift audit: confirmed the high-volume selectors against the 2026-05-26 puppeteer snapshots — feed posts/likes/comments alive, messaging conversation items + compose + send alive, received-invitations accept buttons alive, notifications alive. Production action-log success rates back this up: like-latest-post-on-page 95%, resolve-profile-urn 98%, worker-tab-spawn 100%. No code selector updates needed this round.";
console.log(`[Nexus] Service worker loaded — build ${NEXUS_BUILD}: ${NEXUS_BUILD_NOTES}`);

// Deliver a {nexusPanel:true,...} progress envelope to whichever surface owns the
// run's panel. The two surfaces subscribe via DIFFERENT channels:
//   • on-page floating panels (content scripts on linkedin.com / x.com) receive
//     chrome.tabs.sendMessage(panelTabId, …)
//   • the Dashboard is an extension PAGE (chrome-extension://…/dashboard.html); it
//     does NOT receive tabs.sendMessage — only chrome.runtime.sendMessage reaches it.
// We fan out to both. Each listener filters on `nexusPanel`, so the extra send is
// harmless. This is the fix for the X dashboard drawer showing only "Starting…"
// forever: stage/progress were sent solely via tabs.sendMessage, which the
// extension-page dashboard never received.
function _deliverToPanel(panelTabId, envelope) {
  if (typeof panelTabId === "number") {
    try {
      chrome.tabs.sendMessage(panelTabId, envelope).catch(() => {});
    } catch {
      /* ignore */
    }
  }
  try {
    chrome.runtime.sendMessage(envelope).catch(() => {});
  } catch {
    /* ignore */
  }
}

// ── Autonomous Mode: ON by default ─────────────────────────────────────
// r40: `debugger` is now a REQUIRED manifest permission, so it's always
// granted at install time. No more chrome.permissions.request() flow,
// no more "Only permissions specified in the manifest may be requested"
// errors. On every SW boot we just flip the flag ON unless the user
// explicitly disabled it via the panel chip / popup pill.
//
// The legacy `_liAutonomousOffered` flag is preserved for storage
// compatibility but no longer gates anything.
(async () => {
  try {
    const { _liAutonomousMode, _liAutonomousExplicitlyDisabled } = await chrome.storage.local.get([
      "_liAutonomousMode",
      "_liAutonomousExplicitlyDisabled",
    ]);
    if (_liAutonomousMode !== true && !_liAutonomousExplicitlyDisabled) {
      await chrome.storage.local.set({ _liAutonomousMode: true });
      console.log(
        "[Nexus] Autonomous Mode auto-enabled on SW boot (debugger is a required permission)",
      );
    }
  } catch (err) {
    console.warn("[Nexus] Autonomous Mode boot check failed:", err.message);
  }
})();

// r40: removed the chrome.runtime.onInstalled → autonomous-grant.html
// auto-open. With `debugger` now in required `permissions`, there's
// nothing to grant at runtime — the permission lands at install time.
// The boot IIFE above sets _liAutonomousMode=true on first run, so
// background mode is already active by the time the user opens the
// popup. The grant page still exists as a fallback for the chip / pill
// fallback paths.

// ── LinkedIn observability shim ───────────────────────────────────────
// Inlined (importScripts isn't reliable in MV3 service workers — see the
// comment on mapChromeCookie). Every LinkedIn action flows through liLog
// so both the bot's linkedin_action_log table and the in-extension ring
// buffer (chrome.storage.session._nxLog) record a trail. Content scripts
// call into this via chrome.runtime.sendMessage({ type: "li-log", payload }).

const _NX_LOG_KEY = "_nxLog";
const _NX_LOG_MAX = 200;

async function _liGetTokenAndBase() {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return null;
  return { token, base: (apiBase || "http://localhost:9091").replace(/\/$/, "") };
}

async function _liPushSessionLog(entry) {
  try {
    const stored = await chrome.storage.session.get(_NX_LOG_KEY);
    const log = Array.isArray(stored[_NX_LOG_KEY]) ? stored[_NX_LOG_KEY] : [];
    log.push(entry);
    if (log.length > _NX_LOG_MAX) log.splice(0, log.length - _NX_LOG_MAX);
    await chrome.storage.session.set({ [_NX_LOG_KEY]: log });
  } catch {
    /* best-effort */
  }
}

/**
 * Classify a thrown error / response into a canonical error_code that
 * matches the server-side taxonomy in schema-v54.sql + utils/linkedin-logger.js.
 */
function liClassifyError(err, ctx = {}) {
  if (!err) return "unknown";
  const status = ctx.status ?? err?.status;
  if (typeof status === "number") {
    if (status === 401 || status === 403) return "session_expired";
    if (status === 423) return "session_locked";
    if (status === 429) return "rate_limited";
    if (status === 400) return "http_400";
    if (status >= 400 && status < 500) return "http_4xx";
    if (status >= 500) return "http_5xx";
  }
  const msg = String(err.message || err).toLowerCase();
  if (err?.name === "AbortError" || msg.includes("timeout")) return "selector_timeout";
  if (msg.includes("no tab")) return "no_tab_open";
  if (msg.includes("selector") && msg.includes("missing")) {
    const m = msg.match(/selector[_\s]+missing[:\s]+(\S+)/);
    return m ? `selector_missing:${m[1]}` : "selector_missing";
  }
  if (msg.includes("not authenticated") || (msg.includes("session") && msg.includes("expired")))
    return "session_expired";
  if (msg.includes("rate limit")) return "rate_limited";
  if (msg.includes("weekly") && msg.includes("limit")) return "weekly_cap";
  if (msg.includes("daily") && msg.includes("limit")) return "daily_cap";
  if (msg.includes("cooldown")) return "cooldown";
  if (msg.includes("network") || msg.includes("failed to fetch")) return "network";
  return "unknown";
}

async function liLog(entry) {
  if (!entry || !entry.action || !entry.status) return;
  const stamped = {
    ts: new Date().toISOString(),
    source: entry.source || "extension",
    action: entry.action,
    status: entry.status,
    durationMs: entry.durationMs ?? null,
    errorCode: entry.errorCode || null,
    errorMessage: entry.errorMessage ? String(entry.errorMessage).slice(0, 300) : null,
    accountIdentifier: entry.accountIdentifier || null,
  };
  _liPushSessionLog({ ...stamped, params: entry.params || null });

  const cfg = await _liGetTokenAndBase();
  if (!cfg) return; // unpaired — local log only

  try {
    await fetch(`${cfg.base}/api/linkedin/log`, {
      method: "POST",
      headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        ...stamped,
        params: entry.params || null,
        result: entry.result || null,
      }),
      signal: AbortSignal.timeout(5000),
    });
  } catch {
    /* best-effort — already mirrored locally */
  }
}

// Upload a DOM diagnostic snapshot relayed from content-linkedin.js. Mirrors
// liLog()'s token/base + best-effort fetch — runs in the worker so it bypasses
// the page CORS that blocks a direct content-script fetch to localhost. Stamps
// the build version here (the worker owns NEXUS_BUILD) so the dump is tagged
// with the code that captured it.
async function liUploadDomSnapshot(payload) {
  const { triggerEvent, url, userAgent, snapshot, notes } = payload || {};
  if (!triggerEvent || !snapshot) return { ok: false };
  const cfg = await _liGetTokenAndBase();
  if (!cfg) return { ok: false }; // unpaired — best-effort, skip silently
  try {
    const res = await fetch(`${cfg.base}/api/linkedin/dom-snapshot`, {
      method: "POST",
      headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        triggerEvent,
        url,
        userAgent,
        buildVersion: NEXUS_BUILD,
        snapshot,
        notes,
      }),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) return { ok: false };
    const body = await res.json().catch(() => ({}));
    return { ok: true, id: body.id };
  } catch {
    return { ok: false }; // bot offline / network — already best-effort
  }
}

function liStart(action, params = null) {
  liLog({ action, status: "start", params }); // fire-and-forget
  return { action, params, startedAt: Date.now() };
}

async function liDone(
  sentinel,
  { status, errorCode = null, errorMessage = null, result = null, accountIdentifier = null } = {},
) {
  if (!sentinel) return;
  await liLog({
    action: sentinel.action,
    status,
    durationMs: Date.now() - sentinel.startedAt,
    errorCode,
    errorMessage,
    result,
    accountIdentifier,
    params: sentinel.params,
  });
}

// Last action timestamp drives the "lastActionAt" on heartbeats so we
// can tell when the extension is paired but idle.
let _liLastActionAt = null;

async function liHeartbeat() {
  const cfg = await _liGetTokenAndBase();
  if (!cfg) return;
  // Tab open = any tab whose URL is on linkedin.com right now.
  let tabOpen = false;
  let currentUrl = null;
  try {
    // Scope the query to LinkedIn tabs instead of enumerating every open tab.
    // The match pattern requires the host itself to be linkedin.com or a
    // subdomain, so it still finds every real LinkedIn tab; the first match is
    // the same tab the previous find() returned for any genuine LinkedIn URL.
    const tabs = await chrome.tabs.query({ url: "*://*.linkedin.com/*" });
    const liTab = tabs.find(t => t.url);
    if (liTab) { tabOpen = true; currentUrl = liTab.url; }
  } catch { /* tabs query failed — assume false */ }

  try {
    await fetch(`${cfg.base}/api/linkedin/extension-heartbeat`, {
      method: "POST",
      headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        tabOpen,
        currentUrl,
        lastActionAt: _liLastActionAt,
        userAgent: navigator.userAgent,
      }),
      signal: AbortSignal.timeout(5000),
    });
  } catch {
    /* best-effort */
  }
}

// ── X / Twitter action log relay (mirror of liLog) ────────────────────
//
// content-twitter.js relays its panel addLog()/setStatus() calls here as
// `tw-log` messages so X actions are persisted in twitter_action_log (and
// the x-action-trace.jsonl file) the same way LinkedIn actions are. Without
// this, X panel logs vanished on reload and a hang left no record.
async function txLog(entry) {
  if (!entry || !entry.action || !entry.status) return;
  const stamped = {
    ts: new Date().toISOString(),
    source: entry.source || "extension",
    action: entry.action,
    status: entry.status,
    durationMs: entry.durationMs ?? null,
    errorCode: entry.errorCode || null,
    errorMessage: entry.errorMessage ? String(entry.errorMessage).slice(0, 300) : null,
    accountIdentifier: entry.accountIdentifier || null,
    runId: entry.runId || null,
  };

  const cfg = await _liGetTokenAndBase();
  if (!cfg) return; // unpaired — nothing to POST to

  try {
    await fetch(`${cfg.base}/api/twitter/log`, {
      method: "POST",
      headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        ...stamped,
        params: entry.params || null,
        result: entry.result || null,
      }),
      signal: AbortSignal.timeout(5000),
    });
  } catch {
    /* best-effort */
  }
}

// Inline cookie mapper (can't use importScripts reliably in MV3 service workers)
function mapChromeCookie(c) {
  return {
    name: c.name,
    value: c.value,
    domain: c.domain,
    path: c.path,
    secure: c.secure,
    httpOnly: c.httpOnly,
    sameSite:
      c.sameSite === "no_restriction"
        ? "None"
        : c.sameSite === "lax"
          ? "Lax"
          : c.sameSite === "strict"
            ? "Strict"
            : "None",
    ...(c.expirationDate ? { expires: c.expirationDate } : {}),
  };
}

const _syncTimers = {}; // debounce state per platform
let _pollInterval = null;

// ── Per-step run screenshots (visual replay) ──────────────────────────
// The current hidden worker tab id (set by tw-run-action-bg) + throttle state
// so a chatty step loop can't spam captures. Best-effort: a failed shot never
// affects the action.
let _twWorkerTabId = null;
let _liWorkerTabId = null;
const _shotState = new Map(); // runId -> { count, lastAt }
const SHOT_MIN_INTERVAL_MS = 800;
const SHOT_MAX_PER_RUN = 30;
const SHOT_STATE_TTL_MS = 10 * 60 * 1000; // drop a run's throttle entry once it's been idle this long

// Capture `tabId` and POST the JPEG to the run-shots endpoint, throttled + capped
// per runId. Platform-agnostic (X + LinkedIn share the same run-shots store,
// keyed by the run's id). Best-effort — a failed shot never affects the action.
async function _captureAndPostShot(tabId, { runId, stepIndex, stepName, status }) {
  if (!runId || tabId == null) return;
  const st = _shotState.get(runId) || { count: 0, lastAt: 0 };
  if (st.count >= SHOT_MAX_PER_RUN) return;
  if (Date.now() - st.lastAt < SHOT_MIN_INTERVAL_MS) return;
  st.count++;
  st.lastAt = Date.now();
  _shotState.set(runId, st);
  // Evict throttle entries for runs that have gone quiet so the map can't grow
  // one permanent entry per runId for the service worker's lifetime. The current
  // run's lastAt was just set to now, so it is never below the cutoff and never
  // dropped — no live run's capture count/interval decision changes.
  const _shotCutoff = Date.now() - SHOT_STATE_TTL_MS;
  for (const [_id, _st] of _shotState) {
    if (_st.lastAt < _shotCutoff) _shotState.delete(_id);
  }

  const shot = await cdpCaptureScreenshot(tabId);
  if (!shot.success || !shot.dataBase64) return;
  const cfg = await _liGetTokenAndBase();
  if (!cfg) return;
  try {
    await fetch(`${cfg.base}/api/twitter/run-shot`, {
      method: "POST",
      headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ runId, stepIndex, stepName, status, imageBase64: shot.dataBase64 }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    /* best-effort */
  }
}
const handleTwShot = (msg) => _captureAndPostShot(_twWorkerTabId, msg);
const handleLiShot = (msg) => _captureAndPostShot(_liWorkerTabId, msg);

// Sender validation for the privileged Circuit-bridge / consent handlers.
// A web page can't reach the SW directly (no externally_connectable), but these
// checks are defense-in-depth: they pin each privileged message to exactly the
// context that is supposed to send it, mirroring content-circuit.js's allow-list.
const CIRCUIT_ORIGINS = new Set(["https://thecircuit.vercel.app", "http://localhost:3000"]);
function senderOrigin(sender) {
  if (sender?.origin) return sender.origin; // set on modern Chrome
  try {
    return sender?.url ? new URL(sender.url).origin : null;
  } catch {
    return null;
  }
}
// circuit-mutate must come from the content script running in an allow-listed
// Circuit tab (it has a sender.tab and a Circuit origin).
function isCircuitSender(sender) {
  return !!sender?.tab && CIRCUIT_ORIGINS.has(senderOrigin(sender));
}
// consent-get/consent-resolve must come from THIS extension's own consent.html
// page — never from a web page or content script — so the untrusted requester
// can't read a pending manifest or forge the Approve/Decline decision.
function isOwnConsentPage(sender) {
  return (
    sender?.id === chrome.runtime.id &&
    typeof sender?.url === "string" &&
    sender.url.startsWith(chrome.runtime.getURL("consent.html"))
  );
}

// ── Message router (popup + content script panel) ─────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  // Content scripts (content-linkedin.js, content-launcher.js) post LI
  // action telemetry through here so it lands in linkedin_action_log.
  if (msg?.type === "li-log") {
    _liLastActionAt = new Date().toISOString();
    liLog(msg.payload || {})
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  if (msg?.type === "li-heartbeat-now") {
    liHeartbeat()
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  // content-linkedin.js relays DOM diagnostic snapshots here so the actual
  // POST happens in the worker (host_permissions → CORS bypass). A direct
  // content-script fetch to localhost is cross-origin and gets blocked.
  if (msg?.type === "li-dom-snapshot") {
    liUploadDomSnapshot(msg.payload || {})
      .then((r) => sendResponse(r))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  // content-twitter.js relays X panel logs here → twitter_action_log + trace file.
  if (msg?.type === "tw-log") {
    txLog(msg.payload || {})
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  // content-twitter.js asks for a screenshot of the current worker tab per step.
  if (msg?.type === "tw-shot") {
    handleTwShot(msg)
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  // content-linkedin.js does the same for the LinkedIn worker tab.
  if (msg?.type === "li-shot") {
    handleLiShot(msg)
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  if (msg?.type === "li-log-recent") {
    // Returns the local in-extension ring buffer for the popup/dashboard.
    chrome.storage.session
      .get(_NX_LOG_KEY)
      .then((stored) => {
        sendResponse({ rows: Array.isArray(stored[_NX_LOG_KEY]) ? stored[_NX_LOG_KEY] : [] });
      })
      .catch(() => sendResponse({ rows: [] }));
    return true;
  }
  if (msg?.type === "li-build-version") {
    // Build marker query — used by dashboard / content scripts to verify
    // they're talking to the latest service worker. If the dashboard's
    // stored build differs from what we report, the user knows a reload
    // is pending.
    sendResponse({ build: NEXUS_BUILD, notes: NEXUS_BUILD_NOTES });
    return false; // synchronous response
  }
  // ── Circuit consent bridge (MetaMask-style execute-from-web) ─────────────
  // A Circuit page requested a MUTATING action via content-circuit.js. Gate it
  // behind an in-extension approval (the untrusted page can't fake it) and run
  // it with THIS install's own connect token → the member acts as themselves,
  // dodging the shared-token multi-tenant blocker. See handleCircuitMutate.
  if (msg?.type === "circuit-mutate") {
    if (!isCircuitSender(sender)) {
      sendResponse({ ok: false, error: "forbidden" });
      return false;
    }
    handleCircuitMutate(msg)
      .then(sendResponse)
      .catch((err) => sendResponse({ ok: false, error: err?.message || String(err) }));
    return true;
  }
  // The consent popup (consent.js) asks for the manifest it should render.
  if (msg?.type === "consent-get") {
    if (!isOwnConsentPage(sender)) {
      sendResponse({ ok: false, error: "forbidden" });
      return false;
    }
    const p = _pendingConsent.get(msg.req);
    sendResponse(
      p ? { ok: true, title: p.title, manifest: p.manifest } : { ok: false, error: "expired" },
    );
    return false;
  }
  // The consent popup reports the user's Approve/Decline decision.
  if (msg?.type === "consent-resolve") {
    if (!isOwnConsentPage(sender)) {
      sendResponse({ ok: false, error: "forbidden" });
      return false;
    }
    const p = _pendingConsent.get(msg.req);
    if (p) p.resolve(!!msg.approved);
    sendResponse({ ok: true });
    return false;
  }
  // Read-only: list this install's active per-quest consent grants (for the
  // Circuit's "quest permissions" panel). Local — never hits the bot API.
  if (msg?.action === "quest-grants") {
    chrome.storage.local
      .get("questGrants")
      .then(({ questGrants }) => {
        const grants = questGrants || {};
        const list = Object.entries(grants)
          .filter(([, g]) => g && !g.revokedAt)
          .map(([ref, g]) => ({ ref, title: g.title || ref, grantedAt: g.grantedAt || null }));
        sendResponse({ grants: list });
      })
      .catch(() => sendResponse({ grants: [] }));
    return true;
  }
  // Pause-resume signals from a typing content script. We surface the
  // pause state via the extension icon badge so the user can see it from
  // any Chrome window — even when they've switched away from LinkedIn.
  if (msg?.type === "li-typing-paused") {
    try {
      chrome.action.setBadgeText({ text: "PAUSE" });
      chrome.action.setBadgeBackgroundColor({ color: "#d33f4e" });
      chrome.action.setTitle({
        title: "Nexus Connect — LinkedIn DM paused, focus the LinkedIn tab to resume",
      });
    } catch {
      /* setBadgeText is fire-and-forget */
    }
    sendResponse({ ok: true });
    return false;
  }
  if (msg?.type === "li-typing-resumed") {
    try {
      chrome.action.setBadgeText({ text: "" });
      chrome.action.setTitle({ title: "Nexus Connect" });
    } catch {
      /* ignore */
    }
    sendResponse({ ok: true });
    return false;
  }
  if (msg.action === "connect") {
    handleConnect(msg)
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "wallet-connect-start") {
    // Orchestrate Phantom connect from the SW (NOT the popup — the popup closes
    // the moment the wallet window opens, which would kill any popup-side poll).
    startWalletConnect({ apiBase: msg.apiBase, mode: msg.mode }).catch((err) => {
      console.warn("[Nexus] wallet connect:", err.message);
      chrome.runtime
        .sendMessage({ type: "wallet-result", ok: false, error: err.message })
        .catch(() => {});
    });
    sendResponse({ started: true });
    return false;
  }
  if (msg.action === "panel-send-connects") {
    runPanelConnectBatch(msg.keyword, msg.limit || 5, sender.tab?.id, msg.degree, msg.message)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "panel-like-posts") {
    runPanelLikeBatch(msg.count || 10, msg.keywordFilter || "", sender.tab?.id)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "panel-like-and-comment") {
    runPanelLikeAndComment(msg, sender.tab?.id)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "generate-ai-comment") {
    generateAIComment(msg.postText, msg.authorName, msg.authorLinkedInUrl)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "crm-lookup") {
    crmLookup(msg.platform, msg.identifier)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "crm-intelligence") {
    crmIntelligence(msg.platform, msg.identifier)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "crm-event") {
    crmLogEvent(msg.platform, msg.type, msg.identifier, msg.detail)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "crm-dashboard") {
    crmDashboard()
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "keepalive") {
    sendResponse({ alive: true });
    return true;
  }
  if (msg.action === "telegram-messages") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      const p = msg.params || {};
      const qs = new URLSearchParams({ chatId: p.chatId || "777000", limit: p.limit || "10" });
      if (p.accountId) qs.set("accountId", p.accountId);
      try {
        const res = await fetch(`${base}/api/telegram/messages?${qs}`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(20000),
        });
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "telegram-scrape") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(`${base}/api/telegram/scrape`, {
          method: "POST",
          headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
          body: JSON.stringify(msg.params),
          signal: AbortSignal.timeout(30000),
        });
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "telegram-groups") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(`${base}/api/telegram/groups`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(10000),
        });
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "telegram-accounts") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(`${base}/api/telegram/accounts`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(10000),
        });
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "telegram-dm") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(`${base}/api/telegram/dm`, {
          method: "POST",
          headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
          body: JSON.stringify(msg.params),
          signal: AbortSignal.timeout(15000),
        });
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  // ── Strategy launcher: list / toggle / action / deeplink ──────────
  if (msg.action === "strategy-list") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(`${base}/api/strategies`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(10000),
        });
        const data = await res.json();
        // Cache for offline fallback
        try {
          await chrome.storage.session.set({
            _strategies_cache: data,
            _strategies_cache_at: Date.now(),
          });
        } catch {}
        sendResponse(data);
      } catch (err) {
        // Fall back to cached version if online fetch failed
        try {
          const cached = await chrome.storage.session.get([
            "_strategies_cache",
            "_strategies_cache_at",
          ]);
          if (cached?._strategies_cache) {
            return sendResponse({
              ...cached._strategies_cache,
              _offline: true,
              _cachedAt: cached._strategies_cache_at,
            });
          }
        } catch {}
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "strategy-toggle") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(
          `${base}/api/strategies/${encodeURIComponent(msg.strategyId)}/toggle`,
          {
            method: "POST",
            headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
            body: JSON.stringify({ enabled: !!msg.enabled }),
            signal: AbortSignal.timeout(15000),
          },
        );
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "strategy-action") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(
          `${base}/api/strategies/${encodeURIComponent(msg.strategyId)}/action`,
          {
            method: "POST",
            headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
            body: JSON.stringify({ context: msg.context || {} }),
            signal: AbortSignal.timeout(30000),
          },
        );
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "strategy-deeplink") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(
          `${base}/api/strategies/${encodeURIComponent(msg.strategyId)}/deeplink`,
          {
            headers: { Authorization: `Bearer ${token}` },
            signal: AbortSignal.timeout(10000),
          },
        );
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }

  // ─── Manual sync triggers from dashboard ────────────────────────
  // Relay the sync function's real {ok,error,identifier} result so the
  // dashboard reports the truth (the functions return {ok:false,error} when no
  // session is present or the server rejects). `?? {ok:true}` keeps older
  // return-nothing paths safe.
  if (msg.action === "sync-cryptorank") {
    syncCryptorank()
      .then((r) => sendResponse(r ?? { ok: true }))
      .catch((e) => sendResponse({ ok: false, error: e.message }));
    return true;
  }
  if (msg.action === "sync-linkedin") {
    syncLinkedIn()
      .then((r) => sendResponse(r ?? { ok: true }))
      .catch((e) => sendResponse({ ok: false, error: e.message }));
    return true;
  }
  if (msg.action === "sync-twitter") {
    syncTwitter()
      .then((r) => sendResponse(r ?? { ok: true }))
      .catch((e) => sendResponse({ ok: false, error: e.message }));
    return true;
  }
  if (msg.action === "sync-luma") {
    syncLuma()
      .then((r) => sendResponse(r ?? { ok: true }))
      .catch((e) => sendResponse({ ok: false, error: e.message }));
    return true;
  }

  // ─── Dashboard API proxies ────────────────────────────────────────
  // Each action maps to a single bot route. The dispatcher below handles
  // the auth header + timeout + error handling for every one.
  if (isDashboardAction(msg.action)) {
    dispatchDashboardCall(msg)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }

  if (msg.action === "crm-stats") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(`${base}/api/crm/stats`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(15000),
        });
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "crm-campaigns") {
    (async () => {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return sendResponse({ error: "Not authenticated" });
      const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
      try {
        const res = await fetch(`${base}/api/crm/campaigns`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(15000),
        });
        sendResponse(await res.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "crm-sync") {
    syncContactToCRM(msg)
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  if (msg.action === "linkedin-command") {
    executeLinkedInCommand(msg.type, msg.params)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "li-send-dm-via-ui") {
    sendDmViaUI(msg)
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  // r50 — generic worker-tab batch starter. Same pattern as the
  // r44 accept-and-dm-batch handler: snag the dashboard/panel tab
  // id, spawn or reuse the hidden worker window, navigate it to the
  // action's start URL, then dispatch to the content script with
  // `panelTabId` so progress can stream back. Used by add-connections,
  // like-posts, message-connections, view-profiles.
  const LI_BG_ACTION_MAP = {
    "li-start-add-connections-bg": {
      url: "https://www.linkedin.com/feed/",
      dispatch: "li:run-add-connections",
    },
    "li-start-like-posts-bg": {
      url: "https://www.linkedin.com/feed/",
      dispatch: "li:run-like-posts",
    },
    "li-start-message-connections-bg": {
      url: "https://www.linkedin.com/mynetwork/invite-connect/connections/",
      dispatch: "li:run-message-connections",
    },
    "li-start-view-profiles-bg": {
      url: "https://www.linkedin.com/feed/",
      dispatch: "li:run-view-profiles",
    },
    // r51: 3 more actions added. These dispatch to the EXISTING
    // li:accept-invites / li:withdraw-invites / li:follow-connections
    // cases (which were already in the panel's dispatch switch); we
    // just pass panelTabId through so the dashboard-side progress
    // relay fires. The case bodies set _NX_BACKGROUND_BATCH when
    // panelTabId is present.
    "li-start-accept-invites-bg": {
      url: "https://www.linkedin.com/mynetwork/invitation-manager/received/",
      dispatch: "li:accept-invites",
    },
    "li-start-withdraw-invites-bg": {
      url: "https://www.linkedin.com/mynetwork/invitation-manager/sent/",
      dispatch: "li:withdraw-invites",
    },
    "li-start-follow-connections-bg": {
      url: "https://www.linkedin.com/mynetwork/",
      dispatch: "li:follow-connections",
    },
  };
  if (LI_BG_ACTION_MAP[msg.action]) {
    const target = LI_BG_ACTION_MAP[msg.action];
    const panelTabId = sender?.tab?.id;
    if (typeof panelTabId !== "number") {
      sendResponse({ error: "no panel tab id (sender)" });
      return false;
    }
    (async () => {
      try {
        const workerTab = await getOrCreateWorkerTab();
        _liWorkerTabId = workerTab.id; // so li-shot can screenshot the right tab
        await chrome.tabs.update(workerTab.id, { url: target.url });
        await waitForTabLoad(workerTab.id, 25_000);
        await new Promise((r) => setTimeout(r, 2500));
        const result = await sendToContentScript(workerTab.id, target.dispatch, {
          config: msg.config || {},
          panelTabId,
          runId: msg.runId || null,
        });
        // Drift the worker tab back to /feed/ for the next batch.
        try {
          await chrome.tabs.update(workerTab.id, { url: "https://www.linkedin.com/feed/" });
        } catch {
          /* ignore */
        }
        sendResponse(result || { error: "no response from worker tab" });
      } catch (err) {
        sendResponse({ success: false, error: err.message });
      }
    })();
    return true;
  }
  // ── X / Twitter "Run from here" (background-tab adapter) ──────────
  // Generic, single handler: the dashboard sends { action:"tw-run-action-bg",
  // actionId, url, config }. We spawn (or reuse) a hidden minimized x.com worker
  // tab, navigate it to `url`, then dispatch the action to content-twitter.js
  // with panelTabId so progress streams back via the SAME nx-batch-progress
  // relay the LinkedIn path uses. The X action logic is unchanged — it just runs
  // in this hidden tab instead of requiring the user to be on x.com. New X
  // actions need NO change here: they just carry their own actionId + url.
  if (msg.action === "tw-run-action-bg") {
    const panelTabId = sender?.tab?.id;
    if (typeof panelTabId !== "number") {
      sendResponse({ error: "no panel tab id (sender)" });
      return false;
    }
    if (!msg.actionId) {
      sendResponse({ error: "no actionId" });
      return false;
    }
    const url = msg.url || "https://x.com/home";
    // Stream stage updates to the dashboard drawer so the long open→load→dispatch
    // chain shows visible motion within ~1s instead of looking frozen. Reuses the
    // same nx-batch-progress envelope the content script uses.
    const stage = (icon, text) =>
      _deliverToPanel(panelTabId, { nexusPanel: true, type: "log", data: { icon, text } });
    const setStage = (text) =>
      _deliverToPanel(panelTabId, { nexusPanel: true, type: "status", data: text });
    // Hard cap on the whole background operation so the dashboard ALWAYS gets a
    // response — it can never hang on "Starting…" forever.
    let _settled = false;
    const respond = (r) => {
      if (_settled) return;
      _settled = true;
      try {
        sendResponse(r);
      } catch {}
    };
    const hardTimeout = setTimeout(() => {
      stage("X", "Timed out after 120s — the X tab didn't respond. Is the tab logged in?");
      respond({ error: "Timed out after 120s — the background X tab didn't respond." });
    }, 120_000);

    (async () => {
      try {
        stage("·", `Nexus ${NEXUS_BUILD} · ${msg.actionId || "?"} · run ${msg.runId || "—"}`);
        setStage("Opening X…");
        stage(
          "…",
          "Opening a background X tab (it appears in your tab strip — click it to watch)…",
        );
        const workerTab = await getOrCreateTwitterWorkerTab();
        _twWorkerTabId = workerTab.id; // so tw-shot can screenshot the right tab

        // Only navigate if we're not already on the right page. Navigating to the
        // SAME url often doesn't fire a load event, so waitForTabLoad would stall
        // the full timeout for nothing. getOrCreateTwitterWorkerTab already loaded
        // x.com/home, so for /home actions we skip the redundant nav entirely.
        let cur = "";
        try {
          cur = (await chrome.tabs.get(workerTab.id))?.url || "";
        } catch {}
        const sameUrl =
          cur && url && cur.split("#")[0].split("?")[0] === url.split("#")[0].split("?")[0];
        if (!sameUrl) {
          setStage("Loading x.com…");
          stage("…", `Navigating to ${url} …`);
          await chrome.tabs.update(workerTab.id, { url });
          await waitForTabLoad(workerTab.id, 25_000);
          await new Promise((r) => setTimeout(r, 3000));
        } else {
          // Already on the target page from creation — just let it settle.
          await new Promise((r) => setTimeout(r, 1500));
        }

        // Fast login-state check: if x.com bounced the worker tab to a login /
        // account-access / landing page, the content script can't load the feed
        // and the dispatch below would silently retry ~105s then hit the 120s
        // timeout. Surface it in ~1s with a clear, actionable message instead.
        let landed = "";
        try {
          landed = (await chrome.tabs.get(workerTab.id))?.url || "";
        } catch {
          /* ignore */
        }
        if (
          /\/(login|i\/flow\/login|account\/access|logout)/.test(landed) ||
          (!landed.includes("x.com") && !landed.includes("twitter.com"))
        ) {
          clearTimeout(hardTimeout);
          stage(
            "X",
            "The background X tab isn't logged in — it's on a login/landing page. Open x.com in a normal tab and log in (the worker tab uses your browser's x.com session), then retry.",
          );
          try {
            await chrome.tabs.update(workerTab.id, { url: "https://x.com/home" });
          } catch {
            /* ignore */
          }
          respond({
            success: false,
            error: "x_tab_not_logged_in",
            detail: `worker tab on ${landed || "unknown url"}`,
          });
          return;
        }

        setStage("Waiting for the X tab…");
        stage("…", "Dispatching the action — waiting up to 105s for the X tab to respond…");
        const result = await sendToContentScript(
          workerTab.id,
          "tw:run-action",
          {
            actionId: msg.actionId,
            config: msg.config || {},
            panelTabId,
            runId: msg.runId || null,
          },
          3,
          105_000,
        );
        clearTimeout(hardTimeout);
        // Leave the worker tab on a clean page so the next action doesn't
        // inherit a half-finished state (mirrors LinkedIn's scrapeInboxViaTab,
        // which always returns the tab to /feed/).
        try {
          await chrome.tabs.update(workerTab.id, { url: "https://x.com/home" });
        } catch {
          /* ignore */
        }
        respond(result || { error: "no response from worker tab" });
      } catch (err) {
        clearTimeout(hardTimeout);
        stage("X", `Background error: ${err.message}`);
        // If the tab hung or became unreachable, drop the cached worker tab id
        // so the NEXT run spawns a fresh tab instead of reusing the stuck one.
        if (/timeout|unreachable/i.test(err?.message || "")) {
          await _clearTwWorkerTabId();
        }
        respond({ success: false, error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-start-accept-and-dm-batch-bg") {
    // r44: kick off the accept-and-DM batch on the hidden worker tab
    // so the user's current tab is never navigated. The worker tab's
    // content script runs runAcceptAndDmBatch and relays addLog /
    // setStatus calls back here as nx-batch-progress messages, which
    // we forward to the panel tab below.
    const panelTabId = sender?.tab?.id;
    if (typeof panelTabId !== "number") {
      sendResponse({ error: "no panel tab id (sender)" });
      return false;
    }
    (async () => {
      try {
        const workerTab = await getOrCreateWorkerTab();
        _liWorkerTabId = workerTab.id; // so li-shot can screenshot the right tab
        await chrome.tabs.update(workerTab.id, {
          url: "https://www.linkedin.com/mynetwork/invitation-manager/received/",
        });
        await waitForTabLoad(workerTab.id, 25_000);
        // Give LinkedIn's React + invitation-card lazy load time to mount.
        await new Promise((r) => setTimeout(r, 2500));

        const result = await sendToContentScript(workerTab.id, "li:run-accept-and-dm-batch", {
          config: msg.config || {},
          panelTabId,
          runId: msg.runId || null,
        });
        // Drift the worker tab back to /feed/ so the next batch / poll
        // starts from a known-good resting page.
        try {
          await chrome.tabs.update(workerTab.id, { url: "https://www.linkedin.com/feed/" });
        } catch {
          /* ignore */
        }
        sendResponse(result || { error: "no response from worker tab" });
      } catch (err) {
        sendResponse({ success: false, error: err.message });
      }
    })();
    return true;
  }
  if (msg.type === "nx-batch-progress" && typeof msg.panelTabId === "number") {
    // r44: relay addLog / setStatus from a worker-tab batch back to
    // the originating panel tab. The panel's existing nexusPanel
    // listener already handles {type:"log"|"status", data:...} so we
    // just translate the relay shape into that envelope.
    if (msg.kind === "log") {
      _deliverToPanel(msg.panelTabId, {
        nexusPanel: true,
        type: "log",
        data: { icon: msg.icon, text: msg.text, url: msg.url || null },
      });
    } else if (msg.kind === "status") {
      _deliverToPanel(msg.panelTabId, {
        nexusPanel: true,
        type: "status",
        data: msg.text,
      });
    }
    sendResponse({ ok: true });
    return false;
  }
  if (msg.action === "li-cancel-bg-batch") {
    // r44: panel clicked Stop while a worker-tab batch is running.
    // Forward the cancel to the worker tab's content script — its
    // signalCancel() flips the loop's isCancelled() check so it
    // bails between iterations.
    (async () => {
      try {
        const workerId = await _readWorkerTabId();
        if (workerId == null) {
          sendResponse({ ok: false, error: "no worker tab" });
          return;
        }
        await chrome.tabs.sendMessage(workerId, { nexusPanel: true, type: "cancel" });
        sendResponse({ ok: true });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-limits-check-v2") {
    // r48: like li-limits-check but also forwards recipientSlug/Urn for
    // the multi-week per-recipient cooldown. Kept as a separate action
    // name so existing callers (or older content scripts post-reload)
    // don't break.
    (async () => {
      try {
        const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
        if (!token)
          return sendResponse({ allowed: true, blockedReason: null, _bypass: "not_paired" });
        const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
        const params = new URLSearchParams();
        params.set("action", msg.liAction || "send-dm-via-ui");
        if (msg.recipientSlug) params.set("recipientSlug", msg.recipientSlug);
        if (msg.recipientUrn) params.set("recipientUrn", msg.recipientUrn);
        const r = await fetch(`${base}/api/linkedin/limits-check?${params}`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(5000),
        });
        sendResponse(await r.json());
      } catch (err) {
        sendResponse({ allowed: true, blockedReason: null, _bypass: `error: ${err.message}` });
      }
    })();
    return true;
  }
  if (msg.action === "li-limits-check") {
    // r47: ask the bot whether a given LinkedIn action is allowed
    // under today's daily + weekly caps (with account-age warmup
    // multiplier applied). The bot reads counters from the shared
    // linkedin_action_log table so panel + cron stay in agreement.
    (async () => {
      try {
        const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
        if (!token)
          return sendResponse({ allowed: true, blockedReason: null, _bypass: "not_paired" });
        const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
        const action = encodeURIComponent(msg.liAction || "send-dm-via-ui");
        const r = await fetch(`${base}/api/linkedin/limits-check?action=${action}`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(5000),
        });
        sendResponse(await r.json());
      } catch (err) {
        // Fail-open so a bot outage doesn't block the user's batch.
        sendResponse({ allowed: true, blockedReason: null, _bypass: `error: ${err.message}` });
      }
    })();
    return true;
  }
  if (msg.action === "li-recipient-context") {
    // r49: fetch recipient profile (headline + derived company) and
    // optionally a 1-line AI hook conditioned on baseTemplate. Used
    // by the panel batch to fill {headline}/{company}/{hook}
    // placeholders so every DM is recipient-specific. Cached 1h
    // server-side per slug. 20s timeout — fits inside the existing
    // 90s DM hang cap.
    (async () => {
      try {
        const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
        if (!token) return sendResponse({ error: "not_paired" });
        const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
        const r = await fetch(`${base}/api/linkedin/recipient-context`, {
          method: "POST",
          headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
          body: JSON.stringify({
            slug: msg.slug,
            baseTemplate: msg.baseTemplate || "",
            generateHook: !!msg.generateHook,
          }),
          signal: AbortSignal.timeout(20_000),
        });
        sendResponse(await r.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-report-incident") {
    // r48: content script detected an account-wall (captcha, restricted,
    // phone-verify, checkpoint). Fire-and-forget POST to the bot so it
    // records the incident, sends a Telegram alert, and triggers the
    // 24h pause. We don't await the response from the panel side.
    (async () => {
      try {
        const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
        if (!token) return sendResponse({ ok: false, error: "not_paired" });
        const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
        const r = await fetch(`${base}/api/linkedin/incident`, {
          method: "POST",
          headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
          body: JSON.stringify({
            incidentType: msg.incidentType,
            url: msg.url || null,
            buildVersion: NEXUS_BUILD,
            details: msg.details || null,
          }),
          signal: AbortSignal.timeout(8000),
        });
        sendResponse(await r.json());
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-report-soft-wall") {
    // r93: content script read a soft wall off the page ("you've reached the
    // weekly invitation limit"). Freezes ONE action category on the account
    // via the gate, unlike li-report-incident which pauses everything for 24h.
    (async () => {
      try {
        const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
        if (!token) return sendResponse({ ok: false, error: "not_paired" });
        const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
        const r = await fetch(`${base}/api/linkedin/soft-wall`, {
          method: "POST",
          headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
          body: JSON.stringify({
            actionType: msg.actionType,
            hours: msg.hours,
            phrase: msg.phrase || null,
            url: msg.url || null,
          }),
          signal: AbortSignal.timeout(8000),
        });
        sendResponse(await r.json());
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-generate-variations") {
    // r47: ask the bot to rewrite the user's template into N siblings.
    // Used by accept-and-DM batch to give each recipient a distinct
    // wording. 30s timeout — AI calls can be slow; the batch is paused
    // on the panel side while this resolves.
    (async () => {
      try {
        const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
        if (!token) return sendResponse({ error: "not_paired" });
        const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
        const r = await fetch(`${base}/api/linkedin/generate-variations`, {
          method: "POST",
          headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
          body: JSON.stringify({
            template: msg.template,
            count: msg.count || 12,
            accountIdentifier: msg.accountIdentifier || null,
            tone: msg.tone || null,
          }),
          signal: AbortSignal.timeout(30_000),
        });
        sendResponse(await r.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-budget") {
    (async () => {
      try {
        const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
        if (!token) return sendResponse({ error: "not_paired" });
        const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
        const r = await fetch(`${base}/api/linkedin/budget`, {
          headers: { Authorization: `Bearer ${token}` },
          signal: AbortSignal.timeout(5000),
        });
        sendResponse(await r.json());
      } catch (err) {
        sendResponse({ error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-like-latest-post") {
    // Like the person's most recent post — Voyager API first, DOM fallback
    // via the worker tab. Used by accept-and-DM as a warm-up engagement
    // step before sending the DM. See _likeLatestPost for the path matrix.
    _likeLatestPost({ slug: msg.slug || null, urn: msg.urn || null })
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-autonomous-status") {
    _isAutonomousModeEnabled()
      .then((enabled) => sendResponse({ enabled }))
      .catch(() => sendResponse({ enabled: false }));
    return true;
  }
  if (msg.action === "li-autonomous-toggle") {
    _toggleAutonomousMode()
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-autonomous-enable") {
    // CRITICAL: do NOT await anything before calling _enableAutonomousMode.
    // The user gesture forwarded with this message is consumed by the first
    // awaited storage/permissions call inside the handler. Routing straight
    // through preserves it.
    _enableAutonomousMode()
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-autonomous-disable") {
    _disableAutonomousMode()
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "open-tab") {
    // Used by the chip-click fallback to open autonomous-grant.html in a
    // new tab. Routed through the SW because chrome.tabs.create isn't
    // available to content scripts.
    chrome.tabs
      .create({ url: msg.url, active: true })
      .then(() => sendResponse({ ok: true }))
      .catch((err) => sendResponse({ ok: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-ensure-autonomous") {
    _ensureAutonomousMode()
      .then(sendResponse)
      .catch((err) => sendResponse({ enabled: false, error: err.message }));
    return true;
  }
  // ── CDP routing from content scripts ─────────────────────────────
  // Content scripts can't talk to chrome.debugger directly, so they
  // forward typing into the worker tab through these handlers.
  //
  // li-cdp-type: focus is assumed to already be on the compose element
  // (the content script does compose.focus() before sending). CDP's
  // Input.insertText then writes a trusted event sequence — works in
  // hidden / unfocused tabs because trusted events bypass the focus
  // check that execCommand requires.
  //
  // li-cdp-detach: called by cancellableBatch's finally block once the
  // whole campaign finishes. Closes out the debugger session so the
  // yellow banner goes away.
  if (msg.action === "li-cdp-type") {
    const tabId = sender?.tab?.id;
    if (typeof tabId !== "number") {
      sendResponse({ success: false, error: "no sender tab id" });
      return false;
    }
    cdpInsertText(tabId, String(msg.text || ""))
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-cdp-attach") {
    const tabId = sender?.tab?.id;
    if (typeof tabId !== "number") {
      sendResponse({ success: false, error: "no sender tab id" });
      return false;
    }
    cdpAttach(tabId)
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-cdp-detach") {
    const tabId = sender?.tab?.id;
    if (typeof tabId !== "number") {
      sendResponse({ success: false, error: "no sender tab id" });
      return false;
    }
    cdpDetach(tabId)
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-worker-tab-status") {
    _readWorkerTabId().then(async (id) => {
      if (id == null) return sendResponse({ exists: false });
      try {
        const t = await chrome.tabs.get(id);
        sendResponse({ exists: true, tabId: id, url: t?.url || null });
      } catch {
        sendResponse({ exists: false });
      }
    });
    return true;
  }
  if (msg.action === "li-autonomous-reset-prompt") {
    // User explicitly wants the auto-prompt to fire again on the next
    // Accept & Message run. Clears the "already offered" flag without
    // touching the current enabled state. Useful when the user dismissed
    // the Chrome permission dialog by mistake the first time.
    chrome.storage.local
      .set({ _liAutonomousOffered: false })
      .then(() => sendResponse({ ok: true }))
      .catch((err) => sendResponse({ ok: false, error: err.message }));
    return true;
  }
  // Dashboard "LinkedIn Activity" view actions — thin proxies to the
  // bot's new observability endpoints (added in Phase 2.1).
  if (msg.action === "li-server-log-recent") {
    fetchLinkedInLogRecent(msg.params || {})
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "li-health-summary") {
    fetchLinkedInHealthSummary()
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  // Dashboard "X Activity" view — proxy to the X observability endpoint.
  if (msg.action === "tw-server-log-recent") {
    fetchTwitterLogRecent(msg.params || {})
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  // Objectives ("Goals") dashboard actions — proxy to /api/objectives*.
  if (msg.action === "objectives-list") {
    objectivesApiCall("GET", "")
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "objectives-get") {
    objectivesApiCall("GET", `/${encodeURIComponent(msg.id)}`)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "objectives-patch") {
    objectivesApiCall("PATCH", `/${encodeURIComponent(msg.id)}`, msg.body || {})
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "objectives-apply-step") {
    objectivesApiCall(
      "POST",
      `/${encodeURIComponent(msg.id)}/step/${encodeURIComponent(msg.stepId)}/apply`,
    )
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "objectives-apply-all") {
    objectivesApiCall("POST", `/${encodeURIComponent(msg.id)}/apply`)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  // ── Boost ("project boosting") dashboard actions ──────────────────
  if (msg.action === "boost-list") {
    boostApiCall("GET", "")
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "boost-get") {
    boostApiCall("GET", `/${encodeURIComponent(msg.id)}`)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "boost-create") {
    boostApiCall("POST", "", msg.body || {})
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "boost-patch") {
    boostApiCall("PATCH", `/${encodeURIComponent(msg.id)}`, msg.body || {})
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "boost-status") {
    boostApiCall(
      "POST",
      `/${encodeURIComponent(msg.id)}/${msg.status === "active" ? "activate" : "pause"}`,
    )
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "boost-run") {
    boostApiCall("POST", `/${encodeURIComponent(msg.id)}/run`)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "boost-media") {
    boostApiCall("POST", `/${encodeURIComponent(msg.id)}/media`, msg.body || {})
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "boost-delete") {
    boostApiCall("DELETE", `/${encodeURIComponent(msg.id)}`)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "twitter-command") {
    executeTwitterCommand(msg.type, msg.params)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "panel-accept-invites") {
    runNavigateAndAction(
      "https://www.linkedin.com/mynetwork/invitation-manager/",
      "li:accept-invites",
      { count: 20 },
      sender.tab?.id,
    )
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "panel-withdraw-invites") {
    runNavigateAndAction(
      "https://www.linkedin.com/mynetwork/invitation-manager/sent/",
      "li:withdraw-invites",
      { count: 20 },
      sender.tab?.id,
    )
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "panel-follow-connections") {
    runNavigateAndAction(
      "https://www.linkedin.com/mynetwork/",
      "li:follow-connections",
      { count: 20 },
      sender.tab?.id,
    )
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "panel-view-profiles") {
    runViewProfiles(msg.count || 10, sender.tab?.id)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "panel-message-connections") {
    runMessageConnections(msg.message, msg.count || 10, msg.useAI, sender.tab?.id)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: err.message }));
    return true;
  }
  if (msg.action === "toggle-auto-respond") {
    toggleAutoRespond(msg.enabled);
    sendResponse({ success: true });
    return true;
  }
});

// ── Auto-sync: fire whenever li_at, auth_token, or cryptorank session cookies change ──
// Debounced to 2s — sites refresh multiple cookies at once,
// so we wait for them all to settle before syncing.
chrome.cookies.onChanged.addListener(({ removed, cookie }) => {
  if (removed) return;

  const isLinkedIn = cookie.domain.includes("linkedin.com") && cookie.name === "li_at";
  const isTwitter =
    (cookie.domain.includes("twitter.com") || cookie.domain.includes("x.com")) &&
    cookie.name === "auth_token";
  // CryptoRank uses FEATURE_ACCESS (JWT) as the logged-in session cookie.
  const isCryptorank =
    cookie.domain.includes("cryptorank.io") &&
    (cookie.name === "FEATURE_ACCESS" ||
      cookie.name === "next-auth.session-token" ||
      cookie.name === "__Secure-next-auth.session-token");

  // Luma uses jwt or luma.auth-session-key cookies
  const isLuma =
    (cookie.domain.includes("lu.ma") || cookie.domain.includes("luma.com")) &&
    (cookie.name === "luma.auth-session-key" ||
      cookie.name === "jwt" ||
      cookie.name === "__Secure-luma.auth-session-key");

  if (!isLinkedIn && !isTwitter && !isCryptorank && !isLuma) return;

  if (isLinkedIn) {
    clearTimeout(_syncTimers.linkedin);
    _syncTimers.linkedin = setTimeout(() => syncLinkedIn(), 2000);
  }

  if (isTwitter) {
    clearTimeout(_syncTimers.twitter);
    _syncTimers.twitter = setTimeout(() => syncTwitter(), 2000);
  }

  if (isCryptorank) {
    clearTimeout(_syncTimers.cryptorank);
    _syncTimers.cryptorank = setTimeout(() => syncCryptorank(), 2000);
  }

  if (isLuma) {
    clearTimeout(_syncTimers.luma);
    _syncTimers.luma = setTimeout(() => syncLuma(), 2000);
  }
});

// ── Startup sync — check for existing sessions on extension load ──────
// The cookie onChanged listener only fires on NEW changes. If the user
// is already logged in when the extension loads, we need to check once.
(async function startupSync() {
  // Wait a moment for storage to be ready
  await new Promise((r) => setTimeout(r, 1500));
  const { token } = await chrome.storage.local.get(["token"]);
  if (!token) return;

  // Run this "already logged in at load" snapshot once per browser session, not
  // on every MV3 service-worker spin-up. chrome.storage.session is cleared when
  // the browser closes, so the gate resets exactly when the snapshot is needed
  // again; cookie changes between spin-ups are already handled by the onChanged
  // listener, so the skipped re-runs would only re-POST an unchanged cookie jar.
  // The flag is set after the token gate so a mid-session pairing still runs once.
  try {
    const { _lastStartupSyncAt } = await chrome.storage.session.get("_lastStartupSyncAt");
    if (_lastStartupSyncAt) return;
    await chrome.storage.session.set({ _lastStartupSyncAt: Date.now() });
  } catch { /* storage.session unavailable — fall through and sync as before */ }

  // Check for existing CryptoRank session
  try {
    const crCookies = [
      ...(await chrome.cookies.getAll({ domain: ".cryptorank.io" })),
      ...(await chrome.cookies.getAll({ domain: "cryptorank.io" })),
      ...(await chrome.cookies.getAll({ url: "https://cryptorank.io" })),
    ];
    const hasCrSession = crCookies.some(
      (c) =>
        c.name === "FEATURE_ACCESS" ||
        c.name === "next-auth.session-token" ||
        c.name === "__Secure-next-auth.session-token",
    );
    if (hasCrSession) {
      console.log("[Nexus] Startup: found existing CryptoRank session, syncing...");
      syncCryptorank();
    }
  } catch (e) {
    console.warn("[Nexus] Startup CryptoRank check failed:", e);
  }

  // Check for existing LinkedIn session
  try {
    const liCookies = await chrome.cookies.getAll({ domain: ".linkedin.com" });
    if (liCookies.some((c) => c.name === "li_at")) {
      console.log("[Nexus] Startup: found existing LinkedIn session, syncing...");
      syncLinkedIn();
    }
  } catch (err) {
    console.warn("[Nexus] Startup LinkedIn cookie check failed:", err?.message || err);
  }

  // Check for existing Luma session
  try {
    const lumaCookies = [
      ...(await chrome.cookies.getAll({ domain: ".lu.ma" })),
      ...(await chrome.cookies.getAll({ domain: ".luma.com" })),
      ...(await chrome.cookies.getAll({ url: "https://lu.ma" })),
      ...(await chrome.cookies.getAll({ url: "https://luma.com" })),
    ];
    if (lumaCookies.some((c) => c.name === "luma.auth-session-key" || c.name === "jwt")) {
      console.log("[Nexus] Startup: found existing Luma session, syncing...");
      syncLuma();
    }
  } catch {}

  // Check for existing Twitter session
  try {
    const twCookies = await chrome.cookies.getAll({ domain: ".x.com" });
    const twCookies2 = await chrome.cookies.getAll({ domain: ".twitter.com" });
    const allTw = [...twCookies, ...twCookies2];
    if (allTw.some((c) => c.name === "auth_token")) {
      console.log("[Nexus] Startup: found existing Twitter session, syncing...");
      syncTwitter();
    }
  } catch {}
})();

// ── Command polling — checks for pending bot commands every 3s ────────
// Steady-state cadence is 3s. On a transport failure (server unreachable) the
// delay widens 3→6→12→24→30s so a down server stops resetting the MV3 idle
// timer and lets the worker unload; it snaps back to 3s on the first reachable
// response, so command-pickup latency is unchanged whenever a command exists.
const _POLL_FAST_MS = 3000;
const _POLL_MAX_MS = 30000;
let _pollDelay = _POLL_FAST_MS;
let _pollGen = 0; // bumped on every start/stop so a stale in-flight tick won't reschedule

function startCommandPolling() {
  if (_pollInterval) return;
  _pollDelay = _POLL_FAST_MS;
  const gen = ++_pollGen;
  const tick = async () => {
    await pollForCommands();
    if (_pollGen === gen) _pollInterval = setTimeout(tick, _pollDelay);
  };
  _pollInterval = setTimeout(tick, _pollDelay);
  console.log("[Nexus] Command polling started");
}

function stopCommandPolling() {
  if (_pollInterval) {
    clearTimeout(_pollInterval);
    _pollInterval = null;
  }
  _pollGen++; // invalidate any in-flight tick so it stops rescheduling
}

// ── Startup + alarm wake (MV3) ───────────────────────────────────────
// An MV3 service worker is torn down after ~30s idle, so the 3s setInterval
// above does NOT survive. chrome.alarms re-spawns the SW on a timer, and
// chrome.runtime.onStartup fires when the browser launches (laptop start) —
// together they guarantee the persistent quest_jobs queue (community-quest
// concierge work) is drained the moment the user's laptop wakes, then kept
// draining by the fast loop while the SW stays alive.
// See docs/platform/05-execution-model.md §3.
const QUEST_DRAIN_ALARM = "nx-quest-drain";

function ensureQuestDrainAlarm() {
  try {
    chrome.alarms.create(QUEST_DRAIN_ALARM, { periodInMinutes: 1 });
  } catch {}
}

async function drainOnWake(reason) {
  const { token } = await chrome.storage.local.get(["token"]);
  if (!token) return; // unpaired — nothing to drain
  startCommandPolling(); // resume the fast loop while the SW is alive
  console.log(`[Nexus] wake (${reason}) — draining queued jobs`);
  pollForCommands(); // immediate first drain (don't wait for the 3s tick)
}

// Listeners must be registered at the top level of the SW (MV3) so they bind
// on every spin-up. Safe to call repeatedly — alarms.create + startCommandPolling
// are both idempotent.
chrome.runtime.onStartup.addListener(() => {
  ensureQuestDrainAlarm();
  drainOnWake("browser-startup");
});
chrome.runtime.onInstalled.addListener(() => {
  ensureQuestDrainAlarm();
});
chrome.alarms.onAlarm.addListener((a) => {
  if (a.name === QUEST_DRAIN_ALARM) drainOnWake("alarm");
});
ensureQuestDrainAlarm(); // also (re)arm when the SW first spins up

let _commandBusy = false; // prevent overlapping command execution

async function pollForCommands() {
  if (_commandBusy) return; // still executing previous command

  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return;

  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  try {
    const res = await fetch(`${base}/api/commands/pending`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    _pollDelay = _POLL_FAST_MS; // server reachable — snap back to the fast interval
    if (!res.ok) return;
    const { command } = await res.json();
    if (!command) return;

    _commandBusy = true;
    console.log("[Nexus] Command received:", command.action, command.id);

    try {
      const result = await executeCommand(command);
      await postCommandResult(base, token, command.id, result);
    } catch (err) {
      console.error("[Nexus] Command failed:", err.message);
      await postCommandResult(base, token, command.id, { error: err.message });
    } finally {
      _commandBusy = false;
    }
  } catch {
    // Network error — server not running, skip silently
    // Widen the poll interval on this transport-failure path so a down server
    // stops holding the service worker resident; resets to fast on next success.
    _pollDelay = Math.min(_pollDelay * 2, _POLL_MAX_MS);
  }
}

/**
 * Execute a command by navigating the LinkedIn tab then talking to the content script.
 * Navigation is handled here (background) so the content script never kills itself.
 */
async function executeCommand(command) {
  const { action, params } = command;

  // Bot-driven session re-sync: pull fresh cookies from the user's logged-in
  // browser on demand (the session-keeper enqueues these). This is how X (and
  // the fallback for LinkedIn/Luma) auto-recovers without a headless login.
  if (action === "resync_session" || action === "resync-session") {
    const p = String(params?.platform || "").toLowerCase();
    if (p === "twitter" || p === "x") return await syncTwitter();
    if (p === "linkedin") return await syncLinkedIn();
    if (p === "luma") return await syncLuma();
    if (p === "cryptorank") return await syncCryptorank();
    return { error: `resync_session: unknown platform '${p}'` };
  }

  // Only handle LinkedIn commands from polling — Twitter goes through executeTwitterCommand directly
  if (!action.startsWith("li:")) {
    return { error: `Polling only handles LinkedIn commands. Got: ${action}` };
  }

  // Prefer the dedicated background worker tab — the user never has to
  // keep LinkedIn focused (or even visible) for queued commands to run.
  // Fall back to any existing user LinkedIn tab if the worker can't be
  // spawned (e.g. Chrome blocked tab creation in some weird state).
  let existingTab = null;
  try {
    existingTab = await getOrCreateWorkerTab();
  } catch (err) {
    console.warn("[Nexus] worker tab unavailable, falling back to user tab:", err?.message || err);
    existingTab = await findLinkedInTab();
  }
  if (!existingTab) {
    // Surface to linkedin_action_log + popup pill so the user sees WHY
    // their queued action didn't run. Without this the command silently
    // drops and the user thinks automation is broken.
    const noTabAction = String(action).replace(/^li:/, "");
    liLog({
      action: noTabAction,
      status: "skip",
      errorCode: "no_tab_open",
      errorMessage: "No LinkedIn tab open; queued command dropped",
      params: { action, params },
    });
    try {
      await chrome.storage.session.set({
        _nxTabStatus: { tabOpen: false, lastDroppedAt: Date.now(), lastDroppedAction: action },
      });
    } catch {
      /* best-effort */
    }
    return { error: "No LinkedIn tab open. Open linkedin.com first." };
  }
  // Tab is open — clear the dropped pill if it was set.
  try {
    await chrome.storage.session.set({ _nxTabStatus: { tabOpen: true, lastSeenAt: Date.now() } });
  } catch {
    /* best-effort */
  }

  if (action === "li:search") {
    const kw = encodeURIComponent(params.query);
    const networkFilter = encodeURIComponent('["S","O"]');
    const url = `https://www.linkedin.com/search/results/people/?keywords=${kw}&network=${networkFilter}&start=${params.start || 0}`;
    const tab = await navigateLinkedInTab(url);
    return await sendToContentScript(tab.id, "li:extract-search", { limit: params.limit || 10 });
  }

  if (action === "li:connect") {
    const tab = await navigateLinkedInTab(params.profileUrl);
    return await sendToContentScript(tab.id, "li:click-connect", { note: params.note || null });
  }

  if (action === "li:ping") {
    return await sendToContentScript(existingTab.id, "li:ping", {});
  }

  return { error: `Unknown LinkedIn action: ${action}` };
}

/**
 * Navigate the LinkedIn tab to a URL and wait for the content script to load.
 */
async function navigateLinkedInTab(url) {
  let tab = await getOrCreateLinkedInTab();
  if (!tab) throw new Error("Cannot open LinkedIn tab");

  // Navigate the tab
  await chrome.tabs.update(tab.id, { url });

  // Wait for the tab to finish loading (content script will be injected on load)
  await waitForTabLoad(tab.id, 15000);

  // Small extra delay for content script to initialize
  await new Promise((r) => setTimeout(r, 1500));

  // Refresh tab reference
  tab = await chrome.tabs.get(tab.id);
  return tab;
}

function waitForTabLoad(tabId, timeoutMs = 15000) {
  return new Promise((resolve) => {
    const timeout = setTimeout(() => {
      chrome.tabs.onUpdated.removeListener(listener);
      resolve(); // resolve anyway after timeout
    }, timeoutMs);

    function listener(id, changeInfo) {
      if (id === tabId && changeInfo.status === "complete") {
        chrome.tabs.onUpdated.removeListener(listener);
        clearTimeout(timeout);
        resolve();
      }
    }
    chrome.tabs.onUpdated.addListener(listener);
  });
}

/**
 * Send a command to the content script on a tab and return the result.
 * Retries once if the content script isn't ready yet.
 */
async function sendToContentScript(tabId, action, params, retries = 3, timeoutMs = 75_000) {
  for (let i = 0; i < retries; i++) {
    try {
      // chrome.tabs.sendMessage has NO built-in timeout: if the content
      // script hangs (crashed page, infinite await deep in the action), the
      // promise never settles and the whole action looks "stuck" forever.
      // Race it against a timeout so a hung worker tab surfaces as an error.
      const result = await Promise.race([
        chrome.tabs.sendMessage(tabId, { nexusCommand: true, action, params }),
        new Promise((_, reject) =>
          setTimeout(
            () => reject(new Error(`content script timeout after ${timeoutMs}ms`)),
            timeoutMs,
          ),
        ),
      ]);
      return result || { error: "No response from content script" };
    } catch (err) {
      const isTimeout = /timeout/i.test(err?.message || "");
      // A timeout means the script is reachable but stuck — retrying won't
      // help and just multiplies the wait. Bail immediately.
      if (isTimeout || i >= retries - 1) {
        throw new Error(isTimeout ? err.message : `Content script unreachable: ${err.message}`);
      }
      console.log(`[Nexus] Content script not ready, retry ${i + 1}/${retries}...`);
      await new Promise((r) => setTimeout(r, 2500));
    }
  }
}

// Get the best LinkedIn tab: sender tab > existing tab > create new one
async function getLinkedInTab(senderTabId) {
  if (senderTabId) {
    try {
      const t = await chrome.tabs.get(senderTabId);
      if (t?.url?.includes("linkedin.com")) return t;
    } catch (err) {
      // Sender tab vanished (closed/discarded) — fall through to scan.
      console.warn("[Nexus] getLinkedInTab sender lookup failed:", err?.message || err);
    }
  }
  const existing = await findLinkedInTab();
  if (existing) return existing;
  return await getOrCreateLinkedInTab();
}

// Find an existing LinkedIn tab (does NOT create one — safe for polling)
async function findLinkedInTab() {
  const tabs = await chrome.tabs.query({ url: "https://*.linkedin.com/*" });
  if (tabs.length > 0) {
    const feedTab = tabs.find(
      (t) =>
        t.url && (t.url.includes("/feed") || t.url.includes("/in/") || t.url.includes("/search")),
    );
    return feedTab || tabs[0];
  }
  return null;
}

// Find or create a LinkedIn tab (only call from explicit user actions, never from polling)
async function getOrCreateLinkedInTab() {
  const existing = await findLinkedInTab();
  if (existing) return existing;

  try {
    const tab = await chrome.tabs.create({ url: "https://www.linkedin.com/feed/", active: false });
    await waitForTabLoad(tab.id, 10000);
    await new Promise((r) => setTimeout(r, 1500));
    return tab;
  } catch {
    return null;
  }
}

// ── Dedicated background worker tab ──────────────────────────────────
//
// All scheduled automations (polling command queue, autopilot, inbox
// scrape) run inside ONE hidden linkedin.com tab the user never has to
// look at or focus. Manual panel actions still run on whatever tab the
// user clicked from, so panel UX is unchanged.
//
// The id is stored in chrome.storage.session so it resets per browser
// session (a stale tab id from yesterday is useless anyway — Chrome
// re-numbers on restart). The chrome.tabs.onRemoved listener at the
// bottom of this file clears the cached id if the tab is closed for
// any reason, so the next scheduled cycle respawns it.
const _WORKER_TAB_KEY = "_nxWorkerTabId";
// r45: also remember the worker tab's window id so we can re-minimize
// the popup window if Chrome ever brings it to the foreground (some
// debugger attach events on certain Chrome builds cause a brief raise).
const _WORKER_WIN_KEY = "_nxWorkerWindowId";
let _workerTabPromise = null; // de-dupe concurrent calls during the same cycle

async function _readWorkerTabId() {
  try {
    const { [_WORKER_TAB_KEY]: id } = await chrome.storage.session.get(_WORKER_TAB_KEY);
    return typeof id === "number" ? id : null;
  } catch {
    return null;
  }
}

async function _writeWorkerTabId(id, windowId) {
  try {
    const payload = { [_WORKER_TAB_KEY]: id };
    if (typeof windowId === "number") payload[_WORKER_WIN_KEY] = windowId;
    await chrome.storage.session.set(payload);
  } catch {
    /* ignore */
  }
}

async function _readWorkerWindowId() {
  try {
    const { [_WORKER_WIN_KEY]: id } = await chrome.storage.session.get(_WORKER_WIN_KEY);
    return typeof id === "number" ? id : null;
  } catch {
    return null;
  }
}

async function _clearWorkerTabId() {
  try {
    await chrome.storage.session.remove([_WORKER_TAB_KEY, _WORKER_WIN_KEY]);
  } catch {
    /* ignore */
  }
}

/**
 * Return the dedicated hidden LinkedIn worker tab, opening one if needed.
 *
 * r45: previously the worker tab was created inside the user's currently
 * focused Chrome window via `chrome.tabs.create({active:false})`. The tab
 * itself stayed in the background but it still showed up in the user's
 * tab strip and the LinkedIn favicon would flash there — visible /
 * distracting even when never focused.
 *
 * Now we create a SEPARATE popup-style Chrome window via
 * `chrome.windows.create({type:"popup", state:"minimized"})` — `state`
 * ALONE, because Chrome rejects a non-normal `state` combined with
 * `focused` or bounds (throws "Invalid value for state").
 * The new window is born minimized into the taskbar, never grabs OS
 * focus, and lives apart from the user's tabs entirely. Closing it is
 * one click on the taskbar; the next batch respawns it.
 *
 * Multiple concurrent callers in the same cycle share one in-flight
 * creation so we don't accidentally spawn three popup windows when
 * three polls land in the same tick.
 */
async function getOrCreateWorkerTab() {
  if (_workerTabPromise) return _workerTabPromise;

  _workerTabPromise = (async () => {
    const cachedId = await _readWorkerTabId();
    if (cachedId != null) {
      try {
        const t = await chrome.tabs.get(cachedId);
        if (t && typeof t.url === "string" && t.url.includes("linkedin.com")) {
          // Defensive: re-minimize the window in case Chrome promoted it
          // (debugger-attach causes a brief raise on some platforms).
          const winId = await _readWorkerWindowId();
          if (typeof winId === "number") {
            try {
              await chrome.windows.update(winId, { state: "minimized", focused: false });
            } catch {
              /* ignore */
            }
          }
          return t;
        }
        // Cached tab no longer points at LinkedIn — fall through and respawn.
      } catch {
        // Tab id is dead.
      }
      await _clearWorkerTabId();
    }

    let win;
    let tab;
    try {
      // Chrome REJECTS a non-normal `state` ("minimized"/"maximized"/
      // "fullscreen") when it's combined with `focused` or ANY of
      // left/top/width/height — the call throws "Invalid value for
      // state". So the born-minimized window is created with `state`
      // ALONE: a minimized window never takes focus (so `focused:false`
      // is both redundant and illegal here) and bounds are meaningless
      // while minimized. The windows.update() below re-asserts minimized
      // for WMs that ignore the initial create-time state flag.
      win = await chrome.windows.create({
        url: "https://www.linkedin.com/feed/",
        type: "popup",
        state: "minimized",
      });
      tab = win?.tabs?.[0];
      if (!tab) throw new Error("windows.create returned no tabs");
      // Belt-and-braces: explicitly re-apply minimized + unfocused. Some
      // platforms (Linux + certain WMs) ignore the initial `state` flag.
      try {
        await chrome.windows.update(win.id, { state: "minimized", focused: false });
      } catch {
        /* ignore */
      }
    } catch (err) {
      throw new Error(`worker_window_create_failed: ${err.message}`);
    }
    await _writeWorkerTabId(tab.id, win.id);
    await waitForTabLoad(tab.id, 15_000);
    // LinkedIn's hydration takes a beat past 'complete' on the feed.
    await new Promise((r) => setTimeout(r, 1500));
    liLog({
      source: "extension",
      action: "worker-tab-spawn",
      status: "ok",
      result: { tabId: tab.id, windowId: win.id, type: "popup_minimized" },
    }).catch(() => {});
    return tab;
  })().finally(() => {
    // Always clear so subsequent batches can respawn if the tab got
    // closed between cycles. We only need de-dup within ONE creation.
    _workerTabPromise = null;
  });

  return _workerTabPromise;
}

// Auto-clear the cached worker-tab id if the tab is closed (user
// closed it, navigated away, etc.).
try {
  chrome.tabs.onRemoved.addListener(async (tabId) => {
    const cached = await _readWorkerTabId();
    if (cached === tabId) {
      await _clearWorkerTabId();
      _cdpAttached.delete(tabId); // also clear any phantom debugger record
    }
    const twCached = await _readTwWorkerTabId();
    if (twCached === tabId) await _clearTwWorkerTabId();
  });
} catch (err) {
  console.warn("[Nexus] Could not attach tabs.onRemoved listener:", err.message);
}

// ── Dedicated X / Twitter background worker tab ──────────────────────
// Direct mirror of getOrCreateWorkerTab() but for x.com. A separate hidden,
// minimized popup window so "Run from here" X actions execute without the user
// being on x.com and without touching their tabs. Own session-storage keys so
// it's independent of the LinkedIn worker tab.
const _TW_WORKER_TAB_KEY = "_nxTwWorkerTabId";
const _TW_WORKER_WIN_KEY = "_nxTwWorkerWindowId";
let _twWorkerTabPromise = null;

async function _readTwWorkerTabId() {
  try {
    const { [_TW_WORKER_TAB_KEY]: id } = await chrome.storage.session.get(_TW_WORKER_TAB_KEY);
    return typeof id === "number" ? id : null;
  } catch {
    return null;
  }
}
async function _writeTwWorkerTabId(id, windowId) {
  try {
    const payload = { [_TW_WORKER_TAB_KEY]: id };
    if (typeof windowId === "number") payload[_TW_WORKER_WIN_KEY] = windowId;
    await chrome.storage.session.set(payload);
  } catch {
    /* ignore */
  }
}
async function _readTwWorkerWindowId() {
  try {
    const { [_TW_WORKER_WIN_KEY]: id } = await chrome.storage.session.get(_TW_WORKER_WIN_KEY);
    return typeof id === "number" ? id : null;
  } catch {
    return null;
  }
}
async function _clearTwWorkerTabId() {
  try {
    await chrome.storage.session.remove([_TW_WORKER_TAB_KEY, _TW_WORKER_WIN_KEY]);
  } catch {
    /* ignore */
  }
}

async function getOrCreateTwitterWorkerTab() {
  if (_twWorkerTabPromise) return _twWorkerTabPromise;

  _twWorkerTabPromise = (async () => {
    const cachedId = await _readTwWorkerTabId();
    if (cachedId != null) {
      try {
        const t = await chrome.tabs.get(cachedId);
        const u = typeof t?.url === "string" ? t.url : "";
        if (t && (u.includes("x.com") || u.includes("twitter.com"))) {
          return t;
        }
      } catch {
        /* tab id dead */
      }
      await _clearTwWorkerTabId();
    }

    // VISIBLE background tab (not a hidden popup). The user expects to see X
    // open and the actions happen — so we create a real tab in their current
    // window with active:false (it appears in the tab strip, doesn't steal
    // focus, and they can click it to watch). This is the key difference from
    // the LinkedIn worker, which is intentionally invisible.
    let tab;
    try {
      tab = await chrome.tabs.create({ url: "https://x.com/home", active: false });
      if (!tab) throw new Error("tabs.create returned nothing");
    } catch (err) {
      throw new Error(`tw_worker_tab_create_failed: ${err.message}`);
    }
    await _writeTwWorkerTabId(tab.id, tab.windowId);
    await waitForTabLoad(tab.id, 15_000);
    // x.com hydration is slow past 'complete'; let the content script mount.
    await new Promise((r) => setTimeout(r, 2000));
    return tab;
  })().finally(() => {
    _twWorkerTabPromise = null;
  });

  return _twWorkerTabPromise;
}

async function postCommandResult(base, token, commandId, result) {
  try {
    await fetch(`${base}/api/commands/${commandId}/result`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(result),
    });
  } catch (err) {
    console.error("[Nexus] Failed to post command result:", err.message);
  }
}

// ── Panel-driven connect batch (runs entirely in the extension) ────────

async function runPanelConnectBatch(keyword, limit, senderTabId, degree = "SO", message = null) {
  // Use the sender tab (the LinkedIn tab where the panel lives) — don't create a new one
  let tab = senderTabId ? await chrome.tabs.get(senderTabId).catch(() => null) : null;
  if (!tab) tab = await findLinkedInTab();
  if (!tab) tab = await getOrCreateLinkedInTab();
  if (!tab) return { error: "No LinkedIn tab available" };

  // Notify panel: searching
  notifyPanel(senderTabId || tab.id, "status", `Searching "${keyword}"...`);
  notifyPanel(senderTabId || tab.id, "log", { icon: "S", text: `Searching: "${keyword}"` });

  // Step 1: Navigate to search and extract results
  const kw = encodeURIComponent(keyword);
  const networkArr = degree === "S" ? '["S"]' : degree === "O" ? '["O"]' : '["S","O"]';
  const networkFilter = encodeURIComponent(networkArr);
  const searchUrl = `https://www.linkedin.com/search/results/people/?keywords=${kw}&network=${networkFilter}&start=0`;

  await chrome.tabs.update(tab.id, { url: searchUrl });
  await waitForTabLoad(tab.id, 15000);
  await new Promise((r) => setTimeout(r, 2000));

  // Fetch extra results so we have enough after filtering out connected people
  const searchResult = await sendToContentScript(tab.id, "li:extract-search", { limit: limit * 3 });
  if (searchResult.error) return { error: `Search failed: ${searchResult.error}` };
  if (!searchResult.results?.length) {
    notifyPanel(senderTabId || tab.id, "status", "No results found");
    return { success: true, sent: 0, failed: 0, message: "No results found" };
  }

  // Filter out 1st-degree connections (already connected)
  const fresh = (searchResult.results || []).filter((r) => !r.connected);
  const skippedSearch = (searchResult.results || []).length - fresh.length;

  notifyPanel(
    senderTabId || tab.id,
    "status",
    `Found ${fresh.length} candidates${skippedSearch ? ` (${skippedSearch} connected, skipped)` : ""}. Sending ${Math.min(limit, fresh.length)} connects...`,
  );
  notifyPanel(senderTabId || tab.id, "log", {
    icon: "V",
    text: `${fresh.length} candidates${skippedSearch ? `, ${skippedSearch} already connected` : ""}`,
  });

  if (fresh.length === 0) {
    notifyPanel(senderTabId || tab.id, "status", "All results are already connected");
    return { success: true, sent: 0, failed: 0, message: "All results already connected" };
  }

  // Step 2: Send connection requests — keep going until we hit `limit` successful sends
  let sent = 0,
    failed = 0,
    skipped = 0;
  let candidateIdx = 0;

  while (sent < limit && candidateIdx < fresh.length) {
    const person = fresh[candidateIdx];
    candidateIdx++;
    const name = person.name || "Unknown";
    const profileUrl = person.linkedinUrl;
    if (!profileUrl) continue;

    notifyPanel(senderTabId || tab.id, "status", `(${sent + 1}/${limit}) Connecting: ${name}...`);
    notifyPanel(senderTabId || tab.id, "log", {
      icon: "...",
      text: `Connecting: ${name}`,
      url: profileUrl,
    });

    // Navigate to profile and wait for content script to load
    await chrome.tabs.update(tab.id, { url: profileUrl });
    await waitForTabLoad(tab.id, 15000);
    await new Promise((r) => setTimeout(r, 3000));

    // Click connect
    let connectResult;
    try {
      connectResult = await sendToContentScript(tab.id, "li:click-connect", { note: message });
    } catch (err) {
      connectResult = { error: err.message, success: false };
    }

    if (connectResult.success) {
      sent++;
      notifyPanel(senderTabId || tab.id, "log", {
        icon: "V",
        text: `Sent: ${name}`,
        url: profileUrl,
      });
      // Log to CRM
      crmLogEvent(
        "linkedin",
        "connect_sent",
        person.linkedinUrl?.split("/in/")[1] || name,
        name,
      ).catch(() => {});
      // Humanized delay only after successful connects
      await new Promise((r) => setTimeout(r, 2000 + Math.random() * 2000));
    } else if (connectResult.reason === "already_connected") {
      skipped++;
      notifyPanel(senderTabId || tab.id, "log", {
        icon: "-",
        text: `${name}: already connected`,
        url: profileUrl,
      });
      await new Promise((r) => setTimeout(r, 500));
    } else if (connectResult.reason === "weekly_limit_reached") {
      // STOP the entire batch — LinkedIn won't accept more this week
      notifyPanel(
        senderTabId || tab.id,
        "status",
        "Weekly invitation limit reached! Stopping batch.",
      );
      notifyPanel(senderTabId || tab.id, "log", {
        icon: "X",
        text: "LinkedIn weekly limit reached — batch stopped",
      });
      break;
    } else {
      failed++;
      notifyPanel(senderTabId || tab.id, "log", {
        icon: "X",
        text: `${name}: ${connectResult.reason || connectResult.error || "failed"}`,
        url: profileUrl,
      });
      await new Promise((r) => setTimeout(r, 1000));
    }
  }

  const summary = `Done! Sent: ${sent}${skipped ? `, Skipped: ${skipped}` : ""}${failed ? `, Failed: ${failed}` : ""}`;
  notifyPanel(senderTabId || tab.id, "status", summary);
  notifyPanel(senderTabId || tab.id, "log", { icon: "V", text: summary });
  return { success: true, sent, failed, skipped };
}

// ── Like posts batch (runs on current feed page, no navigation) ────────

async function runPanelLikeBatch(count, keywordFilter, senderTabId) {
  const tab = await getLinkedInTab(senderTabId);
  if (!tab) return { error: "No LinkedIn tab available" };

  // Make sure we're on the feed
  if (!tab.url?.includes("/feed")) {
    await chrome.tabs.update(tab.id, { url: "https://www.linkedin.com/feed/" });
    await waitForTabLoad(tab.id, 15000);
    await new Promise((r) => setTimeout(r, 2000));
  }

  notifyPanel(senderTabId || tab.id, "status", `Liking ${count} posts...`);
  const result = await sendToContentScript(tab.id, "li:like-posts", { count, keywordFilter });
  if (result.liked !== undefined) {
    notifyPanel(senderTabId || tab.id, "status", `Done! Liked ${result.liked} posts`);
    notifyPanel(senderTabId || tab.id, "log", { icon: "V", text: `Liked ${result.liked} posts` });
  }
  return result;
}

async function runPanelLikeAndComment(params, senderTabId) {
  const tab = await getLinkedInTab(senderTabId);
  if (!tab) return { error: "No LinkedIn tab available" };

  if (!tab.url?.includes("/feed")) {
    await chrome.tabs.update(tab.id, { url: "https://www.linkedin.com/feed/" });
    await waitForTabLoad(tab.id, 15000);
    await new Promise((r) => setTimeout(r, 2000));
  }

  notifyPanel(senderTabId || tab.id, "status", `Like + Comment on ${params.count} posts...`);
  const result = await sendToContentScript(tab.id, "li:like-and-comment", {
    count: params.count || 10,
    commentText: params.commentText || "",
    useAI: params.useAI || false,
    commentEveryN: params.commentEveryN || 3,
    keywordFilter: params.keywordFilter || "",
  });
  if (result.liked !== undefined) {
    notifyPanel(
      senderTabId || tab.id,
      "status",
      `Done! Liked ${result.liked}, Commented ${result.commented || 0}`,
    );
    notifyPanel(senderTabId || tab.id, "log", {
      icon: "V",
      text: `Liked ${result.liked}, commented ${result.commented || 0}`,
    });
  }
  return result;
}

// ── Generic navigate-then-act helper ───────────────────────────────────

async function runNavigateAndAction(url, action, params, senderTabId) {
  const tab = await getLinkedInTab(senderTabId);
  if (!tab) return { error: "No LinkedIn tab available" };

  // Navigate to the target page
  if (!tab.url?.includes(new URL(url).pathname)) {
    await chrome.tabs.update(tab.id, { url });
    await waitForTabLoad(tab.id, 15000);
    await new Promise((r) => setTimeout(r, 2500));
  }

  notifyPanel(senderTabId || tab.id, "status", `Running ${action}...`);
  const result = await sendToContentScript(tab.id, action, params);

  // Summarize result
  const count = result.accepted || result.withdrawn || result.followed || result.endorsed || 0;
  const verb = action.includes("accept")
    ? "accepted"
    : action.includes("withdraw")
      ? "withdrawn"
      : action.includes("follow")
        ? "followed"
        : "completed";
  notifyPanel(senderTabId || tab.id, "status", `Done! ${count} ${verb}`);
  notifyPanel(senderTabId || tab.id, "log", { icon: "V", text: `${count} ${verb}` });
  return result;
}

async function runViewProfiles(count, senderTabId) {
  const tab = await getLinkedInTab(senderTabId);
  if (!tab) return { error: "No LinkedIn tab available" };

  // First extract profile URLs from current page (should be search results)
  const extractResult = await sendToContentScript(tab.id, "li:view-profiles", { count });
  if (!extractResult.profiles?.length) {
    notifyPanel(senderTabId || tab.id, "status", "No profiles found — run a search first");
    return { error: "No profiles on current page" };
  }

  const profiles = extractResult.profiles;
  notifyPanel(senderTabId || tab.id, "status", `Viewing ${profiles.length} profiles...`);
  notifyPanel(senderTabId || tab.id, "log", {
    icon: "V",
    text: `Viewing ${profiles.length} profiles`,
  });

  let viewed = 0;
  for (const p of profiles) {
    notifyPanel(
      senderTabId || tab.id,
      "status",
      `(${viewed + 1}/${profiles.length}) Viewing: ${p.name}`,
    );
    notifyPanel(senderTabId || tab.id, "log", { icon: "E", text: `Viewed: ${p.name}`, url: p.url });
    await chrome.tabs.update(tab.id, { url: p.url });
    await waitForTabLoad(tab.id, 15000);
    await new Promise((r) => setTimeout(r, 3000 + Math.random() * 2000)); // 3-5s on each profile
    viewed++;
  }

  notifyPanel(senderTabId || tab.id, "status", `Done! Viewed ${viewed} profiles`);
  notifyPanel(senderTabId || tab.id, "log", { icon: "V", text: `Viewed ${viewed} profiles` });
  return { success: true, viewed };
}

// ── Message connections batch ──────────────────────────────────────────

async function runMessageConnections(messageTemplate, count, useAI, senderTabId) {
  const tab = await getLinkedInTab(senderTabId);
  if (!tab) return { error: "No LinkedIn tab available" };

  // Navigate to connections list to get connection profile URLs
  await chrome.tabs.update(tab.id, {
    url: "https://www.linkedin.com/mynetwork/invite-connect/connections/",
  });
  await waitForTabLoad(tab.id, 15000);
  await new Promise((r) => setTimeout(r, 2500));

  const extractResult = await sendToContentScript(tab.id, "li:get-connections", { count });
  if (!extractResult.connections?.length) {
    notifyPanel(senderTabId || tab.id, "status", "No connections found");
    return { error: "No connections found on page" };
  }

  const connections = extractResult.connections.slice(0, count);
  notifyPanel(senderTabId || tab.id, "status", `Messaging ${connections.length} connections...`);
  let sent = 0,
    failed = 0;

  for (let i = 0; i < connections.length; i++) {
    const conn = connections[i];
    notifyPanel(
      senderTabId || tab.id,
      "status",
      `(${i + 1}/${connections.length}) Messaging: ${conn.name}`,
    );

    // Navigate to their profile
    await chrome.tabs.update(tab.id, { url: conn.url });
    await waitForTabLoad(tab.id, 15000);
    await new Promise((r) => setTimeout(r, 3000));

    // Personalize message
    let msg = messageTemplate.replace(/\{name\}/gi, conn.name.split(" ")[0]);
    if (useAI && messageTemplate) {
      try {
        const aiResult = await generateAIComment(
          `I want to send a message to ${conn.name} on LinkedIn. Base message: "${messageTemplate}". Make it personal and professional, 1-3 sentences.`,
          conn.name,
        );
        if (aiResult.comment) msg = aiResult.comment;
      } catch {
        /* use template as-is */
      }
    }

    // Click Message and send
    const result = await sendToContentScript(tab.id, "li:send-message", { message: msg });
    if (result.success) {
      sent++;
      notifyPanel(senderTabId || tab.id, "log", {
        icon: "V",
        text: `Messaged: ${conn.name}`,
        url: conn.url,
      });
    } else {
      failed++;
      notifyPanel(senderTabId || tab.id, "log", {
        icon: "X",
        text: `${conn.name}: ${result.reason || "failed"}`,
        url: conn.url,
      });
    }

    await new Promise((r) => setTimeout(r, 3000 + Math.random() * 3000)); // 3-6s between messages
  }

  const summary = `Done! Sent: ${sent}${failed ? `, Failed: ${failed}` : ""}`;
  notifyPanel(senderTabId || tab.id, "status", summary);
  notifyPanel(senderTabId || tab.id, "log", { icon: "V", text: summary });
  return { success: true, sent, failed };
}

// ── Auto-respond to messages ──────────────────────────────────────────

let _autoRespondInterval = null;

function toggleAutoRespond(enabled) {
  if (enabled && !_autoRespondInterval) {
    _autoRespondInterval = setInterval(checkAndAutoRespond, 30000); // every 30s
    console.log("[Nexus] Auto-respond started");
    checkAndAutoRespond(); // run immediately
  } else if (!enabled && _autoRespondInterval) {
    clearInterval(_autoRespondInterval);
    _autoRespondInterval = null;
    console.log("[Nexus] Auto-respond stopped");
  }
}

async function checkAndAutoRespond() {
  const tab = await findLinkedInTab();
  if (!tab) return;

  // Navigate to messaging if not already there
  if (!tab.url?.includes("/messaging")) {
    await chrome.tabs.update(tab.id, { url: "https://www.linkedin.com/messaging/" });
    await waitForTabLoad(tab.id, 15000);
    await new Promise((r) => setTimeout(r, 2500));
  }

  // Check for unread messages
  let unreadResult;
  try {
    unreadResult = await sendToContentScript(tab.id, "li:check-messages", {});
  } catch {
    return;
  }

  if (!unreadResult.unread?.length) return;

  console.log(`[Nexus] Auto-respond: ${unreadResult.unread.length} unread messages`);

  for (const thread of unreadResult.unread.slice(0, 3)) {
    // max 3 per cycle
    // Click the thread to open it (we'd need to navigate)
    // For now, log it — full implementation would click the thread, extract last message, generate AI reply
    try {
      const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
      if (!token) return;

      const aiResult = await generateAIComment(
        `Someone named ${thread.name} sent me this LinkedIn message: "${thread.preview}". Write a brief, professional reply. 1-2 sentences.`,
        thread.name,
      );

      if (aiResult.comment) {
        // Store the reply for when we can send it
        await notifyPanel(tab.id, "log", {
          icon: "A",
          text: `Auto-reply to ${thread.name}: "${aiResult.comment.slice(0, 40)}..."`,
        });
      }
    } catch {
      /* skip this thread */
    }
  }
}

// ── CRM API helpers ──────────────────────────────────────────────────

async function crmLookup(platform, identifier) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const res = await fetch(
    `${base}/api/crm/contact?platform=${encodeURIComponent(platform)}&identifier=${encodeURIComponent(identifier)}`,
    {
      headers: { Authorization: `Bearer ${token}` },
    },
  );
  return await res.json();
}

async function crmIntelligence(platform, identifier) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  try {
    const res = await fetch(
      `${base}/api/crm/contact-intelligence?platform=${encodeURIComponent(platform)}&identifier=${encodeURIComponent(identifier)}`,
      {
        headers: { Authorization: `Bearer ${token}` },
      },
    );
    if (!res.ok) {
      // Fall back to basic crm-lookup if intelligence endpoint not available
      return await crmLookup(platform, identifier);
    }
    const data = await res.json();
    data._source = "intelligence"; // Flag so content scripts know which endpoint was used
    return data;
  } catch {
    // Fall back to basic lookup
    return await crmLookup(platform, identifier);
  }
}

async function crmLogEvent(platform, type, identifier, detail) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const res = await fetch(`${base}/api/crm/event`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ platform, type, identifier, detail }),
  });
  return await res.json();
}

async function crmDashboard() {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const res = await fetch(`${base}/api/crm/dashboard`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  return await res.json();
}

// ── Twitter command proxy (extension → bot API) ──────────────────────

async function executeLinkedInCommand(type, params = {}) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated — pair your extension first" };

  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 60000);
  // Strip the leading "linkedin:" prefix so the action name in the log
  // matches the rest of the taxonomy (e.g. "http-search" not "linkedin:http-search").
  const actionName = String(type).replace(/^linkedin:/, "");
  const sentinel = liStart(actionName, { type, params });
  _liLastActionAt = new Date().toISOString();
  try {
    const res = await fetch(`${base}/api/linkedin/command`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ type, ...params }),
      signal: controller.signal,
    });
    clearTimeout(timeout);
    const data = await res.json().catch(() => ({}));
    if (!res.ok) {
      const err = new Error(data.error || `HTTP ${res.status}`);
      err.status = res.status;
      throw err;
    }
    // The bot reports inline errors via `{ error: "..." }` even on 200,
    // so classify and log those as failures too.
    if (data?.error) {
      const code = liClassifyError(data.error, { status: res.status });
      await liDone(sentinel, { status: "fail", errorCode: code, errorMessage: data.error });
    } else {
      await liDone(sentinel, { status: "ok", result: _summarizeResult(data) });
    }
    return data;
  } catch (err) {
    clearTimeout(timeout);
    if (err.name === "AbortError") {
      await liDone(sentinel, {
        status: "fail",
        errorCode: "selector_timeout",
        errorMessage: "Bot API request timed out (60s)",
      });
      throw new Error("Bot API request timed out");
    }
    await liDone(sentinel, {
      status: "fail",
      errorCode: liClassifyError(err),
      errorMessage: err.message,
    });
    throw err;
  }
}

// ── DOM-driven LinkedIn DM send (OpenOutreach pattern) ────────────────
//
// LinkedIn's Voyager messaging endpoint is reliably broken in 2026 — same
// HTTP 400 endpoint family as the inbox scanner. OpenOutreach
// (linkedin/actions/message.py) uses the DOM as the PRIMARY message path
// and Voyager as fallback. We mirror that here.
//
// Coordination strategy: the calling content script (on /received/, in the
// middle of an accept-and-dm batch) stays put. We open a NEW background
// tab to /messaging/thread/new/?recipient=<URN>, drive its content script
// to type + send, then close the tab. The original tab keeps its scroll
// position + DOM snapshot, no awkward navigations to coordinate.
//
// Failure modes (returned in `errorCode` for the caller's classifier):
//   resolve_urn_failed   — bot couldn't look up the URN (session dead, etc.)
//   open_tab_failed      — chrome.tabs.create rejected
//   wrong_page           — LinkedIn redirected the /messaging/thread/new/?recipient= URL
//   selector_missing:*   — compose box or send button moved
//   send_disabled        — send button visible but disabled (text didn't register)
//   send_unconfirmed     — clicked send but compose box still has text after 1.5s

// Bumped 2026-05-27 (r19): profile pages are React-heavy and Chrome throttles
// background tabs (1Hz setTimeout, lower JS priority). 30s is enough for slow
// profile renders on throttled connections; settle delay covers React's
// post-load hydration.
const _DM_TAB_LOAD_TIMEOUT = 30_000;
const _DM_TAB_SETTLE_MS = 3_000;

/**
 * Open a new LinkedIn tab and IMMEDIATELY foreground it for max load speed.
 *
 * Chrome's tab-throttling model treats `active: true` as "this is the
 * selected tab within its window" — but if the WINDOW itself isn't focused
 * (e.g., user alt-tabbed to another app, or has multiple Chrome windows),
 * the renderer still gets background priority: 1Hz timer clamping, reduced
 * V8 budget, deprioritized network. For React-heavy LinkedIn pages this
 * adds 2-5 seconds to render.
 *
 * `chrome.windows.update(windowId, { focused: true })` brings the window
 * to the OS-level foreground, which is what Chrome actually checks for
 * priority decisions. Combined with `active: true`, the new tab gets full
 * CPU/network priority from the first paint.
 *
 * The caller is responsible for restoring the user's original tab/window
 * after the DM is done (sendDmViaUI / _sendDmViaProfileMessage both do this).
 *
 * Returns the created tab object, or throws (caller catches and surfaces
 * the open_tab_failed errorCode).
 */
async function _openActiveDmTab(url) {
  const tab = await chrome.tabs.create({ url, active: true });
  // Foreground the window holding the new tab — without this, an unfocused
  // window still gets background-throttled even when `active: true` is set.
  try {
    if (tab.windowId != null) {
      await chrome.windows.update(tab.windowId, { focused: true });
    }
  } catch {
    /* user revoked windows permission, or window already gone */
  }
  return tab;
}

async function sendDmViaUI({ slug, message, urn: urnFromCaller = null }) {
  if (!slug && !urnFromCaller) {
    return { success: false, error: "missing slug or urn", errorCode: "missing_params" };
  }
  if (!message) {
    return { success: false, error: "missing message", errorCode: "missing_params" };
  }

  // 1. Resolve URN — skip if caller already supplied one (saves a Voyager
  // round-trip). Race against a 10s timeout because Voyager's
  // resolve-profile-urn is in the same fragile endpoint family that
  // sometimes 400s. If URN resolution fails AND we have a slug, we fall
  // through to the profile-page Message fallback below — no need to abort.
  let urn = urnFromCaller;
  let urnResolutionFailed = false;
  if (!urn) {
    try {
      const urnRes = await Promise.race([
        executeLinkedInCommand("linkedin:resolve-profile-urn", { publicIdentifier: slug }),
        new Promise((_, rej) =>
          setTimeout(() => rej(new Error("resolve-urn timeout (10s)")), 10_000),
        ),
      ]);
      if (urnRes?.success && urnRes?.urn) {
        urn = urnRes.urn;
      } else {
        urnResolutionFailed = true;
        console.warn("[Nexus] URN resolution failed:", urnRes?.error || "no urn returned");
      }
    } catch (err) {
      urnResolutionFailed = true;
      console.warn("[Nexus] URN resolution threw:", err.message);
    }
  }

  // If URN resolution failed but we have a slug, try the profile-page
  // Message fallback. Returns directly if successful.
  if (!urn && slug) {
    const profileResult = await _sendDmViaProfileMessage({ slug, message });
    // r29: handleProfileMessage may discover that the "Message" button is
    // actually an anchor link to /messaging/thread/new/?recipient=URN.
    // It extracts the URN from the href and returns it as recoveredUrn so
    // we can route through the normal thread/new flow (this very function,
    // but now with a URN in hand).
    if (!profileResult?.success && profileResult?.recoveredUrn) {
      console.log(
        "[Nexus] profile path recovered URN from anchor — retrying via thread/new:",
        profileResult.recoveredUrn,
      );
      return await sendDmViaUI({ urn: profileResult.recoveredUrn, message });
    }
    return profileResult;
  }

  // 2. Get a tab to drive the DM into. Two paths:
  //
  // Autonomous Mode (default since r40 — debugger is always granted):
  //   Reuse the hidden background worker tab. Navigating the worker tab
  //   to the thread URL does NOT yank the user away from whatever they
  //   were doing. Chrome would normally throttle the worker tab to 1Hz,
  //   but the debugger attachment we'll add via cdpType makes Chrome
  //   treat it as foreground for scheduling — so no speed penalty.
  //
  // Focus-required (debugger not granted, legacy path):
  //   Open a NEW active tab + foreground its window — the only way
  //   execCommand("insertText") will work since it silently no-ops in
  //   unfocused docs. This is the path that yanked the user away from
  //   their window. With debugger now required at install time (r40),
  //   this branch is dead code unless the user explicitly disabled
  //   Autonomous Mode via the panel chip.
  //
  // r42: route via worker tab when autonomous mode is on so DMs run
  // truly in the background — no stolen window focus.
  const autonomous = await _isAutonomousModeEnabled();
  const threadUrl = `https://www.linkedin.com/messaging/thread/new/?recipient=${encodeURIComponent(urn)}`;

  let dmTab;
  let usingWorkerTab = false;
  let originalActiveTab = null;
  let originalFocusedWindowId = null;

  if (autonomous) {
    try {
      dmTab = await getOrCreateWorkerTab();
      usingWorkerTab = true;
      await chrome.tabs.update(dmTab.id, { url: threadUrl });
      await waitForTabLoad(dmTab.id, _DM_TAB_LOAD_TIMEOUT);
      await new Promise((r) => setTimeout(r, _DM_TAB_SETTLE_MS));
    } catch (err) {
      return {
        success: false,
        error: `worker tab load failed: ${err.message}`,
        errorCode: "worker_tab_load_failed",
        stage: "open_tab",
      };
    }
  } else {
    // Legacy path — snapshot the user's current tab + window BEFORE we
    // open the DM tab so we can restore both at the end. (Querying after
    // the DM tab is active would return the DM tab itself.)
    try {
      const focusedWin = await chrome.windows.getLastFocused({ populate: false });
      originalFocusedWindowId = focusedWin?.id ?? null;
      const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
      originalActiveTab = activeTabs[0] || null;
    } catch {
      /* ignore */
    }

    try {
      // Open active AND foreground the window — Chrome only gives full
      // CPU/network priority when the window is OS-focused.
      dmTab = await _openActiveDmTab(threadUrl);
    } catch (err) {
      return {
        success: false,
        error: err.message,
        errorCode: "open_tab_failed",
        stage: "open_tab",
      };
    }

    try {
      await waitForTabLoad(dmTab.id, _DM_TAB_LOAD_TIMEOUT);
      await new Promise((r) => setTimeout(r, _DM_TAB_SETTLE_MS));
    } catch (err) {
      try {
        await chrome.tabs.remove(dmTab.id);
      } catch {
        /* ignore */
      }
      return {
        success: false,
        error: `tab load timeout: ${err.message}`,
        errorCode: "tab_load_timeout",
        stage: "tab_load",
      };
    }
  }

  let dmResult;
  if (autonomous) {
    try {
      // 1. Have the content script find & focus the compose box.
      const focusRes = await sendToContentScript(dmTab.id, "li:focus-compose", {});
      if (!focusRes?.success) {
        dmResult = focusRes || { success: false, error: "focus failed", errorCode: "focus_failed" };
      } else {
        // 2. CDP-insert the text (trusted beforeinput event).
        const cdpRes = await cdpType(dmTab.id, message);
        if (!cdpRes.success) {
          dmResult = cdpRes;
        } else {
          // 3. Content script clicks Send (just a DOM click — doesn't need focus).
          dmResult = await sendToContentScript(dmTab.id, "li:click-send-after-cdp", {});
          if (dmResult?.success) dmResult.via = "cdp";
        }
      }
    } catch (err) {
      dmResult = { success: false, error: err.message, errorCode: "cdp_path_threw" };
    }
  } else {
    // Focus-required path — tab is already active from the open. Content
    // script types via execCommand + Slate beforeinput.
    try {
      dmResult = await sendToContentScript(dmTab.id, "li:type-and-send-message", { message, urn });
    } catch (err) {
      dmResult = { success: false, error: err.message, errorCode: "content_script_unreachable" };
    }
  }

  // 5. Cleanup branches by which tab we used:
  //
  //   Worker tab (autonomous): don't close it — it's the persistent
  //     background tab driving the rest of the batch. Drift it back to
  //     /feed/ so the next action starts from a known-good resting page.
  //     Nothing to restore on the user's side because we never stole focus.
  //
  //   Ephemeral tab (legacy active path): restore the user's tab + window
  //     focus, then close the DM tab. Otherwise we'd leave the user on
  //     /messaging/ and pile up tabs across a batch.
  if (usingWorkerTab) {
    try {
      await chrome.tabs.update(dmTab.id, { url: "https://www.linkedin.com/feed/" });
    } catch {
      /* ignore */
    }
  } else {
    if (originalActiveTab?.id && originalActiveTab.id !== dmTab.id) {
      try {
        await chrome.tabs.update(originalActiveTab.id, { active: true });
      } catch {
        /* ignore */
      }
    }
    if (originalFocusedWindowId != null && originalFocusedWindowId !== dmTab.windowId) {
      try {
        await chrome.windows.update(originalFocusedWindowId, { focused: true });
      } catch {
        /* ignore */
      }
    }
    try {
      await chrome.tabs.remove(dmTab.id);
    } catch {
      /* tab already closed */
    }
  }

  // 6. If the thread-page send failed in a way the profile-page fallback
  // might solve (typing didn't register in Slate, or Send button stayed
  // disabled), try the profile-page Message path. Different DOM, different
  // Slate instance — sometimes the drawer compose works when the
  // /messaging/thread/new/ compose doesn't.
  const profileFallbackReasons = new Set([
    "send_disabled",
    "send_unconfirmed",
    "slate_typing_unresponsive",
    "selector_missing:compose_input",
    "selector_missing:compose_send",
  ]);
  if (slug && !dmResult?.success && profileFallbackReasons.has(dmResult?.errorCode)) {
    console.warn(
      `[Nexus] Thread-page DM failed with ${dmResult.errorCode}; trying profile-page Message fallback`,
    );
    const profileResult = await _sendDmViaProfileMessage({ slug, message });
    if (profileResult?.success) {
      return {
        ...profileResult,
        urn,
        stage: "sent_via_profile_fallback",
        _firstAttemptError: dmResult.errorCode,
      };
    }
    // Both paths failed — return a result that shows both errors so the
    // dashboard can attribute correctly.
    return {
      success: false,
      error: `Thread-page: ${dmResult.error} | Profile-page: ${profileResult?.error || "no response"}`,
      errorCode: `both_paths_failed:${dmResult.errorCode}+${profileResult?.errorCode || "unknown"}`,
      stage: "all_paths_failed",
      urn,
    };
  }

  return { ...dmResult, urn, stage: dmResult?.success ? "sent" : dmResult?.errorCode || "send" };
}

// ── Like-latest-post (warm-up engagement before DM) ───────────────────
//
// Used by the accept-and-DM flow to like the person's most recent post
// right after accepting their invite. Tries Voyager API first (fastest,
// no tab navigation, no selector dependency); falls back to DOM in the
// worker tab if Voyager 400s or the person's posts aren't in the
// Voyager response (sometimes the case for very-new accounts).
//
// "Latest post" = the first post with substantive original text. We
// skip posts with empty commentary (likely a repost) and pick the next
// one. If everything looks reshared we just take posts[0]. If there
// are no posts at all, return `{ success: false, reason: "no_posts" }`
// and the caller proceeds to DM without the like.
//
// Caller passes either `slug` (publicIdentifier) or `urn`. Slug is
// required for the Voyager profile-posts call AND the DOM fallback URL,
// so we error early if both are missing.
//
// Returns:
//   { success: true, path: "voyager"|"dom", postUrn, postText }
//   { success: false, reason: "no_posts"|"no_slug"|"voyager_failed", error, path }

async function _likeLatestPost({ slug, urn }) {
  if (!slug) {
    return { success: false, reason: "no_slug", error: "slug required for profile-posts lookup" };
  }

  // ── Voyager path ─────────────────────────────────────────────────
  // Use executeLinkedInCommand which already handles auth + logging.
  // Race against an 8s timeout because profile-posts is in the wobbly
  // Voyager family — same reason the pending-invitations prefetch in
  // accept-and-dm uses 8s instead of the default 60s.
  let voyagerError = null;
  try {
    const postsRes = await Promise.race([
      executeLinkedInCommand("linkedin:http-profile-posts", { publicIdentifier: slug, count: 5 }),
      new Promise((_, rej) => setTimeout(() => rej(new Error("profile-posts timeout (8s)")), 8000)),
    ]);
    const posts = Array.isArray(postsRes?.posts) ? postsRes.posts : [];
    if (postsRes?.success && posts.length > 0) {
      // Prefer the first post with substantive text (likely original);
      // fall back to posts[0] if everything looks reshared.
      const original = posts.find((p) => (p.text || "").trim().length > 30) || posts[0];
      if (!original?.urn) {
        voyagerError = "post has no urn";
      } else {
        const likeRes = await executeLinkedInCommand("linkedin:http-like-post", {
          postUrn: original.urn,
        });
        if (likeRes?.success) {
          return {
            success: true,
            path: "voyager",
            postUrn: original.urn,
            postText: (original.text || "").slice(0, 80),
          };
        }
        if (likeRes?.reason === "already_liked") {
          return {
            success: true,
            path: "voyager",
            postUrn: original.urn,
            postText: (original.text || "").slice(0, 80),
            alreadyLiked: true,
          };
        }
        voyagerError =
          likeRes?.error || likeRes?.detail || `like failed (${likeRes?.status || "no status"})`;
      }
    } else if (postsRes?.success && posts.length === 0) {
      // Voyager succeeded but the person has no shares — no point trying DOM,
      // the recent-activity page will be empty too.
      return { success: false, reason: "no_posts", error: "person has no recent posts" };
    } else {
      voyagerError = postsRes?.error || "profile-posts returned no posts and no error";
    }
  } catch (err) {
    voyagerError = err.message;
  }

  // ── DOM fallback ──────────────────────────────────────────────────
  // Voyager failed or returned no usable posts. Navigate the worker tab
  // to the person's shares page and click the first Like button. Same
  // selector chain as handleLikePosts uses on the feed — `data-urn`
  // post containers + `aria-label*="Like"` button.
  let workerTab;
  try {
    workerTab = await getOrCreateWorkerTab();
  } catch (err) {
    return {
      success: false,
      reason: "voyager_failed",
      path: "dom_skipped",
      error: `voyager: ${voyagerError} | worker tab unavailable: ${err.message}`,
    };
  }

  try {
    // r41: use /recent-activity/shares/ instead of /all/. The /all/ tab
    // mixes in posts the person reacted to / commented on — so the first
    // data-urn post container is often someone ELSE's post that this
    // person engaged with. Liking that doesn't give the target person
    // any engagement signal, which is the whole point of this step.
    // /shares/ filters down to just their own posts + reshares.
    const sharesUrl = `https://www.linkedin.com/in/${encodeURIComponent(slug)}/recent-activity/shares/`;
    await chrome.tabs.update(workerTab.id, { url: sharesUrl });
    await waitForTabLoad(workerTab.id, 20_000);
    // Posts are lazy-loaded — give React + the feed component time to render.
    await new Promise((r) => setTimeout(r, 3500));
  } catch (err) {
    return {
      success: false,
      reason: "voyager_failed",
      path: "dom_load_failed",
      error: `voyager: ${voyagerError} | dom: ${err.message}`,
    };
  }

  let domResult;
  try {
    // Pass targetSlug so the content script can filter to posts actually
    // authored by this person — not reshares of others. r41 fix.
    domResult = await sendToContentScript(workerTab.id, "li:like-latest-post-on-page", {
      skipReposts: true,
      targetSlug: slug,
    });
  } catch (err) {
    domResult = { success: false, error: `content_script_unreachable: ${err.message}` };
  }

  // Drift the worker tab back to /feed/ so the next scheduled action
  // (poll, autopilot) starts from a known-good resting page.
  try {
    await chrome.tabs.update(workerTab.id, { url: "https://www.linkedin.com/feed/" });
  } catch {
    /* ignore */
  }

  if (domResult?.success) {
    return {
      success: true,
      path: "dom",
      postUrn: domResult.postUrn || null,
      postText: (domResult.postText || "").slice(0, 80),
    };
  }
  return {
    success: false,
    reason: "voyager_failed",
    path: "dom_failed",
    error: `voyager: ${voyagerError} | dom: ${domResult?.error || "unknown DOM failure"}`,
  };
}

// ── Autonomous Mode (chrome.debugger API) ─────────────────────────────
//
// The only way to inject text into a hidden tab without document focus
// is via Chrome's debugger protocol. CDP's Input.insertText generates a
// TRUSTED beforeinput event at the browser-process level — the same kind
// real typing produces — so React/Slate's isTrusted filters accept it.
//
// Trade-off: while debugger is attached, Chrome shows a yellow banner
// ("Nexus Connect started debugging this browser"). User accepts this
// once when they enable autonomous mode.
//
// Lifecycle: attach → focus compose → insertText → detach. Per-DM, so
// the banner only appears for the ~5-10s of each send.

// r40: debugger is now a required manifest permission, so "enabled"
// reduces to "the user hasn't explicitly disabled it". No more
// permissions.contains() probe — it would always return true anyway.
async function _isAutonomousModeEnabled() {
  try {
    const { _liAutonomousMode = false } = await chrome.storage.local.get("_liAutonomousMode");
    return !!_liAutonomousMode;
  } catch {
    return false;
  }
}

/**
 * Enable autonomous mode — just flip the storage flag. The debugger
 * permission is granted at install time (it's in `permissions`, not
 * `optional_permissions`), so there's no Chrome prompt to surface and
 * no gesture-preservation dance.
 *
 * Return shape preserved for backwards compat with callers that branch
 * on `{ enabled: true }` vs `{ permissionDenied: true }` — the latter
 * branch is now unreachable.
 */
async function _enableAutonomousMode() {
  await chrome.storage.local.set({
    _liAutonomousMode: true,
    _liAutonomousOffered: true,
    _liAutonomousExplicitlyDisabled: false,
  });
  return { enabled: true };
}

/**
 * Disable autonomous mode — clear the flag AND record that the user
 * explicitly turned it off. The "explicitlyDisabled" marker prevents the
 * SW-boot auto-enable from flipping it back on next time the worker
 * restarts. Re-enabling (via chip click or popup) clears the marker.
 */
async function _disableAutonomousMode() {
  await chrome.storage.local.set({
    _liAutonomousMode: false,
    _liAutonomousExplicitlyDisabled: true,
  });
  return { enabled: false };
}

/**
 * Ensure autonomous mode is on — called from the Accept & Message flow.
 * Since the permission is now always granted, this is just a flag flip.
 */
async function _ensureAutonomousMode() {
  const { _liAutonomousMode = false } = await chrome.storage.local.get("_liAutonomousMode");
  if (_liAutonomousMode) return { enabled: true };
  await chrome.storage.local.set({ _liAutonomousMode: true });
  return { enabled: true, justEnabled: true };
}

// Legacy toggle — kept for any callers still using li-autonomous-toggle.
async function _toggleAutonomousMode() {
  const { _liAutonomousMode = false } = await chrome.storage.local.get("_liAutonomousMode");
  if (_liAutonomousMode) return _disableAutonomousMode();
  return _enableAutonomousMode();
}

/**
 * Type `text` into the currently focused element on the given tab using
 * Chrome's debugger protocol. The compose box must be focused first (by
 * the caller, via a content-script message). CDP's Input.insertText sends
 * a trusted beforeinput+input event sequence — React's onChange and
 * Slate's beforeinput handler accept these because isTrusted is true.
 *
 * Returns { success: true } or { success: false, error, errorCode }.
 */
// ── Persistent debugger attachment for batch runs ────────────────────
//
// Originally cdpType attached → insertText → detached per call. Each
// attach flickers the yellow "Nexus Connect started debugging this
// browser" banner and costs ~200ms. Worse, Chrome treats a debugger-
// attached tab as foreground for scheduling purposes — meaning while
// we're attached, the worker tab bypasses the 1Hz background-tab
// throttle and React-heavy pages hydrate at full speed.
//
// New design: attach lazily on first cdpInsertText(tabId, ...) and KEEP
// the attachment until the campaign explicitly calls cdpDetach(tabId)
// at batch end (or the tab closes). One banner per batch, full CPU
// priority while writing, much less event-spam visible to the user.
//
// The map's value records when we attached so a future watchdog could
// auto-detach attachments that have been idle too long.
const _cdpAttached = new Map(); // tabId -> { attachedAt: number }

async function cdpAttach(tabId) {
  if (_cdpAttached.has(tabId)) return { success: true, alreadyAttached: true };
  try {
    await chrome.debugger.attach({ tabId }, "1.3");
    _cdpAttached.set(tabId, { attachedAt: Date.now() });
    return { success: true };
  } catch (err) {
    return {
      success: false,
      error: err.message || String(err),
      errorCode: err.message?.includes("Cannot attach")
        ? "debugger_attach_failed"
        : "cdp_attach_failed",
    };
  }
}

async function cdpDetach(tabId) {
  if (!_cdpAttached.has(tabId)) return { success: true, alreadyDetached: true };
  _cdpAttached.delete(tabId);
  try {
    await chrome.debugger.detach({ tabId });
    return { success: true };
  } catch (err) {
    // Tab may have closed first — not a real failure.
    return { success: true, detachThrew: err.message };
  }
}

// Clear the cached attachment record when the debugger detaches for any
// reason (target closed, user clicked Cancel on the banner, another
// debugger client attached). Without this, _cdpAttached would say
// "attached" when it isn't and the next insertText would silently no-op.
try {
  chrome.debugger.onDetach.addListener((source /* , reason */) => {
    if (source?.tabId != null) _cdpAttached.delete(source.tabId);
  });
} catch (err) {
  console.warn("[Nexus] Could not attach debugger.onDetach listener:", err.message);
}

// Capture a screenshot of a (possibly hidden) tab via CDP. captureVisibleTab
// can't see a hidden worker tab; Page.captureScreenshot can. Reuses the same
// lazy-attach lifecycle as cdpInsertText. Returns { success, dataBase64 }.
async function cdpCaptureScreenshot(tabId, { quality = 40 } = {}) {
  if (tabId == null) return { success: false, error: "no tab" };
  if (!_cdpAttached.has(tabId)) {
    const att = await cdpAttach(tabId);
    if (!att.success) return att;
  }
  try {
    const r = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", {
      format: "jpeg",
      quality,
      captureBeyondViewport: false,
    });
    return { success: true, dataBase64: r && r.data };
  } catch (err) {
    return { success: false, error: err.message || String(err) };
  }
}

async function cdpInsertText(tabId, text) {
  // Lazy-attach so callers that just want a one-shot type still work
  // without an explicit cdpAttach call.
  if (!_cdpAttached.has(tabId)) {
    const att = await cdpAttach(tabId);
    if (!att.success) return att;
  }

  try {
    // Input.insertText inserts the entire string atomically with proper
    // event semantics. For humanization we could split into per-key
    // dispatchKeyEvent calls instead, but Input.insertText is enough
    // to make the message land and the Send button enable. Per-key
    // typing can be added later if anti-bot heuristics catch us.
    await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text });
    return { success: true };
  } catch (err) {
    // If insertText fails the attachment may be dead — drop it so the
    // next call re-attaches cleanly.
    _cdpAttached.delete(tabId);
    return {
      success: false,
      error: err.message || String(err),
      errorCode: "cdp_insert_failed",
    };
  }
}

// Legacy single-shot API — preserved so existing call sites work. Now
// implemented on top of the attach/insert/detach primitives. Callers
// running long batches should switch to cdpAttach + cdpInsertText (per
// item) + cdpDetach (once at the end) to avoid banner flicker.
async function cdpType(tabId, text) {
  const wasAlreadyAttached = _cdpAttached.has(tabId);
  const res = await cdpInsertText(tabId, text);
  // If THIS call was the one that attached, detach now to preserve the
  // old single-shot semantics. If a batch had already attached, leave
  // it alone — the batch owns the lifecycle.
  if (!wasAlreadyAttached) {
    await cdpDetach(tabId);
  }
  return res;
}

// ── Profile-page Message fallback ────────────────────────────────────
//
// Used by sendDmViaUI when Voyager's resolve-profile-urn endpoint is
// down (which it often is — same 400 family as the inbox API). Instead
// of needing the URN to navigate /messaging/thread/new/?recipient=URN,
// we go straight to the person's profile page (/in/<slug>/), click the
// "Message" button, type into the slide-out drawer, and send.
//
// Trade-off vs URN-based path:
//   + Works without ANY Voyager call (zero risk of Voyager being down)
//   + Same Slate.js compose drawer (typing infrastructure unchanged)
//   - Slower (profile pages are heavier than the thread/new page)
//   - Profile pages have a "Send" button enabled only when the slide-out
//     drawer is open, not by default — needs an extra click
async function _sendDmViaProfileMessage({ slug, message }) {
  const profileUrl = `https://www.linkedin.com/in/${slug}/`;
  const autonomous = await _isAutonomousModeEnabled();

  // r42: route via the hidden worker tab when Autonomous Mode is on so
  // the user is never yanked away from their current window. Debugger
  // attachment (added by cdpType later) bypasses Chrome's 1Hz throttle
  // for background tabs, so the profile-page load isn't slower than
  // the legacy active-tab path. Legacy path is preserved for the
  // (rare since r40) case where the user explicitly disabled
  // Autonomous Mode via the panel chip.
  let dmTab;
  let usingWorkerTab = false;
  let originalActiveTab = null;
  let originalFocusedWindowId = null;

  if (autonomous) {
    try {
      dmTab = await getOrCreateWorkerTab();
      usingWorkerTab = true;
      await chrome.tabs.update(dmTab.id, { url: profileUrl });
      await waitForTabLoad(dmTab.id, _DM_TAB_LOAD_TIMEOUT);
      await new Promise((r) => setTimeout(r, _DM_TAB_SETTLE_MS));
    } catch (err) {
      return {
        success: false,
        error: `worker tab load failed: ${err.message}`,
        errorCode: "worker_tab_load_failed",
        stage: "profile_open",
      };
    }
  } else {
    // Legacy focus-required path. Snapshot current focus first so we can
    // restore it after the user briefly sees the profile tab flash.
    try {
      const focusedWin = await chrome.windows.getLastFocused({ populate: false });
      originalFocusedWindowId = focusedWin?.id ?? null;
      const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
      originalActiveTab = activeTabs[0] || null;
    } catch {
      /* ignore */
    }

    try {
      dmTab = await _openActiveDmTab(profileUrl);
    } catch (err) {
      return {
        success: false,
        error: err.message,
        errorCode: "open_tab_failed",
        stage: "profile_open",
      };
    }

    try {
      await waitForTabLoad(dmTab.id, _DM_TAB_LOAD_TIMEOUT);
      await new Promise((r) => setTimeout(r, _DM_TAB_SETTLE_MS));
    } catch (err) {
      try {
        await chrome.tabs.remove(dmTab.id);
      } catch {
        /* ignore */
      }
      return {
        success: false,
        error: `tab load timeout: ${err.message}`,
        errorCode: "tab_load_timeout",
        stage: "profile_load",
      };
    }
  }

  let result;
  try {
    if (autonomous) {
      // CDP flow: prep (click Message + focus compose) → cdpType → click Send.
      const prep = await sendToContentScript(dmTab.id, "li:profile-message", {
        message,
        prepOnlyForCdp: true,
      });
      if (!prep?.success) {
        result = prep || { success: false, error: "profile prep returned no response" };
      } else {
        const cdpRes = await cdpType(dmTab.id, message);
        if (!cdpRes.success) {
          result = cdpRes;
        } else {
          result = await sendToContentScript(dmTab.id, "li:click-send-after-cdp", {});
          if (result?.success) result.via = "cdp-profile";
        }
      }
    } else {
      // Focus-required flow (legacy): single-shot type + send via
      // handleProfileMessage. Tab is already active so the focus check
      // succeeds; but if the user switches windows mid-send they'll see
      // the "return to LinkedIn" pause until they switch back.
      result = await sendToContentScript(dmTab.id, "li:profile-message", { message });
    }
  } catch (err) {
    result = { success: false, error: err.message, errorCode: "content_script_unreachable" };
  } finally {
    // Cleanup: worker tab drifts back to /feed/; ephemeral tab closes +
    // user's original focus is restored.
    if (usingWorkerTab) {
      try {
        await chrome.tabs.update(dmTab.id, { url: "https://www.linkedin.com/feed/" });
      } catch {
        /* ignore */
      }
    } else {
      if (originalActiveTab?.id && originalActiveTab.id !== dmTab.id) {
        try {
          await chrome.tabs.update(originalActiveTab.id, { active: true });
        } catch {
          /* ignore */
        }
      }
      if (originalFocusedWindowId != null && originalFocusedWindowId !== dmTab.windowId) {
        try {
          await chrome.windows.update(originalFocusedWindowId, { focused: true });
        } catch {
          /* ignore */
        }
      }
      try {
        await chrome.tabs.remove(dmTab.id);
      } catch {
        /* ignore */
      }
    }
  }

  return { ...result, stage: result?.success ? "sent_via_profile" : "profile_message_failed" };
}

// ── DOM-driven inbox scrape (replaces deprecated Voyager inbox endpoint) ──
//
// LinkedIn's /voyager/api/messaging/conversations returns 400 reliably in
// 2026. The server-side scanner has been on 1h cooldown ever since. This
// coordinator runs every 30 minutes from a background alarm, opens a
// hidden tab to https://www.linkedin.com/messaging/, scrapes the visible
// conversation list, POSTs the result to the bot's inbox-ingest endpoint,
// and closes the tab.
//
// Same hidden-tab pattern as sendDmViaUI — preserves the user's current
// /received/ or /feed/ context.

async function scrapeInboxViaTab() {
  const cfg = await _liGetTokenAndBase();
  if (!cfg) return { success: false, error: "not_paired" };

  // Reuse the dedicated background worker tab so the user's foreground
  // LinkedIn tab (if any) is never disturbed AND we don't spawn / close
  // a fresh tab every 30 minutes. The worker tab navigates to
  // /messaging/ briefly, scrapes, then drifts back to /feed/ on the
  // next campaign run when the next action navigates it.
  let workerTab;
  try {
    workerTab = await getOrCreateWorkerTab();
  } catch (err) {
    return { success: false, error: `worker_tab_unavailable: ${err.message}` };
  }

  try {
    await chrome.tabs.update(workerTab.id, { url: "https://www.linkedin.com/messaging/" });
    await waitForTabLoad(workerTab.id, 20_000);
    // Messaging page is heavy — give React + lazy conversation list time to render.
    await new Promise((r) => setTimeout(r, 3000));
  } catch (err) {
    return { success: false, error: `tab_load_timeout: ${err.message}` };
  }

  let scrapeResult;
  try {
    scrapeResult = await sendToContentScript(workerTab.id, "li:scrape-inbox", { scrollPasses: 2 });
  } catch (err) {
    scrapeResult = { success: false, error: `content_script_unreachable: ${err.message}` };
  }

  // Don't close the worker tab — it stays around for the next batch.
  // Return it to /feed/ so subsequent actions that navigate by URL
  // start from a known-good resting page.
  try {
    await chrome.tabs.update(workerTab.id, { url: "https://www.linkedin.com/feed/" });
  } catch {
    /* ignore */
  }

  if (!scrapeResult?.success || !Array.isArray(scrapeResult.conversations)) {
    // Still log a fail row so the dashboard sees this as a scan attempt.
    liLog({
      source: "extension",
      action: "inbox-scan",
      status: "fail",
      errorCode: "scrape_failed",
      errorMessage: scrapeResult?.error || "unknown scrape failure",
    });
    return { success: false, error: scrapeResult?.error || "scrape_failed" };
  }

  // POST to the bot's ingest endpoint. The bot runs the same dedup +
  // upsert logic that the broken Voyager scanner used to.
  try {
    const ingestRes = await fetch(`${cfg.base}/api/linkedin/inbox-ingest`, {
      method: "POST",
      headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ conversations: scrapeResult.conversations }),
      signal: AbortSignal.timeout(15_000),
    });
    if (!ingestRes.ok) {
      const body = await ingestRes.text().catch(() => "");
      liLog({
        source: "extension",
        action: "inbox-scan",
        status: "fail",
        errorCode: `http_${ingestRes.status}`,
        errorMessage: `inbox-ingest HTTP ${ingestRes.status}: ${body.slice(0, 200)}`,
      });
      return { success: false, error: `ingest HTTP ${ingestRes.status}` };
    }
    const body = await ingestRes.json().catch(() => ({}));
    liLog({
      source: "extension",
      action: "inbox-scan",
      status: "ok",
      result: {
        scraped: scrapeResult.conversations.length,
        newMessages: body.newMessages || 0,
        newContacts: body.newContacts || 0,
      },
    });
    return { success: true, ...body };
  } catch (err) {
    liLog({
      source: "extension",
      action: "inbox-scan",
      status: "fail",
      errorCode: "network",
      errorMessage: err.message,
    });
    return { success: false, error: err.message };
  }
}

// Fetch the recent linkedin_action_log rows for the Dashboard activity feed.
// Accepts { limit, since, status, source, action, stats } and forwards as query string.
async function fetchLinkedInLogRecent(params = {}) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const qs = new URLSearchParams();
  if (params.limit) qs.set("limit", String(params.limit));
  if (params.since) qs.set("since", String(params.since));
  if (params.status) qs.set("status", String(params.status));
  if (params.source) qs.set("source", String(params.source));
  if (params.action) qs.set("action", String(params.action));
  if (params.account) qs.set("account", String(params.account));
  if (params.stats) qs.set("stats", "1");
  try {
    const res = await fetch(`${base}/api/linkedin/log/recent?${qs}`, {
      headers: { Authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) return { error: `HTTP ${res.status}` };
    return await res.json();
  } catch (err) {
    return { error: err.message };
  }
}

async function fetchTwitterLogRecent(params = {}) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const qs = new URLSearchParams();
  if (params.limit) qs.set("limit", String(params.limit));
  if (params.since) qs.set("since", String(params.since));
  if (params.status) qs.set("status", String(params.status));
  if (params.source) qs.set("source", String(params.source));
  if (params.action) qs.set("action", String(params.action));
  if (params.runId) qs.set("runId", String(params.runId));
  if (params.account) qs.set("account", String(params.account));
  if (params.stats) qs.set("stats", "1");
  try {
    const res = await fetch(`${base}/api/twitter/log/recent?${qs}`, {
      headers: { Authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) return { error: `HTTP ${res.status}` };
    return await res.json();
  } catch (err) {
    return { error: err.message };
  }
}

async function fetchLinkedInHealthSummary() {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  try {
    const res = await fetch(`${base}/api/linkedin/health-summary`, {
      headers: { Authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) return { error: `HTTP ${res.status}` };
    return await res.json();
  } catch (err) {
    return { error: err.message };
  }
}

// Objectives ("Goals") API proxy — thin wrapper over /api/objectives*.
// Attaches the stored bearer token like the other dashboard fetchers.
async function objectivesApiCall(method, path, body) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  try {
    const opts = {
      method,
      headers: { Authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(15_000),
    };
    if (body !== undefined) {
      opts.headers["Content-Type"] = "application/json";
      opts.body = JSON.stringify(body);
    }
    const res = await fetch(`${base}/api/objectives${path}`, opts);
    const data = await res.json().catch(() => ({}));
    if (!res.ok) return { error: data.error || `HTTP ${res.status}`, ...data };
    return data;
  } catch (err) {
    return { error: err.message };
  }
}

// Boost ("project boosting") API proxy — coordinated posts, engagement, raids.
async function boostApiCall(method, path, body) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  try {
    const opts = {
      method,
      headers: { Authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(20_000),
    };
    if (body !== undefined) {
      opts.headers["Content-Type"] = "application/json";
      opts.body = JSON.stringify(body);
    }
    const res = await fetch(`${base}/api/boost${path}`, opts);
    const data = await res.json().catch(() => ({}));
    if (!res.ok) return { error: data.error || `HTTP ${res.status}`, ...data };
    return data;
  } catch (err) {
    return { error: err.message };
  }
}

// Voyager results can be huge (search hits, inbox conversations) — strip
// to small summary fields so linkedin_action_log doesn't bloat.
function _summarizeResult(data) {
  if (!data || typeof data !== "object") return null;
  const summary = {};
  if (Array.isArray(data.results)) summary.results = data.results.length;
  if (Array.isArray(data.conversations)) summary.conversations = data.conversations.length;
  if (Array.isArray(data.posts)) summary.posts = data.posts.length;
  if (Array.isArray(data.invitations)) summary.invitations = data.invitations.length;
  if (Array.isArray(data.notifications)) summary.notifications = data.notifications.length;
  if (typeof data.success === "boolean") summary.success = data.success;
  return Object.keys(summary).length ? summary : null;
}

async function executeTwitterCommand(type, params = {}) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated — pair your extension first" };

  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const controller = new AbortController();
  // 10s was far too short: engagement commands (engage-feed/-hashtag) run a
  // real server-side loop, and cookie extraction alone can take ~15s. The old
  // abort fired while the worker kept running, so the dashboard reported a
  // failure that didn't match reality. 90s sits just under the content-script
  // dispatch timeout (105s) and the background hard backstop (120s) so the
  // layers fail in order, innermost first.
  const TW_CMD_TIMEOUT_MS = 90_000;
  const timeout = setTimeout(() => controller.abort(), TW_CMD_TIMEOUT_MS);
  try {
    const res = await fetch(`${base}/api/twitter/command`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ type, ...params }),
      signal: controller.signal,
    });
    clearTimeout(timeout);
    const data = await res.json().catch(() => ({}));
    if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
    return data;
  } catch (err) {
    clearTimeout(timeout);
    if (err.name === "AbortError")
      throw new Error(`Bot API request timed out (${Math.round(TW_CMD_TIMEOUT_MS / 1000)}s)`);
    throw err;
  }
}

// ── CRM Contact Sync (fire-and-forget) ────────────────────────────────

async function syncContactToCRM({
  platform,
  identifier,
  displayName,
  headline,
  company,
  source,
  action,
}) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return;
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  try {
    await fetch(`${base}/api/crm/sync-contact`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        platform,
        identifier,
        displayName,
        headline,
        company,
        source,
        action,
      }),
      signal: AbortSignal.timeout(5000),
    });
  } catch {} // non-critical — fire and forget
}

async function generateAIComment(postText, authorName, authorLinkedInUrl) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not authenticated" };

  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  try {
    const res = await fetch(`${base}/api/ai-comment`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        postText,
        authorName,
        authorLinkedInUrl: authorLinkedInUrl || undefined,
      }),
    });
    const data = await res.json();
    if (data.comment) return { comment: data.comment };
    return { error: data.error || "No comment generated" };
  } catch (err) {
    return { error: `AI comment request failed: ${err.message}` };
  }
}

async function notifyPanel(tabId, type, data) {
  // Write to session storage so the panel picks it up after navigation
  if (type === "status") {
    await chrome.storage.session.set({ _nxStatus: data }).catch(() => {});
  }
  if (type === "log") {
    try {
      const stored = await chrome.storage.session.get(["_nxLog"]);
      const log = stored._nxLog || [];
      log.unshift({
        ...data,
        time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
      });
      if (log.length > 50) log.length = 50;
      await chrome.storage.session.set({ _nxLog: log });
    } catch {
      /* ignore */
    }
  }
  // Also try direct message (works if tab hasn't navigated yet)
  try {
    chrome.tabs.sendMessage(tabId, { nexusPanel: true, type, data }).catch(() => {});
  } catch {
    /* tab may have navigated */
  }
}

// ── LinkedIn Daily Scheduler ──────────────────────────────────────────
// Checks every minute if a scheduled batch should run

let _schedulerInterval = null;

function startScheduler() {
  if (_schedulerInterval) return;
  _schedulerInterval = setInterval(checkSchedule, 60000); // every minute
  console.log("[Nexus] Scheduler started");
}

async function checkSchedule() {
  const data = await chrome.storage.local.get(["liSchedule"]);
  const schedule = data.liSchedule;
  if (!schedule || !schedule.enabled) return;

  const now = new Date();
  const currentHour = now.getHours();
  const currentMin = now.getMinutes();
  const today = now.toISOString().split("T")[0]; // YYYY-MM-DD

  // Check if it's time to run (within the scheduled minute)
  if (currentHour !== schedule.hour || currentMin !== schedule.minute) return;

  // Check if we already ran today
  if (schedule.lastRun === today) return;

  // Prefer the dedicated background worker tab so daily batches run
  // without a user-visible LinkedIn tab. Fall back to the user's tab
  // if the worker fails to spawn for any reason.
  let tab = null;
  try {
    tab = await getOrCreateWorkerTab();
  } catch (err) {
    console.warn("[Nexus] Scheduler: worker tab spawn failed, falling back:", err?.message || err);
    tab = await findLinkedInTab();
  }
  if (!tab) {
    console.log("[Nexus] Scheduler: LinkedIn tab not open, skipping today");
    return;
  }

  console.log(
    `[Nexus] Scheduler: Running daily batch — ${schedule.count} connects, keyword: "${schedule.keyword}"`,
  );

  // Mark as run today (even if it fails, don't retry)
  schedule.lastRun = today;
  await chrome.storage.local.set({ liSchedule: schedule });

  // Run the connect batch
  try {
    await runPanelConnectBatch(
      schedule.keyword || "CEO web3",
      schedule.count || 10,
      tab.id,
      schedule.degree || "SO",
      schedule.message || null,
    );
  } catch (err) {
    console.error("[Nexus] Scheduler batch failed:", err.message);
  }
}

// Start polling and scheduler when extension loads
chrome.storage.local.get(["token"], ({ token }) => {
  if (token) {
    startCommandPolling();
    startScheduler();
  }
});

// Start/stop when token changes
chrome.storage.onChanged.addListener((changes) => {
  if (changes.token) {
    if (changes.token.newValue) {
      startCommandPolling();
      startScheduler();
    } else stopCommandPolling();
  }
});

// ── LinkedIn Autopilot Scheduler ──────────────────────────────────────

// Create each scheduler alarm only if it doesn't already exist. A bare
// chrome.alarms.create on every service-worker spin-up clears+replaces the
// alarm, and with only periodInMinutes supplied the first fire is scheduled a
// full period from now — so frequent SW restarts starve the long-period jobs,
// which never reach their first fire. Alarms persist across SW teardown, so the
// existence guard leaves exactly one registration on its original schedule.
// NOTE (approved behavior change): this un-starves li-autopilot / tw-autopilot /
// li-inbox-scrape — they will now fire on their configured cadence.
(async () => {
  const scheduled = [
    ["li-autopilot", { periodInMinutes: 60 }],
    ["tw-autopilot", { periodInMinutes: 60 }],
    // Heartbeat tells the bot the extension is alive + whether a linkedin.com
    // tab is open. Chrome MV3 alarms cap minimum at 0.5min (30s) — anything
    // shorter is silently coerced.
    ["li-heartbeat", { periodInMinutes: 0.5 }],
    // Inbox scrape — every 30 minutes. Replaces the broken Voyager scanner.
    // Opens a hidden tab to /messaging/, scrapes the visible conversation list,
    // POSTs to /api/linkedin/inbox-ingest, closes the tab. See scrapeInboxViaTab.
    ["li-inbox-scrape", { periodInMinutes: 30 }],
  ];
  for (const [name, opts] of scheduled) {
    try {
      if (!(await chrome.alarms.get(name))) chrome.alarms.create(name, opts);
    } catch { /* alarms API unavailable this spin-up — onAlarm listeners stay bound */ }
  }
})();

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "li-heartbeat") liHeartbeat();
  if (alarm.name === "li-inbox-scrape")
    scrapeInboxViaTab().catch((err) =>
      console.warn("[Nexus] inbox scrape failed:", err?.message || err),
    );
});
// Also fire an immediate heartbeat on service-worker boot so the bot
// learns about the extension within a few seconds of pairing/wake-up.
liHeartbeat();

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== "li-autopilot") return;

  const data = await chrome.storage.local.get(["token", "liAutopilot"]);
  if (!data.token || !data.liAutopilot?.enabled) return;

  const config = data.liAutopilot;
  const today = new Date().toISOString().split("T")[0];
  const hour = new Date().getHours();

  // Only run during configured hours (default: 9am-6pm)
  const startHour = config.startHour ?? 9;
  const endHour = config.endHour ?? 18;
  if (hour < startHour || hour > endHour) return;

  // Check if already ran this hour
  if (config.lastRunHour === `${today}-${hour}`) return;
  config.lastRunHour = `${today}-${hour}`;
  await chrome.storage.local.set({ liAutopilot: config });

  console.log("[Nexus] Autopilot: running hourly check");

  // Accept invitations (every run)
  if (config.acceptInvites) {
    try {
      const result = await executeLinkedInCommand("linkedin:http-pending-invitations", {
        count: 10,
      });
      let accepted = 0,
        failed = 0;
      for (const inv of (result.invitations || []).slice(0, 5)) {
        try {
          await executeLinkedInCommand("linkedin:http-accept-invite", {
            invitationId: inv.invitationId,
            sharedSecret: inv.sharedSecret,
          });
          accepted++;
        } catch (err) {
          failed++;
          console.warn(
            `[Nexus] Autopilot accept-invite failed (invitationId=${inv.invitationId}):`,
            err?.message || err,
          );
        }
        await new Promise((r) => setTimeout(r, 1000 + Math.random() * 2000));
      }
      if (accepted || failed)
        console.log(`[Nexus] Autopilot: accepted ${accepted} invites (${failed} failed)`);
    } catch (err) {
      console.warn("[Nexus] Autopilot accept-invites batch failed:", err?.message || err);
    }
  }

  // Daily engagement (once per day) — uses the DOM-driven like flow via the
  // user's LinkedIn tab. Voyager's linkedin:http-feed endpoint returns 400 in
  // 2026 (same deprecation family as the inbox endpoint), so we no longer
  // pre-fetch feed posts; instead we tell the content script on the open
  // LinkedIn tab to scroll the feed and like N posts directly.
  if (config.dailyEngage && config.lastEngageDate !== today) {
    config.lastEngageDate = today;
    await chrome.storage.local.set({ liAutopilot: config });
    let tab = null;
    try {
      tab = await getOrCreateWorkerTab();
    } catch (err) {
      console.warn(
        "[Nexus] Autopilot daily engage: worker tab spawn failed, falling back:",
        err?.message || err,
      );
      tab = await findLinkedInTab();
    }
    if (!tab) {
      console.log("[Nexus] Autopilot daily engage skipped — no LinkedIn tab open");
      liLog({
        source: "extension",
        action: "autopilot-engage",
        status: "skip",
        errorCode: "no_tab_open",
        errorMessage: "Daily engage skipped (no LinkedIn tab)",
      });
    } else
      try {
        // Drive the DOM handler on the open tab. handleLikePosts scrolls the
        // visible feed and clicks like buttons — no Voyager fetch needed.
        const result = await sendToContentScript(tab.id, "li:like-posts", {
          count: config.likesPerDay || 10,
          keywordFilter: "",
        });
        const liked = result?.liked || 0;
        const likeErrs = (result?.requested || config.likesPerDay || 10) - liked;
        console.log(`[Nexus] Autopilot: liked ${liked} posts (${likeErrs} failed)`);
      } catch (err) {
        console.warn("[Nexus] Autopilot daily-engage batch failed:", err?.message || err);
      }
  }

  // Watchlist auto-scan (check once per run if any companies need scanning)
  if (config.watchlistScan !== false) {
    try {
      const wl = await executeLinkedInCommand("linkedin:watchlist-list", {});
      const active = (wl.watchlist || []).filter(
        (w) => w.status === "active" && w.next_run_at && new Date(w.next_run_at) <= new Date(),
      );
      for (const w of active.slice(0, 2)) {
        // max 2 scans per autopilot run
        console.log(`[Nexus] Autopilot: scanning watchlist company ${w.company_name}`);
        try {
          await executeLinkedInCommand("linkedin:watchlist-scan", { companySlug: w.company_slug });
        } catch (err) {
          console.warn(
            `[Nexus] Autopilot watchlist-scan failed (${w.company_slug}):`,
            err?.message || err,
          );
        }
        await new Promise((r) => setTimeout(r, 5000));
      }
    } catch (err) {
      console.warn("[Nexus] Autopilot watchlist batch failed:", err?.message || err);
    }
  }
});

// ── Twitter Autopilot Scheduler ───────────────────────────────────────

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== "tw-autopilot") return;

  const data = await chrome.storage.local.get(["token", "twAutopilot"]);
  if (!data.token || !data.twAutopilot?.enabled) return;

  const config = data.twAutopilot;
  const today = new Date().toISOString().split("T")[0];
  const hour = new Date().getHours();

  if (hour < (config.startHour ?? 9) || hour > (config.endHour ?? 20)) return;
  if (config.lastRunHour === `${today}-${hour}`) return;
  config.lastRunHour = `${today}-${hour}`;
  await chrome.storage.local.set({ twAutopilot: config });

  console.log("[Nexus] Twitter Autopilot: running hourly check");

  // Daily likes
  if (config.dailyLikes && config.lastLikeDate !== today) {
    config.lastLikeDate = today;
    await chrome.storage.local.set({ twAutopilot: config });
    try {
      // Get first available account
      const accounts = await executeTwitterCommand("twitter:accounts", {});
      const username = (accounts.accounts || [])[0];
      if (!username) return;
      const handle = typeof username === "string" ? username : username.username || username.handle;

      const feed = await executeTwitterCommand("twitter:http-home-feed", {
        username: handle,
        count: (config.likesPerDay || 15) * 2,
      });
      let liked = 0;
      for (const tweet of feed.tweets || []) {
        if (liked >= (config.likesPerDay || 15)) break;
        const tweetId = tweet.id || tweet.rest_id;
        if (!tweetId || tweet.favorited || tweet.liked) continue;
        try {
          await executeTwitterCommand("twitter:http-like", { username: handle, tweetId });
          liked++;
        } catch {}
        await new Promise((r) => setTimeout(r, 2000 + Math.random() * 3000));
      }
      console.log(`[Nexus] Twitter Autopilot: liked ${liked} posts for @${handle}`);
    } catch {}
  }
});

// ── Cookie sync functions ─────────────────────────────────────────────

// Returns { ok, error?, identifier? } so the dashboard's Force-sync can tell the
// user the TRUTH — a missing session / server rejection must NOT report success.
async function syncLinkedIn() {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { ok: false, error: "Not paired — connect the extension first" };

  const liAll = await chrome.cookies.getAll({ domain: ".linkedin.com" });
  const liAtCookie = liAll.find((c) => c.name === "li_at");
  if (!liAtCookie)
    return { ok: false, error: "No LinkedIn session found — log into linkedin.com first" };

  const jsess = liAll.find((c) => c.name === "JSESSIONID");
  const email = liAll.find((c) => c.name === "liap_email")?.value || null;
  const identifier = email || "li_" + liAtCookie.value.slice(0, 16);
  const allCookies = liAll.map(mapChromeCookie);

  const result = await handleConnect({
    token,
    apiBase,
    body: {
      platform: "linkedin",
      liAt: liAtCookie.value,
      jsessionid: jsess ? jsess.value : "",
      identifier,
      cookiesJson: JSON.stringify(allCookies),
      userAgent: navigator.userAgent,
    },
  });
  return result?.success
    ? { ok: true, identifier }
    : { ok: false, error: result?.error || "Server rejected the session" };
}

// Returns { ok, error?, identifier? } — see syncLinkedIn note.
async function syncTwitter() {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { ok: false, error: "Not paired — connect the extension first" };

  // X migrated twitter.com → x.com. A stale .twitter.com login of a PREVIOUS primary
  // account can linger and — because the old code concatenated twitter.com FIRST and
  // took the first auth_token — get synced instead of the account you're actually logged
  // into on x.com (symptom: every sync resolves to the same wrong @handle). Prefer x.com,
  // and take auth_token+ct0+twid as ONE consistent set from the same domain.
  const pickSet = (cookies) => {
    const authToken = cookies.find((c) => c.name === "auth_token");
    const ct0 = cookies.find((c) => c.name === "ct0");
    const twid = cookies.find((c) => c.name === "twid") || cookies.find((c) => c.name === "twid_u");
    return authToken && ct0 ? { authToken, ct0, twid } : null;
  };
  const xCookies = await chrome.cookies.getAll({ domain: ".x.com" });
  const twitterCookies = await chrome.cookies.getAll({ domain: ".twitter.com" });
  const set = pickSet(xCookies) || pickSet(twitterCookies);
  if (!set) return { ok: false, error: "No X session found — log into x.com first" };
  const { authToken, ct0, twid } = set;

  // Send tw_<numericId> — the server resolves the real @handle via twitter:http-current-user-with-cookies
  const numericId = twid
    ? decodeURIComponent(twid.value).replace(/^u%3D/, "").replace(/^u=/, "")
    : null;
  const username = numericId ? `tw_${numericId}` : `tw_unknown_${Date.now()}`;

  const result = await handleConnect({
    token,
    apiBase,
    body: {
      platform: "twitter",
      authToken: authToken.value,
      ct0: ct0.value,
      twid: twid?.value || null,
      username,
      userAgent: navigator.userAgent,
    },
  });
  return result?.success
    ? { ok: true, identifier: username }
    : { ok: false, error: result?.error || "Server rejected the session" };
}

// ── CryptoRank session sync ─────────────────────────────────────────
// Captures all cryptorank.io cookies (Next.js session-based auth) so the
// bot's authed scraper can hit /funds/{slug}/team and portfolio pages.
async function syncCryptorank() {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) {
    console.warn("[Nexus] CryptoRank sync: no token");
    return { ok: false, error: "Not paired — connect the extension first" };
  }

  // Query both with and without dot prefix to catch all cookies
  const cr1 = await chrome.cookies.getAll({ domain: ".cryptorank.io" });
  const cr2 = await chrome.cookies.getAll({ domain: "cryptorank.io" });
  // Also try URL-based lookup which is more permissive
  const cr3 = await chrome.cookies.getAll({ url: "https://cryptorank.io" });
  // Dedupe by name
  const seen = new Set();
  const crAll = [];
  for (const c of [...cr1, ...cr2, ...cr3]) {
    const key = `${c.name}:${c.domain}`;
    if (!seen.has(key)) {
      seen.add(key);
      crAll.push(c);
    }
  }
  console.log(
    "[Nexus] CryptoRank cookies found:",
    crAll.length,
    crAll.map((c) => c.name).join(", "),
  );
  if (!crAll.length)
    return { ok: false, error: "No cryptorank.io cookies — log into cryptorank.io first" };

  // Only sync if the user is actually logged in — look for the session cookie
  const sessionCookie = crAll.find(
    (c) =>
      c.name === "FEATURE_ACCESS" ||
      c.name === "next-auth.session-token" ||
      c.name === "__Secure-next-auth.session-token",
  );
  if (!sessionCookie) return { ok: false, error: "Not logged into CryptoRank — sign in first" };

  const allCookies = crAll.map(mapChromeCookie);

  // Try to extract an identifier — check for email-like cookies or derive from JWT
  const emailCookie = crAll.find((c) => /email|user/i.test(c.name) && /@/.test(c.value || ""));
  let identifier = emailCookie?.value;
  if (!identifier && sessionCookie.name === "FEATURE_ACCESS") {
    // JWT — decode the payload to see if there's a user identifier
    try {
      const payload = JSON.parse(atob(sessionCookie.value.split(".")[1]));
      identifier = payload.email || payload.sub || payload.userId || null;
    } catch {}
  }
  if (!identifier) identifier = "cr_" + sessionCookie.value.slice(0, 16);

  const result = await handleConnect({
    token,
    apiBase,
    body: {
      platform: "cryptorank",
      identifier,
      cookiesJson: JSON.stringify(allCookies),
      userAgent: navigator.userAgent,
    },
  });
  if (result?.success) {
    console.log("[Nexus] CryptoRank session synced:", identifier);
    return { ok: true, identifier };
  }
  console.warn("[Nexus] CryptoRank sync failed:", result?.error);
  return { ok: false, error: result?.error || "Server rejected the session" };
}

async function syncLuma() {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) {
    console.warn("[Nexus] Luma sync: no token");
    return { ok: false, error: "Not paired — connect the extension first" };
  }

  const lumaAll = [
    ...(await chrome.cookies.getAll({ domain: ".lu.ma" })),
    ...(await chrome.cookies.getAll({ domain: ".luma.com" })),
    ...(await chrome.cookies.getAll({ domain: "lu.ma" })),
    ...(await chrome.cookies.getAll({ domain: "luma.com" })),
    ...(await chrome.cookies.getAll({ url: "https://lu.ma" })),
    ...(await chrome.cookies.getAll({ url: "https://luma.com" })),
  ];
  // Dedupe
  const seen = new Set();
  const cookies = [];
  for (const c of lumaAll) {
    const key = `${c.name}:${c.domain}`;
    if (!seen.has(key)) {
      seen.add(key);
      cookies.push(c);
    }
  }
  console.log("[Nexus] Luma cookies found:", cookies.length, cookies.map((c) => c.name).join(", "));
  if (!cookies.length) return { ok: false, error: "No lu.ma cookies — log into lu.ma first" };

  // Look for the session cookie
  const sessionCookie = cookies.find(
    (c) =>
      c.name === "luma.auth-session-key" ||
      c.name === "__Secure-luma.auth-session-key" ||
      c.name === "jwt",
  );
  if (!sessionCookie) return { ok: false, error: "Not logged into Luma — sign in first" };

  const allCookies = cookies.map(mapChromeCookie);

  // Try to find an email identifier
  let identifier = null;
  const emailCookie = cookies.find((c) => /email|user/i.test(c.name) && /@/.test(c.value || ""));
  if (emailCookie) identifier = emailCookie.value;
  if (!identifier) {
    // Try decoding JWT payload
    try {
      const payload = JSON.parse(atob(sessionCookie.value.split(".")[1]));
      identifier = payload.email || payload.sub || null;
    } catch {}
  }
  if (!identifier) identifier = "luma_" + sessionCookie.value.slice(0, 16);

  const result = await handleConnect({
    token,
    apiBase,
    body: {
      platform: "luma",
      identifier,
      cookiesJson: JSON.stringify(allCookies),
      authToken: sessionCookie.value,
      userAgent: navigator.userAgent,
    },
  });
  if (result?.success) {
    console.log("[Nexus] Luma session synced:", identifier);
    return { ok: true, identifier };
  }
  console.warn("[Nexus] Luma sync failed:", result?.error);
  return { ok: false, error: result?.error || "Server rejected the session" };
}

// ── Phantom / Solana wallet connect (SW-orchestrated) ──────────────────
// Opens the bot-served connect page in a compact popup window (Phantom only
// injects into web pages), then polls the bot for the result. Login mode parks
// a token; link mode proves ownership and we finish with an authenticated
// /wallet/link call. Survives the popup closing because it runs in the SW.
async function startWalletConnect({ apiBase, mode } = {}) {
  const url = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const isLink = mode === "link";
  const sid = self.crypto?.randomUUID?.() || Date.now() + "_" + Math.random().toString(16).slice(2);

  const win = await chrome.windows.create({
    url: `${url}/landing/wallet-connect.html?sid=${encodeURIComponent(sid)}${isLink ? "&mode=link" : ""}`,
    type: "popup",
    width: 440,
    height: 660,
    focused: true,
  });
  const winId = win?.id;
  const closeWin = async () => {
    try {
      if (winId != null) await chrome.windows.remove(winId);
    } catch {
      /* already closed */
    }
  };
  const notify = (payload) =>
    chrome.runtime.sendMessage({ type: "wallet-result", ...payload }).catch(() => {});

  const deadline = Date.now() + 3 * 60 * 1000;
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, 2000));
    let data;
    try {
      const res = await fetch(`${url}/api/auth/wallet/poll?sid=${encodeURIComponent(sid)}`);
      data = await res.json().catch(() => ({}));
    } catch {
      continue;
    } // network blip / server starting — keep polling

    if (data && data.token) {
      // login mode — token minted
      await chrome.storage.local.set({
        token: data.token,
        apiBase: url,
        walletAddress: data.address || null,
      });
      await closeWin();
      notify({ ok: true, address: data.address, linked: false });
      return;
    }
    if (data && data.verified && isLink) {
      // link mode — ownership proven, attach to current user
      const { token } = await chrome.storage.local.get(["token"]);
      let linkData = {};
      try {
        const linkRes = await fetch(`${url}/api/auth/wallet/link`, {
          method: "POST",
          headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
          body: JSON.stringify({ sid }),
        });
        linkData = await linkRes.json().catch(() => ({}));
        await closeWin();
        if (linkRes.ok && linkData.ok) {
          await chrome.storage.local.set({ walletAddress: linkData.walletAddress || null });
          notify({ ok: true, address: linkData.walletAddress, linked: true });
        } else {
          notify({ ok: false, error: linkData.message || "Couldn't link wallet." });
        }
      } catch (err) {
        await closeWin();
        notify({ ok: false, error: err.message });
      }
      return;
    }
  }
  await closeWin();
  notify({ ok: false, error: "Timed out waiting for the wallet." });
}

async function handleConnect({ token, apiBase, body }) {
  const url = `${(apiBase || "http://localhost:9091").replace(/\/$/, "")}/api/connect`;
  try {
    // Multi-tenant: attach the connected account to the active client workspace.
    const { workspaceId } = await chrome.storage.local.get(["workspaceId"]);
    const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
    if (workspaceId) headers["X-Workspace-Id"] = workspaceId;
    const res = await fetch(url, {
      method: "POST",
      headers,
      body: JSON.stringify(body),
    });
    const data = await res.json().catch(() => ({}));
    if (res.ok && data.success) {
      return { success: true, platform: data.platform };
    }
    return { success: false, error: data.error || `HTTP ${res.status}` };
  } catch (err) {
    return { success: false, error: `Network error: ${err.message}` };
  }
}

// ── Dashboard API dispatcher ────────────────────────────────────────
//
// Maps dashboard.js action names to bot HTTP routes. Each entry describes:
//   method: GET | POST | DELETE
//   path:   function that takes the msg payload and returns the URL path
//   body:   (optional) function that takes msg and returns the request body
//
// All dispatcher routes share the same token/auth/timeout handling — this
// prevents the 25+ near-identical if-blocks we'd otherwise need.
const DASHBOARD_ROUTES = {
  // Campaign creation
  "campaign-dm-create": {
    method: "POST",
    path: () => `/api/campaigns/dm/create`,
    body: (m) => m.body,
  },
  "campaign-email-create": {
    method: "POST",
    path: () => `/api/campaigns/email/create`,
    body: (m) => m.body,
  },

  // Accounts + Settings
  "accounts-all": { method: "GET", path: () => `/api/accounts/all` },
  "connections-test": { method: "POST", path: () => `/api/connections/test`, body: (m) => m.body },
  "manual-login": { method: "POST", path: () => `/api/accounts/manual-login`, body: (m) => m.body },
  "manual-login-status": {
    method: "GET",
    path: (m) => `/api/accounts/manual-login/status${m.queryString || ""}`,
  },
  // Telegram (MTProto) in-dashboard add flow
  "tg-add-start": {
    method: "POST",
    path: () => `/api/accounts/telegram/start`,
    body: (m) => m.body || {},
  },
  "tg-add-code": {
    method: "POST",
    path: () => `/api/accounts/telegram/code`,
    body: (m) => m.body || {},
  },
  "tg-add-password": {
    method: "POST",
    path: () => `/api/accounts/telegram/password`,
    body: (m) => m.body || {},
  },
  "tg-add-cancel": {
    method: "POST",
    path: () => `/api/accounts/telegram/cancel`,
    body: (m) => m.body || {},
  },
  "activity-feed": { method: "GET", path: (m) => `/api/activity/feed${m.queryString || ""}` },
  "twitter-run-shots": {
    method: "GET",
    path: (m) => `/api/twitter/run-shots${m.queryString || ""}`,
  },
  today: { method: "GET", path: () => `/api/today` },
  // Earn section + Circuit bridge — quest seam reads (both read-only)
  "quests-summary": { method: "GET", path: () => `/api/quests/summary` },
  "mutuals-summary": { method: "GET", path: () => `/api/mutuals/summary` },
  "mutuals-subscriptions": { method: "GET", path: () => `/api/mutuals/subscriptions` },
  "mutuals-subscribe": {
    method: "POST",
    path: () => `/api/mutuals/subscriptions`,
    body: (m) => m.body || {},
  },
  "mutuals-sub-items": {
    method: "PATCH",
    path: (m) => `/api/mutuals/subscriptions/${encodeURIComponent(m.id)}/items`,
    body: (m) => m.body || {},
  },
  "engine-health": { method: "GET", path: () => `/api/engine/health` },
  "stats-overview": { method: "GET", path: (m) => `/api/stats/overview${m.queryString || ""}` },
  "settings-get": { method: "GET", path: () => `/api/settings` },
  "settings-update": { method: "POST", path: () => `/api/settings/update`, body: (m) => m.body },
  "admin-trigger": { method: "POST", path: () => `/api/admin/trigger`, body: (m) => m.body },
  "report-get": { method: "GET", path: () => `/api/report` },
  "crm-export": { method: "GET", path: (m) => `/api/crm/contacts/export${m.queryString || ""}` },

  // Campaigns
  "campaigns-dm": { method: "GET", path: (m) => `/api/campaigns/dm${m.queryString || ""}` },
  "campaigns-email": { method: "GET", path: (m) => `/api/campaigns/email${m.queryString || ""}` },
  "campaigns-outreach": {
    method: "GET",
    path: (m) => `/api/campaigns/outreach${m.queryString || ""}`,
  },
  "campaigns-stats": { method: "GET", path: () => `/api/campaigns/stats` },
  "campaigns-all": { method: "GET", path: (m) => `/api/campaigns/all${m.queryString || ""}` },
  "campaigns-action": { method: "POST", path: () => `/api/campaigns/action`, body: (m) => m.body },
  "campaigns-detail": { method: "GET", path: (m) => `/api/campaigns/detail${m.queryString || ""}` },
  "campaign-detail": {
    method: "GET",
    path: (m) => `/api/campaigns/${encodeURIComponent(m.type)}/${encodeURIComponent(m.id)}`,
  },
  "campaign-action": {
    method: "POST",
    path: (m) =>
      `/api/campaigns/${encodeURIComponent(m.type)}/${encodeURIComponent(m.id)}/${encodeURIComponent(m.operation)}`,
  },

  // Signals
  "signals-list": { method: "GET", path: (m) => `/api/signals${m.queryString || ""}` },
  "signals-keywords-list": { method: "GET", path: () => `/api/signals/keywords` },
  "signals-keywords-add": {
    method: "POST",
    path: () => `/api/signals/keywords`,
    body: (m) => m.body,
  },
  "signals-keywords-delete": {
    method: "DELETE",
    path: (m) => `/api/signals/keywords/${encodeURIComponent(m.keywordId)}`,
  },
  "signal-preview": {
    method: "GET",
    path: (m) => `/api/signals/${encodeURIComponent(m.signalId)}/preview`,
  },
  "signal-regen-comment": {
    method: "POST",
    path: (m) => `/api/signals/${encodeURIComponent(m.signalId)}/regenerate-comment`,
  },
  "signal-publish": {
    method: "POST",
    path: (m) => `/api/signals/${encodeURIComponent(m.signalId)}/publish`,
    body: (m) => m.body,
  },
  "signal-dismiss": {
    method: "POST",
    path: (m) => `/api/signals/${encodeURIComponent(m.signalId)}/dismiss`,
  },
  "signal-status": {
    method: "GET",
    path: (m) => `/api/signals/${encodeURIComponent(m.signalId)}/status`,
  },
  "signals-setup-from-url": {
    method: "POST",
    path: () => `/api/signals/setup-from-url`,
    body: (m) => m.body,
  },

  // VC Outreach
  "vc-queue": { method: "GET", path: () => `/api/vc-outreach/queue` },
  "vc-detail": {
    method: "GET",
    path: (m) => `/api/vc-outreach/${encodeURIComponent(m.investorId)}`,
  },
  "vc-scrape": {
    method: "POST",
    path: (m) => `/api/vc-outreach/${encodeURIComponent(m.investorId)}/scrape`,
  },
  "vc-generate": {
    method: "POST",
    path: (m) =>
      `/api/vc-outreach/${encodeURIComponent(m.investorId)}/${encodeURIComponent(m.contactId)}/${encodeURIComponent(m.companyId)}/generate`,
  },
  "vc-publish": {
    method: "POST",
    path: (m) =>
      `/api/vc-outreach/${encodeURIComponent(m.investorId)}/${encodeURIComponent(m.contactId)}/${encodeURIComponent(m.companyId)}/publish`,
    body: (m) => m.body,
  },
  "vc-campaigns": { method: "GET", path: () => `/api/vc-outreach/campaigns` },
  "vc-auto-queue": {
    method: "POST",
    path: () => `/api/vc-outreach/auto-queue`,
    body: (m) => m.body,
  },

  // CRM browse
  "crm-contacts-list": { method: "GET", path: (m) => `/api/crm/contacts${m.queryString || ""}` },
  // CRM-as-a-source for the Composer (Part F.1): segment preview + facet options
  "crm-segment": { method: "GET", path: (m) => `/api/crm/segment${m.queryString || ""}` },
  "crm-segment-options": {
    method: "GET",
    path: (m) => `/api/crm/segment/options${m.queryString || ""}`,
  },
  "crm-segment-ids": { method: "GET", path: (m) => `/api/crm/segment/ids${m.queryString || ""}` },
  "crm-companies-list": { method: "GET", path: (m) => `/api/crm/companies${m.queryString || ""}` },
  "crm-search": { method: "GET", path: (m) => `/api/crm/search${m.queryString || ""}` },
  "crm-enrichment-recent": {
    method: "GET",
    path: (m) => `/api/crm/enrichment/recent${m.queryString || ""}`,
  },
  "crm-lead-analysis": {
    method: "POST",
    path: () => `/api/crm/lead-analysis`,
    body: (m) => m.body,
  },
  "crm-lead-analysis-bulk": {
    method: "POST",
    path: () => `/api/crm/lead-analysis/bulk`,
    body: (m) => m.body,
  },
  "crm-lead-analysis-bulk-get": {
    method: "GET",
    path: (m) => `/api/crm/lead-analysis/bulk/${encodeURIComponent(m.jobId)}`,
  },
  "crm-lead-analysis-bulk-cancel": {
    method: "POST",
    path: (m) => `/api/crm/lead-analysis/bulk/${encodeURIComponent(m.jobId)}/cancel`,
  },
  // v63 bulk enrich — companies + contacts share the /api/crm/enrich/bulk/:id
  // poll + cancel namespace; only the start endpoint differs by entity kind.
  "crm-companies-enrich-bulk": {
    method: "POST",
    path: () => `/api/crm/companies/enrich/bulk`,
    body: (m) => m.body,
  },
  "crm-contacts-enrich-bulk": {
    method: "POST",
    path: () => `/api/crm/contacts/enrich/bulk`,
    body: (m) => m.body,
  },
  "crm-enrich-bulk-get": {
    method: "GET",
    path: (m) => `/api/crm/enrich/bulk/${encodeURIComponent(m.jobId)}`,
  },
  "crm-enrich-bulk-cancel": {
    method: "POST",
    path: (m) => `/api/crm/enrich/bulk/${encodeURIComponent(m.jobId)}/cancel`,
  },
  // Enrich-then-Analyze orchestrator — one button, two phases. Body shape:
  //   { kind: "companies"|"contacts", ids: number[], waitMs?, force?, skipEnrich? }
  "crm-enrich-then-analyze": {
    method: "POST",
    path: () => `/api/crm/enrich-then-analyze`,
    body: (m) => m.body,
  },
  "crm-enrich-then-analyze-get": {
    method: "GET",
    path: (m) => `/api/crm/enrich-then-analyze/${encodeURIComponent(m.jobId)}`,
  },
  "crm-enrich-then-analyze-cancel": {
    method: "POST",
    path: (m) => `/api/crm/enrich-then-analyze/${encodeURIComponent(m.jobId)}/cancel`,
  },

  // ICP scoring (throxy three-gate + empirical ensemble)
  "icp-config-get": { method: "GET", path: () => `/api/icp/config` },
  "icp-config-save": { method: "PUT", path: () => `/api/icp/config`, body: (m) => m.body },
  "icp-score-one": { method: "POST", path: () => `/api/icp/score-one`, body: (m) => m.body },
  "icp-companies-list": { method: "GET", path: (m) => `/api/icp/companies${m.queryString || ""}` },
  "icp-rescore-all": { method: "POST", path: () => `/api/icp/rescore-all` },
  "icp-status": { method: "GET", path: () => `/api/icp/status` },
  "icp-company-signals": {
    method: "POST",
    path: (m) => `/api/icp/companies/${encodeURIComponent(m.companyId)}/signals`,
    body: (m) => m.body,
  },
  "icp-scrape-next": {
    method: "GET",
    path: (m) => `/api/icp/scrape-queue/next?url=${encodeURIComponent(m.url || "")}`,
  },
  "icp-scrape-result": {
    method: "POST",
    path: (m) => `/api/icp/scrape-queue/${encodeURIComponent(m.taskId)}/result`,
    body: (m) => m.body,
  },
  "icp-auto-config": { method: "POST", path: () => `/api/icp/auto-config`, body: (m) => m.body },
  "icp-services-get": { method: "GET", path: () => `/api/icp/services` },
  "icp-services-save": { method: "PUT", path: () => `/api/icp/services`, body: (m) => m.body },
  "icp-filters-get": { method: "GET", path: () => `/api/icp/filters` },
  "icp-filters-save": { method: "PUT", path: () => `/api/icp/filters`, body: (m) => m.body },
  "icp-filters-generate": {
    method: "POST",
    path: () => `/api/icp/filters/generate`,
    body: (m) => m.body,
  },
  "icp-score-one-ai": { method: "POST", path: () => `/api/icp/score-one-ai`, body: (m) => m.body },

  "crm-contact-detail": {
    method: "GET",
    path: (m) => `/api/crm/contacts/${encodeURIComponent(m.contactId)}`,
  },
  "crm-contact-timeline": {
    method: "GET",
    path: (m) => `/api/crm/contacts/${encodeURIComponent(m.contactId)}/timeline`,
  },
  "crm-suggested-follow-ups": { method: "GET", path: () => `/api/crm/suggested-follow-ups` },
  // Basic mode — Objective Search funnel (URL -> research -> ranked suggestions -> activate)
  "search-start": { method: "POST", path: () => `/api/search`, body: (m) => m.body },
  "search-poll": { method: "GET", path: (m) => `/api/search/${encodeURIComponent(m.slug)}` },
  "search-select": {
    method: "POST",
    path: (m) => `/api/search/${encodeURIComponent(m.slug)}/select`,
    body: (m) => m.body,
  },
  "search-activation-plan": {
    method: "GET",
    path: (m) => `/api/search/${encodeURIComponent(m.slug)}/activation-plan`,
  },
  "search-activate": {
    method: "POST",
    path: (m) => `/api/search/${encodeURIComponent(m.slug)}/activate`,
    body: (m) => m.body,
  },
  "search-launch": {
    method: "POST",
    path: (m) => `/api/search/${encodeURIComponent(m.slug)}/launch`,
    body: (m) => m.body,
  },
  "crm-contact-update": {
    method: "POST",
    path: (m) => `/api/crm/contacts/${encodeURIComponent(m.contactId)}/update`,
    body: (m) => m.body,
  },
  "crm-company-contacts": {
    method: "GET",
    path: (m) => `/api/crm/companies/${encodeURIComponent(m.companyId)}/contacts`,
  },
  // Company Campaign Console + Conversation Plays (keep in sync with dashboard-routes.js)
  "company-console": {
    method: "GET",
    path: (m) => `/api/company/${encodeURIComponent(m.companyId)}/console`,
  },
  "company-create-campaign": {
    method: "POST",
    path: (m) => `/api/company/${encodeURIComponent(m.companyId)}/campaign`,
    body: (m) => m.body,
  },
  "company-update-campaign": {
    method: "POST",
    path: (m) =>
      `/api/company/${encodeURIComponent(m.companyId)}/campaign/${encodeURIComponent(m.campaignId)}`,
    body: (m) => m.body,
  },
  "company-publish": {
    method: "POST",
    path: (m) => `/api/company/${encodeURIComponent(m.companyId)}/publish`,
    body: (m) => m.body || {},
  },
  "conversation-plays": { method: "GET", path: () => `/api/conversation-plays` },
  "conversation-play-create": {
    method: "POST",
    path: () => `/api/conversation-plays`,
    body: (m) => m.body,
  },
  "conversation-play-get": {
    method: "GET",
    path: (m) => `/api/conversation-plays/${encodeURIComponent(m.id)}`,
  },
  "conversation-play-update": {
    method: "POST",
    path: (m) => `/api/conversation-plays/${encodeURIComponent(m.id)}`,
    body: (m) => m.body,
  },
  "conversation-play-status": {
    method: "POST",
    path: (m) => `/api/conversation-plays/${encodeURIComponent(m.id)}/status`,
    body: (m) => m.body,
  },
  "conversation-play-delete": {
    method: "DELETE",
    path: (m) => `/api/conversation-plays/${encodeURIComponent(m.id)}`,
  },
  "conversation-play-preview": {
    method: "POST",
    path: (m) => `/api/conversation-plays/${encodeURIComponent(m.id)}/preview`,
    body: (m) => m.body,
  },
  "crm-contacts-tags": { method: "GET", path: () => `/api/crm/contacts/tags` },
  "crm-bulk-campaign": {
    method: "POST",
    path: () => `/api/crm/contacts/bulk-campaign`,
    body: (m) => m.body,
  },
  "crm-contacts-sectors": { method: "GET", path: () => `/api/crm/contacts/sectors` },
  "crm-activity": { method: "GET", path: () => `/api/crm/activity` },
  "crm-inbox": { method: "GET", path: (m) => `/api/crm/inbox${m.queryString || ""}` },
  "crm-inbox-handled": {
    method: "POST",
    path: () => `/api/crm/inbox/handled`,
    body: (m) => m.body,
  },
  "crm-deals": { method: "GET", path: (m) => `/api/crm/deals${m.queryString || ""}` },
  "crm-deals-update": { method: "POST", path: () => `/api/crm/deals/update`, body: (m) => m.body },
  "crm-deals-create": { method: "POST", path: () => `/api/crm/deals/create`, body: (m) => m.body },
  // Event promotion campaigns (v60)
  "evc-list": { method: "GET", path: () => `/api/crm/event-campaigns` },
  "evc-create": { method: "POST", path: () => `/api/crm/event-campaigns`, body: (m) => m.body },
  "evc-detail": {
    method: "GET",
    path: (m) => `/api/crm/event-campaigns/${encodeURIComponent(m.id)}${m.queryString || ""}`,
  },
  "evc-tg-groups": {
    method: "POST",
    path: (m) => `/api/crm/event-campaigns/${encodeURIComponent(m.id)}/tg-groups`,
    body: (m) => m.body,
  },
  "evc-start": {
    method: "POST",
    path: (m) => `/api/crm/event-campaigns/${encodeURIComponent(m.id)}/start`,
  },
  "evc-pause": {
    method: "POST",
    path: (m) => `/api/crm/event-campaigns/${encodeURIComponent(m.id)}/pause`,
  },
  "evc-stop": {
    method: "POST",
    path: (m) => `/api/crm/event-campaigns/${encodeURIComponent(m.id)}/stop`,
  },
  "evc-approve": {
    method: "POST",
    path: () => `/api/crm/event-campaigns/audience/approve`,
    body: (m) => m.body,
  },
  "evc-skip": {
    method: "POST",
    path: () => `/api/crm/event-campaigns/audience/skip`,
    body: (m) => m.body,
  },
  "evc-edit": {
    method: "POST",
    path: () => `/api/crm/event-campaigns/audience/edit`,
    body: (m) => m.body,
  },
  // LinkedIn comment-drafts approval queue (crypto_hiring_outreach)
  "lic-list": { method: "GET", path: (m) => `/api/crm/comment-drafts${m.queryString || ""}` },
  "lic-approve": {
    method: "POST",
    path: () => `/api/crm/comment-drafts/approve`,
    body: (m) => m.body,
  },
  "lic-skip": { method: "POST", path: () => `/api/crm/comment-drafts/skip`, body: (m) => m.body },
  "lic-edit": { method: "POST", path: () => `/api/crm/comment-drafts/edit`, body: (m) => m.body },
  "grd-list": { method: "GET", path: (m) => `/api/group-reply-drafts${m.queryString || ""}` },
  "grd-approve": {
    method: "POST",
    path: () => `/api/group-reply-drafts/approve`,
    body: (m) => m.body,
  },
  "grd-skip": { method: "POST", path: () => `/api/group-reply-drafts/skip`, body: (m) => m.body },
  "grd-edit": { method: "POST", path: () => `/api/group-reply-drafts/edit`, body: (m) => m.body },
  "live-map": { method: "GET", path: () => `/api/admin/live-map` },
  "automations-proof": { method: "GET", path: () => `/api/admin/automations/proof` },
  "ai-briefing": { method: "GET", path: () => `/api/admin/ai-briefing` },
  "ai-briefing-refresh": { method: "POST", path: () => `/api/admin/ai-briefing/refresh` },
  "emergency-stop": { method: "POST", path: () => `/api/admin/emergency-stop` },
  "account-control": {
    method: "POST",
    path: (m) =>
      `/api/accounts/${encodeURIComponent(m.platform)}/${encodeURIComponent(m.accountId)}/control`,
    body: (m) => m.body,
  },
  "account-session-remove": {
    method: "POST",
    path: (m) => `/api/accounts/${encodeURIComponent(m.platform)}/session-remove`,
    body: (m) => m.body,
  },
  "proxies-list": { method: "GET", path: () => `/api/proxies` },
  "proxy-add": { method: "POST", path: () => `/api/proxies`, body: (m) => m.body || {} },
  "job-control": {
    method: "POST",
    path: (m) => `/api/admin/jobs/${encodeURIComponent(m.jobName)}/control`,
    body: (m) => m.body,
  },
  "crm-contacts-create": {
    method: "POST",
    path: () => `/api/crm/contacts/create`,
    body: (m) => m.body,
  },
  "crm-companies-create": {
    method: "POST",
    path: () => `/api/crm/companies/create`,
    body: (m) => m.body,
  },
  "crm-company-update": {
    method: "POST",
    path: (m) => `/api/crm/companies/${encodeURIComponent(m.companyId)}/update`,
    body: (m) => m.body,
  },
  "crm-pipeline": { method: "GET", path: () => `/api/crm/pipeline` },
  "crm-connection-requests": { method: "GET", path: () => `/api/crm/connection-requests` },
  "connect-account": { method: "POST", path: () => `/api/connect`, body: (m) => m.body },

  // Telegram features (dashboard-native)
  "tg-autoreply-list": { method: "GET", path: () => `/api/telegram/autoreply` },
  "tg-autoreply-save": {
    method: "POST",
    path: () => `/api/telegram/autoreply`,
    body: (m) => m.body,
  },
  "tg-autoreply-toggle": {
    method: "POST",
    path: () => `/api/telegram/autoreply/toggle`,
    body: (m) => m.body,
  },
  "tg-broadcast-history": { method: "GET", path: () => `/api/telegram/broadcast/history` },
  "tg-broadcast-send": {
    method: "POST",
    path: () => `/api/telegram/broadcast/send`,
    body: (m) => m.body,
  },
  "tg-invite-list": { method: "GET", path: () => `/api/telegram/invite/list` },
  "tg-invite-create": {
    method: "POST",
    path: () => `/api/telegram/invite/create`,
    body: (m) => m.body,
  },
  "tg-invite-stop": { method: "POST", path: (m) => `/api/telegram/invite/${m.jobId}/stop` },
  "tg-forward-list": { method: "GET", path: () => `/api/telegram/forward/list` },
  "tg-forward-create": {
    method: "POST",
    path: () => `/api/telegram/forward/create`,
    body: (m) => m.body,
  },
  "tg-forward-toggle": {
    method: "POST",
    path: (m) => `/api/telegram/forward/${m.ruleId}/toggle`,
    body: (m) => m.body,
  },
  "tg-forward-delete": { method: "POST", path: (m) => `/api/telegram/forward/${m.ruleId}/delete` },
  "tg-activemember-list": { method: "GET", path: () => `/api/telegram/activemember/list` },
  "tg-activemember-create": {
    method: "POST",
    path: () => `/api/telegram/activemember/create`,
    body: (m) => m.body,
  },
  "tg-activemember-toggle": {
    method: "POST",
    path: (m) => `/api/telegram/activemember/${m.configId}/toggle`,
    body: (m) => m.body,
  },
  "tg-activemember-delete": {
    method: "POST",
    path: (m) => `/api/telegram/activemember/${m.configId}/delete`,
  },
  "tg-activemember-log": {
    method: "GET",
    path: (m) => `/api/telegram/activemember/${m.configId}/log`,
  },
  "tg-passive-groups": { method: "GET", path: () => `/api/telegram/passive/groups` },
  "tg-passive-add": {
    method: "POST",
    path: () => `/api/telegram/passive/add`,
    body: (m) => m.body,
  },
  "tg-passive-remove": {
    method: "POST",
    path: () => `/api/telegram/passive/remove`,
    body: (m) => m.body,
  },
  "tg-stories-get": { method: "GET", path: () => `/api/telegram/stories` },
  "tg-stories-config": {
    method: "POST",
    path: () => `/api/telegram/stories/config`,
    body: (m) => m.body,
  },
  "tg-stories-toggle": {
    method: "POST",
    path: () => `/api/telegram/stories/toggle`,
    body: (m) => m.body,
  },
  "tg-groups": { method: "GET", path: () => `/api/telegram/groups` },
  "luma-subs": { method: "GET", path: () => `/api/luma/subscriptions` },
  "luma-sub-create": {
    method: "POST",
    path: () => `/api/luma/subscriptions/create`,
    body: (m) => m.body,
  },
  "luma-sub-delete": { method: "POST", path: (m) => `/api/luma/subscriptions/${m.subId}/delete` },

  // Inbox, Mission Control, Email Sequences/Warmup, Scan, Import
  "inbox-replies": { method: "GET", path: () => `/api/inbox/replies` },
  "mission-control": { method: "GET", path: () => `/api/mission-control` },
  "email-sequences-list": { method: "GET", path: () => `/api/email/sequences` },
  "email-sequences-create": {
    method: "POST",
    path: () => `/api/email/sequences/create`,
    body: (m) => m.body,
  },
  "email-sequences-toggle": {
    method: "POST",
    path: (m) => `/api/email/sequences/${m.seqId}/toggle`,
    body: (m) => m.body,
  },
  "email-warmup-list": { method: "GET", path: () => `/api/email/warmup` },
  "email-warmup-start": {
    method: "POST",
    path: () => `/api/email/warmup/start`,
    body: (m) => m.body,
  },
  "email-warmup-stop": {
    method: "POST",
    path: () => `/api/email/warmup/stop`,
    body: (m) => m.body,
  },
  "scan-url": { method: "POST", path: () => `/api/scan`, body: (m) => m.body },
  "import-preview": { method: "POST", path: () => `/api/crm/import/preview`, body: (m) => m.body },
  "import-execute": { method: "POST", path: () => `/api/crm/import/execute`, body: (m) => m.body },

  // Visual workflow builder (preset DAGs from src/workflow/presets/*.json)
  "automations-list": { method: "GET", path: () => `/api/workflows` },
  "automation-detail": { method: "GET", path: (m) => `/api/workflows/${encodeURIComponent(m.id)}` },
  "automation-detail-custom": {
    method: "GET",
    path: (m) => `/api/workflows/custom/${encodeURIComponent(m.customId)}`,
  },
  "automations-registry": { method: "GET", path: () => `/api/workflows/registry` },
  "automation-clone": { method: "POST", path: () => `/api/workflows/clone`, body: (m) => m.body },
  "automation-update-custom": {
    method: "PATCH",
    path: (m) => `/api/workflows/custom/${encodeURIComponent(m.customId)}`,
    body: (m) => m.body,
  },
  "automation-archive-custom": {
    method: "POST",
    path: (m) => `/api/workflows/custom/${encodeURIComponent(m.customId)}/archive`,
  },
  "automation-run-now": {
    method: "POST",
    path: (m) => `/api/workflows/custom/${encodeURIComponent(m.customId)}/run-now`,
  },
  "automations-runs-summary": {
    method: "GET",
    path: (m) => `/api/workflows/runs/summary?days=${encodeURIComponent(m.days || 1)}`,
  },

  // Campaign workflows + group campaigns
  "workflows-list": { method: "GET", path: () => `/api/campaigns/workflows` },
  "workflow-create": {
    method: "POST",
    path: () => `/api/campaigns/workflows/create`,
    body: (m) => m.body,
  },
  "workflow-toggle": {
    method: "POST",
    path: (m) => `/api/campaigns/workflows/${m.workflowId}/toggle`,
    body: (m) => m.body,
  },
  "workflow-delete": {
    method: "POST",
    path: (m) => `/api/campaigns/workflows/${m.workflowId}/delete`,
  },
  "workflow-control": {
    method: "GET",
    path: (m) => `/api/campaigns/workflows/${m.workflowId}/control`,
  },
  "workflow-run-now": {
    method: "POST",
    path: (m) => `/api/campaigns/workflows/${m.workflowId}/run-now`,
  },
  "group-campaigns-list": { method: "GET", path: () => `/api/campaigns/groups` },
  "group-campaign-create": {
    method: "POST",
    path: () => `/api/campaigns/groups/create`,
    body: (m) => m.body,
  },
  "signals-search": { method: "POST", path: () => `/api/signals/search`, body: (m) => m.body },
  "activity-live": { method: "GET", path: (m) => `/api/activity/live${m.queryString || ""}` },
  "dashboard-running": { method: "GET", path: () => `/api/dashboard/running` },
  "account-stats": {
    method: "GET",
    path: (m) => `/api/accounts/stats?platform=${m.platform}&id=${m.id}`,
  },
  "admin-health": { method: "GET", path: () => `/api/admin/health` },

  // Settings: AI keys + preferences (Phase 1)
  "settings-api-keys-list": { method: "GET", path: () => `/api/settings/api-keys` },
  "settings-api-keys-save": {
    method: "POST",
    path: () => `/api/settings/api-keys`,
    body: (m) => m.body,
  },
  "settings-api-keys-delete": {
    method: "DELETE",
    path: (m) => `/api/settings/api-keys/${encodeURIComponent(m.provider)}`,
  },
  "settings-preferences-get": { method: "GET", path: () => `/api/settings/preferences` },
  "settings-preferences-save": {
    method: "POST",
    path: () => `/api/settings/preferences`,
    body: (m) => m.body,
  },

  // Premium status (read-only)
  "premium-status": { method: "GET", path: () => `/api/premium/status` },

  // Home dashboard payload (existing endpoint, exposed for new renderHome)
  "dashboard-home": { method: "GET", path: () => `/api/dashboard/home` },

  // People Workspace (Phase 2)
  "people-counts": { method: "GET", path: () => `/api/people/counts` },
  "people-recommended": {
    method: "GET",
    path: (m) => `/api/people/recommended${m.queryString || ""}`,
  },
  "people-requests": { method: "GET", path: () => `/api/people/requests` },
  "people-sent": { method: "GET", path: (m) => `/api/people/sent${m.queryString || ""}` },
  "people-connections": {
    method: "GET",
    path: (m) => `/api/people/connections${m.queryString || ""}`,
  },
  "people-detail": { method: "GET", path: (m) => `/api/people/${encodeURIComponent(m.profileId)}` },
  "people-connect": {
    method: "POST",
    path: (m) => `/api/people/${encodeURIComponent(m.profileId)}/connect`,
    body: (m) => m.body || {},
  },
  "people-pass": {
    method: "POST",
    path: (m) => `/api/people/${encodeURIComponent(m.profileId)}/pass`,
  },
  "people-block": {
    method: "POST",
    path: (m) => `/api/people/${encodeURIComponent(m.profileId)}/block`,
  },
  "people-bookmark-add": {
    method: "POST",
    path: (m) => `/api/people/${encodeURIComponent(m.profileId)}/bookmark`,
  },
  "people-bookmark-remove": {
    method: "DELETE",
    path: (m) => `/api/people/${encodeURIComponent(m.profileId)}/bookmark`,
  },
  "people-request-accept": {
    method: "POST",
    path: (m) => `/api/people/requests/${encodeURIComponent(m.requestId)}/accept`,
  },
  "people-request-decline": {
    method: "POST",
    path: (m) => `/api/people/requests/${encodeURIComponent(m.requestId)}/decline`,
  },

  // CRM Google Sheets (Phase 3)
  "crm-sheets-status": { method: "GET", path: () => `/api/crm/sheets/status` },
  "crm-sheets-create": {
    method: "POST",
    path: () => `/api/crm/sheets/create`,
    body: (m) => m.body || {},
  },
  "crm-sheets-connect": {
    method: "POST",
    path: () => `/api/crm/sheets/connect`,
    body: (m) => m.body || {},
  },
  "crm-sheets-sync": { method: "POST", path: () => `/api/crm/sheets/sync` },

  // Competitor Intel (Phase 5)
  "competitors-list": { method: "GET", path: () => `/api/competitors` },
  "competitors-add": { method: "POST", path: () => `/api/competitors`, body: (m) => m.body || {} },
  "competitors-scan-all": { method: "POST", path: () => `/api/competitors/scan-all` },
  "competitor-detail": {
    method: "GET",
    path: (m) => `/api/competitors/${encodeURIComponent(m.id)}`,
  },
  "competitor-scan": {
    method: "POST",
    path: (m) => `/api/competitors/${encodeURIComponent(m.id)}/scan`,
  },
  "competitor-archive": {
    method: "POST",
    path: (m) => `/api/competitors/${encodeURIComponent(m.id)}/archive`,
  },
  "competitor-routine-add": {
    method: "POST",
    path: (m) => `/api/competitors/${encodeURIComponent(m.id)}/routines`,
    body: (m) => m.body || {},
  },
  "competitor-routine-toggle": {
    method: "POST",
    path: (m) => `/api/competitors/routines/${encodeURIComponent(m.routineId)}/toggle`,
    body: (m) => m.body || {},
  },

  // AI Service Match (Phase 5)
  "service-match-profile-get": { method: "GET", path: () => `/api/service-match/profile` },
  "service-match-profile-save": {
    method: "POST",
    path: () => `/api/service-match/profile`,
    body: (m) => m.body || {},
  },
  "service-match-analyze": {
    method: "POST",
    path: () => `/api/service-match/analyze`,
    body: (m) => m.body || {},
  },
  "service-match-companies": {
    method: "GET",
    path: (m) => `/api/service-match/companies${m.queryString || ""}`,
  },
  "service-match-network": {
    method: "GET",
    path: (m) => `/api/service-match/network/${encodeURIComponent(m.companyId)}`,
  },
  "service-match-plan-create": {
    method: "POST",
    path: () => `/api/service-match/plan`,
    body: (m) => m.body || {},
  },
  "service-match-plan-pending": { method: "GET", path: () => `/api/service-match/plan` },
  "service-match-performance": { method: "GET", path: () => `/api/service-match/performance` },
  "service-match-plan-item-approve": {
    method: "POST",
    path: (m) => `/api/service-match/plan/items/${encodeURIComponent(m.itemId)}/approve`,
  },
  "service-match-plan-item-skip": {
    method: "POST",
    path: (m) => `/api/service-match/plan/items/${encodeURIComponent(m.itemId)}/skip`,
  },
  "service-match-plan-execute": {
    method: "POST",
    path: (m) => `/api/service-match/plan/${encodeURIComponent(m.planId)}/execute`,
  },
  "service-match-company-execute": {
    method: "POST",
    path: (m) => `/api/service-match/companies/${encodeURIComponent(m.companyId)}/execute`,
    body: (m) => m.body || {},
  },

  // Your Profile (Phase 6)
  "profile-get": { method: "GET", path: () => `/api/profile` },
  "profile-update": { method: "POST", path: () => `/api/profile`, body: (m) => m.body || {} },
  "profile-delete": { method: "DELETE", path: () => `/api/profile` },

  // Bookmarks (per-user CRM/profile/company/opportunity starring)
  "bookmarks-list": { method: "GET", path: (m) => `/api/bookmarks${m.queryString || ""}` },
  "bookmarks-counts": { method: "GET", path: () => `/api/bookmarks/counts` },
  "bookmark-add": {
    method: "POST",
    path: (m) => `/api/bookmarks/${encodeURIComponent(m.type)}/${encodeURIComponent(m.id)}`,
  },
  "bookmark-remove": {
    method: "DELETE",
    path: (m) => `/api/bookmarks/${encodeURIComponent(m.type)}/${encodeURIComponent(m.id)}`,
  },

  // Operations cockpit — what's running right now + per-instance control.
  // ops-live aggregates customs + loops + recent system crons (per-user).
  // ops-system-jobs is the global registry of all named cron jobs.
  // ops-loop-action toggles individual long-running loops by (type, id).
  "ops-live": { method: "GET", path: () => `/api/ops/live` },
  "ops-system-jobs": { method: "GET", path: () => `/api/ops/system-jobs` },
  "ops-loop-action": {
    method: "POST",
    path: (m) =>
      `/api/ops/loops/${encodeURIComponent(m.type)}/${encodeURIComponent(m.id)}/${encodeURIComponent(m.action)}`,
  },
  "ops-job-trigger": {
    method: "POST",
    path: (m) => `/api/ops/system-jobs/${encodeURIComponent(m.name)}/trigger`,
  },
  "ops-run-verify": {
    method: "POST",
    path: (m) => `/api/ops/runs/${encodeURIComponent(m.runId)}/verify`,
    body: (m) => m.body,
  },
  "ops-coverage": { method: "GET", path: () => `/api/ops/coverage` },
  // Outcome metrics (conversion funnel + per-account scoreboard)
  "ops-outcomes": { method: "GET", path: (m) => `/api/ops/outcomes${m.queryString || ""}` },
  "ops-account-scoreboard": {
    method: "GET",
    path: (m) => `/api/ops/account-scoreboard${m.queryString || ""}`,
  },

  // Always-on service toggles (per-user enable/disable for cron engines)
  "services-toggles": { method: "GET", path: () => `/api/services/toggles` },
  "service-toggle-set": {
    method: "PATCH",
    path: () => `/api/services/toggles`,
    body: (m) => m.body,
  },

  // Per-run output drill-down (signals + outreach events the run produced)
  "automation-run-outputs": {
    method: "GET",
    path: (m) => `/api/automations/runs/${encodeURIComponent(m.runId)}/outputs`,
  },

  // Account lockout recovery — clears lockout_state after the user has
  // solved a captcha / re-logged-in / etc. on the platform itself.
  "social-account-unlock": {
    method: "POST",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/unlock`,
  },
  "social-account-pause": {
    method: "POST",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/pause`,
    body: (m) => m.body || {},
  },

  // AI reply generation (humanized, voice-aware) — used by extension
  // panel + signal engagement to replace hardcoded canned replies.
  "ai-reply": { method: "POST", path: () => `/api/ai/reply`, body: (m) => m.body },

  // Voice profile management for AI reply humanization.
  "voice-profile-get": {
    method: "GET",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/voice-profile`,
  },
  "voice-profile-set": {
    method: "PUT",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/voice-profile`,
    body: (m) => m.body,
  },
  "voice-profile-refresh": {
    method: "POST",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/voice-profile/refresh`,
  },
  "voice-presets-list": { method: "GET", path: () => `/api/voice-presets` },
  "voice-profile-apply-preset": {
    method: "POST",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/voice-profile/preset`,
    body: (m) => m.body,
  },
  // Phase 3: mimic another account's voice
  "voice-profile-mimic": {
    method: "POST",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/voice-profile/mimic`,
    body: (m) => m.body,
  },
  // Phase 4: A/B telemetry — log sent replies + read aggregate stats
  "ai-reply-log": { method: "POST", path: () => `/api/ai/reply-log`, body: (m) => m.body },
  "voice-profile-stats": {
    method: "GET",
    path: (m) => `/api/social-accounts/${encodeURIComponent(m.accountId)}/voice-profile/stats`,
  },

  // Telegram: join group by invite link (Active Member modal)
  "tg-join-by-invite": {
    method: "POST",
    path: () => `/api/telegram/groups/join-by-invite`,
    body: (m) => m.body,
  },

  // Workflow Compositions (WHO × WHERE × WHAT composer)
  "compositions-list": { method: "GET", path: (m) => `/api/compositions${m.queryString || ""}` },
  "composition-get": {
    method: "GET",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}`,
  },
  "composition-create": { method: "POST", path: () => `/api/compositions`, body: (m) => m.body },
  "composition-update": {
    method: "PATCH",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}`,
    body: (m) => m.body,
  },
  "composition-delete": {
    method: "DELETE",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}`,
  },
  "composition-activate": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/activate`,
  },
  "composition-pause": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/pause`,
  },
  "composition-archive": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/archive`,
  },
  "composition-killswitch": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/killswitch`,
  },
  "composition-account-budgets": { method: "GET", path: () => `/api/compositions/account-budgets` },
  "composition-operator-summary": {
    method: "GET",
    path: () => `/api/compositions/operator-summary`,
  },
  "composition-clone": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/clone`,
    body: (m) => m.body || {},
  },
  "composition-compare": {
    method: "GET",
    path: (m) => `/api/compositions/compare?ids=${encodeURIComponent((m.ids || []).join(","))}`,
  },
  "composition-run": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/run`,
    body: (m) => m.body || {},
  },
  "composition-preview": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/preview`,
  },
  "composition-audience-count": {
    method: "POST",
    path: () => `/api/compositions/audience-count`,
    body: (m) => m.body,
  },
  "composition-source-catalog": { method: "GET", path: () => `/api/compositions/source-catalog` },
  "composition-play-catalog": { method: "GET", path: () => `/api/compositions/play-catalog` },
  "composition-event-sources": { method: "GET", path: () => `/api/compositions/event-sources` },
  "composition-cron-preview": {
    method: "GET",
    path: (m) => `/api/compositions/cron-preview?expr=${encodeURIComponent(m.expr)}&n=${m.n || 3}`,
  },
  "composition-templates": {
    method: "GET",
    path: (m) =>
      `/api/compositions/templates${m?.includeDeprecated ? "?include_deprecated=true" : ""}`,
  },
  "composition-from-template": {
    method: "POST",
    path: (m) => `/api/compositions/from-template/${encodeURIComponent(m.templateId)}`,
    body: (m) => m.body || {},
  },
  // Phase 31e + 32: voice catalog + performance dashboards
  "composition-voice-catalog": { method: "GET", path: () => `/api/compositions/voice-catalog` },
  "composition-performance-templates": {
    method: "GET",
    path: () => `/api/compositions/performance/templates`,
  },
  "composition-performance-tracks": {
    method: "GET",
    path: () => `/api/compositions/performance/tracks`,
  },
  "composition-performance-accounts": {
    method: "GET",
    path: () => `/api/compositions/performance/accounts`,
  },
  "composition-performance-cost": {
    method: "GET",
    path: () => `/api/compositions/performance/cost-per-reply`,
  },
  "composition-save-as-template": {
    method: "POST",
    path: () => `/api/compositions/save-as-template`,
    body: (m) => m.body,
  },
  "composition-delete-user-template": {
    method: "DELETE",
    path: (m) => `/api/compositions/user-templates/${encodeURIComponent(m.id)}`,
  },
  "composition-runs": {
    method: "GET",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/runs?limit=${m.limit || 20}`,
  },
  "composition-funnel": {
    method: "GET",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/funnel`,
  },
  "composition-variants": {
    method: "GET",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/variants`,
  },
  "composition-run-audit": {
    method: "GET",
    path: (m) => `/api/compositions/runs/${encodeURIComponent(m.runId)}/audit`,
  },
  "composition-run-play-status": {
    method: "GET",
    path: (m) => `/api/compositions/runs/${encodeURIComponent(m.runId)}/play-status`,
  },
  "composition-suggestions": {
    method: "POST",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/suggestions`,
    body: () => ({}),
  },
  // Phase ZA — Composer health check (live diagnostic UI)
  "composer-health-check-start": {
    method: "POST",
    path: () => `/api/composer/health-check`,
    body: () => ({}),
  },
  "composer-health-check-status": {
    method: "GET",
    path: (m) => `/api/composer/health-check/${encodeURIComponent(m.runId)}`,
  },
  // Phase ZB — Test-all-compositions (sequential per-composition dry-run with live log)
  "composer-test-all-start": {
    method: "POST",
    path: () => `/api/composer/test-all-compositions`,
    body: (m) => m.body || {},
  },
  "composer-test-all-status": {
    method: "GET",
    path: (m) => `/api/composer/test-all-compositions/${encodeURIComponent(m.runId)}`,
  },
  "composer-test-all-cancel": {
    method: "DELETE",
    path: (m) => `/api/composer/test-all-compositions/${encodeURIComponent(m.runId)}`,
  },
  // Phase ZF — Test-live-one (REAL execution of one template/composition on 1 contact, watch the browser do it)
  "composer-test-live-one": {
    method: "POST",
    path: () => `/api/composer/test-live-one`,
    body: (m) => m.body || {},
  },
  // Phase ZD — Sales proposals
  "proposals-list": { method: "GET", path: (m) => `/api/proposals${m.queryString || ""}` },
  "proposals-get": { method: "GET", path: (m) => `/api/proposals/${encodeURIComponent(m.id)}` },
  "proposals-update": {
    method: "PATCH",
    path: (m) => `/api/proposals/${encodeURIComponent(m.id)}`,
    body: (m) => m.body,
  },
  "proposals-revoke": {
    method: "DELETE",
    path: (m) => `/api/proposals/${encodeURIComponent(m.id)}`,
  },
  "proposals-views": {
    method: "GET",
    path: (m) => `/api/proposals/${encodeURIComponent(m.id)}/views`,
  },
  "proposals-templates": { method: "GET", path: () => `/api/proposals/templates` },
  "composition-contact-audit": {
    method: "GET",
    path: (m) => `/api/compositions/contacts/${encodeURIComponent(m.contactId)}/audit`,
  },
  "composition-resolve-one": {
    method: "POST",
    path: () => `/api/compositions/audience-resolve-one`,
    body: (m) => m.body,
  },

  // Outreach orchestrator queue
  "outreach-queue": { method: "GET", path: (m) => `/api/outreach/queue${m.queryString || ""}` },
  "outreach-channel-retry": {
    method: "POST",
    path: (m) =>
      `/api/outreach/campaigns/${encodeURIComponent(m.campaignId)}/channels/${encodeURIComponent(m.channel)}/retry`,
  },
  "outreach-channel-cancel": {
    method: "POST",
    path: (m) =>
      `/api/outreach/campaigns/${encodeURIComponent(m.campaignId)}/channels/${encodeURIComponent(m.channel)}/cancel`,
  },
  "outreach-cancel-all": {
    method: "POST",
    path: (m) => `/api/outreach/campaigns/${encodeURIComponent(m.campaignId)}/cancel-all`,
  },
  "outreach-resume": {
    method: "POST",
    path: (m) => `/api/outreach/campaigns/${encodeURIComponent(m.campaignId)}/resume`,
  },

  // Health + DNC
  "health-accounts": { method: "GET", path: () => `/api/health/accounts` },
  "coinmm-status": { method: "GET", path: () => `/api/coinmm/status` },
  "run-preview": { method: "POST", path: () => `/api/run/preview`, body: (m) => m.body || {} },
  "run-previews": { method: "GET", path: () => `/api/run/previews` },
  "run-preview-get": {
    method: "GET",
    path: (m) => `/api/run/previews/${encodeURIComponent(m.id)}`,
  },
  "run-preview-confirm": {
    method: "POST",
    path: (m) => `/api/run/previews/${encodeURIComponent(m.previewId)}/confirm`,
    body: () => ({}),
  },

  // In-extension notification center (the bell)
  "notifications-list": { method: "GET", path: (m) => `/api/notifications${m.queryString || ""}` },
  "notification-read": {
    method: "POST",
    path: (m) => `/api/notifications/${encodeURIComponent(m.id)}/read`,
    body: () => ({}),
  },
  "notifications-read-all": {
    method: "POST",
    path: (m) => `/api/notifications/read-all${m.queryString || ""}`,
    body: () => ({}),
  },

  // Capabilities + Telegram add-on linking
  capabilities: { method: "GET", path: () => `/api/capabilities` },
  "telegram-link": { method: "POST", path: () => `/api/telegram/link`, body: (m) => m.body || {} },

  // Workspaces (multi-tenant / agency layer)
  "workspaces-list": { method: "GET", path: () => `/api/workspaces` },
  "workspace-create": {
    method: "POST",
    path: () => `/api/workspaces`,
    body: (m) => ({ name: m.name, brand: m.brand || null }),
  },
  "workspace-brand-get": {
    method: "GET",
    path: (m) => `/api/workspaces/${encodeURIComponent(m.id)}/brand`,
  },
  "workspace-brand-set": {
    method: "PUT",
    path: (m) => `/api/workspaces/${encodeURIComponent(m.id)}/brand`,
    body: (m) => ({ brand: m.brand || {} }),
  },
  "agency-overview": { method: "GET", path: (m) => `/api/agency/overview${m.queryString || ""}` },
  "dnc-suggestions": { method: "GET", path: () => `/api/health/dnc-suggestions` },
  "dnc-list": { method: "GET", path: (m) => `/api/contacts/dnc${m.queryString || ""}` },
  "contact-dnc-toggle": {
    method: "POST",
    path: (m) => `/api/contacts/${encodeURIComponent(m.contactId)}/dnc`,
    body: (m) => m.body || {},
  },

  // Inbox reply (in-extension X / LinkedIn / Email reply)
  "inbox-reply": {
    method: "POST",
    path: (m) => `/api/inbox/${encodeURIComponent(m.eventId)}/reply`,
    body: (m) => m.body || {},
  },

  // Playbooks ("mission sections" that bundle customized automations).
  // Registered here (not a bespoke proxy) so they inherit the X-Workspace-Id
  // header — playbooks scope to the active client workspace.
  "playbooks-list": { method: "GET", path: () => `/api/playbooks` },
  "playbook-create": { method: "POST", path: () => `/api/playbooks`, body: (m) => m.body || {} },
  "playbook-get": { method: "GET", path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}` },
  "playbook-patch": {
    method: "PATCH",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}`,
    body: (m) => m.body || {},
  },
  "playbook-delete": {
    method: "DELETE",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}`,
  },
  "playbook-attachable": {
    method: "GET",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/attachable`,
  },
  "playbook-attach": {
    method: "POST",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/attach`,
    body: (m) => m.body || {},
  },
  "playbook-detach": {
    method: "DELETE",
    path: (m) =>
      `/api/playbooks/${encodeURIComponent(m.id)}/attach/${encodeURIComponent(m.attachId)}`,
  },
  "playbook-templates": { method: "GET", path: () => `/api/playbooks/templates` },
  "playbook-install": {
    method: "POST",
    path: () => `/api/playbooks/install`,
    body: (m) => m.body || {},
  },
  "playbook-save-template": {
    method: "POST",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/save-as-template`,
    body: (m) => m.body || {},
  },
  "playbook-preview": {
    method: "POST",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/preview`,
    body: () => ({}),
  },
  "playbook-run": {
    method: "POST",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/run`,
    body: () => ({}),
  },
  "playbook-catalog-add": {
    method: "POST",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/catalog-add`,
    body: (m) => m.body || {},
  },
  "playbook-publish": {
    method: "POST",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/publish`,
    body: (m) => m.body || {},
  },
  "playbook-unpublish": {
    method: "POST",
    path: (m) => `/api/playbooks/${encodeURIComponent(m.id)}/unpublish`,
    body: () => ({}),
  },

  // Clients (per-customer segmentation inside a workspace). Inherit X-Workspace-Id
  // so segments + reports scope to the active workspace.
  "clients-list": { method: "GET", path: () => `/api/clients` },
  "client-create": { method: "POST", path: () => `/api/clients`, body: (m) => m.body || {} },
  "client-get": { method: "GET", path: (m) => `/api/clients/${encodeURIComponent(m.id)}` },
  "client-update": {
    method: "PATCH",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}`,
    body: (m) => m.body || {},
  },
  "client-delete": {
    method: "DELETE",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}${m.queryString || ""}`,
  },
  "client-set-accounts": {
    method: "POST",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/accounts`,
    body: (m) => m.body || {},
  },
  "client-companies": {
    method: "GET",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/companies`,
  },
  "client-set-companies": {
    method: "POST",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/companies`,
    body: (m) => m.body || {},
  },
  "account-set-clients": {
    method: "POST",
    path: (m) =>
      `/api/accounts/${encodeURIComponent(m.platform)}/${encodeURIComponent(m.accountId)}/clients`,
    body: (m) => m.body || {},
  },
  "work-tag-client": {
    method: "POST",
    path: (m) => `/api/work/${encodeURIComponent(m.kind)}/${encodeURIComponent(m.workId)}/client`,
    body: (m) => m.body || {},
  },
  "work-taggable": { method: "GET", path: () => `/api/work/taggable` },
  "client-report": { method: "GET", path: (m) => `/api/client-report${m.queryString || ""}` },
  "client-report-share": {
    method: "POST",
    path: () => `/api/client-report/share`,
    body: (m) => m.body || {},
  },
  "client-report-shares": {
    method: "GET",
    path: (m) => `/api/client-report/shares${m.queryString || ""}`,
  },
  "client-report-share-revoke": {
    method: "DELETE",
    path: (m) => `/api/client-report/share/${encodeURIComponent(m.token)}`,
  },
};

function isDashboardAction(action) {
  return Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTES, action);
}

async function dispatchDashboardCall(msg) {
  const route = DASHBOARD_ROUTES[msg.action];
  if (!route) return { error: "unknown_action" };

  const { token, apiBase, workspaceId, clientId } = await chrome.storage.local.get([
    "token",
    "apiBase",
    "workspaceId",
    "clientId",
  ]);
  if (!token) return { error: "Not authenticated" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");

  const path = route.path(msg);
  const url = `${base}${path}`;

  // Per-route timeout. The CRM list endpoints can be slow on large
  // databases (correlated subqueries × LIMIT 50 = lots of work) — give
  // them more headroom. Bulk-enrich and lead-analysis are quick after
  // dispatch (heavy work runs in the background) so 30s is plenty.
  const SLOW_ROUTES = new Set(["crm-contacts-list", "crm-companies-list", "crm-search"]);
  const timeoutMs = SLOW_ROUTES.has(msg.action) ? 60000 : 30000;

  const init = {
    method: route.method,
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(timeoutMs),
  };
  // Multi-tenant: act as the selected client workspace (server resolves the
  // effective owner from this header; ignored if you're not a member).
  if (workspaceId) init.headers["X-Workspace-Id"] = workspaceId;
  // Active client: newly-created campaigns/workflows auto-tag to this client so
  // they show up in its report. Ignored by read endpoints + if the id is stale.
  if (clientId) init.headers["X-Client-Id"] = clientId;
  if (route.body) {
    init.headers["Content-Type"] = "application/json";
    init.body = JSON.stringify(route.body(msg) || {});
  }

  try {
    const res = await fetch(url, init);
    const data = await res.json().catch(() => ({}));
    if (!res.ok && !data.error) data.error = `HTTP ${res.status}`;
    // Tag 401 responses so the dashboard can surface a re-pair overlay
    // instead of treating it as a generic error.
    if (res.status === 401) data.unauthorized = true;
    return data;
  } catch (err) {
    return { error: err.message };
  }
}

// ── Circuit consent bridge (MetaMask-style execute-from-web) ─────────────────
// Allowlist of MUTATING actions a Circuit page may REQUEST over the bridge. It
// still can't RUN them without the in-extension approval below. Keep this tight
// — never a generic proxy (the read-only set lives in content-circuit.js).
const CIRCUIT_MUTATE_ALLOWED = new Set([
  "quest-subscribe",
  "search-activate",
  "playbook-publish",
  "revoke-grant",
]);
const _pendingConsent = new Map(); // req nonce → { resolve, manifest, title }
const CONSENT_TIMEOUT_MS = 3 * 60 * 1000;

// Minimal authed call to the bot API with THIS install's own connect token
// (mirrors dispatchDashboardCall's auth). Used only by the consent flow.
async function botApiFetch(method, path, body) {
  const { token, apiBase } = await chrome.storage.local.get(["token", "apiBase"]);
  if (!token) return { error: "Not paired" };
  const base = (apiBase || "http://localhost:9091").replace(/\/$/, "");
  const init = {
    method,
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(30000),
  };
  if (body !== undefined) {
    init.headers["Content-Type"] = "application/json";
    init.body = JSON.stringify(body);
  }
  try {
    const res = await fetch(`${base}${path}`, init);
    const data = await res.json().catch(() => ({}));
    if (!res.ok && !data.error) data.error = `HTTP ${res.status}`;
    if (res.status === 401) data.unauthorized = true;
    return data;
  } catch (err) {
    return { error: err.message };
  }
}

// Per-quest consent grants (approve-once-per-quest). chrome.storage.local under
// "questGrants": { "<ref>": { grantedAt, revokedAt? } }. Revoke = set revokedAt.
async function getActiveGrant(ref) {
  const { questGrants } = await chrome.storage.local.get("questGrants");
  const g = questGrants && questGrants[ref];
  return g && !g.revokedAt ? g : null;
}
async function saveQuestGrant(ref, meta = {}) {
  const { questGrants } = await chrome.storage.local.get("questGrants");
  const map = questGrants || {};
  // Merge so successive writes (approval → subscription id) accumulate on the grant.
  map[ref] = { grantedAt: Date.now(), ...(map[ref] || {}), ...meta };
  await chrome.storage.local.set({ questGrants: map });
}

// Open the in-extension consent popup for a manifest; resolves true (approve) /
// false (decline or timeout). The popup is an EXTENSION page — the requesting
// web page can never render or fake it.
function askConsent({ title, manifest }) {
  return new Promise((resolve) => {
    const req =
      self.crypto?.randomUUID?.() || String(Date.now()) + Math.random().toString(16).slice(2);
    let settled = false;
    const done = (v) => {
      if (!settled) {
        settled = true;
        _pendingConsent.delete(req);
        resolve(v);
      }
    };
    _pendingConsent.set(req, { resolve: done, manifest, title });
    setTimeout(() => done(false), CONSENT_TIMEOUT_MS); // never hang forever
    chrome.windows
      .create({
        url: chrome.runtime.getURL(`consent.html?req=${encodeURIComponent(req)}`),
        type: "popup",
        width: 460,
        height: 640,
        focused: true,
      })
      .catch(() => done(false));
  });
}

async function handleCircuitMutate(msg) {
  const action = msg?.action;
  if (!CIRCUIT_MUTATE_ALLOWED.has(action))
    return { ok: false, error: "action not allowed over the bridge" };
  const { token } = await chrome.storage.local.get(["token"]);
  if (!token) return { ok: false, error: "not_paired" };

  // Run someone's published campaign on MY accounts — consent-gated (approve-once).
  if (action === "quest-subscribe") {
    const ref = String(msg?.payload?.ref || "").trim();
    if (!ref || !ref.includes(":")) return { ok: false, error: "missing campaign ref" };
    const grant = await getActiveGrant(ref);
    // Already approved AND subscribed → don't create a duplicate subscription
    // (guards a double-click / a late-timeout retry from re-running the quest).
    if (grant && grant.subscriptionId != null)
      return { ok: true, alreadyRunning: true, subscription: null };
    // No active *subscribed* grant → require a fresh approval EVERY time until a
    // subscription actually succeeds. A grant is persisted only after the
    // subscription call returns ok (below), so a mid-flow failure can never leave
    // an approved-but-not-running grant that would silently skip re-consent (TOCTOU).
    // AUTHORITATIVE manifest built with OUR token — the page can't spoof it.
    const preview = await botApiFetch("POST", "/api/run/preview", {
      kind: "quest-subscribe",
      params: { ref },
    });
    if (preview?.error) return { ok: false, error: preview.error };
    const approved = await askConsent({
      title: msg?.payload?.title || "Run this quest",
      manifest: preview.manifest,
    });
    if (!approved) return { ok: false, declined: true };
    if (preview.previewId)
      await botApiFetch("POST", `/api/run/previews/${preview.previewId}/confirm`);
    // Execute as THIS member (own token → own accounts) — dodges shared-token "G2".
    const sub = await botApiFetch("POST", "/api/mutuals/subscriptions", {
      targetType: "campaign",
      targetRef: ref,
      origin: "circuit",
    });
    if (sub?.error) return { ok: false, error: sub.error };
    // Persist the grant ONLY now that the subscription exists — records the
    // subscription id so revoke can actually stop it, and clears any prior
    // revokedAt so this re-approval reactivates the grant.
    await saveQuestGrant(ref, {
      subscriptionId: sub.subscription?.id ?? null,
      previewId: preview.previewId || null,
      title: msg?.payload?.title || null,
      revokedAt: null,
    });
    return { ok: true, subscription: sub.subscription || null };
  }

  // Materialise MY funnel selections into a draft Playbook (my own data, nothing
  // runs yet) — no popup; the user explicitly triggered it from the funnel.
  if (action === "search-activate") {
    const slug = String(msg?.payload?.slug || "").trim();
    if (!slug) return { ok: false, error: "missing slug" };
    const r = await botApiFetch("POST", `/api/search/${encodeURIComponent(slug)}/activate`, {
      accounts: msg?.payload?.accounts || {},
    });
    if (r?.error) return { ok: false, error: r.error };
    return { ok: true, playbook: r };
  }

  // Publish MY playbook as a public community quest — outward-facing, so a light confirm.
  if (action === "playbook-publish") {
    const playbookId = msg?.payload?.playbookId;
    if (playbookId == null || playbookId === "") return { ok: false, error: "missing playbookId" };
    const title = String(msg?.payload?.title || "this playbook");
    const approved = await askConsent({
      title: "Publish to your community",
      manifest: {
        summary: `Publish “${title}” as a public community quest — members can subscribe and run it on their own accounts.`,
        warnings: ["This creates a public listing on the Circuit."],
        actions: [],
      },
    });
    if (!approved) return { ok: false, declined: true };
    const r = await botApiFetch(
      "POST",
      `/api/playbooks/${encodeURIComponent(playbookId)}/publish`,
      {
        title: msg?.payload?.title,
        description: msg?.payload?.description,
        reward: msg?.payload?.reward || { type: "xp", xpPerAction: 10 },
      },
    );
    if (r?.error) return { ok: false, error: r.error };
    return { ok: true, published: r };
  }

  // Revoke a per-quest consent grant (local; harmless → no popup). Next run of
  // that quest will prompt for approval again.
  if (action === "revoke-grant") {
    const ref = String(msg?.payload?.ref || "").trim();
    if (!ref) return { ok: false, error: "missing ref" };
    const { questGrants } = await chrome.storage.local.get("questGrants");
    const map = questGrants || {};
    const grant = map[ref];
    // Actually STOP the running subscription (not just block future re-approval),
    // so "revoke" means what the consent copy promises.
    if (grant && grant.subscriptionId != null) {
      await botApiFetch(
        "DELETE",
        `/api/mutuals/subscriptions/${encodeURIComponent(grant.subscriptionId)}`,
      );
    }
    if (grant) {
      grant.revokedAt = Date.now();
      await chrome.storage.local.set({ questGrants: map });
    }
    return { ok: true, revoked: ref };
  }

  return { ok: false, error: "unsupported action" };
}
