const vscode = require('vscode');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createClient } = require('@supabase/supabase-js');
const dotenv = require('dotenv');
const clipboardy = require('clipboardy');
const crypto = require('crypto');
const { exec, execFile } = require('child_process');

let statusBarItem;
let outputChannel;
let rawOutputChannel;
let supabase;
let messageSubscription;
let activityTimer;
let lastActivityTime = 0;
let lastMobileHeartbeatAt = 0;
let ideHeartbeatInterval = null;
let lastConnectionSignal = { key: null, sentAt: 0 };
let reconnectAttempts = 0;
let refreshConnectionInProgress = false;
let reconnectTimer = null;
let globalContext;
let cursorStateWatcher = null;
let cursorMirrorPollInterval = null;
let cursorMirrorProcessTimer = null;
let cursorMirrorProcessing = false;
let cursorMirrorProcessAgain = false;
const activeMirrorJobs = new Map();
const mirroredReplyCache = new Map();
// Queue for mobile prompts to ensure we inject them into Cursor one-by-one.
const promptQueue = [];
let promptQueueProcessing = false;

// [v1.0.4] All logs are local-only — no DB writes.
function remoteLog(event, message, payload = {}, level = 'info') {
    try {
        const emoji = level === 'error' ? '🔴' : level === 'warn' ? '🟡' : '🔹';
        rawOutputChannel?.appendLine(`${emoji} [DEBUG-LOG] ${event}: ${message}`);
        if (payload && Object.keys(payload).length > 0) {
            rawOutputChannel?.appendLine(`   Payload: ${JSON.stringify(payload)}`);
        }
    } catch (e) {
        // Silent fail for logging
    }
}

// Load .env from workspace or extension dir (optional; for dev override)
const envPaths = [
    path.join(process.cwd(), '.env'),
    path.join(__dirname, '..', '.env'),
];
let envPathLoaded = null;
for (const p of envPaths) {
    if (fs.existsSync(p)) {
        dotenv.config({ path: p });
        envPathLoaded = p;
        break;
    }
}

// Built-in Supabase config (same as iOS app). Used when .env has no credentials — so end users work without .env.
const DEFAULT_SUPABASE_URL = 'https://dtaegtkfdwgdbyolcxht.supabase.co';
// Public anon key for Supabase (RLS-protected). Safe to ship in client. secret-detector:ignore
const DEFAULT_SUPABASE_ANON_KEY = 'sb_publishable__giFEuM62kLTFecXfufGIw_a-Dv9y6O';

const URL_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const globalInstanceId = crypto.randomUUID();
const CONNECT_FUNCTION_URL = 'https://mcp.luch.dev/functions/v1/connect';
const TEST_DEVICE_ID = '00000000-0000-0000-0000-000000000001';
const CURSOR_CLIENT_TYPE = 'cursor';
let SOURCE_VERSION = 'cursor-ext/unknown'; // Updated in activate() from packageJSON
const CONNECTION_SIGNAL_DEDUPE_MS = 60000; // [v1.0.170] 60s instead of 30s to reduce reconnect spam
const CURSOR_STATE_DEBOUNCE_MS = 100;
const CURSOR_MIRROR_POLL_MS = 500;
const CURSOR_MIRROR_STABLE_MS = 1200;
const CURSOR_MIRROR_MAX_LIFETIME_MS = 3600000; // 60 minutes — Cursor can generate for a very long time
const CURSOR_MIRROR_RECENT_ROWS_LIMIT = 250;
const CURSOR_MIRROR_CONVERSATION_ROWS_LIMIT = 80;
const CURSOR_MIRROR_PROGRESS_LOG_MS = 5000;
const CURSOR_MIRROR_JOB_INTERVAL_MS = 500;
const CURSOR_MIRROR_MAX_PROMPT_SCANS = 25;
const CURSOR_MIRROR_MAX_ASSISTANT_SCANS = 60; // More scans for long responses
const MIRRORED_REPLY_CACHE_LIMIT = 200;

// [v1.0.150] Deduplication: prevent processing same mobile message twice (iOS retries)
const processedMessageIds = new Map(); // Map<id, timestamp> for ordered eviction

function generateUuidV5(name, namespace) {
    const namespaceBytes = Buffer.from(namespace.replace(/-/g, ''), 'hex');
    const nameBytes = Buffer.from(name, 'utf8');
    const hash = crypto.createHash('sha1').update(namespaceBytes).update(nameBytes).digest();

    hash[6] = (hash[6] & 0x0f) | 0x50; // version 5
    hash[8] = (hash[8] & 0x3f) | 0x80; // variant 1

    const hex = hash.toString('hex');
    return `${hex.substr(0, 8)}-${hex.substr(8, 4)}-${hex.substr(12, 4)}-${hex.substr(16, 4)}-${hex.substr(20, 12)}`;
}

function determineSessionFromChat(chatText) {
    const firstUserMatch = chatText.match(/User:\s*([\s\S]*?)(?=\n*Antigravity Agent:|\n*Agent:|\n*GitHub Copilot:|$)/i);
    if (!firstUserMatch || !firstUserMatch[1]) return null;

    const firstPrompt = firstUserMatch[1].trim();
    const title = firstPrompt.length > 50 ? firstPrompt.substring(0, 47) + '...' : firstPrompt;
    const sessionId = generateUuidV5(firstPrompt, URL_NAMESPACE);

    return { sessionId, title };
}

function shouldShowOutputLine(line) {
    const text = String(line || '');
    const trimmed = text.trimStart();

    if (!trimmed) return false;
    // Чистый пользовательский лог: только ключевые события.
    if (trimmed.startsWith('[USER PROMPT]')) return true;
    if (trimmed.startsWith('[AGENT REPLY]')) return true;
    if (trimmed.startsWith('[ERROR]')) return true;
    if (trimmed.startsWith('[SIGNAL]')) return true;
    if (trimmed.startsWith('[WIRE]')) return true; // точный JSON, который получает iOS через Supabase
    if (trimmed.startsWith('[DEBUG]')) return true;
    if (trimmed.startsWith('[MODEL-WATCH]')) return true;
    if (trimmed.startsWith('[LM]')) return true;
    if (trimmed.startsWith('[PROMPT]')) return true;
    if (trimmed.startsWith('🚀')) return true; // VERSION line
    if (trimmed.startsWith('====')) return true;
    if (trimmed.startsWith('[CONNECT]')) return true;
    // Прокси-ошибки тоже полезно видеть.
    if (trimmed.startsWith('[PROXY]') && /error|failed|closed/i.test(trimmed)) return true;
    if (trimmed.startsWith('[MIRROR]')) return true;
    if (trimmed.startsWith('[REPLY]')) return true;
    if (trimmed.startsWith('[QUEUE]')) return true;
    return false;
}

function installCleanOutputFilter(channel) {
    const originalAppendLine = channel.appendLine.bind(channel);
    channel.appendLine = (line) => {
        if (shouldShowOutputLine(line)) {
            originalAppendLine(line);
        }
    };
    return channel;
}

function appendChatPrompt(content, model) {
    const text = normalizeMirrorText(content);
    if (!text || !outputChannel) return;
    const modelStr = model ? ` | Model: ${model}` : '';
    outputChannel.appendLine(`\n[USER PROMPT] ========================================`);
    outputChannel.appendLine(`[USER PROMPT] Content:${modelStr}`);
    for (const line of text.split('\n')) {
        outputChannel.appendLine(`[USER PROMPT] ${line}`);
    }
    outputChannel.appendLine(`[USER PROMPT] ========================================\n`);
    outputChannel.appendLine(`[DEBUG] appendChatPrompt finished.`);
}

function isUuid(value) {
    return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || ''));
}


function getLinkedOrTestToken() {
    return globalContext?.globalState?.get('mobile_token') || TEST_DEVICE_ID;
}

function buildCursorMagicLink(token) {
    const params = new URLSearchParams({
        token: String(token || ''),
        ide: 'cursor'
    });
    return `${CONNECT_FUNCTION_URL}?${params.toString()}`;
}

function buildCursorTestSql(token) {
    const safeToken = String(token || '').replace(/'/g, "''");
    const sessionValue = isUuid(token) ? `'${safeToken}'::uuid` : 'NULL';
    return [
        'insert into public.messages (user_id, role, content, status, session_id, client_type)',
        `values ('${safeToken}', 'user', 'Test from Supabase SQL editor', 'pending', ${sessionValue}, 'cursor');`
    ].join('\n');
}

function hashText(text) {
    return crypto.createHash('sha1').update(String(text || ''), 'utf8').digest('hex');
}

function normalizeMirrorText(text) {
    return String(text || '').replace(/\r\n/g, '\n').trim();
}

function getCursorStateDbPath() {
    if (process.platform === 'darwin') {
        return path.join(os.homedir(), 'Library/Application Support/Cursor/User/globalStorage/state.vscdb');
    }
    if (process.platform === 'win32') {
        const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
        return path.join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb');
    }
    return path.join(os.homedir(), '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
}

function getCursorStatePaths() {
    const dbPath = getCursorStateDbPath();
    return {
        dbPath,
        walPath: `${dbPath}-wal`,
        shmPath: `${dbPath}-shm`,
        dirPath: path.dirname(dbPath)
    };
}

function execFileAsync(command, args, options = {}) {
    return new Promise((resolve, reject) => {
        execFile(command, args, options, (err, stdout, stderr) => {
            if (err) {
                const wrapped = new Error(stderr || err.message);
                wrapped.stdout = stdout;
                wrapped.stderr = stderr;
                reject(wrapped);
                return;
            }
            resolve(stdout);
        });
    });
}

function copyIfExists(sourcePath, targetPath) {
    if (fs.existsSync(sourcePath)) {
        fs.copyFileSync(sourcePath, targetPath);
    }
}

function createCursorStateSnapshot() {
    const { dbPath, walPath, shmPath } = getCursorStatePaths();
    if (!fs.existsSync(dbPath)) {
        throw new Error(`Cursor DB not found: ${dbPath}`);
    }

    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-cursor-state-'));
    const snapshotDbPath = path.join(tempDir, 'state.vscdb');
    try {
        // Copy db + WAL + SHM files for a snapshot that includes uncommitted WAL data.
        // The old `.backup` approach produced a clean DB but missed in-flight WAL writes,
        // which is where Cursor stores the latest streaming text.
        fs.copyFileSync(dbPath, snapshotDbPath);
        if (fs.existsSync(walPath)) {
            fs.copyFileSync(walPath, snapshotDbPath + '-wal');
        }
        if (fs.existsSync(shmPath)) {
            fs.copyFileSync(shmPath, snapshotDbPath + '-shm');
        }
    } catch (e) {
        try {
            fs.rmSync(tempDir, { recursive: true, force: true });
        } catch (_) { }
        throw e;
    }

    return {
        snapshotDbPath,
        cleanup() {
            try {
                fs.rmSync(tempDir, { recursive: true, force: true });
            } catch (_) { }
        }
    };
}

function execFileSyncCompat(command, args, options = {}) {
    const { execFileSync } = require('child_process');
    return execFileSync(command, args, options);
}

async function runSqliteJsonQuery(dbPath, sql) {
    const stdout = await execFileAsync('sqlite3', ['-json', dbPath, sql], {
        maxBuffer: 20 * 1024 * 1024,
        timeout: 5000
    });
    const trimmed = String(stdout || '').trim();
    if (!trimmed) return [];
    try {
        const parsed = JSON.parse(trimmed);
        return Array.isArray(parsed) ? parsed : [];
    } catch (e) {
        outputChannel?.appendLine(`[MIRROR] Failed to parse sqlite JSON output: ${e.message}`);
        return [];
    }
}

async function getCursorStateMaxBubbleRowIdFromSnapshot(snapshotDbPath) {
    const rows = await runSqliteJsonQuery(
        snapshotDbPath,
        `SELECT IFNULL(MAX(rowid), 0) AS maxRowId FROM cursorDiskKV WHERE key GLOB 'bubbleId:*'`
    );
    return Number(rows[0]?.maxRowId || 0);
}

async function getCursorStateMaxBubbleRowId() {
    let snapshot;
    try {
        snapshot = createCursorStateSnapshot();
        return await getCursorStateMaxBubbleRowIdFromSnapshot(snapshot.snapshotDbPath);
    } catch (e) {
        outputChannel?.appendLine(`[MIRROR] Unable to read Cursor DB watermark: ${e.message}`);
        return 0;
    } finally {
        snapshot?.cleanup();
    }
}

async function queryRecentBubbleRows(snapshotDbPath, limit) {
    const safeLimit = Math.max(1, Number(limit) || CURSOR_MIRROR_RECENT_ROWS_LIMIT);
    return runSqliteJsonQuery(
        snapshotDbPath,
        `SELECT rowid, key, json_extract(value, '$.type') AS type, json_extract(value, '$.text') AS text, json_extract(value, '$.turnDurationMs') AS duration, json_extract(value, '$.createdAt') AS createdAt FROM cursorDiskKV WHERE key GLOB 'bubbleId:*' ORDER BY rowid DESC LIMIT ${safeLimit}`
    );
}

async function queryBubbleRowsAfterRowId(snapshotDbPath, rowidWatermark, limit) {
    const safeWatermark = Math.max(0, Number(rowidWatermark) || 0);
    const safeLimit = Math.max(1, Number(limit) || CURSOR_MIRROR_RECENT_ROWS_LIMIT);
    return runSqliteJsonQuery(
        snapshotDbPath,
        `SELECT rowid, key, json_extract(value, '$.type') AS type, json_extract(value, '$.text') AS text, json_extract(value, '$.turnDurationMs') AS duration, json_extract(value, '$.createdAt') AS createdAt FROM cursorDiskKV WHERE key GLOB 'bubbleId:*' AND rowid > ${safeWatermark} ORDER BY rowid ASC LIMIT ${safeLimit}`
    );
}

async function queryConversationBubbleRows(snapshotDbPath, conversationId, limit, minRowId = 0) {
    const safeConversationId = String(conversationId || '').replace(/'/g, "''");
    const safeLimit = Math.max(1, Number(limit) || CURSOR_MIRROR_CONVERSATION_ROWS_LIMIT);
    const safeMinRowId = Math.max(0, Number(minRowId) || 0);
    const rowFilter = safeMinRowId > 0 ? ` AND rowid >= ${safeMinRowId}` : '';
    return runSqliteJsonQuery(
        snapshotDbPath,
        `SELECT rowid, key, json_extract(value, '$.type') AS type, json_extract(value, '$.text') AS text, json_extract(value, '$.turnDurationMs') AS duration, json_extract(value, '$.createdAt') AS createdAt FROM cursorDiskKV WHERE key GLOB 'bubbleId:${safeConversationId}:*'${rowFilter} ORDER BY rowid DESC LIMIT ${safeLimit}`
    );
}

function buildMirroredReplyCacheKey(msg, sessionId, content) {
    const userId = msg?.user_id || 'unknown-user';
    const originalMessageId = msg?.id || 'no-message-id';
    const normalizedContent = normalizeMirrorText(content);
    const finalSessionId = sessionId || 'no-session-id';
    return `${userId}:${originalMessageId}:${finalSessionId}:${hashText(normalizedContent)}`;
}

function hasMirroredReplyRecently(cacheKey) {
    const entry = mirroredReplyCache.get(cacheKey);
    if (!entry) return false;
    if (Date.now() - entry.sentAt > CURSOR_MIRROR_MAX_LIFETIME_MS) {
        mirroredReplyCache.delete(cacheKey);
        return false;
    }
    return true;
}

function rememberMirroredReply(cacheKey) {
    mirroredReplyCache.set(cacheKey, { sentAt: Date.now() });
    if (mirroredReplyCache.size <= MIRRORED_REPLY_CACHE_LIMIT) return;

    const oldestKey = mirroredReplyCache.keys().next().value;
    if (oldestKey) {
        mirroredReplyCache.delete(oldestKey);
    }
}

// [v1.0.142] Periodic TTL cleanup for mirroredReplyCache
// Moved to activate() for proper cleanup in deactivate()
let _mirrorCacheCleanupInterval = null;

function mirrorJobElapsedMs(job) {
    return Math.max(0, Date.now() - Number(job.createdAt || Date.now()));
}

function markMirrorJobStage(job, stage, reason) {
    if (!job) return;
    if (job.stage !== stage) {
        job.stage = stage;
        outputChannel?.appendLine(
            `[MIRROR] Stage msg=${job.originalMessageId}: ${stage}${reason ? ` (${reason})` : ''}`
        );
    }
}

function logMirrorJobProgressIfNeeded(job, details) {
    const now = Date.now();
    const lastAt = Number(job.lastProgressLogAt || 0);
    if (now - lastAt < CURSOR_MIRROR_PROGRESS_LOG_MS) return;
    job.lastProgressLogAt = now;

    const parts = [];
    if (job.conversationId) parts.push(`conv=${job.conversationId.substring(0, 8)}...`);
    if (job.userBubbleRowId) parts.push(`userRow=${job.userBubbleRowId}`);
    if (job.assistantBubbleId) parts.push(`assistantBubble=${job.assistantBubbleId.substring(0, 8)}...`);
    if (job.assistantTextHash) parts.push(`assistantLen=${job.assistantText.length}`);
    if (details) parts.push(details);

    outputChannel?.appendLine(
        `[MIRROR] Progress msg=${job.originalMessageId}, stage=${job.stage || 'unknown'}, elapsed=${mirrorJobElapsedMs(job)}ms${parts.length ? `, ${parts.join(', ')}` : ''}`
    );
}

function parseBubbleRow(row) {
    if (!row || !row.key) return null;
    const key = String(row.key);
    const parts = key.split(':');
    if (parts.length < 3) return null;

    return {
        rowid: Number(row.rowid || 0),
        key,
        conversationId: parts[1],
        bubbleId: parts.slice(2).join(':'),
        bubble: {
            type: Number(row.type),
            text: typeof row.text === 'string' ? row.text : '',
            duration: row.duration !== undefined && row.duration !== null ? Number(row.duration) : null,
            createdAtMs: row.createdAt ? new Date(row.createdAt).getTime() : 0
        }
    };
}

function findPromptBubble(rows, job) {
    const promptText = job.promptNormalized;
    const cleanPrompt = promptText.replace(/\s+/g, ' ').trim();

    for (const row of rows) {
        const parsed = parseBubbleRow(row);
        if (!parsed || parsed.rowid <= job.rowidWatermark) continue;
        if (parsed.bubble?.type !== 1) continue;

        const bubbleText = normalizeMirrorText(parsed.bubble?.text);
        if (!bubbleText) continue;

        const cleanBubble = bubbleText.replace(/\s+/g, ' ').trim();

        // Exact match on normalized text
        if (cleanBubble === cleanPrompt) return parsed;

        // Substring match if prompt is long enough
        if (cleanPrompt.length > 10 && (cleanBubble.includes(cleanPrompt) || cleanPrompt.includes(cleanBubble))) {
            return parsed;
        }
    }
    return null;
}

function pickAssistantBubble(rows, job, excludeBubbleIds = []) {
    if (!rows || !rows.length) return null;

    // Determine the upper bound for this job: the next user bubble after this job's userBubbleRowId.
    let upperRowLimit = 0;
    for (const row of rows) {
        const parsed = parseBubbleRow(row);
        if (!parsed) continue;
        if (parsed.bubble?.type !== 1) continue; // user bubble
        if (!job.userBubbleRowId) continue;
        if (parsed.rowid > job.userBubbleRowId) {
            if (!upperRowLimit || parsed.rowid < upperRowLimit) {
                upperRowLimit = parsed.rowid;
            }
        }
    }

    const orderedRows = [...rows].sort((a, b) => {
        const tA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
        const tB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
        if (tA && tB && tA !== tB) return tB - tA; // DESC by time
        return Number(b.rowid || 0) - Number(a.rowid || 0); // DESC by rowid fallback
    });
    for (const row of orderedRows) {
        const parsed = parseBubbleRow(row);
        if (!parsed) continue;
        if (parsed.bubble?.type !== 2) continue; // assistant bubble
        if (job.userBubbleRowId && parsed.rowid <= job.userBubbleRowId) continue;
        // If there is another user bubble after this job's userBubbleRowId,
        // do not let this job "claim" assistant messages that belong to the next prompt.
        if (upperRowLimit && parsed.rowid >= upperRowLimit) continue;
        // [v1.0.152] Skip already-sent bubbles during continuation phase
        if (excludeBubbleIds.length > 0 && excludeBubbleIds.includes(parsed.bubbleId)) continue;

        const text = normalizeMirrorText(parsed.bubble?.text);
        if (!text) continue;

        return {
            ...parsed,
            text,
            duration: parsed.bubble?.duration
        };
    }
    return null;
}

// [v1.0.152] Collect ALL assistant bubbles for a job (used for continuation merge)
function collectAllAssistantBubbleTexts(rows, job) {
    if (!rows || !rows.length) return [];

    let upperRowLimit = 0;
    for (const row of rows) {
        const parsed = parseBubbleRow(row);
        if (!parsed) continue;
        if (parsed.bubble?.type !== 1) continue;
        if (!job.userBubbleRowId) continue;
        if (parsed.rowid > job.userBubbleRowId) {
            if (!upperRowLimit || parsed.rowid < upperRowLimit) {
                upperRowLimit = parsed.rowid;
            }
        }
    }

    const results = [];
    const orderedRows = [...rows].sort((a, b) => {
        const tA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
        const tB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
        if (tA && tB && tA !== tB) return tA - tB; // ASC by time
        return Number(a.rowid || 0) - Number(b.rowid || 0); // ASC by rowid fallback
    });
    for (const row of orderedRows) {
        const parsed = parseBubbleRow(row);
        if (!parsed) continue;
        if (parsed.bubble?.type !== 2) continue;
        if (job.userBubbleRowId && parsed.rowid <= job.userBubbleRowId) continue;
        if (upperRowLimit && parsed.rowid >= upperRowLimit) continue;

        const text = normalizeMirrorText(parsed.bubble?.text);
        if (!text) continue;

        results.push({
            bubbleId: parsed.bubbleId,
            text,
            rowid: parsed.rowid,
            duration: parsed.bubble?.duration
        });
    }
    return results;
}

function ensureCursorMirrorMonitoring() {
    const { dirPath } = getCursorStatePaths();
    if (!cursorStateWatcher) {
        try {
            cursorStateWatcher = fs.watch(dirPath, (eventType, filename) => {
                const name = String(filename || '');
                if (!name.startsWith('state.vscdb')) return;
                scheduleCursorMirrorProcessing(`fs:${eventType}:${name}`, CURSOR_STATE_DEBOUNCE_MS);
            });
            cursorStateWatcher.on('error', (err) => {
                outputChannel?.appendLine(`[MIRROR] state watcher error: ${err.message}`);
            });
            outputChannel?.appendLine(`[MIRROR] Watching Cursor state in ${dirPath}`);
        } catch (e) {
            outputChannel?.appendLine(`[MIRROR] Unable to start state watcher: ${e.message}`);
        }
    }

    if (!cursorMirrorPollInterval) {
        cursorMirrorPollInterval = setInterval(() => {
            if (activeMirrorJobs.size > 0) {
                scheduleCursorMirrorProcessing('poll', 0);
            }
        }, CURSOR_MIRROR_POLL_MS);
        outputChannel?.appendLine(`[MIRROR] Poll watchdog enabled (${CURSOR_MIRROR_POLL_MS}ms)`);
    }
}

function triggerImmediateMirrorProcessing(reason) {
    if (activeMirrorJobs.size === 0) return;
    setImmediate(() => {
        processActiveMirrorJobs(reason).catch((e) => {
            outputChannel?.appendLine(`[MIRROR] Immediate processing failure (${reason}): ${e.message}`);
        });
    });
}

function stopCursorMirrorMonitoringIfIdle() {
    if (activeMirrorJobs.size > 0) return;

    if (cursorMirrorProcessTimer) {
        clearTimeout(cursorMirrorProcessTimer);
        cursorMirrorProcessTimer = null;
    }
    if (cursorMirrorPollInterval) {
        clearInterval(cursorMirrorPollInterval);
        cursorMirrorPollInterval = null;
    }
    if (cursorStateWatcher) {
        try {
            cursorStateWatcher.close();
        } catch (_) { }
        cursorStateWatcher = null;
    }
}

function scheduleCursorMirrorProcessing(reason, delayMs = CURSOR_STATE_DEBOUNCE_MS) {
    if (activeMirrorJobs.size === 0) return;
    const safeDelay = Math.max(0, Number(delayMs) || 0);

    // For immediate triggers, avoid clearing/restarting endlessly: one queued run is enough.
    if (safeDelay === 0) {
        if (cursorMirrorProcessTimer) return;
        cursorMirrorProcessTimer = setTimeout(() => {
            cursorMirrorProcessTimer = null;
            processActiveMirrorJobs(reason).catch((e) => {
                outputChannel?.appendLine(`[MIRROR] Processing failure: ${e.message}`);
            });
        }, 0);
        return;
    }

    // Debounced watcher trigger: schedule only if no timer is currently queued.
    // This prevents starvation when fs.watch emits many events in a burst.
    if (cursorMirrorProcessTimer) return;
    cursorMirrorProcessTimer = setTimeout(() => {
        cursorMirrorProcessTimer = null;
        processActiveMirrorJobs(reason).catch((e) => {
            outputChannel?.appendLine(`[MIRROR] Processing failure: ${e.message}`);
        });
    }, safeDelay);
}

function createMirrorJob({ userId, sessionId, originalMessageId, promptText, rowidWatermark, requestedModel }) {
    const now = Date.now();
    return {
        userId,
        sessionId,
        originalMessageId,
        promptText: String(promptText || ''),
        promptNormalized: normalizeMirrorText(promptText),
        promptHash: hashText(normalizeMirrorText(promptText)),
        rowidWatermark: Number(rowidWatermark || 0),
        createdAt: now,
        expiresAt: now + CURSOR_MIRROR_MAX_LIFETIME_MS,
        conversationId: null,
        userBubbleId: null,
        userBubbleRowId: 0,
        assistantBubbleId: null,
        assistantText: '',
        assistantTextHash: null,
        assistantLastChangedAt: 0,
        supabaseRecordId: null, // Track the inserted row ID for streaming UPDATEs
        lastStreamUpdateAt: 0,  // Throttle updates
        finalized: false,
        stage: 'queued',
        lastProgressLogAt: 0,
        promptScanAttempts: 0,
        assistantScanAttempts: 0,
        bootstrapTimers: [],
        intervalHandle: null,
        requestedModel: requestedModel || null
    };
}

function scheduleMirrorJobBootstrap(job) {
    // Poll aggressively in the first few seconds, then fall back to the regular interval.
    const bootstrapDelays = [0, 150, 400, 800, 1400, 2200, 3500];
    job.bootstrapTimers = bootstrapDelays.map((delay) => setTimeout(() => {
        if (!activeMirrorJobs.has(job.originalMessageId)) return;
        processActiveMirrorJobs(`bootstrap-${delay}ms`).catch((e) => {
            outputChannel?.appendLine(`[MIRROR] Bootstrap processing failure (${delay}ms): ${e.message}`);
        });
    }, delay));
}

function startCursorMirrorJob(params) {
    const job = createMirrorJob(params);
    activeMirrorJobs.set(job.originalMessageId, job);
    outputChannel?.appendLine(
        `[MIRROR] Tracking reply for msg=${job.originalMessageId}, watermark=${job.rowidWatermark}, promptHash=${job.promptHash.substring(0, 8)}`
    );
    markMirrorJobStage(job, 'waiting_for_prompt_bubble');
    ensureCursorMirrorMonitoring();
    scheduleMirrorJobBootstrap(job);
    job.intervalHandle = setInterval(() => {
        if (!activeMirrorJobs.has(job.originalMessageId)) return;
        processActiveMirrorJobs(`job-interval-${CURSOR_MIRROR_JOB_INTERVAL_MS}ms`).catch((e) => {
            outputChannel?.appendLine(`[MIRROR] Job interval processing failure: ${e.message}`);
        });
    }, CURSOR_MIRROR_JOB_INTERVAL_MS);
    outputChannel?.appendLine(`[MIRROR] Job interval enabled for msg=${job.originalMessageId} (${CURSOR_MIRROR_JOB_INTERVAL_MS}ms)`);
    processActiveMirrorJobs('job-start-inline').catch((e) => {
        outputChannel?.appendLine(`[MIRROR] Inline processing failure (job-start-inline): ${e.message}`);
    });
    triggerImmediateMirrorProcessing('job-start-immediate');
    scheduleCursorMirrorProcessing('job-start', 0);
}

function finalizeMirrorJob(originalMessageId) {
    const job = activeMirrorJobs.get(originalMessageId);
    if (job?.bootstrapTimers?.length) {
        for (const timer of job.bootstrapTimers) {
            clearTimeout(timer);
        }
    }
    if (job?.intervalHandle) {
        clearInterval(job.intervalHandle);
    }
    activeMirrorJobs.delete(originalMessageId);
    stopCursorMirrorMonitoringIfIdle();
}

async function processSingleMirrorJob(job, context) {
    const now = context.now;
    const timedOutWithoutAssistantText = now >= job.expiresAt && !job.assistantText;
    // [v1.0.171] Reduced verbosity — only log stage transitions
    remoteLog('mirror_process', `Processing job stage=${job.stage}`, {
        msgId: job.originalMessageId,
        stage: job.stage,
        trigger: context.reason
    });
    logMirrorJobProgressIfNeeded(job, `trigger=${context.reason}`);

    if (!job.conversationId) {
        let promptRows = context.promptRowsCache.get(job.rowidWatermark);
        if (!promptRows) {
            promptRows = await queryBubbleRowsAfterRowId(
                context.snapshotDbPath,
                job.rowidWatermark,
                CURSOR_MIRROR_RECENT_ROWS_LIMIT
            );
            context.promptRowsCache.set(job.rowidWatermark, promptRows);
        }

        let promptBubble = findPromptBubble(promptRows, job);
        outputChannel?.appendLine(
            `[MIRROR] promptRows msg=${job.originalMessageId}: count=${promptRows.length}, watermark=${job.rowidWatermark}`
        );
        if (!promptBubble && timedOutWithoutAssistantText) {
            const expandedPromptRows = await queryBubbleRowsAfterRowId(
                context.snapshotDbPath,
                job.rowidWatermark,
                CURSOR_MIRROR_RECENT_ROWS_LIMIT * 6
            );
            if (expandedPromptRows.length > promptRows.length) {
                promptRows = expandedPromptRows;
                context.promptRowsCache.set(job.rowidWatermark, promptRows);
                promptBubble = findPromptBubble(promptRows, job);
                outputChannel?.appendLine(
                    `[MIRROR] Expanded prompt scan for msg=${job.originalMessageId} to ${expandedPromptRows.length} rows`
                );
            }
        }
        if (!promptBubble) {
            job.promptScanAttempts = (job.promptScanAttempts || 0) + 1;

            // Safety valve: if Cursor DB never shows the prompt bubble (e.g. schema changed
            // or chat logging is disabled), stop hammering sqlite snapshots to avoid
            // freezing the IDE. In this edge case we also notify the mobile side that
            // Cursor did not accept the prompt (so the user doesn't wait forever).
            if (job.promptScanAttempts >= CURSOR_MIRROR_MAX_PROMPT_SCANS) {
                outputChannel?.appendLine(
                    `[MIRROR] Giving up on msg=${job.originalMessageId}; reason=no_prompt_bubble_after_${job.promptScanAttempts}_scans`
                );
                remoteLog(
                    'mirror_aborted_no_prompt',
                    'Aborted mirroring: prompt bubble never appeared in Cursor DB',
                    { msgId: job.originalMessageId, scans: job.promptScanAttempts },
                    'warn'
                );

                // Best-effort: send a clear error reply back to mobile so the user
                // understands that the IDE never recorded this prompt.
                try {
                    const safePreview = (job.promptText || '').slice(0, 200);
                    const errorText =
                        'Cursor IDE did not accept the last prompt. The message was not recorded in the chat history.' +
                        (safePreview
                            ? `\n\nYou can retry by sending it again or pasting this text manually in Cursor:\n\n\"${safePreview}\"`
                            : '');
                    sendAgentReplyToMobile(job, job.sessionId, errorText, 'error').catch((e) => {
                        outputChannel?.appendLine(
                            `[MIRROR] Failed to send delivery-error reply to mobile: ${e.message}`
                        );
                    });
                } catch (e) {
                    outputChannel?.appendLine(
                        `[MIRROR] Error while notifying mobile about no-prompt: ${e.message}`
                    );
                }

                // [v1.0.137] Mark user message completed on error so iOS unblocks input
                try {
                    if (job.originalMessageId && supabase) {
                        await supabase.from('messages').update({ status: 'completed' }).eq('id', job.originalMessageId);
                    }
                } catch (_) {}

                finalizeMirrorJob(job.originalMessageId);
                return;
            }

            markMirrorJobStage(job, 'waiting_for_prompt_bubble');
            logMirrorJobProgressIfNeeded(job, `rowsAfterWatermark=${promptRows.length}`);
            if (timedOutWithoutAssistantText) {
                outputChannel?.appendLine(
                    `[MIRROR] Timeout waiting for assistant reply for msg=${job.originalMessageId}; reason=no_assistant_text_after_timeout`
                );

                finalizeMirrorJob(job.originalMessageId);
            }
            return;
        }

        job.conversationId = promptBubble.conversationId;
        job.userBubbleId = promptBubble.bubbleId;
        job.userBubbleRowId = promptBubble.rowid;
        markMirrorJobStage(job, 'conversation_linked');
        outputChannel?.appendLine(
            `[MIRROR] Linked msg=${job.originalMessageId} to conversation ${job.conversationId.substring(0, 8)}...`
        );
    }

    const conversationCacheKey = `${job.conversationId}:${job.userBubbleRowId || 0}`;
    // Skip cache when actively streaming — we need fresh data every poll
    const isActivelyStreaming = job.stage === 'assistant_streaming' || job.stage === 'waiting_for_stability';
    let conversationRows = isActivelyStreaming ? null : context.conversationRowsCache.get(conversationCacheKey);
    if (!conversationRows) {
        conversationRows = await queryConversationBubbleRows(
            context.snapshotDbPath,
            job.conversationId,
            CURSOR_MIRROR_CONVERSATION_ROWS_LIMIT,
            job.userBubbleRowId || 0
        );
        context.conversationRowsCache.set(conversationCacheKey, conversationRows);
    }
    // conversationRows logging removed for brevity

    // Fallback: if the default window is too small (lots of tool updates),
    // expand scan window while still anchoring at user bubble row.
    if (conversationRows.length >= CURSOR_MIRROR_CONVERSATION_ROWS_LIMIT || timedOutWithoutAssistantText) {
        const hasAssistantWithTextAfterUser = conversationRows.some((row) => {
            const parsed = parseBubbleRow(row);
            if (!parsed) return false;
            if (parsed.rowid < (job.userBubbleRowId || 0)) return false;
            if (parsed.bubble?.type !== 2) return false;
            const text = normalizeMirrorText(parsed.bubble?.text);
            return text.length > 0;
        });

        if (!hasAssistantWithTextAfterUser) {
            const expandedRows = await queryConversationBubbleRows(
                context.snapshotDbPath,
                job.conversationId,
                CURSOR_MIRROR_CONVERSATION_ROWS_LIMIT * 12,
                job.userBubbleRowId || 0
            );
            if (expandedRows.length > conversationRows.length) {
                conversationRows = expandedRows;
                context.conversationRowsCache.set(conversationCacheKey, conversationRows);
                outputChannel?.appendLine(
                    `[MIRROR] Expanded conversation scan for msg=${job.originalMessageId} to ${expandedRows.length} rows`
                );
            }
        }
    }

    const assistantBubble = pickAssistantBubble(conversationRows, job);
    if (assistantBubble) {
        outputChannel?.appendLine(
            `[MIRROR] assistantCandidate msg=${job.originalMessageId}: row=${assistantBubble.rowid}, len=${assistantBubble.text.length}`
        );
    }
    if (!assistantBubble) {
        job.assistantScanAttempts = (job.assistantScanAttempts || 0) + 1;

        // [v1.0.153] Check if Cursor is actively working (code ops, tool calls, etc.)
        // If there are ANY rows after user bubble, Cursor is generating code — don't give up
        const hasAnyActivityAfterUser = conversationRows.some(r => {
            const parsed = parseBubbleRow(r);
            return parsed && parsed.rowid > (job.userBubbleRowId || 0);
        });
        
        if (hasAnyActivityAfterUser) {
            // Cursor is working (generating code, etc.) but hasn't written text yet — keep waiting
            job.assistantScanAttempts = 0; // Reset scan counter — Cursor IS working
            if ((job.assistantScanAttempts || 0) % 20 === 0 || job.assistantScanAttempts === 0) {
                // Waiting silently for assistant bubble
            }
            markMirrorJobStage(job, 'waiting_for_assistant_bubble');
            return;
        }

        // Safety valve: если Cursor не создаёт ассистентский bubble И нет активности,
        // не продолжаем бесконечно переснимать sqlite — просто отказываемся от зеркалирования этого ответа.
        if (job.assistantScanAttempts >= CURSOR_MIRROR_MAX_ASSISTANT_SCANS || now >= job.expiresAt) {
            outputChannel?.appendLine(
                `[MIRROR] Giving up on msg=${job.originalMessageId}; reason=no_assistant_bubble_after_${job.assistantScanAttempts}_scans`
            );
            remoteLog(
                'mirror_aborted_no_assistant',
                'Aborted mirroring: assistant bubble never appeared in Cursor DB',
                {
                    msgId: job.originalMessageId,
                    scans: job.assistantScanAttempts,
                    hasConversation: !!job.conversationId
                },
                'warn'
            );

            // [v1.0.143] Send error to mobile so user doesn't wait forever
            try {
                const errorText = 'Cursor IDE processed the prompt but the response could not be captured. Please try again.';
                sendAgentReplyToMobile(job, job.sessionId, errorText, 'error').catch((e) => {
                    outputChannel?.appendLine(`[MIRROR] Failed to send no-assistant error: ${e.message}`);
                });
            } catch (_) {}

            // Mark user message completed so iOS unblocks input
            try {
                if (job.originalMessageId && supabase) {
                    await supabase.from('messages').update({ status: 'completed' }).eq('id', job.originalMessageId);
                }
            } catch (_) {}

            finalizeMirrorJob(job.originalMessageId);
            return;
        }

        markMirrorJobStage(job, 'waiting_for_assistant_bubble');
        logMirrorJobProgressIfNeeded(job, `conversationRows=${conversationRows.length}`);
        return;
    }

    // Once we know the bubble ID, read text from the LIVE database (not snapshot).
    // Snapshots miss WAL data, causing text to appear truncated.
    if (assistantBubble && job.conversationId) {
        const bubbleKey = `bubbleId:${String(job.conversationId).replace(/'/g, "''")}:${String(assistantBubble.bubbleId).replace(/'/g, "''")}`;
        try {
            const { dbPath } = getCursorStatePaths();
            // [v1.0.120] Using json_object for bulletproof parsing of multiline/special-char text
            const sql = `SELECT json_object('t', json_extract(value, '$.text'), 'd', json_extract(value, '$.turnDurationMs')) FROM cursorDiskKV WHERE key = '${bubbleKey}' LIMIT 1`;
            const liveResult = execFileSyncCompat('sqlite3', [dbPath, '.timeout 2000', sql], {
                timeout: 4000,
                maxBuffer: 10 * 1024 * 1024,
                encoding: 'utf8'
            });
            const trimmed = (liveResult || '').trim();
            if (trimmed) {
                try {
                    const parsedLive = JSON.parse(trimmed);
                    const liveText = typeof parsedLive.t === 'string' ? parsedLive.t : '';
                    const liveDuration = parsedLive.d !== undefined && parsedLive.d !== null ? Number(parsedLive.d) : null;

                    if (liveText.length > assistantBubble.text.length) {
                        outputChannel?.appendLine(
                            `[MIRROR] Live DB override: snapshot=${assistantBubble.text.length} chars → live=${liveText.length} chars`
                        );
                        assistantBubble.text = liveText;
                        assistantBubble.duration = liveDuration;
                    } else if (liveDuration !== null && assistantBubble.duration === null) {
                        assistantBubble.duration = liveDuration;
                    }
                } catch (parseErr) {
                    outputChannel?.appendLine(`[MIRROR] Live DB parse error: ${parseErr.message}`);
                }
            }
        } catch (e) {
            outputChannel?.appendLine(`[MIRROR] Live DB read failed, using snapshot: ${e.message}`);
        }
    }

    const nextHash = hashText(assistantBubble.text);
    if (job.assistantTextHash !== nextHash) {
        markMirrorJobStage(job, 'assistant_streaming');
        job.assistantBubbleId = assistantBubble.bubbleId;
        job.assistantText = assistantBubble.text;
        job.assistantTextHash = nextHash;
        job.assistantLastChangedAt = now;
        job.assistantDuration = assistantBubble.duration;

        // [v1.0.143] Streaming disabled — iOS needs ONE complete message, not chunks
        // Just log locally, don't send to Supabase
        // job.lastStreamUpdateAt = now;

        outputChannel?.appendLine(
            `[MIRROR] Assistant bubble updated for msg=${job.originalMessageId} (${job.assistantText.length} chars)`
        );
        return;
    }

    if (!job.assistantLastChangedAt) {
        job.assistantLastChangedAt = now;
        return;
    }

    const stableForMs = now - job.assistantLastChangedAt;

    // Progressive stability window: short answers arrive fast, long ones wait for full completion.
    const len = (job.assistantText || '').length;
    const isFinishedInCursor = job.assistantDuration !== null;
    const hadStreamingUpdates = (job.lastStreamUpdateAt || 0) > 0;
    
    // [v1.0.148] Three-tier finalization:
    // 1. turnDurationMs present → Cursor said "done" → wait 5s for DB flush → send
    // 2. No turnDurationMs but text stable 12s+ → text stopped growing → send
    // 3. No text after 3 min → timeout → error
    if (isFinishedInCursor) {
        // Tier 1: Cursor explicitly finished — WAL flush is ~100ms, 1s is plenty
        const requiredStableMs = 1000;
        if (stableForMs < requiredStableMs && now < job.expiresAt) {
            markMirrorJobStage(job, 'waiting_for_stability', `finished=true, stableForMs=${stableForMs}/${requiredStableMs}`);
            return;
        }
        outputChannel?.appendLine(`[MIRROR] Cursor finished (duration=${job.assistantDuration}ms). Finalizing after ${stableForMs}ms stable.`);
    } else if (len >= 30 && stableForMs >= 6000) {
        // Tier 2: No turnDurationMs (Agent mode), but text stopped growing for 6s
        // [v1.0.168] Require min 30 chars — single-char bubbles ('С') are mid-generation preambles, not final text
        outputChannel?.appendLine(`[MIRROR] No turnDurationMs but text stable for ${stableForMs}ms (${len} chars). Finalizing.`);
    } else if (now >= job.expiresAt) {
        // Tier 3: Hard timeout
        outputChannel?.appendLine(`[MIRROR] ⚠️ Job expired (${len} chars captured).`);
        if (len > 50) {
            outputChannel?.appendLine(`[MIRROR] Sending ${len} chars as completed (timeout fallback).`);
        } else {
            // Send error to mobile
            try {
                const errorText = 'Cursor IDE is still generating or the response could not be captured. Please try again.';
                await sendAgentReplyToMobile(job, job.sessionId, errorText, 'error');
            } catch (_) {}
            // [v1.0.146] No ghost processing to clean up — error reply INSERT above is enough.
            // Unblock user input
            try {
                if (job.originalMessageId && supabase) {
                    await supabase.from('messages').update({ status: 'completed' }).eq('id', job.originalMessageId);
                }
            } catch (_) {}
            remoteLog('mirror_timeout', 'Mirror job expired without finish signal', { msgId: job.originalMessageId, len }, 'warn');
            finalizeMirrorJob(job.originalMessageId);
            return;
        }
    } else {
        // Still waiting — text is growing or hasn't stabilized yet
        markMirrorJobStage(job, 'waiting_for_cursor_finish', `len=${len}, stableForMs=${stableForMs}, waiting...`);
        return;
    }

    // CRITICAL: Before finalizing, query the LIVE Cursor DB directly (not a snapshot).
    // Snapshots (.backup and file copy) miss WAL data, causing text truncation.
    // Direct sqlite3 queries always see the latest committed+WAL data.
    // [v1.0.149] Added verification loop: re-read after 3s delay to catch WAL flush lag.
    if (job.conversationId && job.assistantBubbleId) {
        try {
            const { dbPath } = getCursorStatePaths();
            const bubbleKey = `bubbleId:${String(job.conversationId).replace(/'/g, "''")}:${String(job.assistantBubbleId).replace(/'/g, "''")}`;
            
            // [v1.0.159] Fast verification: read DB, wait 1s, confirm (was 3 passes × 3s = 9s)
            for (let verifyPass = 0; verifyPass < 2; verifyPass++) {
                const rows = await runSqliteJsonQuery(dbPath, `SELECT json_extract(value, '$.text') as t, json_extract(value, '$.turnDurationMs') as d FROM cursorDiskKV WHERE key = '${bubbleKey}' LIMIT 1`);
                
                if (rows && rows.length > 0) {
                    const liveText = typeof rows[0].t === 'string' ? rows[0].t : '';
                    const liveDuration = rows[0].d !== undefined && rows[0].d !== null ? Number(rows[0].d) : null;
                    const oldLen = job.assistantText.length;
                    const liveHash = hashText(liveText);

                    if (liveHash !== job.assistantTextHash || (liveDuration !== null && job.assistantDuration === null)) {
                        outputChannel?.appendLine(
                            `[MIRROR] ⚡ Live DB update (pass ${verifyPass + 1}): text=${oldLen}→${liveText.length}, duration=${job.assistantDuration}→${liveDuration}.`
                        );
                        job.assistantText = liveText;
                        job.assistantTextHash = liveHash;
                        job.assistantDuration = liveDuration;
                        job.assistantLastChangedAt = Date.now();
                        
                        if (liveText.length > oldLen + 20) {
                            markMirrorJobStage(job, 'waiting_for_stability', `Growth detected pass ${verifyPass + 1} (${oldLen} -> ${liveText.length}), resetting wait.`);
                            return; // Go back to waiting / polling loop
                        }
                    }
                    job.assistantText = liveText;
                }
                
                // Wait 1s between verification passes (was 3s)
                if (verifyPass < 1) {
                    outputChannel?.appendLine(`[MIRROR] Verification pass ${verifyPass + 1}/2: ${job.assistantText.length} chars. Confirming in 1s...`);
                    await new Promise(resolve => setTimeout(resolve, 1000));
                }
            }
            outputChannel?.appendLine(`[MIRROR] ✅ Verified stable after 2 passes: ${job.assistantText.length} chars, duration=${job.assistantDuration || 'none'}`);
        } catch (e) {
            outputChannel?.appendLine(`[MIRROR] Live DB query failed (proceeding with cached text): ${e.message}`);
        }
    }

    // ==================== FIRST BUBBLE SEND ====================
    markMirrorJobStage(job, 'sending_to_supabase');

    // One last read from live DB right before send (Cursor may have written more after stability check).
    let finalText = job.assistantText;
    if (job.conversationId && job.assistantBubbleId) {
        try {
            const { dbPath } = getCursorStatePaths();
            const bubbleKey = `bubbleId:${job.conversationId}:${job.assistantBubbleId}`;
            const rows = await runSqliteJsonQuery(dbPath, `SELECT json_extract(value, '$.text') as t FROM cursorDiskKV WHERE key = '${bubbleKey}' LIMIT 1`);
            if (rows && rows.length > 0) {
                const lastText = typeof rows[0].t === 'string' ? rows[0].t : '';
                if (lastText.length > finalText.length) {
                    outputChannel?.appendLine(`[MIRROR] Final live read: using ${lastText.length} chars (was ${finalText.length}).`);
                    finalText = lastText;
                }
            }
        } catch (e) {
            outputChannel?.appendLine(`[MIRROR] Final live read failed, using cached: ${e.message}`);
        }
    }
    
    // [v1.0.158] Smart status: if Cursor already finished (has turnDurationMs) → 'completed'.
    // If still working (no turnDurationMs) → 'streaming' to keep iOS input locked.
    const turnAlreadyDone = !!(job.assistantDuration);
    let firstBubbleStatus = turnAlreadyDone ? 'completed' : 'streaming';
    try {
        await sendAgentReplyToMobile(job, job.sessionId, finalText, firstBubbleStatus);
        outputChannel?.appendLine(
            `[MIRROR] ✅ First bubble sent as ${firstBubbleStatus.toUpperCase()} for msg=${job.originalMessageId} (${finalText.length} chars, turnDone=${turnAlreadyDone})`
        );

        remoteLog('mirror_first_bubble', `First bubble mirrored`, {
            msgId: job.originalMessageId,
            len: finalText.length
        });
    } catch (e) {
        outputChannel?.appendLine(
            `[MIRROR] ❌ First bubble send failed for msg=${job.originalMessageId}: ${e.message}`
        );
        return;
    }

    // ==================== CONTINUATION PHASE ====================
    // [v1.0.152] After sending the first bubble, keep monitoring for new assistant bubbles.
    // In Agent mode, Cursor generates: plan bubble → code operations → summary bubble.
    // We need to catch the summary bubble and UPDATE the existing message.
    
    const CONTINUATION_MAX_MS = 1800000; // max 30min after first bubble (code gen can be VERY slow)
    const CONTINUATION_IDLE_MS = 60000; // [v1.0.166] increased to 60s safety net
    const CONTINUATION_POLL_MS = 2000;  // [v1.0.159] check every 2s (was 4s)
    
    const continuationStartedAt = Date.now();
    const sentBubbleIds = [job.assistantBubbleId];
    let lastActivityAt = Date.now();
    let continuationBubbleCount = 1;
    let previousTotalBubbleCount = 0; // Track ALL bubble types (not just type=2)
    
    // [v1.0.159] Skip continuation entirely for simple responses (turn done + sent as completed)
    if (turnAlreadyDone && firstBubbleStatus === 'completed') {
        outputChannel?.appendLine(
            `[MIRROR] ⚡ Skipping continuation (turn already done, first bubble=completed). Fast path!`
        );
    } else {
    outputChannel?.appendLine(
        `[MIRROR] 🔄 Starting continuation phase for msg=${job.originalMessageId} (watching for more bubbles up to ${CONTINUATION_MAX_MS / 1000}s)`
    );
    markMirrorJobStage(job, 'continuation_scan');
    
    while (true) {
        await new Promise(resolve => setTimeout(resolve, CONTINUATION_POLL_MS));
        
        const elapsed = Date.now() - continuationStartedAt;
        const idleTime = Date.now() - lastActivityAt;
        
        // Hard timeout
        if (elapsed >= CONTINUATION_MAX_MS) {
            outputChannel?.appendLine(`[MIRROR] Continuation hard timeout (${Math.round(elapsed / 1000)}s). Finalizing.`);
            break;
        }
        
        // Scan for new assistant bubbles AND check if turn is still active
        try {
            let snapshot = null;
            try {
                snapshot = createCursorStateSnapshot();
            } catch (e) {
                outputChannel?.appendLine(`[MIRROR] Continuation snapshot failed: ${e.message}`);
                continue;
            }
            
            try {
                const conversationRows = await queryConversationBubbleRows(
                    snapshot.snapshotDbPath,
                    job.conversationId,
                    200,
                    job.userBubbleRowId || 0
                );
                
                // Count ALL rows after user bubble (any type) to detect activity
                const totalRowsAfterUser = conversationRows.filter(r => {
                    const parsed = parseBubbleRow(r);
                    return parsed && parsed.rowid > (job.userBubbleRowId || 0);
                }).length;
                
                if (totalRowsAfterUser !== previousTotalBubbleCount) {
                    // ANY new bubble appeared (including code ops, tool calls, etc.)
                    // continuation activity logging removed for brevity
                    previousTotalBubbleCount = totalRowsAfterUser;
                    lastActivityAt = Date.now(); // Reset idle timer — Cursor is still working
                }
                
                const allTextBubbles = collectAllAssistantBubbleTexts(conversationRows, job);
                
                // [v1.0.160] Check turnDurationMs on the LAST text bubble (not the first).
                // In Agent mode, only the final summary bubble gets turnDurationMs.
                let turnStillInProgress = false;
                const lastTextBubble = allTextBubbles.length > 0 ? allTextBubbles[allTextBubbles.length - 1] : null;
                if (lastTextBubble && job.conversationId) {
                    try {
                        const { dbPath } = getCursorStatePaths();
                        const lastBKey = `bubbleId:${String(job.conversationId).replace(/'/g, "''")}:${String(lastTextBubble.bubbleId).replace(/'/g, "''")}`;
                        const durationRows = await runSqliteJsonQuery(dbPath, `SELECT json_extract(value, '$.turnDurationMs') as d FROM cursorDiskKV WHERE key = '${lastBKey}' LIMIT 1`);
                        if (durationRows && durationRows.length > 0) {
                            const dur = durationRows[0].d;
                            if (dur === null || dur === undefined) {
                                turnStillInProgress = true;
                            }
                        }
                    } catch (e) {
                        turnStillInProgress = true;
                    }
                } else if (!lastTextBubble) {
                    // No text bubbles yet but rows exist → still in progress
                    turnStillInProgress = totalRowsAfterUser > 0;
                }
                
                if (turnStillInProgress) {
                    // Don't reset idle timer constantly — only when new rows appear
                    // (previously this blocked exit for 180s)
                    if (elapsed % 15000 < CONTINUATION_POLL_MS) {
                        outputChannel?.appendLine(
                            `[MIRROR] ⏳ Turn still in progress (last bubble no turnDurationMs). Elapsed ${Math.round(elapsed / 1000)}s. Waiting...`
                        );
                    }
                }
                
                // [v1.0.162] Idle timeout — ONLY exit when turn is finished.
                // When turn is in progress, progressive updates keep user informed.
                // Exit when turn finishes + 3s idle, OR safety net 45s idle.
                const effectiveIdleMs = turnStillInProgress ? CONTINUATION_IDLE_MS : 3000;
                const shouldExit = idleTime >= effectiveIdleMs;
                
                const localMergedText = allTextBubbles.map(b => b.text ? b.text.trim() : '').filter(Boolean).join('\n\n---\n\n');
                const isNewBubble = allTextBubbles.length > continuationBubbleCount;
                const isContentGrown = localMergedText.length > finalText.length + 30; // Debounce minor character updates
                // [v1.0.168] Don't send mid-generation stubs — require at least 50 chars when turn is still in progress
                const isContentSubstantial = shouldExit || localMergedText.length >= 50 || !turnStillInProgress;
                
                if ((isNewBubble || isContentGrown || shouldExit) && isContentSubstantial) {
                    // [v1.0.165] Trigger if new bubble appears, existing text grows, OR we are about to exit
                    if (isNewBubble) {
                        const newBubbles = allTextBubbles.filter(b => !sentBubbleIds.includes(b.bubbleId));
                        outputChannel?.appendLine(
                            `[MIRROR] 🆕 Continuation found ${newBubbles.length} new text bubble(s) (total: ${allTextBubbles.length})`
                        );
                    } else if (isContentGrown) {
                        outputChannel?.appendLine(
                            `[MIRROR] 📝 Continuation bubble content grew: ${localMergedText.length} chars (was ${finalText.length})`
                        );
                    }
                    
                    if (!shouldExit) {
                        lastActivityAt = Date.now(); // Reset idle timer ONLY if not exiting — new content found
                    }
                    
                    // Check if last bubble has turnDurationMs (turn finished)
                    const lastBubble = allTextBubbles[allTextBubbles.length - 1];
                    const lastBubbleHasDuration = lastBubble && lastBubble.duration !== null && lastBubble.duration !== undefined;
                    
                    if (!lastBubbleHasDuration && !shouldExit) {
                        outputChannel?.appendLine(`[MIRROR] Last bubble still streaming (no turnDurationMs). Sending progressive update...`);
                    }
                    
                    // All bubbles are done — read their final texts from live DB
                    const { dbPath } = getCursorStatePaths();
                    const mergedTexts = [];
                    
                    for (const bubble of allTextBubbles) {
                        try {
                            const bKey = `bubbleId:${String(job.conversationId).replace(/'/g, "''")}:${String(bubble.bubbleId).replace(/'/g, "''")}`;
                            const liveRows = await runSqliteJsonQuery(dbPath, `SELECT json_extract(value, '$.text') as t FROM cursorDiskKV WHERE key = '${bKey}' LIMIT 1`);
                            if (liveRows && liveRows.length > 0) {
                                const liveText = typeof liveRows[0].t === 'string' ? liveRows[0].t.trim() : '';
                                if (liveText) mergedTexts.push(liveText);
                            } else {
                                if (bubble.text && bubble.text.trim()) mergedTexts.push(bubble.text.trim());
                            }
                        } catch (e) {
                            if (bubble.text && bubble.text.trim()) mergedTexts.push(bubble.text.trim());
                        }
                        if (!sentBubbleIds.includes(bubble.bubbleId)) {
                            sentBubbleIds.push(bubble.bubbleId);
                        }
                    }
                    
                    continuationBubbleCount = allTextBubbles.length;
                    
                    // Merge all bubble texts and UPDATE existing Supabase record
                    const mergedContent = mergedTexts.join('\n\n---\n\n');
                    
                    // [v1.0.165] We want to update Supabase if we're exiting and the content changed EVEN SLIGHTLY,
                    // or if it changed enough to bypass the debounce (+30 chars) during normal polling
                    if ((mergedContent.length > finalText.length || shouldExit) && job.supabaseRecordId) {
                        outputChannel?.appendLine(
                            `[MIRROR] 📝 Updating Supabase with merged content: ${mergedContent.length} chars (was ${finalText.length}), shouldExit=${shouldExit}`
                        );
                        
                        try {
                            await sendAgentReplyToMobile(job, job.sessionId, mergedContent, 'completed', true);
                            finalText = mergedContent;
                            outputChannel?.appendLine(
                                `[MIRROR] ✅ Multi-bubble UPDATE sent (${continuationBubbleCount} bubbles, ${mergedContent.length} chars)`
                            );
                        } catch (e) {
                            outputChannel?.appendLine(`[MIRROR] ⚠️ Continuation UPDATE failed: ${e.message}`);
                        }
                    } else if (mergedContent.length <= finalText.length && !shouldExit) {
                        outputChannel?.appendLine(`[MIRROR] Live DB text wasn't longer than what we already sent. Waiting...`);
                    }
                }
                
                if (shouldExit) {
                    outputChannel?.appendLine(`[MIRROR] Exiting continuation. Idle for ${Math.round(idleTime / 1000)}s (turn ${turnStillInProgress ? 'in progress — safety exit' : 'finished'}). Finalizing.`);
                    break;
                }
            } finally {
                snapshot.cleanup();
            }
        } catch (e) {
            outputChannel?.appendLine(`[MIRROR] Continuation scan error: ${e.message}`);
        }
    }
    } // end of continuation else-block
    
    // ==================== FINAL CLEANUP ====================
    job.finalized = true;
    markMirrorJobStage(job, 'completed');
    outputChannel?.appendLine(
        `[MIRROR] ✅ Mirror job fully completed for msg=${job.originalMessageId} (${continuationBubbleCount} bubble(s), ${finalText.length} chars)`
    );

    // [v1.0.158] Only send final 'completed' if first bubble was 'streaming'
    // (if first bubble was already 'completed', no need to update again)
    if (firstBubbleStatus === 'streaming') {
        try {
            if (job.supabaseRecordId && supabase) {
                await sendAgentReplyToMobile(job, job.sessionId, finalText, 'completed', true);
                outputChannel?.appendLine(`[MIRROR] ✅ Agent reply ${job.supabaseRecordId} marked COMPLETED (iOS input unlocked).`);
            }
        } catch (e) {
            outputChannel?.appendLine(`[MIRROR] ⚠️ Failed to mark agent reply completed: ${e.message}`);
        }
    } else {
        outputChannel?.appendLine(`[MIRROR] ℹ️ First bubble was already COMPLETED — skipping final update.`);
    }

    // Mark user message as completed
    try {
        if (job.originalMessageId && supabase) {
            await supabase.from('messages').update({ status: 'completed' }).eq('id', job.originalMessageId);
            outputChannel?.appendLine(`[MIRROR] ✅ User message ${job.originalMessageId} marked completed.`);
        }
    } catch (e) {
        outputChannel?.appendLine(`[MIRROR] ⚠️ Failed to mark user message completed: ${e.message}`);
    }

    remoteLog('mirror_completed', `Successfully mirrored reply (multi-bubble)`, {
        msgId: job.originalMessageId,
        len: finalText.length,
        bubbles: continuationBubbleCount
    });

    finalizeMirrorJob(job.originalMessageId);
}

async function processActiveMirrorJobs(reason) {
    if (activeMirrorJobs.size === 0) {
        stopCursorMirrorMonitoringIfIdle();
        return;
    }
    if (cursorMirrorProcessing) {
        cursorMirrorProcessAgain = true;
        return;
    }

    cursorMirrorProcessing = true;
    try {
        do {
            cursorMirrorProcessAgain = false;

            let snapshot = null;
            try {
                snapshot = createCursorStateSnapshot();
            } catch (e) {
                outputChannel?.appendLine(`[MIRROR] Snapshot failed (${reason}): ${e.message}`);
                break;
            }

            try {
                const recentRows = await queryRecentBubbleRows(snapshot.snapshotDbPath, CURSOR_MIRROR_RECENT_ROWS_LIMIT);
                const context = {
                    now: Date.now(),
                    reason,
                    snapshotDbPath: snapshot.snapshotDbPath,
                    recentRows,
                    promptRowsCache: new Map(),
                    conversationRowsCache: new Map()
                };

                for (const job of [...activeMirrorJobs.values()]) {
                    await processSingleMirrorJob(job, context);
                }
            } finally {
                snapshot.cleanup();
            }
        } while (cursorMirrorProcessAgain && activeMirrorJobs.size > 0);
    } finally {
        cursorMirrorProcessing = false;
        stopCursorMirrorMonitoringIfIdle();
    }
}

// Token and Activity State
let lastMobileUserId = null;
let lastMobileActivityTime = 0;

// --- Multi-Window Lock Management ---
function makeWindowPrimary() {
    try {
        const tokenDir = path.join(os.homedir(), '.gemini', 'antigravity');
        if (!fs.existsSync(tokenDir)) fs.mkdirSync(tokenDir, { recursive: true });

        const payload = JSON.stringify({
            id: globalInstanceId,
            updatedAt: Date.now()
        });
        fs.writeFileSync(path.join(tokenDir, 'active_vscode_window.txt'), payload, 'utf8');
        outputChannel?.appendLine(`[LOCK] This window took the active lock: ${globalInstanceId.substring(0, 6)}`);
    } catch (e) { }
}

// [v1.0.142] Unified device connection lifecycle — called from EVERY connection point
async function onDeviceConnected(token) {
    if (!token) return;
    outputChannel?.appendLine(`[CONNECT] 🔗 onDeviceConnected(${token.substring(0, 8)}...)`);
    
    // 1. Set global state
    lastMobileUserId = token;
    lastMobileActivityTime = Date.now();
    
    // 2. Take lock
    makeWindowPrimary();
    
    // 3. [FIX] sendConnectionSignal is already called in refreshConnection's SUBSCRIBED handler.
    //    Calling it here too caused DUPLICATE inserts on every connect.
    //    Removed to prevent double Connection established messages.
    
    // 4. Sync workspace (project name → iOS)
    syncCurrentWorkspace(token).catch(e => {
        outputChannel?.appendLine(`[CONNECT] ⚠️ Workspace sync failed: ${e.message}`);
    });
    
    // 5. Sync models (per-user)
    syncAvailableModelsToSupabase().catch(e => {
        outputChannel?.appendLine(`[CONNECT] ⚠️ Model sync failed: ${e.message}`);
    });
    
    // 6. Remote log
    remoteLog('connection_established', 'Device connected via onDeviceConnected', { token: token.substring(0, 8) });
    
    outputChannel?.appendLine(`[CONNECT] ✅ All connection tasks dispatched for ${token.substring(0, 8)}...`);
}
// ------------------------------------
// Cross-platform: simulate Enter/Escape (click confirm) — requires Accessibility/equivalent on each OS
function runCmd(cmd, timeoutMs = 10000) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
            reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`));
        }, timeoutMs);
        exec(cmd, (err, stdout) => {
            clearTimeout(timer);
            err ? reject(err) : resolve(stdout);
        });
    });
}

async function simulateKey(keyName) {
    const plat = process.platform;
    const macCodes = { enter: 36, escape: 53 };
    const winKeys = { enter: '{ENTER}', escape: '{ESC}' };
    const xdoKeys = { enter: 'Return', escape: 'Escape' };

    if (plat === 'darwin') {
        const code = macCodes[keyName] || 36;
        // [v1.0.139] Send key code to Cursor process.
        // `set frontmost to true` ensures Cursor is the active window before key press.
        // NOTE: Do NOT use `activate` here — it disrupts composer focus.
        const script = `
tell application "System Events"
    tell process "Cursor"
        set frontmost to true
        key code ${code}
    end tell
end tell`;
        await runCmd(`osascript -e '${script.replace(/'/g, "'\\''")}'`);
    } else if (plat === 'win32') {
        const key = winKeys[keyName] || '{ENTER}';
        // [v1.0.137] Target Cursor window + restore if minimized on Windows
        const ps = `
Add-Type -AssemblyName System.Windows.Forms
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WinAPI {
    [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
    [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
}
"@
$cursor = Get-Process -Name "Cursor" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cursor) {
    [WinAPI]::ShowWindow($cursor.MainWindowHandle, 9)
    [WinAPI]::SetForegroundWindow($cursor.MainWindowHandle)
    Start-Sleep -Milliseconds 200
}
[System.Windows.Forms.SendKeys]::SendWait('${key}')`;
        const enc = Buffer.from(ps, 'utf16le').toString('base64');
        await runCmd(`powershell -NoProfile -EncodedCommand ${enc}`);
    } else {
        const key = xdoKeys[keyName] || 'Return';
        // [v1.0.137] Target Cursor window + restore if minimized on Linux
        try {
            // windowactivate also restores minimized windows
            await runCmd(`xdotool search --name "Cursor" windowactivate --sync windowfocus --sync && sleep 0.2 && xdotool key ${key}`);
        } catch (_) {
            try {
                await runCmd(`xdotool key ${key}`);
            } catch (__) {
                await runCmd(`wtype -k ${key}`);
            }
        }
    }
    outputChannel.appendLine(`[KEY] ${keyName}`);
}

async function simulateEnter() {
    await simulateKey('enter');
}

async function simulateEnterThenEscape() {
    await simulateKey('enter');
    await new Promise(r => setTimeout(r, 100));
    await simulateKey('escape');
}

async function captureClipboardReplyAndSend(label) {
    try {
        const text = await vscode.env.clipboard.readText();
        const trimmed = (text || '').trim();
        outputChannel.appendLine(`[CLIP-REPLY] label=${label || 'none'}, length=${trimmed.length}, first 200: ${JSON.stringify(trimmed.substring(0, 200))}`);
        if (!trimmed || trimmed.length < 2) {
            vscode.window.showWarningMessage('Clipboard is empty or too short to use as a reply.');
            return;
        }

        if (!lastMobileUserId) {
            try {
                const tokenDir = path.join(os.homedir(), '.gemini', 'antigravity');
                const tokenPath = path.join(tokenDir, 'mobile_token.txt');
                if (fs.existsSync(tokenPath)) {
                    const token = fs.readFileSync(tokenPath, 'utf8').trim();
                    if (token) {
                        lastMobileUserId = token;
                        outputChannel.appendLine('[CLIP-REPLY] Loaded lastMobileUserId from mobile_token.txt.');
                    }
                }
            } catch (e) {
                outputChannel.appendLine(`[CLIP-REPLY] Failed to load mobile_token.txt: ${e.message}`);
            }
            if (!lastMobileUserId) {
                outputChannel.appendLine('[CLIP-REPLY] No lastMobileUserId — logging only, not sending to Supabase.');
                vscode.window.showInformationMessage('Reply captured from clipboard (no linked mobile user).');
                return;
            }
        }

        const msg = { user_id: lastMobileUserId, id: null };
        await sendAgentReplyToMobile(msg, null, trimmed);
        vscode.window.showInformationMessage('Reply captured from clipboard and sent to mobile.');
    } catch (e) {
        outputChannel.appendLine(`[CLIP-REPLY] Error: ${e.message}`);
        vscode.window.showErrorMessage(`Failed to capture reply from clipboard: ${e.message}`);
    }
}

async function doSwitchModel(displayName) {
    if (!displayName) return;
    const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
    outputChannel.appendLine(`[MODEL] doSwitchModel("${displayName}")`);
    try {
        const savedClipboard = await vscode.env.clipboard.readText();

        await vscode.commands.executeCommand('composer.focusComposer');
        await delay(300);
        await vscode.commands.executeCommand('composer.openModelToggle');
        await delay(700);

        const searchText = displayName === 'Auto' ? 'Auto' : displayName;
        await vscode.env.clipboard.writeText(searchText);
        await vscode.commands.executeCommand('execPaste');
        outputChannel.appendLine(`[MODEL] Pasted "${searchText}" into search.`);
        await delay(500);

        await simulateEnterThenEscape();

        await vscode.env.clipboard.writeText(savedClipboard);

        outputChannel.appendLine(`[MODEL] Done.`);
        vscode.window.showInformationMessage(`Model: ${displayName}`);
    } catch (e) {
        outputChannel.appendLine(`[MODEL] Error: ${e.message}`);
        let hint = '';
        if (process.platform === 'darwin') {
            const m = String(e.message || '');
            if (m.includes('1002') || m.includes('not allowed') || m.includes('keystroke')) {
                hint = ' Grant Cursor in System Settings > Privacy & Security > Accessibility.';
            }
        } else if (process.platform === 'linux') {
            hint = ' Install: sudo apt install xdotool (X11) or wtype (Wayland).';
        } else if (process.platform === 'win32') {
            hint = ' Ensure Cursor is the active window.';
        }
        vscode.window.showErrorMessage(`Model switch failed: ${e.message}${hint}`);
    }
}

async function handlePrompt(msg) {
    try {
        outputChannel.appendLine(`[v1.0.87] [DEBUG] ========== handlePrompt START ==========`);
        await new Promise(r => setTimeout(r, 150));
        if (!msg || !msg.content) return;
        
        // [v1.0.150] Deduplication: ignore retry of same message
        if (msg.id && processedMessageIds.has(msg.id)) {
            outputChannel.appendLine(`[DEDUP] ⚠️ Message ${msg.id} already processed. Skipping duplicate.`);
            return;
        }
        if (msg.id) {
            processedMessageIds.set(msg.id, Date.now());
            // Evict oldest entries when map exceeds limit
            if (processedMessageIds.size > 500) {
                const oldest = processedMessageIds.keys().next().value;
                processedMessageIds.delete(oldest);
            }
        }
        
        // Safe console dump (prevents freezing on huge base64 images from "mobile debug")
        outputChannel.appendLine(`[v1.0.87] [RAW_DATA_DUMP] Here is the safe JSON payload (truncated):`);
        const safeMsg = { ...msg };
        for (const k of Object.keys(safeMsg)) {
            if (typeof safeMsg[k] === 'string' && safeMsg[k].length > 500) {
                safeMsg[k] = safeMsg[k].substring(0, 500) + '... [TRUNCATED ' + safeMsg[k].length + ' chars]';
            }
        }
        outputChannel.appendLine(JSON.stringify(safeMsg, null, 2));
        outputChannel.appendLine(`[v1.0.87] [RAW_DATA_DUMP] End of JSON payload.`);

        outputChannel.appendLine(`[v1.0.87] [DEBUG] msg.id=${msg.id}, msg.role=${msg.role}, msg.model=${msg.model || 'none'}`);
        outputChannel.appendLine(`[v1.0.87] [DEBUG] msg.content (first 80): ${(msg.content || '').substring(0, 80)}`);
        outputChannel.appendLine(`[v1.0.87] [DEBUG] full msg keys: ${JSON.stringify(Object.keys(msg))}`);

        // Multi-Window Guard: Check if we are the actively focused VS Code window
        try {
            const lockPath = path.join(os.homedir(), '.gemini', 'antigravity', 'active_vscode_window.txt');
            const now = Date.now();
            let claimLock = true;

            if (fs.existsSync(lockPath)) {
                try {
                    const lockData = fs.readFileSync(lockPath, 'utf8').trim();
                    let lockObj = { id: lockData, updatedAt: 0 };
                    if (lockData.startsWith('{')) lockObj = JSON.parse(lockData);

                    // [v1.0.121] Reduced lock timeout from 30s to 15s.
                    // If the primary window's Realtime connection dies, we want the other window to take over faster.
                    if (lockObj.id !== globalInstanceId && (now - (lockObj.updatedAt || 0) < 15000)) {
                        outputChannel?.appendLine(`[SKIP] Msg ${msg.id} ignored (Window ${lockObj.id.substring(0, 6)} is primary and active).`);
                        return;
                    }
                } catch (e) {
                    outputChannel.appendLine(`[DEBUG] Lock parse error: ${e.message}`);
                }
            }

            // Claim/Refresh lock
            fs.mkdirSync(path.dirname(lockPath), { recursive: true });
            fs.writeFileSync(lockPath, JSON.stringify({ id: globalInstanceId, updatedAt: now }), 'utf8');
            outputChannel.appendLine(`[v1.0.87] [DEBUG] Window lock claimed (ID: ${globalInstanceId.substring(0, 6)}).`);
        } catch (e) {
            outputChannel.appendLine(`[v1.0.87] [DEBUG] Window lock check error: ${e.message}`);
        }

        const content = msg.content;
        const keys = Object.keys(msg);
        outputChannel.appendLine(`[v1.0.87] [EVENT] Received prompt: "${content.substring(0, 50)}..." (keys: ${keys.join(',')})`);
        appendChatPrompt(content, msg.model);

        // Ensure we capture the correct session_id (handle different cases just in case)
        const sessionId = msg.session_id || msg.sessionId;
        outputChannel.appendLine(`[v1.0.87] [DEBUG] sessionId=${sessionId || 'NONE'}`);

        outputChannel.appendLine(`[v1.0.87] [DEBUG] Message claim check skipped (per user request).`);

        lastMobileUserId = msg.user_id;
        lastMobileActivityTime = Date.now();

        // Cursor: one focus then send — avoid opening/closing multiple panels (was causing window thrashing)
        // [v1.0.112+] For normal text-only mobile input we do NOT silently truncate.
        // We only guard against truly extreme cases (hundreds of KB), and even тогда
        // отвечаем в Supabase явной ошибкой вместо скрытой обрезки.
        const MAX_CONTENT_LEN = 500000; // ~500K chars; well above typical long chats.
        if (content.length > MAX_CONTENT_LEN) {
            const safePreview = content.substring(0, 500);
            const errorText =
                'Cursor IDE could not accept the last prompt because it is extremely large for a single message. ' +
                'The message was NOT sent to the IDE. Please shorten it or split it into smaller parts.\n\n' +
                'Preview of the beginning:\n\n' +
                safePreview;
            outputChannel.appendLine(
                `[PROMPT] ❌ Mobile prompt too large (len=${content.length}). Sending error reply instead of truncating.`
            );
            try {
                await sendAgentReplyToMobile(msg, sessionId, errorText, 'error');
            } catch (e) {
                outputChannel.appendLine(
                    `[PROMPT] Failed to send \"prompt too large\" error reply: ${e.message}`
                );
            }
            return;
        }

        const mobileContent = content;

        outputChannel.appendLine(`[v1.0.87] [DEBUG] Preparing to send prompt...`);

        // [v1.0.147] Model switching DISABLED for now — focus on message delivery debugging.
        const requestedModel = (msg.model || '').trim();
        outputChannel.appendLine(`[MODEL] msg.model="${requestedModel}" (model switching disabled)`);

        let mirrorWatermark = 0;

        try {
            outputChannel.appendLine(`[v1.0.87] [DEBUG] Attempting to get DB watermark...`);
            mirrorWatermark = await getCursorStateMaxBubbleRowId();
            outputChannel?.appendLine(`[v1.0.87] [MIRROR] Pre-send watermark for msg ${msg.id}: ${mirrorWatermark}`);
        } catch (e) {
            outputChannel.appendLine(`[v1.0.87] [ERROR] Watermark failed: ${e.message}. Falling back to 0.`);
        }

        outputChannel.appendLine(`[v1.0.87] [DEBUG] Starting strategy execution...`);
        remoteLog('prompt_handling', 'Starting prompt strategies', { msgId: msg.id, watermark: mirrorWatermark });

        try {
            outputChannel.appendLine(`[v1.0.87] [DEBUG] Focusing composer...`);
            await vscode.commands.executeCommand('composer.focusComposer');
            await new Promise(resolve => setTimeout(resolve, 500));
        } catch (_) { }

        let sentSuccessfully = false;

        // [v1.0.139] Ensure Cursor window is visible and in front BEFORE paste flow.
        // This handles minimized windows (yellow button) and Split View.
        if (process.platform === 'darwin') {
            try {
                const activateScript = `
tell application "Cursor"
    try
        set miniaturized of every window to false
    end try
    activate
end tell`;
                await runCmd(`osascript -e '${activateScript.replace(/'/g, "'\\''")}'`);
                await new Promise(resolve => setTimeout(resolve, 600));
                outputChannel.appendLine(`[PROMPT] ✅ Cursor window activated/unminimized.`);
            } catch (e) {
                outputChannel.appendLine(`[PROMPT] ⚠️ Failed to activate Cursor: ${e.message}`);
            }
        }

        // [v1.0.91] Reliable Flow: Focus current chat if it exists, иначе открыть новый → Paste → Enter
        try {
            outputChannel.appendLine(`[v1.0.91] [PROMPT] Starting reliable flow...`);

            // 1. Prepare clipboard
            const oldClipboard = await vscode.env.clipboard.readText();
            await vscode.env.clipboard.writeText(mobileContent);

            // 2. Сначала попробуем просто сфокусировать уже открытый чат.
            // Если чата нет или фокус не удастся, откроем новый composer.
            let composerFocused = false;
            try {
                outputChannel.appendLine(`[v1.0.91] [PROMPT] Trying composer.focusComposer first...`);
                await vscode.commands.executeCommand('composer.focusComposer');
                await new Promise(resolve => setTimeout(resolve, 400));
                composerFocused = true;
            } catch (e) {
                outputChannel.appendLine(`[v1.0.91] [WARN] composer.focusComposer failed: ${e.message}`);
            }

            if (!composerFocused) {
                outputChannel.appendLine(`[v1.0.91] [PROMPT] Opening composer via composer.openComposer (no active chat)...`);
                try {
                    await vscode.commands.executeCommand('composer.openComposer');
                    await new Promise(resolve => setTimeout(resolve, 800));
                    await vscode.commands.executeCommand('composer.focusComposer');
                    await new Promise(resolve => setTimeout(resolve, 400));
                } catch (e) {
                    outputChannel.appendLine(`[v1.0.91] [WARN] composer.openComposer fallback failed: ${e.message}`);
                }
            }

            // 3. Re-focus composer input right before paste (fixes second prompt not submitting)
            try {
                await vscode.commands.executeCommand('composer.focusComposer');
                await new Promise(resolve => setTimeout(resolve, 300));
            } catch (_) {}

            // 4. Paste
            outputChannel.appendLine(`[v1.0.91] [PROMPT] Pasting text...`);
            await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
            await new Promise(resolve => setTimeout(resolve, 600));

            // 5. Re-focus input again before Enter (cursor may shift after paste)
            try {
                await vscode.commands.executeCommand('composer.focusComposer');
                await new Promise(resolve => setTimeout(resolve, 200));
            } catch (_) {}

            // 6. Submit via system Enter (AppleScript)
            outputChannel.appendLine(`[v1.0.139] [PROMPT] Submitting via simulateEnter...`);
            await simulateEnter();

            // 5. Restore clipboard (deferred)
            setTimeout(async () => {
                try { await vscode.env.clipboard.writeText(oldClipboard); } catch (e) { }
            }, 4000);

            outputChannel.appendLine(`[v1.0.121] [PROMPT] ✅ Insertion flow completed.`);
            sentSuccessfully = true;
        } catch (e) {
            outputChannel.appendLine(`[v1.0.121] [PROMPT] ❌ Insertion flow failed: ${e.message}`);
        }

        // Final Fallback: AI Chat Panel (Regular chat)
        if (!sentSuccessfully) {
            try {
                outputChannel.appendLine(`[v1.0.87] [STRATEGY] Trying S5: AI Chat Panel Fallback`);
                await vscode.commands.executeCommand('workbench.action.chat.open');
                await new Promise(resolve => setTimeout(resolve, 600));
                await vscode.commands.executeCommand('type', { text: mobileContent });
                await new Promise(resolve => setTimeout(resolve, 300));
                await simulateEnter();
                outputChannel.appendLine(`[v1.0.87] [STRATEGY] ✅ S5 completed.`);
                sentSuccessfully = true;
            } catch (e) {
                outputChannel.appendLine(`[v1.0.87] [STRATEGY] ❌ S5 failed: ${e.message}`);
            }
        }

        if (!sentSuccessfully) {
            outputChannel.appendLine(`[ERROR] ❌ All strategies failed to send the prompt.`);
            remoteLog('strategy_exec', 'all_failed', { msgId: msg.id }, 'error');
            return;
        }

        // Mark user message as processing + send processing status to iOS (fire-and-forget, don't block mirror)
        if (msg.id && !String(msg.id).startsWith('manual_')) {
            supabase.from('messages').update({ status: 'processing' }).eq('id', msg.id)
                .then(() => outputChannel.appendLine(`[PROMPT] ✅ User message ${msg.id} marked as processing.`))
                .catch(e => outputChannel.appendLine(`[PROMPT] ⚠️ Failed to update user message status: ${e.message}`));
        }
        // [v1.0.146] Ghost processing INSERT removed.
        // iOS ignores empty processing rows, so we skip it entirely.
        // The mirror job will INSERT one completed message with full text when ready.

        // After sending, wait and capture the reply from chat UI (starts immediately, no delay)
        outputChannel.appendLine(`[DEBUG] About to call waitForCursorChatAndMirror(userId=${(msg.user_id || msg.userId || '').substring(0,8)}, msgId=${msg.id}, promptLen=${(mobileContent||'').length}, watermark=${mirrorWatermark})`);
        try {
            waitForCursorChatAndMirror(msg.user_id || msg.userId, sessionId, msg.id, mobileContent, mirrorWatermark, requestedModel);
            outputChannel.appendLine(`[DEBUG] waitForCursorChatAndMirror returned OK.`);
        } catch (mirrorErr) {
            outputChannel.appendLine(`[DEBUG] ❌ waitForCursorChatAndMirror THREW: ${mirrorErr.message}\n${mirrorErr.stack}`);
        }
        outputChannel.appendLine(`[DEBUG] ========== handlePrompt END ==========`);

    } catch (err) {
        outputChannel.appendLine(`[ERROR] handlePrompt failed: ${err.message}`);
        outputChannel.appendLine(`[DEBUG] handlePrompt stack: ${err.stack}`);
    }
}

async function sendAgentReplyToMobile(msg, sessionId, content, status = 'completed', isUpdate = false) {
    try {
        outputChannel.appendLine(`[REPLY] [DEBUG] ========== sendAgentReplyToMobile START ==========`);
        outputChannel.appendLine(`[REPLY] [DEBUG] content length: ${(content || '').length}`);
        outputChannel.appendLine(`[REPLY] [DEBUG] status: ${status}`);

        if (!supabase) {
            initSupabase();
            if (!supabase) {
                outputChannel.appendLine('[REPLY] Skipping reply: Supabase not initialized.');
                return false;
            }
        }
        
        const userId = msg.user_id || msg.userId;
        if (!userId) {
            outputChannel.appendLine('[REPLY] Skipping reply: no user_id on message object.');
            return false;
        }

        const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId);

        // [v1.0.139] Trim leading/trailing whitespace — Cursor often starts responses with \n
        const trimmedContent = (content || '').trim();

        const replyData = {
            user_id: userId,
            role: 'agent',
            content: trimmedContent,
            status: status,
            client_type: CURSOR_CLIENT_TYPE,
            source_version: SOURCE_VERSION
        };

        // If we are given an original message ID, link it!
        let origMsgId = msg.id || msg.originalMessageId;
        if (origMsgId) {
            replyData.parent_message_id = origMsgId;
        }

        let finalSessionId = sessionId;
        if (!finalSessionId && isUuid) {
            finalSessionId = userId;
        }
        if (finalSessionId) {
            replyData.session_id = finalSessionId;
        }

        // Streaming logic: UPDATE vs INSERT
        if (msg.supabaseRecordId) {
            // We already started streaming this bubble, just UPDATE it
            outputChannel.appendLine(`[REPLY] Updating existing agent reply (${msg.supabaseRecordId}) for ${userId.substring(0, 8)}...`);
            const { error: updateErr } = await supabase
                .from('messages')
                .update(replyData)
                .eq('id', msg.supabaseRecordId);

            if (updateErr) {
                outputChannel.appendLine(`[REPLY] ❌ Supabase error updating reply: ${updateErr.message}`);
                return false;
            } else {
                outputChannel.appendLine(`[REPLY] ✅ Agent reply updated in messages (status=${status}).`);
                if (status === 'completed') {
                    logFullAgentReplySent(msg.supabaseRecordId, origMsgId, content, isUpdate, msg.requestedModel);
                }
                return true;
            }
        } else {
            // This is the first time we're sending this bubble, INSERT it
            outputChannel.appendLine(`[REPLY] Inserting new agent reply for ${userId.substring(0, 8)}..., session: ${finalSessionId || 'none'}`);
            const { data, error: insertErr } = await supabase
                .from('messages')
                .insert(replyData)
                .select('id')
                .single();

            if (insertErr) {
                outputChannel.appendLine(`[REPLY] ❌ Supabase error sending reply: ${insertErr.message}`);
                return false;
            } else {
                outputChannel.appendLine(`[REPLY] ✅ Agent reply inserted into messages (status=${status}). ID=${data?.id}`);
                // Save the inserted ID back to the job object so we can update it next time
                if (data?.id) {
                    msg.supabaseRecordId = data.id;
                }
                if (status === 'completed') {
                    logFullAgentReplySent(data?.id, origMsgId, content, isUpdate, msg.requestedModel);
                }
                return true;
            }
        }
    } catch (e) {
        outputChannel.appendLine(`[REPLY] CRITICAL error while sending reply: ${e.message}`);
        return false;
    } finally {
        outputChannel.appendLine(`[REPLY] [DEBUG] ========== sendAgentReplyToMobile END ==========`);
    }
}

// Log the full reply we sent to Supabase. Realtime payloads are truncated at ~1MB,
// so this is the only way to see the complete content in extension Output.
function logFullAgentReplySent(replyId, parentId, content, isUpdate = false, model = null) {
    const label = '[AGENT REPLY]';
    const updateLabel = isUpdate ? ' (POST-FINAL UPDATE)' : '';
    outputChannel.appendLine(`\n${label} ========== SENT TO SUPABASE${updateLabel} (full content) ==========`);
    outputChannel.appendLine(`${label} ID: ${replyId || 'n/a'} (Event: ${isUpdate ? 'UPDATE' : 'INSERT'} by extension)`);
    if (parentId) outputChannel.appendLine(`${label} Parent: ${parentId}`);
    if (model) outputChannel.appendLine(`${label} Model: ${model}`);
    outputChannel.appendLine(`${label} Status: completed`);
    outputChannel.appendLine(`${label} Content (${(content || '').length} chars):`);
    
    // We log the text line-by-line to avoid potential Output channel formatting/limit issues with massive strings
    const replyLines = String(content || '').split('\n');
    for (const line of replyLines) {
        outputChannel.appendLine(`${label} ${line}`);
    }
    outputChannel.appendLine(`${label} =====================================================================\n`);
}

async function enqueueAndProcessPrompt(msg) {
    try {
        // [v1.0.142] Queue size limit to prevent IDE hang from rapid prompts
        const MAX_QUEUE_SIZE = 5;
        if (promptQueue.length >= MAX_QUEUE_SIZE) {
            outputChannel?.appendLine(
                `[QUEUE] ❌ Queue full (${promptQueue.length}/${MAX_QUEUE_SIZE}). Rejecting msgId=${msg.id}`
            );
            try {
                await sendAgentReplyToMobile(msg, msg.session_id, 'Too many prompts at once. Please wait for the current one to finish.', 'error');
            } catch (_) {}
            return;
        }
        promptQueue.push(msg);
        outputChannel?.appendLine(
            `[QUEUE] Enqueued prompt msgId=${msg.id}, queueLength=${promptQueue.length}`
        );
        if (promptQueueProcessing) {
            return;
        }
        promptQueueProcessing = true;

        while (promptQueue.length > 0) {
            const next = promptQueue.shift();
            if (!next) continue;
            outputChannel?.appendLine(
                `[QUEUE] Processing prompt msgId=${next.id}, remaining=${promptQueue.length}`
            );
            try {
                // [v1.0.121] Added a hard timeout for each prompt processing to prevent queue stalls.
                // If handlePrompt hangs for > 30s, we move on to the next prompt.
                await Promise.race([
                    handlePrompt(next),
                    new Promise((_, reject) => setTimeout(() => reject(new Error('handlePrompt timeout (30s)')), 30000))
                ]);
            } catch (e) {
                outputChannel?.appendLine(
                    `[QUEUE] Error while handling prompt msgId=${next.id}: ${e.message}`
                );
            }
        }
    } finally {
        promptQueueProcessing = false;
        outputChannel?.appendLine('[QUEUE] Idle (no more pending prompts).');
    }
}

function waitForCursorChatAndMirror(userId, sessionId, originalMessageId, promptText, rowidWatermark = 0, requestedModel = null) {
    try {
        outputChannel.appendLine(`[MIRROR] [DEBUG] waitForCursorChatAndMirror called (msgId: ${originalMessageId})`);

        if (!userId || !promptText) {
            outputChannel.appendLine(`[MIRROR] [DEBUG] ❌ ABORT: Missing userId or promptText.`);
            return;
        }

        if (activeMirrorJobs.has(originalMessageId)) {
            outputChannel.appendLine(`[MIRROR] Mirror job already running for msg=${originalMessageId}, skipping duplicate.`);
            return;
        }

        startCursorMirrorJob({ userId, sessionId, originalMessageId, promptText, rowidWatermark, requestedModel });

    } catch (e) {
        outputChannel.appendLine(`[MIRROR] Error starting mirror job: ${e.message}`);
    }
}

// SQL-based mirror logic is now the primary mechanism in v1.0.46
// (Old clipboard code removed)
async function syncCurrentWorkspace(userId) {
    if (!supabase) initSupabase();
    if (!supabase || !userId) return;

    // Get the current workspace folder name
    const workspaceFolders = vscode.workspace.workspaceFolders;
    if (!workspaceFolders || workspaceFolders.length === 0) {
        outputChannel.appendLine(`[SYNC] ⚠️ No workspace folder open.`);
        return;
    }

    const folder = workspaceFolders[0];
    const projectName = folder.name;
    const folderUri = folder.uri.toString();
    const sessionId = generateUuidV5(folderUri, URL_NAMESPACE);

    outputChannel.appendLine(`[SYNC] 📁 Current workspace: "${projectName}" -> ${sessionId}`);

    try {
        const { error } = await supabase
            .from('sessions')
            .upsert({
                id: sessionId,
                user_id: userId,
                title: projectName,
                client_type: CURSOR_CLIENT_TYPE,
                updated_at: new Date().toISOString()
            }, { onConflict: 'id' });

        if (error) {
            outputChannel.appendLine(`[SYNC] ❌ Supabase error syncing workspace: ${error.message}`);
        } else {
            outputChannel.appendLine(`[SYNC] ✅ Workspace "${projectName}" synced as session ${sessionId}`);
        }
    } catch (e) {
        outputChannel.appendLine(`[SYNC] CRITICAL Error: ${e.message}`);
    }
}

// showMenu moved inline into registerCommand('showMenu') — removed duplicate

function activate(context) {
    globalContext = context;
    const extensionVersion = context.extension.packageJSON.version;
    SOURCE_VERSION = `cursor-ext/${extensionVersion}`;
    rawOutputChannel = vscode.window.createOutputChannel(`Cursor Mobile [v${extensionVersion}]`);
    outputChannel = installCleanOutputFilter(rawOutputChannel);

    outputChannel.appendLine("========================================");
    outputChannel.appendLine(`🚀 VERSION v${extensionVersion} STARTED at ${new Date().toISOString()}`);
    outputChannel.appendLine(`[INFO] Extension ID: ${context.extension.id}`);
    outputChannel.appendLine(`[INFO] URI Scheme: ${vscode.env.uriScheme}`);
    outputChannel.appendLine(`[INFO] Env path: ${envPathLoaded || 'none (using built-in Supabase)'}`);
    outputChannel.appendLine(`[INFO] Env loaded: ${envPathLoaded ? '✅' : '❌'}`);
    outputChannel.appendLine("========================================");

    outputChannel.appendLine(`[INFO] Mobile message format: messages table rows are JSON with fields { id, user_id, role, content, status, session_id, client_type, created_at }.`);
    outputChannel.appendLine(`[INFO] Agent replies use: role="agent", status="completed", client_type="${CURSOR_CLIENT_TYPE}".`);
    outputChannel.appendLine(`[INFO] Example mobile → Supabase row: {"user_id":"<MOBILE_TOKEN>","role":"user","content":"Привет","status":"pending","session_id":"<SESSION_ID>","client_type":"mobile"}.`);
    outputChannel.appendLine(`[INFO] Example Cursor reply row: {"user_id":"<MOBILE_TOKEN>","role":"agent","content":"Ответ IDE","status":"completed","session_id":"<SESSION_ID>","client_type":"${CURSOR_CLIENT_TYPE}"}.`);

    // Log Language Model API availability
    if (vscode.lm && vscode.lm.selectChatModels) {
        outputChannel.appendLine(`[LM] Language Model API: AVAILABLE`);
    } else {
        outputChannel.appendLine(`[LM] Language Model API: NOT AVAILABLE`);
    }

    statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
    statusBarItem.command = 'cursor-mobile-connector.showMenu';
    statusBarItem.text = "$(device-mobile) Mobile: Initializing...";
    statusBarItem.show();
    context.subscriptions.push(statusBarItem);

    // Check macOS Accessibility permissions (required for key simulation)
    if (process.platform === 'darwin') {
        const { exec } = require('child_process');
        exec(`osascript -e 'tell application "System Events" to keystroke ""'`, (err) => {
            if (err && err.message && err.message.includes('not allowed')) {
                outputChannel.appendLine(`[WARN] Accessibility permissions not granted. Key simulation will not work.`);
                vscode.window.showWarningMessage(
                    '📱 Mobile Connector: Accessibility permission required to send messages.',
                    'Open Settings'
                ).then(selection => {
                    if (selection === 'Open Settings') {
                        const { exec: openExec } = require('child_process');
                        openExec(`open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"`);
                    }
                });
            } else {
                outputChannel.appendLine(`[INFO] Accessibility permissions: OK`);
            }
        });
    }

    // Initial state: Connect if token is present
    const savedToken = globalContext.globalState.get('mobile_token');
    if (savedToken) {
        outputChannel.appendLine(`[INIT] Found saved token: ${savedToken.substring(0, 8)}... Auto-connecting.`);
        updateStatusDisplay(false, savedToken.substring(0, 4), "Linked (auto-connecting)");
        refreshConnection();
        // [v1.0.142] Run full connection lifecycle for auto-connect too
        setTimeout(() => onDeviceConnected(savedToken), 2000);
    } else {
        updateStatusDisplay(false, "", "Not Linked");
    }

    // 2. Register URI Handler
    context.subscriptions.push(vscode.window.registerUriHandler({
        handleUri(uri) {
            outputChannel.appendLine(`[URI] TRIGGERED! fullUri: ${uri.toString()}`);
            // outputChannel.appendLine(`[URI] Details -> scheme: ${uri.scheme}, authority: ${uri.authority}, path: ${uri.path}, query: ${uri.query}`);

            const cleanPath = uri.path.replace(/^\//, '');
            if (cleanPath === 'connect' || uri.path === '/connect') {
                const params = new URLSearchParams(uri.query);
                const token = params.get('token');
                if (token) {
                    outputChannel.appendLine(`[URI] SUCCESS! New token received: ${token}`);
                    globalContext.globalState.update('mobile_token', token);
                    outputChannel.appendLine(`[URI] Device linked (${token.substring(0, 4)}...). Connecting silently.`);
                    refreshConnection(true);
                    // [v1.0.142] Unified connection lifecycle
                    setTimeout(() => {
                        if (supabase && token) {
                            onDeviceConnected(token);
                        }
                    }, 800);
                } else {
                    outputChannel.appendLine(`[URI] ERROR: 'token' missing in query.`);
                }
            } else {
                outputChannel.appendLine(`[URI] Path mismatch: ${cleanPath}`);
            }
        }
    }));

    // 3. Initialize Supabase
    try {
        initSupabase();
    } catch (e) {
        outputChannel.appendLine(`[ERROR] Supabase init failed: ${e.message}`);
    }

    // 3.1. Start periodic TTL cleanup for mirroredReplyCache (moved from module scope for proper cleanup)
    _mirrorCacheCleanupInterval = setInterval(() => {
        const now = Date.now();
        for (const [key, entry] of mirroredReplyCache) {
            if (now - entry.sentAt > CURSOR_MIRROR_MAX_LIFETIME_MS * 2) {
                mirroredReplyCache.delete(key);
            }
        }
    }, 300000); // every 5 min

    // 3.5. Sync available models to Supabase + start watcher for realtime changes
    setTimeout(() => {
        outputChannel.appendLine(`[DEBUG] 🔧 Starting model sync + watcher...`);
        syncAvailableModelsToSupabase().catch(e => {
            outputChannel.appendLine(`[LM] Model sync on activate failed: ${e.message}`);
        });
        // [v1.0.141] Start model watcher — polls every 5s, syncs only when changes detected
        try {
            startModelWatcher();
            outputChannel.appendLine(`[DEBUG] 🔧 startModelWatcher() called successfully.`);
        } catch (e) {
            outputChannel.appendLine(`[DEBUG] ❌ startModelWatcher() CRASHED: ${e.message}\n${e.stack}`);
        }
    }, 3000);

    // 4. Register Commands
    context.subscriptions.push(vscode.commands.registerCommand('cursor-mobile-connector.showMenu', async () => {
        const token = globalContext.globalState.get('mobile_token');
        const items = [
            { label: "$(info) Show connection info", id: "info" },
            { label: "$(vm-connect) Connect to this project", id: "connect_window" },
            { label: "$(debug-disconnect) Disconnect and clear data", id: "clear", description: "Warning: this will un-link your device" }
        ];

        const selected = await vscode.window.showQuickPick(items, {
            placeHolder: token ? `Linked to ${token.substring(0, 8)}...` : "Mobile is not linked"
        });

        if (!selected) return;

        switch (selected.id) {
            case "info":
                vscode.commands.executeCommand('cursor-mobile-connector.showInfo');
                break;
            case "connect_window":
                refreshConnection(true);
                outputChannel.appendLine("[UI] Connected to this project.");
                break;
            case "clear":
                vscode.commands.executeCommand('cursor-mobile-connector.clear');
                break;
        }
    }));



    context.subscriptions.push(vscode.commands.registerCommand('cursor-mobile-connector.clear', async () => {
        const token = globalContext.globalState.get('mobile_token');
        if (token) {
            if (!supabase) initSupabase();
            await sendConnectionTerminated(token);
        }
        globalContext.globalState.update('mobile_token', undefined);
        if (messageSubscription) messageSubscription.unsubscribe();
        messageSubscription = null;
        stopIdeHeartbeatTimer();
        updateStatusDisplay();
        outputChannel.appendLine(`[RESET] Global state cleared.`);
        vscode.window.showInformationMessage('Connection data cleared.');
    }));

    context.subscriptions.push(vscode.commands.registerCommand('cursor-mobile-connector.showInfo', () => {
        const token = globalContext.globalState.get('mobile_token');
        if (token) {
            vscode.window.showInformationMessage(`Linked iPhone ID: ${token.substring(0, 8)}...`);
        } else {
            vscode.window.showInformationMessage("iPhone is not linked.");
        }
    }));

    context.subscriptions.push(vscode.commands.registerCommand('cursor-mobile-connector.switchModel', async () => {
        const items = await getCursorModelList();
        const pick = await vscode.window.showQuickPick(items.map(m => ({
            label: `${m.enabled !== false ? '✅' : '⛔'} ${m.label}`,
            description: m.id || 'Auto',
            detail: m.enabled === false ? '⚠️ Not enabled in Cursor Settings → Models' : undefined,
            _modelLabel: m.label,
            _enabled: m.enabled !== false
        })), {
            title: 'Switch to model',
            placeHolder: 'Choose model (✅ = enabled, ⛔ = not enabled in Cursor)'
        });
        if (pick) {
            if (!pick._enabled) {
                const proceed = await vscode.window.showWarningMessage(
                    `"${pick._modelLabel}" is not enabled in Cursor. Enable it first in Cursor Settings → Models.`,
                    'Try Anyway', 'Cancel'
                );
                if (proceed !== 'Try Anyway') return;
            }
            await doSwitchModel(pick._modelLabel);
        }
    }));

    context.subscriptions.push(vscode.commands.registerCommand('cursor-mobile-connector.stopGeneration', async () => {
        outputChannel?.appendLine(`[STOP] ⏹ Manual stop triggered via Command Palette.`);

        let composerIds = [];
        try {
            composerIds = await vscode.commands.executeCommand('composer.getOrderedSelectedComposerIds') || [];
            outputChannel?.appendLine(`[STOP] Active composer IDs: ${JSON.stringify(composerIds)}`);
        } catch (e) {
            outputChannel?.appendLine(`[STOP] Could not get composer IDs: ${e.message}`);
        }

        let stopped = false;

        if (composerIds.length > 0) {
            for (const cid of composerIds) {
                try {
                    await vscode.commands.executeCommand('composer.cancelChat', cid);
                    outputChannel?.appendLine(`[STOP] ✅ cancelChat(${cid}) succeeded!`);
                    stopped = true;
                    break;
                } catch (e) {
                    outputChannel?.appendLine(`[STOP] ⚠️ cancelChat(${cid}) failed: ${e.message}`);
                }
            }
        }

        if (!stopped) {
            try {
                await vscode.commands.executeCommand('composer.focusComposer');
                await new Promise(r => setTimeout(r, 200));
                await vscode.commands.executeCommand('composer.cancelChat');
                outputChannel?.appendLine(`[STOP] ✅ cancelChat() without args`);
                stopped = true;
            } catch (e) {
                outputChannel?.appendLine(`[STOP] ⚠️ cancelChat() without args failed: ${e.message}`);
            }
        }

        if (!stopped) {
            try {
                await vscode.commands.executeCommand('composer.cancelComposerStep');
                outputChannel?.appendLine(`[STOP] ✅ cancelComposerStep`);
            } catch (e) {
                outputChannel?.appendLine(`[STOP] ⚠️ All approaches failed.`);
            }
        }

        vscode.window.showInformationMessage('⏹ Stop generation triggered.');

        if (activeMirrorJobs.size > 0) {
            setTimeout(async () => {
                const token = globalContext?.globalState?.get('mobile_token');
                for (const [jobId, job] of activeMirrorJobs) {
                    const partialText = job.assistantText || '';
                    outputChannel?.appendLine(`[STOP] Finalizing mirror job ${jobId} with ${partialText.length} chars.`);
                    if (token) {
                        try {
                            const sessionId = job.sessionId || '';
                            if (job.supabaseRecordId) {
                                await sendAgentReplyToMobile(
                                    { user_id: token, supabaseRecordId: job.supabaseRecordId, originalMessageId: jobId },
                                    sessionId, partialText || '⏹ Stopped.', 'stopped', true
                                );
                            } else {
                                await sendAgentReplyToMobile(
                                    { user_id: token, id: jobId },
                                    sessionId, partialText || '⏹ Stopped.', 'stopped', false
                                );
                            }
                        } catch (e) {
                            outputChannel?.appendLine(`[STOP] Finalize error: ${e.message}`);
                        }
                    }
                    finalizeMirrorJob(jobId);
                }
                outputChannel?.appendLine(`[STOP] ✅ All mirror jobs finalized.`);
            }, 1500);
        }
    }));

    // Lock Monitor to keep UI in sync across windows (every 15s instead of 2s to reduce I/O)
    setInterval(() => {
        try {
            const lockPath = path.join(os.homedir(), '.gemini', 'antigravity', 'active_vscode_window.txt');
            if (fs.existsSync(lockPath)) {
                const lockData = fs.readFileSync(lockPath, 'utf8');
                if (lockData.startsWith('{')) {
                    const parsed = JSON.parse(lockData);
                    if (parsed.id === globalInstanceId) {
                        makeWindowPrimary(); // Update the heartbeat timestamp
                    }
                } else if (lockData.trim() === globalInstanceId) {
                    makeWindowPrimary(); // Upgrade old string format to json format
                }
            }
        } catch (e) { }

        const token = globalContext.globalState.get('mobile_token');
        if (token && messageSubscription) {
            updateStatusDisplay();
        }
    }, 15000);
}

function initSupabase() {
    const url = process.env.SUPABASE_URL || DEFAULT_SUPABASE_URL;
    const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;

    if (!url || !key) {
        outputChannel.appendLine("[ERROR] ❌ Missing Supabase credentials (no .env and no built-in).");
        return;
    }

    supabase = createClient(url, key);
    const source = process.env.SUPABASE_URL ? '.env' : 'built-in';
    outputChannel.appendLine(`[INIT] ✅ Supabase client initialized (${source}, ${url.substring(0, 30)}...)`);
}

async function fetchUserModelPreference(userId) {
    try {
        if (!supabase) {
            initSupabase();
        }
        if (!supabase) return null;
        if (!userId) return null;

        const { data, error } = await supabase
            .from('user_preferences')
            .select('preferred_model_id, mode')
            .eq('user_id', userId)
            .limit(1)
            .maybeSingle();

        if (error) {
            outputChannel.appendLine(`[LM] Warning: failed to read user_preferences for ${userId.substring(0, 8)}: ${error.message}`);
            return null;
        }

        if (!data) return null;
        return data;
    } catch (e) {
        outputChannel.appendLine(`[LM] Warning: error reading user_preferences: ${e.message}`);
        return null;
    }
}

// Curated Cursor model IDs (from Cursor model selector UI) — used when vscode.lm returns empty
const CURSOR_AVAILABLE_MODELS = [
    // ── TOP (enabled by default) ──
    'Composer 1.5',
    'GPT-5.3 Codex',
    'GPT-5.4',
    'Sonnet 4.6',
    'Opus 4.6',
    // ── Composer ──
    'Composer 1',
    // ── GPT-5.3 Codex ──
    'GPT-5.3 Codex Low',
    'GPT-5.3 Codex Low Fast',
    'GPT-5.3 Codex Fast',
    'GPT-5.3 Codex High',
    'GPT-5.3 Codex High Fast',
    'GPT-5.3 Codex Extra High',
    'GPT-5.3 Codex Extra High Fast',
    'GPT-5.3 Codex Spark Low',
    'GPT-5.3 Codex Spark',
    'GPT-5.3 Codex Spark High',
    'GPT-5.3 Codex Spark Extra High',
    // ── GPT-5.4 ──
    'GPT-5.4 Low',
    'GPT-5.4 Fast',
    'GPT-5.4 High',
    'GPT-5.4 High Fast',
    'GPT-5.4 Extra High',
    'GPT-5.4 Extra High Fast',
    // ── GPT-5.2 ──
    'GPT-5.2',
    'GPT-5.2 Low',
    'GPT-5.2 Low Fast',
    'GPT-5.2 Fast',
    'GPT-5.2 High',
    'GPT-5.2 High Fast',
    'GPT-5.2 Extra High',
    'GPT-5.2 Extra High Fast',
    // ── GPT-5.2 Codex ──
    'GPT-5.2 Codex',
    'GPT-5.2 Codex Low',
    'GPT-5.2 Codex Low Fast',
    'GPT-5.2 Codex Fast',
    'GPT-5.2 Codex High',
    'GPT-5.2 Codex High Fast',
    'GPT-5.2 Codex Extra High',
    'GPT-5.2 Codex Extra High Fast',
    // ── GPT-5.1 Codex ──
    'GPT-5.1 High',
    'GPT-5.1 Codex Max',
    'GPT-5.1 Codex Max Low',
    'GPT-5.1 Codex Max Low Fast',
    'GPT-5.1 Codex Max Medium Fast',
    'GPT-5.1 Codex Max High',
    'GPT-5.1 Codex Max High Fast',
    'GPT-5.1 Codex Max Extra High',
    'GPT-5.1 Codex Max Extra High Fast',
    'GPT-5.1 Codex Mini',
    'GPT-5.1 Codex Mini Low',
    'GPT-5.1 Codex Mini High',
    // ── Claude / Anthropic ──
    'Sonnet 4',
    'Sonnet 4 1M',
    'Sonnet 4.5',
    'Opus 4.6 Max',
    'Opus 4.6 Fast',
    'Opus 4.6 Max Fast',
    'Opus 4.5',
    'Haiku 4.5',
    // ── Google ──
    'Gemini 3.1 Pro',
    'Gemini 3 Pro',
    'Gemini 3 Flash',
    'Gemini 2.5 Flash',
    // ── xAI ──
    'Grok Code',
    // ── OpenAI ──
    'GPT-5 Mini',
    // ── Moonshot ──
    'Kimi K2.5',
];

// Fallback when lmModels is empty: slugs for switchToModel / switchToDynamicModelSlug
const MODEL_ID_VARIANTS = {
    'Auto':                          [],
    'Composer 1.5':                  ['cursor-composer-1-5', 'composer-1.5', 'cursor-composer-1.5'],
    'Composer 1':                    ['cursor-composer-1', 'composer-1', 'cursor-composer'],
    'GPT-5.3 Codex':                 ['gpt-5.3-codex', 'gpt-5-3-codex'],
    'GPT-5.3 Codex Low':             ['gpt-5.3-codex-low', 'gpt-5-3-codex-low'],
    'GPT-5.3 Codex Low Fast':        ['gpt-5.3-codex-low-fast'],
    'GPT-5.3 Codex Fast':            ['gpt-5.3-codex-fast'],
    'GPT-5.3 Codex High':            ['gpt-5.3-codex-high'],
    'GPT-5.3 Codex High Fast':       ['gpt-5.3-codex-high-fast'],
    'GPT-5.3 Codex Extra High':      ['gpt-5.3-codex-extra-high'],
    'GPT-5.3 Codex Extra High Fast': ['gpt-5.3-codex-extra-high-fast'],
    'GPT-5.3 Codex Spark Low':       ['gpt-5.3-codex-spark-low'],
    'GPT-5.3 Codex Spark':           ['gpt-5.3-codex-spark'],
    'GPT-5.3 Codex Spark High':      ['gpt-5.3-codex-spark-high'],
    'GPT-5.3 Codex Spark Extra High':['gpt-5.3-codex-spark-extra-high'],
    'GPT-5.4':                       ['gpt-5.4', 'gpt-5-4'],
    'GPT-5.4 Low':                   ['gpt-5.4-low'],
    'GPT-5.4 Fast':                  ['gpt-5.4-fast'],
    'GPT-5.4 High':                  ['gpt-5.4-high'],
    'GPT-5.4 High Fast':             ['gpt-5.4-high-fast'],
    'GPT-5.4 Extra High':            ['gpt-5.4-extra-high'],
    'GPT-5.4 Extra High Fast':       ['gpt-5.4-extra-high-fast'],
    'GPT-5.2':                       ['gpt-5.2', 'gpt-5-2'],
    'GPT-5.2 Low':                   ['gpt-5.2-low'],
    'GPT-5.2 Low Fast':              ['gpt-5.2-low-fast'],
    'GPT-5.2 Fast':                  ['gpt-5.2-fast'],
    'GPT-5.2 High':                  ['gpt-5.2-high'],
    'GPT-5.2 High Fast':             ['gpt-5.2-high-fast'],
    'GPT-5.2 Extra High':            ['gpt-5.2-extra-high'],
    'GPT-5.2 Extra High Fast':       ['gpt-5.2-extra-high-fast'],
    'GPT-5.2 Codex':                 ['gpt-5.2-codex'],
    'GPT-5.2 Codex Low':             ['gpt-5.2-codex-low'],
    'GPT-5.2 Codex Low Fast':        ['gpt-5.2-codex-low-fast'],
    'GPT-5.2 Codex Fast':            ['gpt-5.2-codex-fast'],
    'GPT-5.2 Codex High':            ['gpt-5.2-codex-high'],
    'GPT-5.2 Codex High Fast':       ['gpt-5.2-codex-high-fast'],
    'GPT-5.2 Codex Extra High':      ['gpt-5.2-codex-extra-high'],
    'GPT-5.2 Codex Extra High Fast': ['gpt-5.2-codex-extra-high-fast'],
    'GPT-5.1 High':                  ['gpt-5.1-high'],
    'GPT-5.1 Codex Max':             ['gpt-5.1-codex-max'],
    'GPT-5.1 Codex Max Low':         ['gpt-5.1-codex-max-low'],
    'GPT-5.1 Codex Max Low Fast':    ['gpt-5.1-codex-max-low-fast'],
    'GPT-5.1 Codex Max Medium Fast': ['gpt-5.1-codex-max-medium-fast'],
    'GPT-5.1 Codex Max High':        ['gpt-5.1-codex-max-high'],
    'GPT-5.1 Codex Max High Fast':   ['gpt-5.1-codex-max-high-fast'],
    'GPT-5.1 Codex Max Extra High':  ['gpt-5.1-codex-max-extra-high'],
    'GPT-5.1 Codex Max Extra High Fast': ['gpt-5.1-codex-max-extra-high-fast'],
    'GPT-5.1 Codex Mini':            ['gpt-5.1-codex-mini'],
    'GPT-5.1 Codex Mini Low':        ['gpt-5.1-codex-mini-low'],
    'Sonnet 4.6':                    ['claude-sonnet-4-6', 'claude-sonnet-4.6', 'sonnet-4-6', 'sonnet-4.6'],
    'Sonnet 4.5':                    ['claude-sonnet-4-5', 'claude-sonnet-4.5', 'sonnet-4-5', 'sonnet-4.5'],
    'Opus 4.6':                      ['claude-opus-4-6', 'claude-opus-4.6', 'opus-4-6', 'opus-4.6'],
    'Opus 4.6 Max':                  ['claude-opus-4-6-max', 'opus-4-6-max'],
    'Opus 4.6 Fast':                 ['claude-opus-4-6-fast', 'opus-4-6-fast'],
    'Opus 4.6 Max Fast':             ['claude-opus-4-6-max-fast', 'opus-4-6-max-fast'],
    'Opus 4.5':                      ['claude-opus-4-5', 'claude-opus-4.5', 'opus-4-5', 'opus-4.5'],
    'Haiku 4.5':                     ['claude-haiku-4-5', 'claude-haiku-4.5', 'haiku-4-5', 'haiku-4.5'],
    'Gemini 3.1 Pro':                ['gemini-3.1-pro', 'gemini-3-1-pro'],
    'Gemini 3 Pro':                  ['gemini-3-pro', 'gemini-3.0-pro'],
    'Gemini 3 Flash':                ['gemini-3-flash', 'gemini-3.0-flash'],
    'Gemini 2.5 Flash':              ['gemini-2.5-flash', 'gemini-2-5-flash'],
    'Grok Code':                     ['grok-code', 'grok-3-code', 'xai-grok-code'],
    'GPT-5.1 Codex Mini High':       ['gpt-5.1-codex-mini-high'],
    'Sonnet 4':                      ['claude-sonnet-4', 'claude-4-sonnet', 'sonnet-4'],
    'Sonnet 4 1M':                   ['claude-sonnet-4-1m', 'sonnet-4-1m'],
    'GPT-5 Mini':                    ['gpt-5-mini', 'openai-gpt-5-mini'],
    'Kimi K2.5':                     ['kimi-k2.5', 'kimi-k2-5', 'moonshot-kimi-k2.5'],
};

// [v1.0.131] Read model list dynamically from Cursor's own globalStorage/state.vscdb
// Key: availableDefaultModels2  –  written by Cursor itself, always up-to-date.
const CURSOR_GLOBAL_STATE_KEY = 'src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.applicationUser';

async function readCursorModelsFromSqlite() {
    const globalDbPath = path.join(
        os.homedir(),
        'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'
    );
    if (!fs.existsSync(globalDbPath)) {
        outputChannel?.appendLine('[MODEL] globalStorage/state.vscdb not found. Skipping dynamic model read.');
        return [];
    }

    // [v1.0.140] Read directly from live DB (no file copy — avoids stale WAL issues)
    try {
        const rows = await runSqliteJsonQuery(
            globalDbPath,
            `SELECT value FROM ItemTable WHERE key = '${CURSOR_GLOBAL_STATE_KEY}' LIMIT 1`
        );
        if (!rows || rows.length === 0 || !rows[0].value) return [];

        const data = typeof rows[0].value === 'string' ? JSON.parse(rows[0].value) : rows[0].value;
        const rawModels = data.availableDefaultModels2;
        if (!Array.isArray(rawModels) || rawModels.length === 0) return [];

        // [v1.0.132] Read user's enabled/disabled overrides from aiSettings
        const aiSettings = data.aiSettings || {};
        const overrideEnabled = new Set(aiSettings.modelOverrideEnabled || []);
        const overrideDisabled = new Set(aiSettings.modelOverrideDisabled || []);

        const result = [{ label: 'Auto', id: 'default', enabled: true }];
        for (const m of rawModels) {
            const label = m.clientDisplayName || m.name;
            const id = m.serverModelName || m.name;
            if (label && id && id !== 'default') {
                // enabled = (defaultOn AND NOT overrideDisabled) OR overrideEnabled
                const isEnabled = (m.defaultOn && !overrideDisabled.has(id)) || overrideEnabled.has(id);
                result.push({ label, id, enabled: isEnabled });
            }
        }
        const enabledCount = result.filter(m => m.enabled).length;
        outputChannel?.appendLine(`[MODEL] readCursorModelsFromSqlite: found ${result.length} models (${enabledCount} enabled)`);
        return result;
    } catch (e) {
        outputChannel?.appendLine(`[MODEL] readCursorModelsFromSqlite error: ${e.message}`);
        return [];
    }
}

async function getCursorModelList() {
    // 1. Try Cursor's own SQLite state (most accurate, always up-to-date)
    try {
        const sqliteModels = await readCursorModelsFromSqlite();
        if (sqliteModels.length > 1) {
            outputChannel?.appendLine(`[MODEL] getCursorModelList: using ${sqliteModels.length} models from state.vscdb`);
            return sqliteModels;
        }
    } catch (e) {
        outputChannel?.appendLine(`[MODEL] getCursorModelList SQLite error: ${e.message}`);
    }

    // 2. Try vscode.lm API (usually empty in Cursor, but keep as fallback)
    const out = [{ label: 'Auto', id: null }];
    try {
        if (vscode.lm && vscode.lm.selectChatModels) {
            const models = await vscode.lm.selectChatModels();
            if (models && models.length > 0) {
                models.forEach(m => {
                    const name = (m.name || m.id || '').trim();
                    if (name && m.id) out.push({ label: name, id: m.id });
                });
                return out;
            }
        }
    } catch (e) {
        outputChannel.appendLine(`[MODEL] getCursorModelList LM error: ${e.message}`);
    }

    // 3. Hardcoded fallback
    outputChannel?.appendLine(`[MODEL] getCursorModelList: falling back to hardcoded list (${CURSOR_AVAILABLE_MODELS.length} models)`);
    CURSOR_AVAILABLE_MODELS.forEach(name => {
        const variants = MODEL_ID_VARIANTS[name];
        const id = (variants && variants[0]) || name.toLowerCase().replace(/\s+/g, '-');
        out.push({ label: name, id });
    });
    return out;
}

let _modelWatcherInterval = null;
let _lastModelHash = null;

function startModelWatcher() {
    if (_modelWatcherInterval) return; // Already running
    
    const POLL_INTERVAL_MS = 10000; // [v1.0.170] Check every 10 seconds (was 5s, compromise for UX vs I/O)
    
    _modelWatcherInterval = setInterval(async () => {
        try {
            const models = await readCursorModelsFromSqlite();
            if (models.length < 2) return; // No models found
            
            // Build a hash of enabled states only (lightweight comparison)
            const enabledIds = models.filter(m => m.enabled).map(m => m.id).sort().join(',');
            const currentHash = hashText(enabledIds);
            
            if (_lastModelHash === null) {
                _lastModelHash = currentHash;
                return; // First read, just store the hash
            }
            
            if (currentHash !== _lastModelHash) {
                _lastModelHash = currentHash;
                outputChannel?.appendLine(`[MODEL-WATCH] 🔄 Model changes detected! Syncing to Supabase...`);
                await syncAvailableModelsToSupabase();
                outputChannel?.appendLine(`[MODEL-WATCH] ✅ Models synced after change.`);
            }
        } catch (e) {
            outputChannel?.appendLine(`[MODEL-WATCH] ⚠️ Error: ${e.message}`);
        }
    }, POLL_INTERVAL_MS);
    
    outputChannel?.appendLine(`[MODEL-WATCH] 👁️ Started model watcher (every ${POLL_INTERVAL_MS / 1000}s)`);
}

async function syncAvailableModelsToSupabase() {
    try {
        if (!supabase) {
            initSupabase();
        }
        if (!supabase) {
            outputChannel.appendLine('[LM] Skipping model sync: Supabase not initialized.');
            return;
        }

        let modelObjects = []; // [{ label, id }]

        // 1. Try Cursor's SQLite state (primary source)
        try {
            const sqliteModels = await readCursorModelsFromSqlite();
            if (sqliteModels.length > 1) {
                modelObjects = sqliteModels;
                outputChannel.appendLine(`[LM] Using ${modelObjects.length} models from Cursor state.vscdb`);
            }
        } catch (e) {
            outputChannel.appendLine(`[LM] SQLite model read error: ${e.message}`);
        }

        // 2. Env override (legacy)
        if (modelObjects.length === 0 && process.env.AVAILABLE_MODELS) {
            const names = process.env.AVAILABLE_MODELS.split(',').map(m => m.trim()).filter(Boolean);
            modelObjects = [{ label: 'Auto', id: 'default' }, ...names.map(n => ({ label: n, id: n.toLowerCase().replace(/\s+/g, '-') }))];
            outputChannel.appendLine(`[LM] Using AVAILABLE_MODELS from .env: ${names.join(', ')}`);
        }

        // 3. vscode.lm API (usually empty)
        if (modelObjects.length === 0 && vscode.lm && vscode.lm.selectChatModels) {
            try {
                const models = await vscode.lm.selectChatModels();
                if (models && models.length > 0) {
                    modelObjects = [{ label: 'Auto', id: 'default' }, ...models.map(m => ({ label: m.name || m.id, id: m.id }))];
                    outputChannel.appendLine(`[LM] Using models from vscode.lm: ${models.map(m => m.id).join(', ')}`);
                }
            } catch (e) {
                outputChannel.appendLine(`[LM] vscode.lm error: ${e.message}`);
            }
        }

        // 4. Hardcoded fallback
        if (modelObjects.length === 0) {
            modelObjects = [{ label: 'Auto', id: 'default' }, ...CURSOR_AVAILABLE_MODELS.map(name => {
                const variants = MODEL_ID_VARIANTS[name];
                return { label: name, id: (variants && variants[0]) || name.toLowerCase().replace(/\s+/g, '-') };
            })];
            outputChannel.appendLine(`[LM] Using curated Cursor models (fallback): ${modelObjects.length} models`);
        }

        outputChannel.appendLine(`[LM] Syncing ${modelObjects.length} models to Supabase`);

        // Get current user's token for per-user model storage
        const userToken = globalContext?.globalState?.get('mobile_token') || lastMobileUserId;
        if (!userToken) {
            outputChannel.appendLine('[LM] Skipping model sync: no user token available.');
            return;
        }
        
        const { error } = await supabase
            .from('system_info')
            .upsert({
                id: 'available_models',
                user_id: userToken,
                data: modelObjects,
                updated_at: new Date().toISOString()
            }, { onConflict: 'id,user_id' });

        if (error) {
            outputChannel.appendLine(`[LM] ❌ Supabase error syncing available models: ${error.message}`);
        } else {
            outputChannel.appendLine('[LM] ✅ Available models synced to Supabase.');
        }
    } catch (e) {
        outputChannel.appendLine(`[LM] CRITICAL error during model sync: ${e.message}`);
    }
}

async function refreshConnection(isManual = false) {
    // [FIX] Guard against parallel refreshConnection calls — prevents burst reconnect loops
    if (refreshConnectionInProgress) {
        outputChannel.appendLine(`[REFRESH] ⏭️ Skipping: refreshConnection already in progress.`);
        return;
    }
    refreshConnectionInProgress = true;

    // [FIX] Clear any pending reconnect timer to prevent stacking
    if (reconnectTimer) {
        clearTimeout(reconnectTimer);
        reconnectTimer = null;
    }

    // [FIX] Reset reconnectAttempts on manual connect
    if (isManual) {
        reconnectAttempts = 0;
    }

    try {
    if (!supabase) initSupabase();
    const token = globalContext.globalState.get('mobile_token') || null;

    outputChannel.appendLine(`[REFRESH] ⏳ refreshConnection called at ${new Date().toISOString()}. isManual: ${isManual}, token: ${token ? token.substring(0, 8) + '...' : 'NULL'}`);

    // If there is a token, check if we should claim the routing lock
    if (token) {
        let shouldTakeLock = isManual;

        // If it's an auto-connect, only take the lock if the existing one is dead or missing
        if (!shouldTakeLock) {
            try {
                const lockPath = path.join(os.homedir(), '.gemini', 'antigravity', 'active_vscode_window.txt');
                if (fs.existsSync(lockPath)) {
                    const lockData = fs.readFileSync(lockPath, 'utf8');
                    if (lockData.startsWith('{')) {
                        const parsed = JSON.parse(lockData);
                        // If older than 45 seconds, consider it dead
                        if (Date.now() - parsed.timestamp > 45000) {
                            shouldTakeLock = true;
                        }
                    } else {
                        // Old format, consider dead
                        shouldTakeLock = true;
                    }
                } else {
                    shouldTakeLock = true;
                }
            } catch (e) {
                shouldTakeLock = true; // take lock on error
            }
        }

        if (shouldTakeLock) {
            makeWindowPrimary();
        } else {
            outputChannel.appendLine(`[LOCK-SKIP] Auto-connect skipping lock. Another window might be active. Click "Connect to this project" to force.`);
        }
    }

    if (messageSubscription) {
        outputChannel.appendLine(`[PROXY] Unsubscribing from previous channel...`);
        messageSubscription.unsubscribe();
        messageSubscription = null;
    }

    if (!token) {
        updateStatusDisplay();
        return;
    }

    outputChannel.appendLine(`[PROXY] Connecting to Supabase for token: ${token}...`);

    // [v1.0.169] Removed initial SELECT query to reduce Disk IO pressure.
    // lastActivityTime is now set only from Realtime callbacks.
    updateStatusDisplay();

    const channelName = `proxy-${token}-${Date.now()}`;
    messageSubscription = supabase
        .channel(channelName)
        .on('postgres_changes',
            { event: '*', schema: 'public', table: 'messages', filter: `user_id=eq.${token}` },
            async (payload) => {
                const msg = payload.new;
                // Accept messages from mobile app (client_type='mobile', role='user' prompts)
                // AND messages from this IDE (client_type='cursor', signals/heartbeats).
                // Previously only 'cursor' was accepted — which SILENTLY DROPPED all mobile prompts!
                const isMobileMsg = msg.client_type === 'mobile';
                const isCursorMsg = msg.client_type === CURSOR_CLIENT_TYPE;
                if (!msg || (!isMobileMsg && !isCursorMsg)) return;
                
                const eventType = payload.eventType; // 'INSERT' or 'UPDATE'
                const shortContent = msg.content ? msg.content.substring(0, 50).replace(/\n/g, ' ') + (msg.content.length > 50 ? '...' : '') : 'NULL';

                if (msg.role === 'agent' && !(msg.content || '').startsWith('SYSTEM:')) {
                    // [v1.0.118] Skip mirroring agent replies that come from Realtime.
                    // We already log the FULL content right before it's sent to Supabase (search for [AGENT REPLY] SENT TO SUPABASE).
                    // Realtime often truncates large messages, so logging it here only adds confusing duplicates with missing text.
                    return;
                } else if (msg.role !== 'agent') {
                    // [v1.0.170] Only log non-system user messages to reduce output noise
                    if (eventType === 'INSERT' && msg.role === 'user' && !(msg.content || '').startsWith('SYSTEM:')) {
                        outputChannel.appendLine(`[MSG ARRIVED] role: ${msg.role}, content: "${shortContent}", user_id: ${msg.user_id.substring(0, 8)}`);
                    }
                }

                // Update mobile heartbeats
                if (msg.content === 'SYSTEM: Heartbeat') {
                    lastMobileHeartbeatAt = Date.now();
                    updateStatusDisplay();
                }

                if (msg.role === 'user' && msg.status === 'pending') {
                    // Queue prompts so we only inject one at a time into Cursor.
                    // This prevents focus/paste races if multiple pending messages arrive quickly.
                    setImmediate(() => {
                        enqueueAndProcessPrompt(msg);
                    });
                }

                // [v1.0.157] Handle MODEL_SWITCH from mobile (debounced, only on INSERT)
                if (eventType === 'INSERT' && msg.role === 'system' && msg.status === 'pending' && (msg.content || '').startsWith('MODEL_SWITCH:')) {
                    const switchMsgId = msg.id;
                    const newModel = msg.content.replace('MODEL_SWITCH:', '').trim();
                    outputChannel.appendLine(`[MODEL] 🔄 Model switch request from mobile: "${newModel}"`);

                    // Debounce: if user rapidly switches models, only process the last one
                    if (global._modelSwitchTimer) {
                        clearTimeout(global._modelSwitchTimer);
                        // Mark previous pending switch as completed (skip it)
                        if (global._modelSwitchPrevId) {
                            supabase.from('messages').update({ status: 'completed' }).eq('id', global._modelSwitchPrevId).then(() => {}).catch(() => {});
                            supabase.from('messages').delete().eq('id', global._modelSwitchPrevId).then(() => {}).catch(() => {});
                        }
                        outputChannel.appendLine(`[MODEL] ⏩ Debounced previous model switch, using latest: "${newModel}"`);
                    }
                    global._modelSwitchPrevId = switchMsgId;
                    global._modelSwitchTimer = setTimeout(async () => {
                        global._modelSwitchTimer = null;
                        global._modelSwitchPrevId = null;
                        try {
                            // Map modelId → displayName (iOS sends modelId, doSwitchModel expects displayName)
                            let displayName;
                            if (newModel === 'auto' || newModel === 'default') {
                                displayName = 'Auto';
                            } else {
                                try {
                                    const models = await getCursorModelList();
                                    const match = models.find(m => m.id === newModel || m.label === newModel);
                                    displayName = match ? match.label : newModel;
                                } catch (e) {
                                    displayName = newModel;
                                }
                            }
                            await doSwitchModel(displayName);
                            outputChannel.appendLine(`[MODEL] ✅ Cursor model switched to: ${displayName}`);
                            // Delete the MODEL_SWITCH message (cleanup DB)
                            try {
                                await supabase.from('messages').delete().eq('id', switchMsgId);
                                outputChannel.appendLine(`[MODEL] 🗑️ MODEL_SWITCH message deleted from DB`);
                            } catch (_) {}
                        } catch (e) {
                            outputChannel.appendLine(`[MODEL] ❌ Model switch failed: ${e.message}`);
                        }
                    }, 1500); // 1.5s debounce — waits for user to stop switching
                }

                // [v1.0.132] Handle STOP request from mobile
                if (msg.status === 'stop_request') {
                    outputChannel.appendLine(`[STOP] ⏹ Received stop_request from mobile. Cancelling generation...`);
                    try {
                        await vscode.commands.executeCommand('composer.focusComposer');
                        await new Promise(r => setTimeout(r, 200));
                        await vscode.commands.executeCommand('composer.cancelChat');
                        outputChannel.appendLine(`[STOP] ✅ focusComposer + cancelChat executed.`);
                    } catch (e) {
                        outputChannel.appendLine(`[STOP] ⚠️ cancelChat failed: ${e.message}. Trying cancelComposerStep...`);
                        try {
                            await vscode.commands.executeCommand('composer.cancelComposerStep');
                        } catch (_) {}
                    }

                    // Force-finalize any active mirror job with "stopped" status
                    setTimeout(async () => {
                        try {
                            if (activeMirrorJobs.size > 0) {
                                for (const [jobId, job] of activeMirrorJobs) {
                                    const partialText = job.assistantText || '';
                                    outputChannel.appendLine(`[STOP] Finalizing mirror job ${jobId} with ${partialText.length} chars of partial text.`);
                                    
                                    const sessionId = job.sessionId || msg.session_id;
                                    if (job.supabaseRecordId) {
                                        // Update existing record with stopped status
                                        await sendAgentReplyToMobile(
                                            { user_id: msg.user_id, supabaseRecordId: job.supabaseRecordId, originalMessageId: jobId },
                                            sessionId,
                                            partialText || '⏹ Generation was stopped by user.',
                                            'stopped',
                                            true // isUpdate
                                        );
                                    } else {
                                        // No Supabase record yet — create one
                                        await sendAgentReplyToMobile(
                                            { user_id: msg.user_id, id: jobId },
                                            sessionId,
                                            partialText || '⏹ Generation was stopped by user.',
                                            'stopped',
                                            false
                                        );
                                    }
                                    finalizeMirrorJob(jobId);
                                }
                                outputChannel.appendLine(`[STOP] ✅ All mirror jobs finalized with status "stopped".`);
                            } else {
                                outputChannel.appendLine(`[STOP] No active mirror jobs. Sending standalone stopped message.`);
                                await sendAgentReplyToMobile(
                                    { user_id: msg.user_id, id: msg.id },
                                    msg.session_id,
                                    '⏹ Generation was stopped by user.',
                                    'stopped',
                                    false
                                );
                            }
                        } catch (e) {
                            outputChannel.appendLine(`[STOP] ❌ Error during stop finalization: ${e.message}`);
                        }
                    }, 1500); // Wait 1.5s for last text to settle in Cursor DB
                }

                // If it's a termination signal, disconnect immediately (from mobile)
                if (msg.content && msg.content.includes('SYSTEM: Connection terminated')) {
                    outputChannel.appendLine(`[PROXY] Received termination signal from MOBILE. Clearing local state.`);
                    if (messageSubscription) {
                        messageSubscription.unsubscribe();
                        messageSubscription = null;
                    }
                    globalContext.globalState.update('mobile_token', undefined);
                    updateStatusDisplay(false, "", "Not Linked");
                    lastActivityTime = 0;
                }
            }
        )
        .subscribe(async (status) => {
            outputChannel.appendLine(`[PROXY] Subscription status: ${status}`);
            if (status === 'SUBSCRIBED') {
                outputChannel.appendLine(`[PROXY] ✅ Subscription status: ${status}`);
                reconnectAttempts = 0; // Reset backoff on success
                updateStatusDisplay();
                outputChannel.appendLine(`[PROXY] 📡 Sending connection signals...`);
                await sendConnectionSignal(token);
                startIdeHeartbeatTimer(token);
            } else if (status === 'CLOSED' || status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
                outputChannel.appendLine(`[PROXY] Channel ${status.toLowerCase()}.`);
                stopIdeHeartbeatTimer();
                messageSubscription = null;
                // [FIX] Exponential backoff with properly initialized reconnectAttempts
                const backoffMs = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
                reconnectAttempts++;
                // [FIX] Cap reconnect attempts to prevent infinite loops
                if (reconnectAttempts > 10) {
                    outputChannel.appendLine(`[PROXY] ❌ Max reconnect attempts (10) reached. Stopping. Use "Connect to this project" to retry.`);
                    return;
                }
                if (token) {
                    outputChannel.appendLine(`[PROXY] Reconnecting in ${backoffMs / 1000}s (attempt ${reconnectAttempts}/10)...`);
                    reconnectTimer = setTimeout(() => {
                        if (globalContext?.globalState?.get('mobile_token') && !messageSubscription) {
                            refreshConnection(false);
                        }
                    }, backoffMs);
                }
            }
        });
    } finally {
        refreshConnectionInProgress = false;
    }
}

function sendConnectionSignal(token) {
    if (!supabase || !token) return;

    // Check if we are the primary window to prevent spamming "Unknown" from background instances
    try {
        const lockPath = path.join(os.homedir(), '.gemini', 'antigravity', 'active_vscode_window.txt');
        if (fs.existsSync(lockPath)) {
            const lockData = fs.readFileSync(lockPath, 'utf8').trim();
            let lockObj = { id: lockData, updatedAt: 0 };
            try {
                if (lockData.startsWith('{')) lockObj = JSON.parse(lockData);
                else lockObj = { id: lockData, updatedAt: 0 };
            } catch (e) { }

            // If another window owns the lock and it's fresh (< 30s), we are NOT primary.
            if (lockObj.id && lockObj.id !== globalInstanceId && (Date.now() - (lockObj.updatedAt || 0) < 30000)) {
                outputChannel.appendLine(`[SIGNAL] Skipping connection signal (Window ${globalInstanceId.substring(0, 6)} is not primary)`);
                return;
            }
        }
    } catch (e) { }

    const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token);

    // Include workspace name in the connection signal
    const workspaceFolders = vscode.workspace.workspaceFolders;
    const workspaceName = workspaceFolders && workspaceFolders.length > 0 ? workspaceFolders[0].name : 'Unknown';
    const signalKey = `${token}:${workspaceName}`;
    const now = Date.now();
    if (lastConnectionSignal.key === signalKey && now - lastConnectionSignal.sentAt < CONNECTION_SIGNAL_DEDUPE_MS) {
        outputChannel.appendLine(`[SIGNAL] Skipping duplicate connection signal for ${token} (workspace: ${workspaceName})`);
        return;
    }

    const signalData = {
        user_id: token,
        role: 'agent',
        content: `SYSTEM: Connection established | workspace:${workspaceName}`,
        status: 'completed',
        client_type: CURSOR_CLIENT_TYPE,
        source_version: SOURCE_VERSION,
    };
    if (isUuid) {
        signalData.session_id = token;
    }
    return supabase.from('messages').insert(signalData).then(({ error }) => {
        if (error) {
            outputChannel.appendLine(`[SIGNAL] ❌ Error: ${error.message}`);
        } else {
            lastConnectionSignal = { key: signalKey, sentAt: now };
            outputChannel.appendLine(`[SIGNAL] ✅ Connection signal sent (workspace: ${workspaceName})`);
        }
    });
}


/** Send "Connection terminated" so the mobile app shows Disconnected. Call when IDE disconnects or closes. */
function sendConnectionTerminated(token) {
    if (!supabase || !token) return Promise.resolve();
    const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token);
    const payload = {
        user_id: token,
        role: 'system',
        content: 'SYSTEM: Connection terminated',
        status: 'completed',
        client_type: CURSOR_CLIENT_TYPE,
        source_version: SOURCE_VERSION,
    };
    if (isUuid) {
        payload.session_id = token;
    }
    outputChannel?.appendLine(`[SIGNAL] 📤 Sending Connection terminated for ${token.substring(0, 8)}...`);
    return supabase.from('messages').insert(payload).then(({ error }) => {
        if (error) {
            outputChannel?.appendLine(`[SIGNAL] ❌ Terminated signal error: ${error.message}`);
        } else {
            outputChannel?.appendLine(`[SIGNAL] ✅ Terminated signal sent (mobile will show Disconnected).`);
        }
    });
}

function sendIdeHeartbeat(token) {
    if (!supabase || !token) return;

    // Только основное окно шлёт heartbeat, чтобы не было дублей.
    try {
        const lockPath = path.join(os.homedir(), '.gemini', 'antigravity', 'active_vscode_window.txt');
        if (fs.existsSync(lockPath)) {
            const lockData = fs.readFileSync(lockPath, 'utf8').trim();
            let lockObj = { id: lockData, updatedAt: 0 };
            try {
                if (lockData.startsWith('{')) lockObj = JSON.parse(lockData);
                else lockObj = { id: lockData, updatedAt: 0 };
            } catch (e) { }

            if (lockObj.id && lockObj.id !== globalInstanceId && (Date.now() - (lockObj.updatedAt || 0) < 30000)) {
                return; // Another window is primary and fresh
            }
        }
        
        // Refresh our own lock timestamp so other windows know we are still alive
        if (!fs.existsSync(path.dirname(lockPath))) fs.mkdirSync(path.dirname(lockPath), { recursive: true });
        fs.writeFileSync(lockPath, JSON.stringify({ id: globalInstanceId, updatedAt: Date.now() }));
    } catch (e) {
        outputChannel?.appendLine(`[HEARTBEAT] Lock update error: ${e.message}`);
    }

    // [v1.0.172] Write heartbeat to ide_presence (UPSERT, one row per user_id) instead of
    // inserting into messages. The ide_presence table is NOT in the Realtime publication,
    // so this generates 0 Realtime Messages — eliminating the biggest source of quota usage.
    // iOS connection status is maintained via agent replies and REST polling.
    const workspaceFolders = vscode?.workspace?.workspaceFolders;
    const workspaceName = workspaceFolders?.length > 0 ? workspaceFolders[0].name : 'Unknown';

    supabase.from('ide_presence').upsert({
        user_id: token,
        workspace_name: workspaceName,
        updated_at: new Date().toISOString()
    }, { onConflict: 'user_id' }).then(({ error }) => {
        if (error) {
            outputChannel?.appendLine(`[HEARTBEAT] ❌ IDE presence upsert error: ${error.message}`);
        }
    });
}

function startIdeHeartbeatTimer(token) {
    stopIdeHeartbeatTimer();
    if (!token) return;
    ideHeartbeatInterval = setInterval(() => {
        sendIdeHeartbeat(token);
    }, 300000); // [v1.0.169] 5 min instead of 1 min to reduce DB write pressure
}

function stopIdeHeartbeatTimer() {
    if (ideHeartbeatInterval) {
        clearInterval(ideHeartbeatInterval);
        ideHeartbeatInterval = null;
    }
}

function startBackgroundSync(token) {
    outputChannel.appendLine(`[SYNC] 🔄 Starting background sync for token: ${token ? token.substring(0, 8) + '...' : 'NULL'}`);
    if (!supabase) initSupabase();
    if (!supabase) {
        outputChannel.appendLine(`[SYNC] ❌ Cannot start background sync: Supabase not initialized`);
        return;
    }
    refreshConnection();
}

function updateStatusDisplay() {
    if (!globalContext || !statusBarItem) return;
    const token = globalContext.globalState.get('mobile_token');

    let isPrimary = false;
    try {
        const lockPath = path.join(os.homedir(), '.gemini', 'antigravity', 'active_vscode_window.txt');
        if (fs.existsSync(lockPath)) {
            const lockData = fs.readFileSync(lockPath, 'utf8').trim();
            let activeId = lockData;
            if (lockData.startsWith('{')) activeId = JSON.parse(lockData).id;
            isPrimary = (activeId === globalInstanceId);
        }
    } catch (e) { }

    const now = Date.now();
    const hasFreshMobileHeartbeat = lastMobileHeartbeatAt > 0 && (now - lastMobileHeartbeatAt < 25000);

    if (!token) {
        statusBarItem.text = `$(device-mobile) Not Linked`;
        statusBarItem.color = '#fbbf24';
        statusBarItem.tooltip = `No device linked\nStatus: Not Linked. Click to connect.`;
    } else if (token && messageSubscription && isPrimary && hasFreshMobileHeartbeat) {
        statusBarItem.text = `$(device-mobile) Mobile: Online`;
        statusBarItem.color = '#4ade80';
        statusBarItem.tooltip = `Device ID: ${token}\nStatus: Online (this window receives mobile prompts).`;
    } else if (token && messageSubscription && isPrimary && !hasFreshMobileHeartbeat) {
        statusBarItem.text = `$(device-mobile) Mobile: Linked`;
        statusBarItem.color = '#fbbf24';
        statusBarItem.tooltip = `Device ID: ${token}\nStatus: Linked (waiting for mobile heartbeat).`;
    } else if (token && messageSubscription && !isPrimary) {
        statusBarItem.text = `$(device-mobile) Mobile: Paused`;
        statusBarItem.color = '#fbbf24';
        statusBarItem.tooltip = `Device ID: ${token}\nStatus: Another VS Code window is active. Click to connect this window.`;
    } else {
        statusBarItem.text = `$(device-mobile) Mobile: Offline`;
        statusBarItem.color = '#f97316';
        statusBarItem.tooltip = `Device ID: ${token}\nStatus: Offline (no recent heartbeats from mobile).`;
    }
}

async function deactivate() {
    // Stop model watcher
    if (_modelWatcherInterval) {
        clearInterval(_modelWatcherInterval);
        _modelWatcherInterval = null;
    }
    const token = globalContext?.globalState?.get('mobile_token');
    if (token && supabase) {
        // [v1.0.121] Only send termination if we are the primary window.
        let isPrimary = true;
        try {
            const lockPath = path.join(os.homedir(), '.gemini', 'antigravity', 'active_vscode_window.txt');
            if (fs.existsSync(lockPath)) {
                const lockData = fs.readFileSync(lockPath, 'utf8').trim();
                let lockId = lockData;
                if (lockData.startsWith('{')) lockId = JSON.parse(lockData).id;
                if (lockId && lockId !== globalInstanceId) isPrimary = false;
            }
        } catch (e) { }

        if (isPrimary) {
            // [v1.0.142] Await so VS Code doesn't kill process before signal is sent
            try {
                await sendConnectionTerminated(token);
            } catch (_) {}
        } else {
            outputChannel?.appendLine(`[SIGNAL] Deactivating background window (skipping termination signal).`);
        }
    }
    activeMirrorJobs.clear();
    mirroredReplyCache.clear();
    processedMessageIds.clear();
    stopCursorMirrorMonitoringIfIdle();
    stopIdeHeartbeatTimer();
    if (_mirrorCacheCleanupInterval) {
        clearInterval(_mirrorCacheCleanupInterval);
        _mirrorCacheCleanupInterval = null;
    }
    if (global._modelSwitchTimer) {
        clearTimeout(global._modelSwitchTimer);
        global._modelSwitchTimer = null;
    }
    if (messageSubscription) messageSubscription.unsubscribe();
    messageSubscription = null;
}

module.exports = {
    activate,
    deactivate
};

