From b5f712cdabaca01bcd7273b102c0335513eda4ac Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 15 Apr 2026 11:16:41 +0800 Subject: [PATCH 1/5] fix: auto-restart stale daemon and improve connection error messages When daemon is running but extension never connected (stale daemon started before extension was installed), the CLI now auto-restarts the daemon to give the extension a fresh WebSocket endpoint, instead of just waiting and then telling the user to install the extension. Also improves error messages across cli.ts, bridge.ts, and doctor.ts to suggest "opencli daemon stop && opencli doctor" as the quick fix, since that's what actually resolves the issue. --- src/browser/bridge.ts | 28 +++++++++++++++------------- src/cli.ts | 2 +- src/doctor.ts | 4 +++- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index fa19abde0..a403fec35 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import type { IPage } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; import { Page } from './page.js'; -import { getDaemonHealth } from './daemon-client.js'; +import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { BrowserConnectError } from '../errors.js'; @@ -66,21 +66,22 @@ export class BrowserBridge implements IBrowserFactory { // Fast path: everything ready if (health.state === 'ready') return; - // Daemon running but no extension — wait for extension with progress + // Daemon running but no extension — wait, then auto-restart daemon if still disconnected if (health.state === 'no-extension') { if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n'); process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); } if (await this._pollUntilReady(timeoutMs)) return; - throw new BrowserConnectError( - 'Browser Bridge extension not connected', - 'Install the Browser Bridge:\n' + - ' 1. Download: https://github.com/jackwener/opencli/releases\n' + - ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' + - ' Then run: opencli doctor', - 'extension-not-connected', - ); + + // Extension still not connected — restart the daemon to give the extension + // a fresh WebSocket endpoint (fixes stale daemon that missed extension registration) + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write('⚠️ Extension not responding. Restarting daemon...\n'); + } + await requestDaemonShutdown(); + await new Promise(resolve => setTimeout(resolve, 500)); + // Fall through to the "No daemon — spawn one" path below } // No daemon — spawn one @@ -113,10 +114,11 @@ export class BrowserBridge implements IBrowserFactory { if (finalHealth.state === 'no-extension') { throw new BrowserConnectError( 'Browser Bridge extension not connected', - 'Install the Browser Bridge:\n' + + 'Make sure Chrome/Chromium is open and the extension is enabled.\n' + + 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' + + 'If not installed:\n' + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + - ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' + - ' Then run: opencli doctor', + ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected', ); } diff --git a/src/cli.ts b/src/cli.ts index d9502a7f1..ba1a9a939 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -313,7 +313,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command } catch (err) { const msg = getErrorMessage(err); if (msg.includes('Extension not connected') || msg.includes('Daemon')) { - log.error(`Browser not connected. Run 'opencli doctor' to diagnose.`); + log.error(`Browser not connected. Try: opencli daemon stop && opencli doctor`); } else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) { log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`); } else if (err instanceof TargetError) { diff --git a/src/doctor.ts b/src/doctor.ts index f0eb07335..e26c910c8 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -133,7 +133,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise Date: Wed, 15 Apr 2026 11:20:57 +0800 Subject: [PATCH 2/5] fix: version-aware stale daemon detection and improved error messages - Daemon /status now includes `daemonVersion` field - bridge.ts: when daemon is running but extension not connected, checks daemonVersion vs CLI version. Only auto-restarts if version mismatch (stale daemon from older CLI). Same-version daemon shows improved error message with "opencli daemon stop && opencli doctor" hint. - doctor.ts: explicitly identifies stale daemon (version mismatch) in diagnostics report, shows daemon version in status line - cli.ts: error message changed to suggest "opencli daemon stop && opencli doctor" --- src/browser/bridge.ts | 44 ++++++++++++++++++++++++------------ src/browser/daemon-client.ts | 1 + src/daemon.ts | 2 ++ src/doctor.ts | 31 +++++++++++++++++-------- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index a403fec35..779313003 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -12,6 +12,7 @@ import { Page } from './page.js'; import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { BrowserConnectError } from '../errors.js'; +import { PKG_VERSION } from '../version.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension @@ -66,22 +67,37 @@ export class BrowserBridge implements IBrowserFactory { // Fast path: everything ready if (health.state === 'ready') return; - // Daemon running but no extension — wait, then auto-restart daemon if still disconnected + // Daemon running but no extension if (health.state === 'no-extension') { - if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { - process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n'); - process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); + // Check if daemon is stale (version mismatch = started by older CLI) + const daemonVersion = health.status?.daemonVersion; + const isStale = daemonVersion && daemonVersion !== PKG_VERSION; + + if (isStale) { + // Stale daemon — restart it so extension gets a fresh WebSocket endpoint + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write(`⚠️ Stale daemon detected (v${daemonVersion} ≠ v${PKG_VERSION}). Restarting...\n`); + } + await requestDaemonShutdown(); + await new Promise(resolve => setTimeout(resolve, 500)); + // Fall through to the "No daemon — spawn one" path below + } else { + // Same version — wait for extension to connect + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n'); + process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); + } + if (await this._pollUntilReady(timeoutMs)) return; + throw new BrowserConnectError( + 'Browser Bridge extension not connected', + 'Make sure Chrome/Chromium is open and the extension is enabled.\n' + + 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' + + 'If not installed:\n' + + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + + ' 2. Open chrome://extensions → Developer Mode → Load unpacked', + 'extension-not-connected', + ); } - if (await this._pollUntilReady(timeoutMs)) return; - - // Extension still not connected — restart the daemon to give the extension - // a fresh WebSocket endpoint (fixes stale daemon that missed extension registration) - if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { - process.stderr.write('⚠️ Extension not responding. Restarting daemon...\n'); - } - await requestDaemonShutdown(); - await new Promise(resolve => setTimeout(resolve, 500)); - // Fall through to the "No daemon — spawn one" path below } // No daemon — spawn one diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index cc7cd3b05..19dd7a7ab 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -65,6 +65,7 @@ export interface DaemonStatus { ok: boolean; pid: number; uptime: number; + daemonVersion?: string; extensionConnected: boolean; extensionVersion?: string; extensionCompatRange?: string; diff --git a/src/daemon.ts b/src/daemon.ts index f4de2ea1b..391cc3343 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -24,6 +24,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws'; import { DEFAULT_DAEMON_PORT } from './constants.js'; import { EXIT_CODES } from './errors.js'; import { log } from './logger.js'; +import { PKG_VERSION } from './version.js'; const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); @@ -123,6 +124,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise ok: true, pid: process.pid, uptime, + daemonVersion: PKG_VERSION, extensionConnected: extensionWs?.readyState === WebSocket.OPEN, extensionVersion, extensionCompatRange, diff --git a/src/doctor.ts b/src/doctor.ts index e26c910c8..b79e178f7 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -60,6 +60,7 @@ export type DoctorReport = { cliVersion?: string; daemonRunning: boolean; daemonFlaky?: boolean; + daemonVersion?: string; extensionConnected: boolean; extensionFlaky?: boolean; extensionVersion?: string; @@ -131,15 +132,24 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise Date: Wed, 15 Apr 2026 11:24:46 +0800 Subject: [PATCH 3/5] fix: treat missing daemonVersion as stale, verify shutdown before respawn - Missing daemonVersion (pre-version daemon) is now treated as stale, covering the most common user scenario (old daemon without version field) - After requestDaemonShutdown(), poll until daemon actually stops (port released) before spawning new one, with 3s timeout - If shutdown request fails, log warning instead of silently proceeding - doctor.ts also treats missing daemonVersion as stale with clear message --- src/browser/bridge.ts | 31 ++++++++++++++++++++++++++----- src/doctor.ts | 7 +++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 779313003..7e146dfff 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -69,17 +69,27 @@ export class BrowserBridge implements IBrowserFactory { // Daemon running but no extension if (health.state === 'no-extension') { - // Check if daemon is stale (version mismatch = started by older CLI) + // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon) const daemonVersion = health.status?.daemonVersion; - const isStale = daemonVersion && daemonVersion !== PKG_VERSION; + const isStale = !daemonVersion || daemonVersion !== PKG_VERSION; if (isStale) { // Stale daemon — restart it so extension gets a fresh WebSocket endpoint + const reason = daemonVersion + ? `v${daemonVersion} ≠ v${PKG_VERSION}` + : `pre-version daemon, CLI is v${PKG_VERSION}`; if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { - process.stderr.write(`⚠️ Stale daemon detected (v${daemonVersion} ≠ v${PKG_VERSION}). Restarting...\n`); + process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`); + } + const stopped = await requestDaemonShutdown(); + if (stopped) { + // Verify port is actually released before spawning + await this._waitForDaemonStop(3000); + } else { + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write('⚠️ Daemon did not respond to shutdown request.\n'); + } } - await requestDaemonShutdown(); - await new Promise(resolve => setTimeout(resolve, 500)); // Fall through to the "No daemon — spawn one" path below } else { // Same version — wait for extension to connect @@ -146,6 +156,17 @@ export class BrowserBridge implements IBrowserFactory { ); } + /** Poll until daemon is fully stopped (port released). */ + private async _waitForDaemonStop(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 200)); + const h = await getDaemonHealth(); + if (h.state === 'stopped') return true; + } + return false; + } + /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */ private async _pollUntilReady(timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; diff --git a/src/doctor.ts b/src/doctor.ts index b79e178f7..7a2e6558e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -133,10 +133,13 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise Date: Wed, 15 Apr 2026 11:28:48 +0800 Subject: [PATCH 4/5] fix: fail explicitly when stale daemon replacement fails - If shutdown request fails or port isn't released within 3s, throw 'Stale daemon could not be replaced' instead of blindly spawning on an occupied port - Add tests for all three stale-daemon branches: same-version (no restart), missing daemonVersion (stale), mismatched version (stale) --- src/browser.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++- src/browser/bridge.ts | 21 +++++++++++--------- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/browser.test.ts b/src/browser.test.ts index 56a0fd13e..1753195c2 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -144,13 +144,15 @@ describe('BrowserBridge state', () => { await expect(bridge.connect()).rejects.toThrow('Session is closing'); }); - it('fails fast when daemon is running but extension is disconnected', async () => { + it('fails fast when daemon is running but extension is disconnected (same version)', async () => { + const { PKG_VERSION } = await import('./version.js'); vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ state: 'no-extension', status: { ok: true, pid: 1, uptime: 0, + daemonVersion: PKG_VERSION, extensionConnected: false, pending: 0, memoryMB: 0, @@ -162,6 +164,47 @@ describe('BrowserBridge state', () => { await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected'); }); + + it('attempts stale daemon replacement when daemonVersion is missing', async () => { + vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ + state: 'no-extension', + status: { + ok: true, + pid: 1, + uptime: 0, + extensionConnected: false, + pending: 0, + memoryMB: 0, + port: 0, + }, + }); + vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false); + + const bridge = new BrowserBridge(); + + await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced'); + }); + + it('attempts stale daemon replacement when daemonVersion mismatches', async () => { + vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ + state: 'no-extension', + status: { + ok: true, + pid: 1, + uptime: 0, + daemonVersion: '0.0.1', + extensionConnected: false, + pending: 0, + memoryMB: 0, + port: 0, + }, + }); + vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false); + + const bridge = new BrowserBridge(); + + await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced'); + }); }); describe('stealth anti-detection', () => { diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 7e146dfff..d31a01d82 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -81,16 +81,19 @@ export class BrowserBridge implements IBrowserFactory { if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`); } - const stopped = await requestDaemonShutdown(); - if (stopped) { - // Verify port is actually released before spawning - await this._waitForDaemonStop(3000); - } else { - if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { - process.stderr.write('⚠️ Daemon did not respond to shutdown request.\n'); - } + const shutdownAccepted = await requestDaemonShutdown(); + const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000); + + if (!portReleased) { + // Stale daemon replacement failed — don't blindly spawn on an occupied port + throw new BrowserConnectError( + 'Stale daemon could not be replaced', + `A stale daemon (${reason}) is running but did not shut down.\n` + + ' Run manually: opencli daemon stop && opencli doctor', + 'daemon-not-running', + ); } - // Fall through to the "No daemon — spawn one" path below + // Port released — fall through to spawn a fresh daemon } else { // Same version — wait for extension to connect if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { From a226b5e78d4c94a0a5d58e9479008a0880f17ce9 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 15 Apr 2026 11:31:15 +0800 Subject: [PATCH 5/5] fix: use type-based error dispatch in browserAction instead of string matching browserAction() now checks `instanceof BrowserConnectError` first and renders both message and hint, instead of string-matching on message content. This ensures stale daemon errors ("Stale daemon could not be replaced") surface the actionable hint to the user. --- src/cli.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ba1a9a939..cc4db59a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { PKG_VERSION } from './version.js'; 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 { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js'; import { TargetError } from './browser/target-errors.js'; import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js'; import { daemonStop } from './commands/daemon.js'; @@ -311,11 +311,9 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command const page = await getBrowserPage(); await fn(page, ...args); } catch (err) { - const msg = getErrorMessage(err); - if (msg.includes('Extension not connected') || msg.includes('Daemon')) { - log.error(`Browser not connected. Try: opencli daemon stop && opencli doctor`); - } else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) { - log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`); + if (err instanceof BrowserConnectError) { + log.error(err.message); + if (err.hint) log.error(`Hint: ${err.hint}`); } else if (err instanceof TargetError) { log.error(`[${err.code}] ${err.message}`); if (err.hint) log.error(`Hint: ${err.hint}`); @@ -324,7 +322,12 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`)); } } else { - log.error(msg); + const msg = getErrorMessage(err); + if (msg.includes('attach failed') || msg.includes('chrome-extension://')) { + log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`); + } else { + log.error(msg); + } } process.exitCode = EXIT_CODES.GENERIC_ERROR; }