/**
 * 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-18-r113";
const NEXUS_BUILD_NOTES_R95 =
  "r95: Menu IA — Clients becomes a top-level (gated) section and Campaigns collapses from six children to two. Seven dead alias routes deleted from the dispatch (new-campaign, new-campaign-wizard, workflows/new, and the four <platform>/hub duplicates); `new-workflow` was KEPT despite being on the same delete list, because two live call sites still navigate to `new-workflow/<templateKey>` with a subpath and parseHash still matches it — deleting it would have re-opened the exact dead-end that alias was added to fix. The ⌘K palette entries that pointed at the two deleted campaign aliases now point at `campaigns` / `workflows/canvas`. Boost and Clients gained palette entries because the Campaigns collapse and the Clients gate respectively removed their only sidebar path. Ctrl/Cmd+1..N now resolves against the RENDERED nav items rather than indexing V3_SECTIONS, so a hidden gated section can't make the shortcut disagree with the number in its own tooltip. The Clients gate is capability-derived (`visibleWhen`), seeded from a persisted last-known value so the room doesn't flash in and out on load, and refreshed from the real client list — it is COSMETIC, server-side owner scoping remains the actual boundary. Two new nav guards: no two nav children may resolve to the same route, and any section marked gated:true must declare a predicate; both mutation-verified. Reload the extension to pick this up. PRIOR r94: Circuit bridge origin FIX — the whole quest/subscribe seam was dead in production. Four places named the Circuit's origin and all four were wrong by one missing '-app': manifest.json's content_scripts match, ALLOWED_ORIGINS in content-circuit.js, CIRCUIT_ORIGINS in background.js, and CIRCUIT_APP_URL in dashboard.js all said https://thecircuit.vercel.app, while the app is served from https://thecircuit-app.vercel.app. Consequences: (1) content-circuit.js NEVER injected on the real app, so the entire bridge — quests-summary/engine-health/quest-grants reads plus the consent-gated quest-subscribe/search-activate/playbook-publish/revoke-grant writes — has been unreachable since it shipped in r77; (2) even had it injected, isCircuitSender() would have rejected it; (3) the three 'open the Circuit' buttons in the dashboard's Earn view sent users to https://thecircuit.vercel.app, which is a live but UNRELATED 644-byte Create-React-App stub titled 'React App' on a host we do not control — allow-listing it also meant the consent-gated bridge was exposed to whoever owns it. All four now say thecircuit-app.vercel.app; the stub host is gone. CIRCUIT_APP_URL additionally gains the /home path: '/' on the Circuit is the public MARKETING SPLASH ('One terminal for communities, quests & traders') and the app itself is at /home ('Dashboard · The Circuit'), so even after the host fix those three buttons would have dropped an already-paired operator on a signup page — landing/vercel.json has always sent every other Circuit entry point to /home, and the drift test now pins the extension to that exact destination rather than merely to the origin. NEW circuit-origin.drift.test.js asserts set-equality across the manifest match, the bridge allow-list and the SW sender gate (both directions), that CIRCUIT_APP_URL is allow-listed, that the stub host appears nowhere, and that every origin is a bare https origin with no trailing slash (a trailing slash silently breaks every comparison, since window.location.origin never has one). Verified by mutation: reverting any one of the four sites fails the suite. THIS BUILD CHANGES manifest.json — a plain service-worker refresh is NOT enough, you must fully reload the extension at chrome://extensions for the new content-script match to register. PRIOR 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_R96 =
  "r96: LinkedIn feed-like repair. Feed discovery now starts from accessible Like controls instead of assuming every post has a data-urn container. Keyword runs scan all five scroll batches and report selector misses, no keyword matches, and already-liked feeds honestly instead of immediately claiming 'Done! Liked 0'. This build adds linkedin-feed-dom.js to the manifest, so fully reload the extension at chrome://extensions. PRIOR " +
  NEXUS_BUILD_NOTES_R95;

const NEXUS_BUILD_NOTES_R105 =
  "r105: Preserve already-liked posts in Competitor Siphon reruns. LinkedIn labels liked profile-activity controls 'Unreact Like'; the scanner previously understood 'Unlike' but dropped this current label, turning two fully rendered liked posts into a false no-post result. Feed discovery now recognizes both labels, carries each card's liked state into the Siphon, skips repeat reactions, and reports how many rendered posts were already liked. The rendered-page fallback remains active after employee activity API failures. LinkedIn's exact downstream-internal activity error now trips a per-run circuit breaker after the first probe, sending all remaining employees directly through rendered pages instead of repeating a known-dead API request. PRIOR r104: Auditable Competitor Siphon Like receipts. Rendered-profile engagement now returns the exact liked post URN and preserves whether LinkedIn confirmed it through the API or the DOM. Activity logs label both paths accurately, include a short post ID so separate Likes cannot look like a duplicate count, and summarize confirmed Likes against distinct posts. PRIOR r103: Competitor Siphon company-first discovery. Siphon now reads authenticated company feed bootstrap data for canonical post URNs before sampling arbitrary employees, so active company pages remain actionable even when sampled employees publish nothing or LinkedIn's employee activity API returns downstream errors. Company posts feed both verified likes and medium/heavy engager discovery; employee pages remain a fallback. Employee discovery no longer aborts company engagement, light mode skips irrelevant connection-attempt checks, and empty rendered activity pages upload route-aware diagnostics. PRIOR r102: Competitor Siphon rendered-profile engagement. Employee activity pages now discover Style-2 cards outside mainFeed, can perform one confirmed portal-based Like in the hidden worker even when LinkedIn withholds a public post URN, and report that action back into the Siphon's real budget and final count. Light/medium scans sample 5/6 employees instead of silently waiting through ten slow page navigations, show per-employee progress, honor Stop between employees, and skip repeated broken activity-API calls. Medium/heavy runs continue to employees when post IDs are unavailable and reuse the verified profile UI connection flow, so Connected requires Pending/Sent proof. PRIOR r101: Restore Style-2 feed discovery and complete its two-stage reaction flow. r100 correctly stopped clicking Open reactions menu as though it were Like, but incorrectly required an inline Like button and therefore dropped every real card on the current feed. Captured r100 snapshots show eight listitem cards and eight chevrons while the actual Like button exists only in a portal after the chevron is opened. The scanner now retains those cards, scopes keywords against their full text, opens the matching card's reactions menu, clicks the portal's real Like option, and requires selected-card state or menu acceptance before counting it. PRIOR r100: Modern LinkedIn Connect + exact feed Like controls. Profile actions are now scoped from the Follow/Message/More cluster, Connect is discovered in portal-rendered hashed More menus, and a failed profile-menu route retries through LinkedIn's exact custom-invite dialog before the existing Pending/Sent verification. Search results prefer real vanity URLs over internal ACo ids, and malformed post URNs are rejected before warm-up likes. Feed scanning no longer mistakes Open reactions menu's chevron for Like; it targets the accessible Like control and reports the API confirmation path as LinkedIn API instead of Voyager. PRIOR r99: Verified LinkedIn connections + immediate Stop. Add Connections no longer relies on the failing normInvitations response or counts an attempted request as sent. Each candidate is opened in a dedicated hidden profile window, the owner-scoped Connect flow is submitted, and success requires either the profile to change to Pending or the exact person to appear in LinkedIn's sent-invitations list. Message alone is no longer misclassified as already connected. Wrapped search-result URNs are canonicalized before dedupe; failed attempts remain retryable and attempt checks are owner-scoped. Both panel and dashboard loops now check Stop between search, enrichment, warm-up, connection, pagination, and delay steps, and delays wake every 250ms on cancellation. PRIOR r98: LinkedIn verified engagement + Competitor Siphon completion. Engage Feed now counts a Like only after Voyager accepts the reaction or LinkedIn's DOM changes to a liked state; an unconfirmed click produces a failure and diagnostic snapshot instead of a false 'Liked 1'. Siphon activity requests now use employees' actual fsd_profile URNs, normalize legacy and current feed/engager payloads, preserve endpoint failures, and fall back to scanning rendered recent-post pages in the hidden worker tab. It likes each unique employee post once and can use eligible employee connections for medium/heavy runs when public engager rosters are empty. PRIOR r97: LinkedIn Style-2 feed repair. Current posts live under main[data-testid=mainFeed] role=listitem cards and Like is an unnamed svg#chevron-up-* control, so the scanner now supports that exact DOM in addition to legacy accessible labels. Starting Engage Feed outside /feed now navigates there and resumes; background summaries preserve real failures instead of replacing them with 'Done! Liked 0'. PRIOR " +
  NEXUS_BUILD_NOTES_R96;

const NEXUS_BUILD_NOTES_R106 =
  "r106: Restore resilient employee activity API use. Live authenticated tests confirmed the current GraphQL and identity endpoints repeatedly return profile posts, so one transient LinkedIn downstream error no longer disables API reads for the entire Siphon. The service retries that exact transient response once, the extension requires failures on two consecutive employees before switching the remaining sample to rendered pages, and a clean response resets the failure streak. Voyager normalization now follows the primary feed collection so nested reshared-content URNs are not miscounted as separate employee posts. DOM remains the fallback for genuine empty or failed API responses. PRIOR " +
  NEXUS_BUILD_NOTES_R105;

const NEXUS_BUILD_NOTES_R108 =
  'r108: Restore a readiness wait on the connect path. r107 replaced it with a fixed `await delay(1500)`, which is a coin flip in the hidden worker tab where Chrome deprioritises rendering: when the guess loses, profileState() finds no controls and the run reports selector_missing on a page that had simply not painted yet. That exact misreading is what sent the last selector audit chasing a Connect button that worked. NEW waitForProfileControls() polls profileState() for up to 8s and returns as soon as the profile exposes anything actionable (connect / pending / more / the 1st-degree badge), so readiness means "the control we are about to click exists" rather than a global marker. It deliberately does NOT probe `[class*="artdeco"]`, the way the earlier waitForPaint did: the 2026 rebuild dropped artdeco from profile, feed, search and network while keeping it on the older Ember pages, so that probe reported "never painted" for a page showing three working More buttons. A timeout returns null and the caller falls through to the honest selector_missing, so a genuinely control-less page is still reported as one. PRIOR r107: Fix the employee activity API at its request boundary. Controlled live probes showed the same authenticated GraphQL request returns real posts without X-Li-Track but LinkedIn\'s downstream-internal error with the worker\'s old fabricated X-Li-Track value. The shared Voyager header builder no longer sends that optional stale fingerprint, while retaining the session\'s captured browser user agent, CSRF token, cookies, protocol version, language, and referer. Production-path tests now return employee posts through the API; rendered pages remain the fallback for private, empty, or genuinely failed activity responses. PRIOR ' +
  NEXUS_BUILD_NOTES_R106;

const NEXUS_BUILD_NOTES_R109 =
  "r109: LinkedIn workflow reliability audit. Inbox timestamps now normalize ISO, epoch-second, epoch-millisecond, and relative values; connection-attempt dedupe is owner-scoped; company posts no longer suppress employee Siphon scans and are accepted only with verified company authorship; warm-up Likes require a real selected state; and Accept/Withdraw invitation totals require confirmed DOM outcomes. Reload the extension and restart the bot to pick this up. PRIOR " +
  NEXUS_BUILD_NOTES_R108;

const NEXUS_BUILD_NOTES =
  "r113: Restore View Profiles after LinkedIn retired the Voyager /profileView endpoint with HTTP 410. Search still uses the authenticated API, but each result is now opened in a second tab inside the existing minimized worker window and counted only after the content script confirms the exact /in/<slug> route and rendered profile controls. The metadata endpoint uses the current profile lookup instead of the dead URL. PRIOR " +
  "r112: Route authenticated one-off feed comments through the user's live extension tab. The content handler already supported li:comment, but the polled command router silently rejected it as unknown; the background now navigates its existing LinkedIn worker tab to /feed and forwards literal text to the confirmed DOM flow. PRIOR " +
  "r111: Complete current LinkedIn feed comments against the captured Style-2 form. The editor's own type=submit control is now preferred (current feeds label it simply Submit), the target post is reacquired after Like/open-editor React remounts, and confirmation follows that live editor so a remounted draft cannot masquerade as a posted comment. Live-session cloning was removed from the test workflow; E2E now uses only the user's existing Chrome session. PRIOR " +
  "r110: Restore current LinkedIn feed comments. Comment discovery now accepts Style-2 text-only controls, opens and submits through trusted CDP mouse events in hidden tabs, recognizes the current Comment submit label, and requires both a cleared editor and newly rendered comment text before incrementing the total. Requested comments that LinkedIn does not confirm now fail the run instead of reporting a misleading success. PRIOR " +
  NEXUS_BUILD_NOTES_R109;

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;
let _liConnectTabId = null;
let _liConnectOperation = null;
let _liConnectCancelled = false;
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.
// Kept in sync with content-circuit.js ALLOWED_ORIGINS + the manifest match by
// circuit-origin.drift.test.js. NOT thecircuit.vercel.app — that host serves an
// unrelated stub we do not control (see r94).
const CIRCUIT_ORIGINS = new Set(["https://thecircuit-app.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-connect-profile-bg") {
    if (_liConnectOperation) {
      sendResponse({
        success: false,
        error: "Another connection request is still being verified",
        reason: "connect_busy",
      });
      return false;
    }
    _liConnectOperation = (async () => {
      _liConnectCancelled = false;
      const slug = String(msg.publicIdentifier || "")
        .replace(/^https?:\/\/(?:www\.)?linkedin\.com\/in\//i, "")
        .replace(/^\/?in\//i, "")
        .replace(/[/?#].*$/, "")
        .trim();
      if (!slug)
        return {
          success: false,
          error: "No public LinkedIn profile identifier",
          reason: "missing_slug",
        };

      let workerWindowId = null;
      try {
        const profileUrl = `https://www.linkedin.com/in/${encodeURIComponent(slug)}/`;
        const win = await chrome.windows.create({
          url: profileUrl,
          type: "popup",
          state: "minimized",
        });
        workerWindowId = win?.id ?? null;
        const tab = win?.tabs?.[0];
        if (!tab?.id) throw new Error("Connection worker window opened without a tab");
        _liConnectTabId = tab.id;

        await waitForTabLoad(tab.id, 25_000);
        await new Promise((resolve) => setTimeout(resolve, 2500));
        if (_liConnectCancelled) return { success: false, terminal: true, reason: "cancelled" };
        let submitted = await sendToContentScript(
          tab.id,
          "li:click-connect",
          {
            note: String(msg.note || "").slice(0, 300),
            expectedName: String(msg.expectedName || "").trim(),
            expectedPublicIdentifier: slug,
            domOnly: true,
          },
          3,
          35_000,
        );

        if (submitted?.success === true && submitted?.verified === true) {
          return { ...submitted, publicIdentifier: slug };
        }
        if (submitted?.terminal) return submitted;
        if (!submitted?.submitted) {
          // Modern profiles commonly make Follow the primary action and place
          // Connect in a portal-rendered More menu. If that DOM route drifts,
          // use LinkedIn's own custom-invite page for this exact vanity name.
          await chrome.tabs.update(tab.id, {
            url: `https://www.linkedin.com/preload/custom-invite/?vanityName=${encodeURIComponent(slug)}`,
          });
          await waitForTabLoad(tab.id, 25_000);
          await new Promise((resolve) => setTimeout(resolve, 2000));
          if (_liConnectCancelled) return { success: false, terminal: true, reason: "cancelled" };
          submitted = await sendToContentScript(
            tab.id,
            "li:submit-connect-invite",
            {
              note: String(msg.note || "").slice(0, 300),
            },
            3,
            25_000,
          );
          if (submitted?.terminal) return submitted;
          if (!submitted?.submitted) {
            return {
              success: false,
              error:
                submitted?.error ||
                submitted?.reason ||
                "LinkedIn connection dialog could not be submitted",
              reason: submitted?.reason || "submit_failed",
            };
          }
        }

        // A click or HTTP status is not proof of delivery. Confirm the exact
        // recipient appears in LinkedIn's own sent-invitations list.
        await chrome.tabs.update(tab.id, {
          url: "https://www.linkedin.com/mynetwork/invitation-manager/sent/",
        });
        await waitForTabLoad(tab.id, 25_000);
        await new Promise((resolve) => setTimeout(resolve, 2500));
        for (let attempt = 0; attempt < 3; attempt++) {
          const verified = await sendToContentScript(
            tab.id,
            "li:scan-sent-invitations",
            {
              publicIdentifier: slug,
              expectedName: String(msg.expectedName || "").trim(),
            },
            2,
            10_000,
          );
          if (verified?.found === true) {
            return {
              success: true,
              verified: true,
              path: "dom_sent_invitations",
              publicIdentifier: slug,
              matchedBy: verified.matchedBy,
            };
          }
          if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 2000));
        }
        return {
          success: false,
          submitted: true,
          verified: false,
          reason: "sent_invitation_not_found",
          error:
            "Send was clicked, but this person did not appear in LinkedIn's sent invitations after verification",
        };
      } catch (err) {
        return { success: false, error: err.message, reason: "connect_worker_failed" };
      } finally {
        _liConnectTabId = null;
        _liConnectCancelled = false;
        if (workerWindowId != null) {
          try {
            await chrome.windows.remove(workerWindowId);
          } catch {
            /* already closed */
          }
        }
      }
    })();
    _liConnectOperation
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }))
      .finally(() => {
        _liConnectOperation = null;
      });
    return true;
  }
  if (msg.action === "li-send-dm-via-ui") {
    sendDmViaUI(msg)
      .then(sendResponse)
      .catch((err) => sendResponse({ success: false, error: err.message }));
    return true;
  }
  if (msg.action === "li-read-profile-posts-bg") {
    (async () => {
      try {
        const slug = String(msg.publicIdentifier || "")
          .replace(/^\/in\//, "")
          .replace(/\/$/, "");
        if (!slug) return sendResponse({ success: false, error: "No publicIdentifier" });
        const workerTab = await getOrCreateWorkerTab();
        _liWorkerTabId = workerTab.id;
        const url = `https://www.linkedin.com/in/${encodeURIComponent(slug)}/recent-activity/all/`;
        await chrome.tabs.update(workerTab.id, { url });
        await waitForTabLoad(workerTab.id, 20_000);
        await new Promise((resolve) => setTimeout(resolve, 2500));
        const result = await sendToContentScript(
          workerTab.id,
          "li:scan-profile-posts",
          {
            count: Math.max(1, Math.min(5, Number(msg.count) || 3)),
            engage: msg.engage === true,
          },
          3,
          30_000,
        );
        sendResponse(result || { success: false, posts: [] });
      } catch (err) {
        sendResponse({ success: false, error: err.message });
      }
    })();
    return true;
  }
  if (msg.action === "li-view-profile-dom-bg") {
    const slug = String(msg.publicIdentifier || "")
      .replace(/^\/in\//, "")
      .replace(/\/$/, "");
    if (!/^[A-Za-z0-9_-]+$/.test(slug)) {
      sendResponse({ success: false, reason: "invalid_profile_slug" });
      return false;
    }
    (async () => {
      let viewTab = null;
      try {
        const coordinator = await getOrCreateWorkerTab();
        viewTab = await chrome.tabs.create({
          windowId: coordinator.windowId,
          url: `https://www.linkedin.com/in/${encodeURIComponent(slug)}/`,
          active: true,
        });
        await waitForTabLoad(viewTab.id, 25_000);
        await new Promise((resolve) => setTimeout(resolve, 800));
        const result = await sendToContentScript(
          viewTab.id,
          "li:confirm-profile-view",
          { publicIdentifier: slug },
          3,
          20_000,
        );
        sendResponse(result || { success: false, reason: "profile_view_no_response" });
      } catch (err) {
        sendResponse({ success: false, reason: err.message || "profile_view_failed" });
      } finally {
        if (viewTab?.id) {
          try {
            await chrome.tabs.remove(viewTab.id);
          } catch {
            /* already closed */
          }
        }
      }
    })();
    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 {
        if (_liConnectOperation) _liConnectCancelled = true;
        const workerId = await _readWorkerTabId();
        const targets = Array.from(
          new Set([workerId, _liConnectTabId].filter((id) => typeof id === "number")),
        );
        if (targets.length === 0) {
          sendResponse({ ok: false, error: "no worker tab" });
          return;
        }
        await Promise.allSettled(
          targets.map((tabId) =>
            chrome.tabs.sendMessage(tabId, { 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-click") {
    const tabId = sender?.tab?.id;
    if (typeof tabId !== "number") {
      sendResponse({ success: false, error: "no sender tab id" });
      return false;
    }
    cdpClick(tabId, Number(msg.x), Number(msg.y))
      .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:comment") {
    const tab = await navigateLinkedInTab("https://www.linkedin.com/feed/");
    return await sendToContentScript(tab.id, "li:comment", {
      postIndex: Number.isInteger(params.postIndex) ? params.postIndex : 0,
      text: String(params.text || ""),
      useAI: false,
    });
  }

  if (action === "li:view-profile") {
    const slug = String(params.publicIdentifier || "")
      .replace(/^\/in\//, "")
      .replace(/\/$/, "");
    if (!/^[A-Za-z0-9_-]+$/.test(slug)) {
      return { success: false, reason: "invalid_profile_slug" };
    }
    const tab = await navigateLinkedInTab(
      `https://www.linkedin.com/in/${encodeURIComponent(slug)}/`,
    );
    return await sendToContentScript(tab.id, "li:confirm-profile-view", {
      publicIdentifier: slug,
    });
  }

  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?.success === false || result?.error) {
    const error = result?.error || "LinkedIn feed action failed";
    notifyPanel(senderTabId || tab.id, "status", error);
    notifyPanel(senderTabId || tab.id, "log", { icon: "X", text: error });
  } else if (result?.reason === "no_keyword_matches") {
    notifyPanel(senderTabId || tab.id, "status", "No loaded posts matched the keyword filter");
  } else if (result?.reason === "no_unliked_posts") {
    notifyPanel(senderTabId || tab.id, "status", "No unliked posts found in the loaded feed");
  } else 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));
  }

  const commentsEnabled = !!params.commentText && Number(params.commentEveryN) > 0;
  notifyPanel(
    senderTabId || tab.id,
    "status",
    `${commentsEnabled ? "Like + Comment on" : "Liking"} ${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?.success === false || result?.error) {
    const error = result?.error || "LinkedIn feed action failed";
    notifyPanel(senderTabId || tab.id, "status", error);
    notifyPanel(senderTabId || tab.id, "log", { icon: "X", text: error });
  } else if (result?.reason === "no_keyword_matches") {
    notifyPanel(senderTabId || tab.id, "status", "No loaded posts matched the keyword filter");
  } else if (result?.reason === "no_unliked_posts") {
    notifyPanel(senderTabId || tab.id, "status", "No unliked posts found in the loaded feed");
  } else 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.
    //
    // `{ success: false }` is the same thing wearing a different shape, and it
    // was NOT handled: linkedin:http-send-connection returns it when Voyager
    // refuses, so a refused connect logged status "ok" with a result of
    // {"success":false}. Observed live 2026-08-17 on tim-gentry-b391b77 — the
    // row reads as a success and the invite was never sent. A receipt that says
    // ok for work that did not happen is worse than no receipt at all.
    const inlineError =
      data?.error ||
      (data?.success === false ? data?.reason || "voyager returned success:false" : null);
    if (inlineError) {
      const code = liClassifyError(inlineError, { status: res.status });
      await liDone(sentinel, { status: "fail", errorCode: code, errorMessage: inlineError });
    } 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",
    };
  }
}

async function cdpClick(tabId, x, y) {
  if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {
    return { success: false, error: "invalid click coordinates", errorCode: "invalid_coordinates" };
  }
  if (!_cdpAttached.has(tabId)) {
    const attached = await cdpAttach(tabId);
    if (!attached.success) return attached;
  }
  try {
    const target = { x, y, button: "left", clickCount: 1 };
    await chrome.debugger.sendCommand({ tabId }, "Input.dispatchMouseEvent", {
      type: "mouseMoved",
      x,
      y,
    });
    await chrome.debugger.sendCommand({ tabId }, "Input.dispatchMouseEvent", {
      ...target,
      type: "mousePressed",
      buttons: 1,
    });
    await chrome.debugger.sendCommand({ tabId }, "Input.dispatchMouseEvent", {
      ...target,
      type: "mouseReleased",
      buttons: 0,
    });
    return { success: true };
  } catch (err) {
    _cdpAttached.delete(tabId);
    return { success: false, error: err.message || String(err), errorCode: "cdp_click_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 {
      // Deliberate no-op, same as the Luma path above: FEATURE_ACCESS is not
      // always a JWT, so a decode failure means "no identity here" and the
      // slice fallback below is the answer.
    }
  }
  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 {
      // Deliberate no-op: the cookie is not always a JWT, so atob/JSON.parse
      // throwing just means "no identity in here". The slice fallback on the
      // next line is the answer, and reporting a parse error would be noise.
    }
  }
  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 || {},
  },
  "tg-set-limit": {
    method: "POST",
    path: () => `/api/accounts/telegram/limits`,
    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,
  },
  "discover-start": {
    method: "POST",
    path: () => `/api/groups/discover`,
    body: (m) => m.body,
  },
  "discover-status": {
    method: "GET",
    path: (m) => `/api/groups/discover/${m.runId}`,
  },
  "discover-candidates": {
    method: "GET",
    path: (m) => `/api/groups/discover/${m.runId}/candidates${m.queryString || ""}`,
  },
  "discover-decision": {
    method: "POST",
    path: (m) => `/api/groups/discover/candidates/${m.candidateId}/decision`,
    body: (m) => m.body,
  },
  "discover-promote": {
    method: "POST",
    path: (m) => `/api/groups/discover/${m.runId}/promote`,
    body: (m) => m.body,
  },
  "discover-resume": {
    method: "POST",
    path: (m) => `/api/groups/discover/${m.runId}/resume`,
  },
  "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,
  },
  "composition-pending-steps": {
    method: "GET",
    path: (m) => `/api/compositions/${encodeURIComponent(m.id)}/pending-steps`,
  },
  "composition-resume-step": {
    method: "POST",
    path: (m) =>
      `/api/compositions/${encodeURIComponent(m.id)}/pending-steps/${encodeURIComponent(m.stepId)}/resume-now`,
  },

  // 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` },
  // Command-center roster + per-client console (Phase 1 backend).
  "clients-overview": { method: "GET", path: () => `/api/clients/overview` },
  "client-console": {
    method: "GET",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/console`,
  },
  "client-limits": {
    method: "GET",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/limits`,
  },
  "inbound-dm-status": {
    method: "PATCH",
    path: (m) => `/api/inbound-dm/${encodeURIComponent(m.id)}`,
    body: (m) => m.body || {},
  },
  "client-automation-order": {
    method: "PUT",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/automation-order`,
    body: (m) => m.body || {},
  },
  "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 || {},
  },
  // Per-client ICP wizard (Task 9b) — website+objective -> auto-config ->
  // review filters -> preview candidates -> kick scoring, with a pollable job.
  "client-icp-auto-config": {
    method: "POST",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/icp/auto-config`,
    body: (m) => m.body || {},
  },
  "client-icp-generate-filters": {
    method: "POST",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/icp/generate-filters`,
    body: (m) => m.body || {},
  },
  "client-icp-candidates": {
    method: "POST",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/icp/candidates`,
    body: (m) => m.body || {},
  },
  "client-icp-score": {
    method: "POST",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/icp/score`,
    body: (m) => m.body || {},
  },
  "client-icp-job": {
    method: "GET",
    path: (m) => `/api/clients/${encodeURIComponent(m.id)}/icp/jobs/${encodeURIComponent(m.jobId)}`,
  },
  "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" };
}
