diff --git a/src/browser/bridge-readiness.test.ts b/src/browser/bridge-readiness.test.ts index 9d64db209..803f94c32 100644 --- a/src/browser/bridge-readiness.test.ts +++ b/src/browser/bridge-readiness.test.ts @@ -54,6 +54,18 @@ describe('waitForBridgeReady', () => { expect(fetchHealth).toHaveBeenCalledTimes(3); }); + it('forwards a preferred profile on every health poll', async () => { + const fetchHealth: HealthFetcher = vi.fn(async () => readyHealth()); + + await waitForBridgeReady(fetchHealth, { + timeoutMs: 10_000, + preferredContextId: 'personal profile', + intervalMs: 1, + }); + + expect(fetchHealth).toHaveBeenCalledWith({ contextId: undefined, preferredContextId: 'personal profile' }); + }); + it('returns the last observed non-ready health when the deadline expires', async () => { const fetchHealth: HealthFetcher = vi.fn(async () => notReadyHealth('profile-disconnected')); diff --git a/src/browser/bridge-readiness.ts b/src/browser/bridge-readiness.ts index f570648c5..2c1637a32 100644 --- a/src/browser/bridge-readiness.ts +++ b/src/browser/bridge-readiness.ts @@ -2,22 +2,22 @@ import type { DaemonHealth } from './daemon-transport.js'; export type { DaemonHealth }; -export type HealthFetcher = (opts?: { timeout?: number; contextId?: string }) => Promise; +export type HealthFetcher = (opts?: { timeout?: number; contextId?: string; preferredContextId?: string }) => Promise; const DEFAULT_POLL_INTERVAL_MS = 200; export async function waitForBridgeReady( fetchHealth: HealthFetcher, - opts: { timeoutMs: number; contextId?: string; intervalMs?: number }, + opts: { timeoutMs: number; contextId?: string; preferredContextId?: string; intervalMs?: number }, ): Promise { const interval = opts.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; - let health = await fetchHealth({ contextId: opts.contextId }); + let health = await fetchHealth({ contextId: opts.contextId, preferredContextId: opts.preferredContextId }); if (health.state === 'ready') return health; const deadline = Date.now() + opts.timeoutMs; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, interval)); - health = await fetchHealth({ contextId: opts.contextId }); + health = await fetchHealth({ contextId: opts.contextId, preferredContextId: opts.preferredContextId }); if (health.state === 'ready') return health; } return health; diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 5eeca0502..354842312 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -41,7 +41,7 @@ export class BrowserBridge implements IBrowserFactory { const routing = opts.contextId || opts.preferredContextId ? { contextId: opts.contextId, preferredContextId: opts.preferredContextId } : profileRouteParams(resolveProfileSelection()); - await this._ensureDaemon(opts.timeout, routing.contextId); + await this._ensureDaemon(opts.timeout, routing.contextId, routing.preferredContextId); if (!opts.session?.trim()) throw new Error('Browser session is required'); this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId); this._state = 'connected'; @@ -61,10 +61,11 @@ export class BrowserBridge implements IBrowserFactory { this._state = 'closed'; } - private async _ensureDaemon(timeoutSeconds?: number, contextId?: string): Promise { + private async _ensureDaemon(timeoutSeconds?: number, contextId?: string, preferredContextId?: string): Promise { const result = await ensureBrowserBridgeReady({ timeoutSeconds: timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000), contextId, + preferredContextId, }); this._daemonProc = result.spawnedProcess; } diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 71b6f9660..5a03bece1 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -137,7 +137,7 @@ describe('daemon-client', () => { await expect(getDaemonHealth()).resolves.toEqual({ state: 'profile-required', status }); }); - it('fetchDaemonStatus includes contextId in the status query', async () => { + it('fetchDaemonStatus includes profile routing in the status query', async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve({ @@ -151,9 +151,9 @@ describe('daemon-client', () => { }), } as Response); - await fetchDaemonStatus({ contextId: 'work' }); + await fetchDaemonStatus({ contextId: 'work', preferredContextId: 'personal profile' }); - expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work$/); + expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work&preferredContextId=personal\+profile$/); }); it('rejects OPENCLI_DAEMON_PORT so CLI and extension cannot split bridge ports', async () => { diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts index 6123199b7..f4f7b75a4 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -94,14 +94,15 @@ export async function restartDaemon(opts: { stopTimeoutMs?: number; startTimeout } export async function ensureBrowserBridgeReady( - opts: { timeoutSeconds?: number; contextId?: string; verbose?: boolean } = {}, + opts: { timeoutSeconds?: number; contextId?: string; preferredContextId?: string; verbose?: boolean } = {}, ): Promise { const timeoutSeconds = opts.timeoutSeconds && opts.timeoutSeconds > 0 ? opts.timeoutSeconds : 10; const timeoutMs = timeoutSeconds * 1000; const verbose = opts.verbose ?? true; const contextId = opts.contextId; + const preferredContextId = opts.preferredContextId; - const health = await getDaemonHealth({ contextId }); + const health = await getDaemonHealth({ contextId, preferredContextId }); const daemonVersion = health.status?.daemonVersion; const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION); let staleDaemonReplaced = false; @@ -158,7 +159,7 @@ export async function ensureBrowserBridgeReady( process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); } - const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); + const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId, preferredContextId }); if (finalHealth.state === 'ready') return { health: finalHealth, spawnedProcess }; throw browserConnectErrorFromHealth(finalHealth, contextId); } diff --git a/src/browser/daemon-transport.ts b/src/browser/daemon-transport.ts index 2fabcb8a1..dc6c82640 100644 --- a/src/browser/daemon-transport.ts +++ b/src/browser/daemon-transport.ts @@ -66,9 +66,12 @@ export async function requestDaemon(pathname: string, init?: RequestInit & { tim } } -export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: string }): Promise { +export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: string; preferredContextId?: string }): Promise { try { - const params = opts?.contextId ? `?contextId=${encodeURIComponent(opts.contextId)}` : ''; + const searchParams = new URLSearchParams(); + if (opts?.contextId) searchParams.set('contextId', opts.contextId); + if (opts?.preferredContextId) searchParams.set('preferredContextId', opts.preferredContextId); + const params = searchParams.size ? `?${searchParams}` : ''; const res = await requestDaemon(`/status${params}`, { timeout: opts?.timeout ?? 2000 }); if (!res.ok) return null; return await res.json() as DaemonStatus; @@ -78,7 +81,7 @@ export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: s } } -export async function getDaemonHealth(opts?: { timeout?: number; contextId?: string }): Promise { +export async function getDaemonHealth(opts?: { timeout?: number; contextId?: string; preferredContextId?: string }): Promise { const status = await fetchDaemonStatus(opts); if (!status) return { state: 'stopped', status: null }; if (status.profileRequired) return { state: 'profile-required', status }; diff --git a/src/daemon.ts b/src/daemon.ts index d68bdd0c7..b56a903d4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -294,7 +294,8 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const mem = process.memoryUsage(); const params = new URL(url, `http://localhost:${PORT}`).searchParams; const requestedContextId = params.get('contextId')?.trim() || undefined; - const route = resolveExtensionConnection(requestedContextId); + const preferredContextId = params.get('preferredContextId')?.trim() || undefined; + const route = resolveExtensionConnection(requestedContextId, preferredContextId); const profiles = activeProfiles().map((profile) => ({ contextId: profile.contextId, extensionConnected: true, @@ -311,7 +312,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise extensionConnected: !!route.connection, extensionVersion: route.connection?.extensionVersion ?? undefined, extensionCompatRange: route.connection?.extensionCompatRange ?? undefined, - contextId: route.connection?.contextId ?? requestedContextId, + contextId: route.connection?.contextId ?? requestedContextId ?? preferredContextId, profileRequired: route.errorCode === 'profile_required', profileDisconnected: route.errorCode === 'profile_disconnected', profiles, diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 5ff160a5e..e35099d8e 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -203,6 +203,8 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); + expect(mockGetDaemonHealth).toHaveBeenCalledWith({ preferredContextId: 'zvypsyje' }); + expect(report.issues).toEqual(expect.arrayContaining([ expect.stringContaining('Default browser profile is stale: work (zvypsyje)'), ])); diff --git a/src/doctor.ts b/src/doctor.ts index 53c6198a0..2ef3f5afe 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -12,7 +12,7 @@ import { getErrorMessage } from './errors.js'; import { getRuntimeLabel } from './runtime-detect.js'; import { getCachedLatestExtensionVersion } from './update-check.js'; import type { BrowserProfileStatus } from './browser/daemon-transport.js'; -import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; +import { aliasForContextId, loadProfileConfig, profileRouteParams, resolveProfileSelection } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; @@ -112,7 +112,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { } }); + it('uses preferredContextId when reporting daemon status', async () => { + if (guard()) return; + const ext = new FakeExtension(); + await ext.connect('ctx-status'); + try { + const res = await fetch(`${BASE}/status?preferredContextId=ctx-status`, { headers: HEADERS }); + const status = await res.json(); + + expect(res.ok).toBe(true); + expect(status.extensionConnected).toBe(true); + expect(status.contextId).toBe('ctx-status'); + expect(status.profileRequired).toBe(false); + } finally { + ext.close(); + } + }); + it('flushes a structured daemon_shutting_down 503 to in-flight dispatched commands on shutdown', async () => { if (guard()) return; const ext = new FakeExtension();