diff --git a/docs/guide/browser-bridge.md b/docs/guide/browser-bridge.md index 2fac087f9..3c6eed5ac 100644 --- a/docs/guide/browser-bridge.md +++ b/docs/guide/browser-bridge.md @@ -38,7 +38,7 @@ The daemon manages the WebSocket connection between your CLI commands and the Ch ## Daemon Lifecycle -The daemon auto-starts on first browser command and stays alive for **4 hours** by default. It exits only when both conditions are met: no CLI requests for the timeout period AND no Chrome extension connected. +The daemon auto-starts on first browser command and stays alive persistently. ```bash opencli daemon status # Check daemon state (PID, uptime, extension, memory) @@ -46,4 +46,4 @@ opencli daemon stop # Graceful shutdown opencli daemon restart # Stop + restart ``` -Override the timeout via the `OPENCLI_DAEMON_TIMEOUT` environment variable (milliseconds). Set to `0` to keep the daemon alive indefinitely. +The daemon is persistent — it stays alive until you explicitly stop it (`opencli daemon stop`) or uninstall the package. diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 9cb7b528b..377355769 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -45,7 +45,7 @@ opencli daemon restart opencli doctor ``` -> The daemon auto-exits after 4 hours of inactivity (no CLI requests and no extension connection). Override with `OPENCLI_DAEMON_TIMEOUT` (milliseconds, `0` = never timeout). +> The daemon is persistent and stays alive until explicitly stopped (`opencli daemon stop`) or the package is uninstalled. ### Desktop adapter connection issues diff --git a/docs/zh/guide/browser-bridge.md b/docs/zh/guide/browser-bridge.md index 1eb33fdd9..23b0eaf59 100644 --- a/docs/zh/guide/browser-bridge.md +++ b/docs/zh/guide/browser-bridge.md @@ -25,7 +25,7 @@ opencli doctor # 检查扩展 + 守护进程连接 ## Daemon 生命周期 -Daemon 在首次运行浏览器命令时自动启动,默认保持 **4 小时**。仅当 CLI 空闲超时**且** Chrome 扩展未连接时才会退出。 +Daemon 在首次运行浏览器命令时自动启动,之后保持常驻运行。 ```bash opencli daemon status # 查看 daemon 状态(PID、运行时长、扩展连接、内存) @@ -33,4 +33,4 @@ opencli daemon stop # 优雅关停 opencli daemon restart # 重启 ``` -通过 `OPENCLI_DAEMON_TIMEOUT` 环境变量覆盖超时时间(毫秒)。设为 `0` 则永不超时。 +Daemon 为常驻模式,会一直运行直到你显式停止(`opencli daemon stop`)或卸载包。 diff --git a/package.json b/package.json index f80e2d99d..a3bb33a49 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "copy-yaml": "node scripts/copy-yaml.cjs", "start": "node dist/src/main.js", "start:bun": "bun dist/src/main.js", + "preuninstall": "node -e \"fetch('http://127.0.0.1:19825/shutdown',{method:'POST',headers:{'X-OpenCLI':'1'},signal:AbortSignal.timeout(3000)}).catch(()=>{})\" || true", "postinstall": "node scripts/postinstall.js || true; node scripts/fetch-adapters.js || true", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", diff --git a/src/browser.test.ts b/src/browser.test.ts index ecb0ea295..56a0fd13e 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -153,7 +153,6 @@ describe('BrowserBridge state', () => { uptime: 0, extensionConnected: false, pending: 0, - lastCliRequestTime: 0, memoryMB: 0, port: 0, }, diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 451256837..fa19abde0 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -51,7 +51,7 @@ export class BrowserBridge implements IBrowserFactory { async close(): Promise { if (this._state === 'closed') return; this._state = 'closing'; - // We don't kill the daemon — it auto-exits on idle. + // We don't kill the daemon — it's persistent. // Just clean up our reference. this._page = null; this._state = 'closed'; diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 7aadfe393..6361233a0 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -23,7 +23,6 @@ describe('daemon-client', () => { extensionConnected: true, extensionVersion: '1.2.3', pending: 0, - lastCliRequestTime: Date.now(), memoryMB: 32, port: 19825, }; @@ -75,7 +74,6 @@ describe('daemon-client', () => { uptime: 10, extensionConnected: false, pending: 0, - lastCliRequestTime: Date.now(), memoryMB: 16, port: 19825, }; @@ -95,7 +93,6 @@ describe('daemon-client', () => { extensionConnected: true, extensionVersion: '1.2.3', pending: 0, - lastCliRequestTime: Date.now(), memoryMB: 32, port: 19825, }; diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 5b31d0465..0dd6d5289 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -66,7 +66,6 @@ export interface DaemonStatus { extensionConnected: boolean; extensionVersion?: string; pending: number; - lastCliRequestTime: number; memoryMB: number; port: number; } diff --git a/src/cli.ts b/src/cli.ts index ac5a89510..31fa6fe51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,7 @@ import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; import { registerAllCommands } from './commanderAdapter.js'; import { EXIT_CODES, getErrorMessage } from './errors.js'; -import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js'; +import { daemonStop } from './commands/daemon.js'; const CLI_FILE = fileURLToPath(import.meta.url); @@ -936,18 +936,10 @@ cli({ // ── Built-in: daemon ────────────────────────────────────────────────────── const daemonCmd = program.command('daemon').description('Manage the opencli daemon'); - daemonCmd - .command('status') - .description('Show daemon status') - .action(async () => { await daemonStatus(); }); daemonCmd .command('stop') .description('Stop the daemon') .action(async () => { await daemonStop(); }); - daemonCmd - .command('restart') - .description('Restart the daemon') - .action(async () => { await daemonRestart(); }); // ── External CLIs ───────────────────────────────────────────────────────── diff --git a/src/commands/daemon.test.ts b/src/commands/daemon.test.ts index fd66cceda..70978a631 100644 --- a/src/commands/daemon.test.ts +++ b/src/commands/daemon.test.ts @@ -11,27 +11,19 @@ const { vi.mock('chalk', () => ({ default: { green: (s: string) => s, - yellow: (s: string) => s, red: (s: string) => s, dim: (s: string) => s, }, })); -const mockConnect = vi.fn(); -vi.mock('../browser/bridge.js', () => ({ - BrowserBridge: class { - connect = mockConnect; - }, -})); - vi.mock('../browser/daemon-client.js', () => ({ fetchDaemonStatus: fetchDaemonStatusMock, requestDaemonShutdown: requestDaemonShutdownMock, })); -import { daemonStatus, daemonStop, daemonRestart } from './daemon.js'; +import { daemonStop } from './daemon.js'; -describe('daemon commands', () => { +describe('daemonStop', () => { let logSpy: ReturnType; let errorSpy: ReturnType; @@ -44,161 +36,48 @@ describe('daemon commands', () => { afterEach(() => { vi.restoreAllMocks(); - mockConnect.mockReset(); }); - describe('daemonStatus', () => { - it('shows "not running" when daemon is unreachable', async () => { - fetchDaemonStatusMock.mockResolvedValue(null); - - await daemonStatus(); - - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running')); - }); - - it('shows "not running" when daemon returns non-ok response', async () => { - fetchDaemonStatusMock.mockResolvedValue(null); - - await daemonStatus(); - - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running')); - }); - - it('shows daemon info when running', async () => { - const status = { - ok: true, - pid: 12345, - uptime: 3661, - extensionConnected: true, - pending: 0, - lastCliRequestTime: Date.now() - 30_000, - memoryMB: 64, - port: 19825, - }; - - fetchDaemonStatusMock.mockResolvedValue(status); - - await daemonStatus(); - - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('running')); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345')); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m')); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('connected')); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB')); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('19825')); - }); - - it('shows disconnected when extension is not connected', async () => { - const status = { - ok: true, - pid: 99, - uptime: 120, - extensionConnected: false, - pending: 0, - lastCliRequestTime: Date.now() - 5000, - memoryMB: 32, - port: 19825, - }; - - fetchDaemonStatusMock.mockResolvedValue(status); + it('reports "not running" when daemon is unreachable', async () => { + fetchDaemonStatusMock.mockResolvedValue(null); - await daemonStatus(); + await daemonStop(); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected')); - }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running')); }); - describe('daemonStop', () => { - it('reports "not running" when daemon is unreachable', async () => { - fetchDaemonStatusMock.mockResolvedValue(null); - - await daemonStop(); - - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running')); + it('sends shutdown and reports success', async () => { + fetchDaemonStatusMock.mockResolvedValue({ + ok: true, + pid: 12345, + uptime: 100, + extensionConnected: true, + pending: 0, + memoryMB: 50, + port: 19825, }); + requestDaemonShutdownMock.mockResolvedValue(true); - it('sends shutdown and reports success', async () => { - fetchDaemonStatusMock.mockResolvedValue({ - ok: true, - pid: 12345, - uptime: 100, - extensionConnected: true, - pending: 0, - lastCliRequestTime: Date.now(), - memoryMB: 50, - port: 19825, - }); - requestDaemonShutdownMock.mockResolvedValue(true); - - await daemonStop(); - - expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped')); - }); + await daemonStop(); - it('reports failure when shutdown request fails', async () => { - fetchDaemonStatusMock.mockResolvedValue({ - ok: true, - pid: 12345, - uptime: 100, - extensionConnected: true, - pending: 0, - lastCliRequestTime: Date.now(), - memoryMB: 50, - port: 19825, - }); - requestDaemonShutdownMock.mockResolvedValue(false); - - await daemonStop(); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon')); - }); + expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped')); }); - describe('daemonRestart', () => { - const statusData = { + it('reports failure when shutdown request fails', async () => { + fetchDaemonStatusMock.mockResolvedValue({ ok: true, pid: 12345, uptime: 100, extensionConnected: true, pending: 0, - lastCliRequestTime: Date.now(), memoryMB: 50, port: 19825, - }; - - it('starts daemon directly when not running', async () => { - fetchDaemonStatusMock.mockResolvedValue(null); - mockConnect.mockResolvedValue(undefined); - - await daemonRestart(); - - expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 }); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted')); - }); - - it('stops then starts when daemon is running', async () => { - fetchDaemonStatusMock - .mockResolvedValueOnce(statusData) - .mockResolvedValueOnce(null); - requestDaemonShutdownMock.mockResolvedValue(true); - mockConnect.mockResolvedValue(undefined); - - await daemonRestart(); - - expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1); - expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 }); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted')); }); + requestDaemonShutdownMock.mockResolvedValue(false); - it('aborts when shutdown fails', async () => { - fetchDaemonStatusMock.mockResolvedValue(statusData); - requestDaemonShutdownMock.mockResolvedValue(false); + await daemonStop(); - await daemonRestart(); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon')); - expect(mockConnect).not.toHaveBeenCalled(); - }); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon')); }); }); diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index bc3a079f2..c3ea5f5dd 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -1,37 +1,10 @@ /** - * CLI commands for daemon lifecycle management: - * opencli daemon status — show daemon state - * opencli daemon stop — graceful shutdown - * opencli daemon restart — stop + respawn + * CLI command for daemon lifecycle: + * opencli daemon stop — graceful shutdown */ import chalk from 'chalk'; import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js'; -import { formatDuration } from '../download/progress.js'; - -function formatTimeSince(timestampMs: number): string { - const seconds = (Date.now() - timestampMs) / 1000; - if (seconds < 60) return `${Math.floor(seconds)}s ago`; - const m = Math.floor(seconds / 60); - if (m < 60) return `${m} min ago`; - const h = Math.floor(m / 60); - return `${h}h ${m % 60}m ago`; -} - -export async function daemonStatus(): Promise { - const status = await fetchDaemonStatus(); - if (!status) { - console.log(`Daemon: ${chalk.dim('not running')}`); - return; - } - - console.log(`Daemon: ${chalk.green('running')} (PID ${status.pid})`); - console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`); - console.log(`Extension: ${status.extensionConnected ? chalk.green('connected') : chalk.yellow('disconnected')}`); - console.log(`Last CLI request: ${formatTimeSince(status.lastCliRequestTime)}`); - console.log(`Memory: ${status.memoryMB} MB`); - console.log(`Port: ${status.port}`); -} export async function daemonStop(): Promise { const status = await fetchDaemonStatus(); @@ -48,33 +21,3 @@ export async function daemonStop(): Promise { process.exitCode = 1; } } - -export async function daemonRestart(): Promise { - const status = await fetchDaemonStatus(); - if (status) { - const ok = await requestDaemonShutdown(); - if (!ok) { - console.error(chalk.red('Failed to stop daemon.')); - process.exitCode = 1; - return; - } - // Wait for daemon to actually exit (poll until unreachable) - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, 200)); - if (!(await fetchDaemonStatus())) break; - } - } - - // Import BrowserBridge to spawn a new daemon - const { BrowserBridge } = await import('../browser/bridge.js'); - const bridge = new BrowserBridge(); - try { - console.log('Starting daemon...'); - await bridge.connect({ timeout: 10 }); - console.log(chalk.green('Daemon restarted.')); - } catch (err) { - console.error(chalk.red(`Failed to restart daemon: ${err instanceof Error ? err.message : err}`)); - process.exitCode = 1; - } -} diff --git a/src/constants.ts b/src/constants.ts index e3a7f9f73..dee85fba7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,8 +5,6 @@ /** Default daemon port for HTTP/WebSocket communication with browser extension */ export const DEFAULT_DAEMON_PORT = 19825; -/** Default idle timeout before daemon auto-exits (ms). Override via OPENCLI_DAEMON_TIMEOUT env var. */ -export const DEFAULT_DAEMON_IDLE_TIMEOUT = 4 * 60 * 60 * 1000; // 4 hours /** URL query params that are volatile/ephemeral and should be stripped from patterns */ export const VOLATILE_PARAMS = new Set([ diff --git a/src/daemon.test.ts b/src/daemon.test.ts deleted file mode 100644 index 5ca371a54..000000000 --- a/src/daemon.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { IdleManager } from './idle-manager.js'; - -describe('IdleManager', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('does not start timer when extension is connected', () => { - const exit = vi.fn(); - const mgr = new IdleManager(300_000, exit); - - mgr.setExtensionConnected(true); - mgr.onCliRequest(); - - vi.advanceTimersByTime(300_000 + 1000); - expect(exit).not.toHaveBeenCalled(); - }); - - it('starts timer when extension disconnects and CLI is idle', () => { - const exit = vi.fn(); - const mgr = new IdleManager(300_000, exit); - - mgr.onCliRequest(); - mgr.setExtensionConnected(true); - mgr.setExtensionConnected(false); - - expect(exit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(300_000 + 1000); - expect(exit).toHaveBeenCalledTimes(1); - }); - - it('exits immediately on extension disconnect if CLI has been idle past timeout', () => { - const exit = vi.fn(); - const mgr = new IdleManager(300_000, exit); - - mgr.onCliRequest(); - mgr.setExtensionConnected(true); // connect before timeout elapses - vi.advanceTimersByTime(400_000); // CLI idle time exceeds timeout, but extension is connected so no exit - - expect(exit).not.toHaveBeenCalled(); - - mgr.setExtensionConnected(false); // disconnect → should exit immediately since CLI idle > timeout - - expect(exit).toHaveBeenCalledTimes(1); - }); - - it('resets timer on new CLI request', () => { - const exit = vi.fn(); - const mgr = new IdleManager(300_000, exit); - - mgr.onCliRequest(); - vi.advanceTimersByTime(200_000); - mgr.onCliRequest(); - - vi.advanceTimersByTime(200_000); - expect(exit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(100_001); - expect(exit).toHaveBeenCalledTimes(1); - }); - - it('does not exit when timeout is 0 (disabled)', () => { - const exit = vi.fn(); - const mgr = new IdleManager(0, exit); - - mgr.onCliRequest(); - vi.advanceTimersByTime(24 * 60 * 60 * 1000); - expect(exit).not.toHaveBeenCalled(); - }); - - it('clears timer when extension connects', () => { - const exit = vi.fn(); - const mgr = new IdleManager(300_000, exit); - - mgr.onCliRequest(); - vi.advanceTimersByTime(200_000); - - mgr.setExtensionConnected(true); - vi.advanceTimersByTime(200_000); - expect(exit).not.toHaveBeenCalled(); - }); -}); diff --git a/src/daemon.ts b/src/daemon.ts index d278f49d2..191efae95 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -15,18 +15,16 @@ * * Lifecycle: * - Auto-spawned by opencli on first browser command - * - Auto-exits after idle timeout (default 4h, configurable via OPENCLI_DAEMON_TIMEOUT) + * - Persistent — stays alive until explicit shutdown, SIGTERM, or uninstall * - Listens on localhost:19825 */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket, type RawData } from 'ws'; -import { DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js'; +import { DEFAULT_DAEMON_PORT } from './constants.js'; import { EXIT_CODES } from './errors.js'; -import { IdleManager } from './idle-manager.js'; const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); -const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON_IDLE_TIMEOUT); // ─── State ─────────────────────────────────────────────────────────── @@ -47,13 +45,6 @@ function pushLog(entry: LogEntry): void { if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift(); } -// ─── Idle auto-exit ────────────────────────────────────────────────── - -const idleManager = new IdleManager(IDLE_TIMEOUT, () => { - console.error('[daemon] Idle timeout (no CLI requests + no Extension), shutting down'); - process.exit(EXIT_CODES.SUCCESS); -}); - // ─── HTTP Server ───────────────────────────────────────────────────── const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM @@ -133,7 +124,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise extensionConnected: extensionWs?.readyState === WebSocket.OPEN, extensionVersion, pending: pending.size, - lastCliRequestTime: idleManager.lastCliRequestTime, memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10, port: PORT, }); @@ -163,7 +153,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise } if (req.method === 'POST' && url === '/command') { - idleManager.onCliRequest(); try { const body = JSON.parse(await readBody(req)); if (!body.id) { @@ -221,7 +210,6 @@ wss.on('connection', (ws: WebSocket) => { console.error('[daemon] Extension connected'); extensionWs = ws; extensionVersion = null; // cleared until hello message arrives - idleManager.setExtensionConnected(true); // ── Heartbeat: ping every 15s, close if 2 pongs missed ── let missedPongs = 0; @@ -280,7 +268,6 @@ wss.on('connection', (ws: WebSocket) => { if (extensionWs === ws) { extensionWs = null; extensionVersion = null; - idleManager.setExtensionConnected(false); // Reject all pending requests since the extension is gone for (const [id, p] of pending) { clearTimeout(p.timer); @@ -295,7 +282,6 @@ wss.on('connection', (ws: WebSocket) => { if (extensionWs === ws) { extensionWs = null; extensionVersion = null; - idleManager.setExtensionConnected(false); // Reject pending requests in case 'close' does not follow this 'error' for (const [, p] of pending) { clearTimeout(p.timer); @@ -310,7 +296,6 @@ wss.on('connection', (ws: WebSocket) => { httpServer.listen(PORT, '127.0.0.1', () => { console.error(`[daemon] Listening on http://127.0.0.1:${PORT}`); - idleManager.onCliRequest(); }); httpServer.on('error', (err: NodeJS.ErrnoException) => { diff --git a/src/idle-manager.ts b/src/idle-manager.ts deleted file mode 100644 index b224c0145..000000000 --- a/src/idle-manager.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Manages daemon idle timeout with dual-condition logic: - * exits only when BOTH CLI is idle AND Extension is disconnected. - */ -export class IdleManager { - private _timer: ReturnType | null = null; - private _lastCliRequestTime = Date.now(); - private _extensionConnected = false; - private _timeoutMs: number; - private _onExit: () => void; - - constructor(timeoutMs: number, onExit: () => void) { - this._timeoutMs = timeoutMs; - this._onExit = onExit; - } - - get lastCliRequestTime(): number { - return this._lastCliRequestTime; - } - - /** Call when an HTTP request arrives from CLI */ - onCliRequest(): void { - this._lastCliRequestTime = Date.now(); - this._resetTimer(); - } - - /** Call when Extension WebSocket connects or disconnects */ - setExtensionConnected(connected: boolean): void { - this._extensionConnected = connected; - if (connected) { - this._clearTimer(); - } else { - this._resetTimer(); - } - } - - private _clearTimer(): void { - if (this._timer) { - clearTimeout(this._timer); - this._timer = null; - } - } - - private _resetTimer(): void { - this._clearTimer(); - - if (this._timeoutMs <= 0) return; - if (this._extensionConnected) return; - - const elapsed = Date.now() - this._lastCliRequestTime; - if (elapsed >= this._timeoutMs) { - this._onExit(); - return; - } - - this._timer = setTimeout(() => { - this._onExit(); - }, this._timeoutMs - elapsed); - } -}