diff --git a/opencode/packages/opencode/src/server/server.ts b/opencode/packages/opencode/src/server/server.ts index dd6140fa..07937471 100644 --- a/opencode/packages/opencode/src/server/server.ts +++ b/opencode/packages/opencode/src/server/server.ts @@ -104,6 +104,36 @@ export namespace Server { return headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()] } + export function formatListenError(opts: { port: number; hostname: string }, cause?: unknown) { + const reason = + cause instanceof Error + ? cause.message + : typeof cause === "string" + ? cause + : "" + + const portTarget = + opts.port === 0 + ? `an available port on ${opts.hostname} (preferred 4096)` + : `${opts.hostname}:${opts.port}` + + const addressInUse = + /EADDRINUSE|address already in use|in use/i.test(reason) || + /failed to bind|failed to listen/i.test(reason) + + if (!reason) { + return addressInUse + ? `Failed to start server on ${portTarget}. The port appears to already be in use.` + : `Failed to start server on ${portTarget}.` + } + + if (addressInUse) { + return `Failed to start server on ${portTarget}. The port appears to already be in use. Original error: ${reason}` + } + + return `Failed to start server on ${portTarget}. Original error: ${reason}` + } + function hasForwardedHeader(headers: Record) { return getHeader(headers, "forwarded") !== undefined || getHeader(headers, "x-forwarded-for") !== undefined } @@ -858,12 +888,14 @@ export namespace Server { const tryServe = (port: number) => { try { return Bun.serve({ ...args, port }) - } catch { + } catch (error) { + lastServeError = error return undefined } } + let lastServeError: unknown const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) - if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + if (!server) throw new Error(formatListenError(opts, lastServeError)) _url = server.url Schedule.init() diff --git a/opencode/packages/opencode/src/tool/browser.ts b/opencode/packages/opencode/src/tool/browser.ts index c4fb9d9b..b74eb9e1 100644 --- a/opencode/packages/opencode/src/tool/browser.ts +++ b/opencode/packages/opencode/src/tool/browser.ts @@ -44,6 +44,9 @@ Call this first to discover which browsers are available and get tab IDs.`, if (status.user) { lines.push(`User Browser: ${status.user.connected ? "Connected" : "Not connected"}`) + if (status.user.tabListSource && status.user.connected) { + lines.push(` Tab source: ${status.user.tabListSource}`) + } if (status.user.tabs.length > 0) { lines.push(" Tabs:") for (const tab of status.user.tabs) { diff --git a/opencode/packages/opencode/test/server/listen-errors.test.ts b/opencode/packages/opencode/test/server/listen-errors.test.ts new file mode 100644 index 00000000..73e43b8f --- /dev/null +++ b/opencode/packages/opencode/test/server/listen-errors.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { Server } from "../../src/server/server" + +describe("server listen error formatting", () => { + test("surfaces port-in-use hints with hostname and port", () => { + const message = Server.formatListenError( + { hostname: "127.0.0.1", port: 4096 }, + new Error("EADDRINUSE: address already in use 127.0.0.1:4096"), + ) + + expect(message).toContain("127.0.0.1:4096") + expect(message).toContain("already be in use") + }) + + test("describes ephemeral port fallback requests clearly", () => { + const message = Server.formatListenError( + { hostname: "127.0.0.1", port: 0 }, + new Error("bind failed"), + ) + + expect(message).toContain("available port") + expect(message).toContain("preferred 4096") + }) +}) diff --git a/packages/browser-extension/src/background/index.ts b/packages/browser-extension/src/background/index.ts index 3bbc2407..b73a49b8 100644 --- a/packages/browser-extension/src/background/index.ts +++ b/packages/browser-extension/src/background/index.ts @@ -37,15 +37,16 @@ function createOpenNonce(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } -async function markSidePanelOpened(): Promise { - await chrome.storage.sync.set({ [SIDE_PANEL_OPEN_NONCE_STORAGE_KEY]: createOpenNonce() }) +async function markSidePanelOpened(openNonce: string): Promise { + await chrome.storage.sync.set({ [SIDE_PANEL_OPEN_NONCE_STORAGE_KEY]: openNonce }) } async function openSidePanel(windowId: number): Promise { - await activateDedicatedNine1TabGroup(windowId).catch((error) => { + const openNonce = createOpenNonce() + await activateDedicatedNine1TabGroup(windowId, { openNonce }).catch((error) => { console.warn('[Nine1Bot Browser Control] Failed to activate dedicated tab group:', error) }) - await markSidePanelOpened().catch((error) => { + await markSidePanelOpened(openNonce).catch((error) => { console.warn('[Nine1Bot Browser Control] Failed to persist side panel open nonce:', error) }) await chrome.sidePanel.open({ windowId }) @@ -77,7 +78,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return true } if (message?.type === 'nine1bot-sidepanel-ensure-tab-group') { - activateDedicatedNine1TabGroup() + activateDedicatedNine1TabGroup(undefined, { + onlyIfMissing: Boolean(message.onlyIfMissing), + }) .then((result) => sendResponse({ ok: true, ...result })) .catch((error) => { sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }) diff --git a/packages/browser-extension/src/background/relay-client.ts b/packages/browser-extension/src/background/relay-client.ts index 1b02f774..58751899 100644 --- a/packages/browser-extension/src/background/relay-client.ts +++ b/packages/browser-extension/src/background/relay-client.ts @@ -20,8 +20,10 @@ import { isAbortError } from '../tools/execution-context' import { setupDiagnosticsListeners } from './diagnostics-buffer' import { addTabToNine1Group, + getTabGroupDiagnostics, getDefaultNine1Tab, getTabsInActiveNine1Group, + getTabsInAllActiveNine1Groups, getTabsInGroupByTab, isTabInActiveNine1Group, setNine1GroupActive, @@ -77,6 +79,31 @@ interface RunningCommand { taskLabel?: string } +interface RelayResyncState { + reason: string + windowId: number | null + attachedTabIds: number[] + detachedTabIds: number[] + updatedTabIds: number[] + authoritativeTabIds: number[] + at: number +} + +interface ExtensionRelayDiagnosticsPayload { + authoritativeTabs: Array<{ + tabId: number + windowId: number + title: string + url: string + }> + activeSessions: Array<{ + tabId: number + sessionId: string + }> + tabGroups: Awaited> + lastResync: RelayResyncState | null +} + // WebSocket 连接状态 let ws: WebSocket | null = null let reconnectTimer: ReturnType | null = null @@ -90,19 +117,17 @@ let lastPongAt = 0 // 当前活动的标签页 session const activeSessions = new Map() // tabId -> sessionId const attachedTabs = new Set() +const targetMetadataByTabId = new Map() +const resyncTimers = new Map>() +let lastResyncState: RelayResyncState | null = null // 命令状态 const runningCommands = new Map() const tabActiveCommandCount = new Map() const tabStopRequestedAt = new Map() -/** - * 生成唯一的 session ID - */ -function generateSessionId(): string { - const bytes = crypto.getRandomValues(new Uint8Array(16)) - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') - return `session_${Date.now()}_${hex}` +export function getManagedSessionId(tabId: number): string { + return `tab_${tabId}` } /** @@ -154,27 +179,26 @@ function detachManagedTarget(tabId: number, reason = 'target_detached'): void { }) activeSessions.delete(tabId) attachedTabs.delete(tabId) + targetMetadataByTabId.delete(tabId) cancelRunningCommands({ tabId, reason }) tabActiveCommandCount.delete(tabId) tabStopRequestedAt.delete(tabId) } -function detachAllActiveSessions(): void { - for (const tabId of Array.from(activeSessions.keys())) { - detachManagedTarget(tabId, 'tab_group_changed') - } -} - async function attachManagedTarget(tab: chrome.tabs.Tab): Promise { if (!tab.id) return null if (!isAutomatableTabUrl(tab.url)) return null - if (!await isTabInActiveNine1Group(tab.id)) return null + if (!await isTabInActiveNine1Group(tab.id, tab.windowId)) return null const existing = activeSessions.get(tab.id) if (existing) return existing - const sessionId = generateSessionId() + const sessionId = getManagedSessionId(tab.id) activeSessions.set(tab.id, sessionId) + targetMetadataByTabId.set(tab.id, { + title: tab.title || '', + url: tab.url || '', + }) forwardCdpEvent('Target.attachedToTarget', { sessionId, @@ -185,6 +209,126 @@ async function attachManagedTarget(tab: chrome.tabs.Tab): Promise return sessionId } +async function getLiveTab(tabId: number): Promise { + try { + return await chrome.tabs.get(tabId) + } catch { + return null + } +} + +async function listAuthoritativeManagedTabs(windowId?: number): Promise { + const tabs = windowId === undefined + ? await getTabsInAllActiveNine1Groups() + : await getTabsInActiveNine1Group(windowId) + + return tabs.filter((tab) => typeof tab.id === 'number' && isAutomatableTabUrl(tab.url)) +} + +async function resyncManagedTargets(options: { + windowId?: number + reason: string + forceBroadcast?: boolean +}): Promise { + const desiredTabs = await listAuthoritativeManagedTabs(options.windowId) + const desiredByTabId = new Map( + desiredTabs + .filter((tab) => typeof tab.id === 'number') + .map((tab) => [tab.id as number, tab]), + ) + const desiredTabIds = new Set(desiredByTabId.keys()) + const attachedTabIds: number[] = [] + const detachedTabIds: number[] = [] + const updatedTabIds: number[] = [] + + for (const tabId of Array.from(activeSessions.keys())) { + if (desiredTabIds.has(tabId)) continue + const tab = await getLiveTab(tabId) + if (options.windowId !== undefined && tab?.windowId !== options.windowId && tab !== null) { + continue + } + detachManagedTarget(tabId, tab === null ? 'tab_missing' : 'tab_left_nine1_group') + detachedTabIds.push(tabId) + } + + for (const [tabId, tab] of desiredByTabId) { + const sessionId = activeSessions.get(tabId) + if (!sessionId) { + const attachedSession = await attachManagedTarget(tab) + if (attachedSession) { + attachedTabIds.push(tabId) + } + continue + } + + if (options.forceBroadcast) { + targetMetadataByTabId.set(tabId, { + title: tab.title || '', + url: tab.url || '', + }) + forwardCdpEvent('Target.targetInfoChanged', { + targetInfo: targetInfoForTab(tab), + }) + updatedTabIds.push(tabId) + continue + } + + const previousTarget = targetMetadataByTabId.get(tabId) + if (!previousTarget || previousTarget.title !== (tab.title || '') || previousTarget.url !== (tab.url || '')) { + targetMetadataByTabId.set(tabId, { + title: tab.title || '', + url: tab.url || '', + }) + forwardCdpEvent('Target.targetInfoChanged', { + targetInfo: targetInfoForTab(tab), + }) + updatedTabIds.push(tabId) + } + } + + lastResyncState = { + reason: options.reason, + windowId: options.windowId ?? null, + attachedTabIds, + detachedTabIds, + updatedTabIds, + authoritativeTabIds: Array.from(desiredTabIds), + at: Date.now(), + } + + if (isRelayConnected()) { + void sendExtensionHealth() + } + + return lastResyncState +} + +function scheduleManagedTargetResync(options: { + windowId?: number + reason: string + delayMs?: number + forceBroadcast?: boolean +}): void { + const key = options.windowId ?? -1 + const existing = resyncTimers.get(key) + if (existing) { + clearTimeout(existing) + } + + const timer = setTimeout(() => { + resyncTimers.delete(key) + resyncManagedTargets({ + windowId: options.windowId, + reason: options.reason, + forceBroadcast: options.forceBroadcast, + }).catch((error) => { + console.warn('[Relay Client] Failed to resync managed targets:', error) + }) + }, options.delayMs ?? 80) + + resyncTimers.set(key, timer) +} + function sendExtensionHello(): void { sendToRelay({ method: 'extension.hello', @@ -203,7 +347,28 @@ function sendExtensionHello(): void { }) } -function sendExtensionHealth(): void { +async function collectRelayDiagnostics(): Promise { + const authoritativeTabs = (await getTabsInAllActiveNine1Groups()) + .filter((tab) => typeof tab.id === 'number' && typeof tab.windowId === 'number' && isAutomatableTabUrl(tab.url)) + .map((tab) => ({ + tabId: tab.id as number, + windowId: tab.windowId as number, + title: tab.title || '', + url: tab.url || '', + })) + + return { + authoritativeTabs, + activeSessions: Array.from(activeSessions.entries()).map(([tabId, sessionId]) => ({ + tabId, + sessionId, + })), + tabGroups: await getTabGroupDiagnostics(), + lastResync: lastResyncState ? { ...lastResyncState } : null, + } +} + +async function sendExtensionHealth(): Promise { sendToRelay({ method: 'extension.health', params: { @@ -211,6 +376,21 @@ function sendExtensionHealth(): void { lastPongAt, activeCommands: runningCommands.size, reconnectAttempt, + diagnostics: await collectRelayDiagnostics().catch((error) => ({ + authoritativeTabs: [], + activeSessions: Array.from(activeSessions.entries()).map(([tabId, sessionId]) => ({ tabId, sessionId })), + tabGroups: { + currentWindowId: null, + bindings: {}, + lastResolutionByWindow: {}, + lastError: { + code: 'diagnostics_collection_failed', + message: error instanceof Error ? error.message : String(error), + at: Date.now(), + }, + }, + lastResync: lastResyncState ? { ...lastResyncState } : null, + })), }, }) } @@ -277,6 +457,13 @@ async function markCommandStart(command: RunningCommand): Promise { bumpTabActiveCount(command.tabId, 1) await addTabToNine1Group(command.tabId, command.taskLabel) await setNine1GroupActive(command.tabId, command.taskLabel) + const commandTab = await getLiveTab(command.tabId) + if (typeof commandTab?.windowId === 'number') { + await resyncManagedTargets({ + windowId: commandTab.windowId, + reason: 'command_start', + }).catch(() => undefined) + } await sendAgentStateToTabs(command.tabId, command.taskLabel) } @@ -293,7 +480,7 @@ function startHealthReporting(): void { if (healthTimer) return healthTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { - sendExtensionHealth() + void sendExtensionHealth() } }, HEALTH_REPORT_INTERVAL) } @@ -696,47 +883,56 @@ async function ensureDebuggerAttached(tabId: number): Promise { function setupTabListeners(): void { // 新标签页创建 chrome.tabs.onCreated.addListener((tab) => { - if (!tab.id) return - setTimeout(() => { - chrome.tabs.get(tab.id!).then((freshTab) => attachManagedTarget(freshTab)).catch(() => {}) - }, 150) + if (typeof tab.windowId !== 'number') return + scheduleManagedTargetResync({ + windowId: tab.windowId, + reason: 'tab_created', + delayMs: 150, + }) }) // 标签页更新(URL/标题变化) chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (typeof tab.windowId !== 'number') return + if (changeInfo.groupId !== undefined) { - if (activeSessions.has(tabId) && !await isTabInActiveNine1Group(tabId)) { - detachManagedTarget(tabId, 'tab_left_nine1_group') - return - } - await attachManagedTarget(tab).catch(() => {}) + scheduleManagedTargetResync({ + windowId: tab.windowId, + reason: 'tab_group_changed', + delayMs: 60, + }) + return } if (changeInfo.url || changeInfo.title) { - if (!await isTabInActiveNine1Group(tabId)) { - detachManagedTarget(tabId, 'tab_left_nine1_group') - return - } - const sessionId = activeSessions.get(tabId) - if (!sessionId) { - await attachManagedTarget(tab).catch(() => {}) - return - } - forwardCdpEvent('Target.targetInfoChanged', { - targetInfo: targetInfoForTab(tab), + scheduleManagedTargetResync({ + windowId: tab.windowId, + reason: 'tab_metadata_changed', + delayMs: 40, }) } }) // 标签页关闭 - chrome.tabs.onRemoved.addListener((tabId) => { + chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { detachManagedTarget(tabId, 'tab_removed') + if (typeof removeInfo.windowId === 'number') { + scheduleManagedTargetResync({ + windowId: removeInfo.windowId, + reason: 'tab_removed', + delayMs: 40, + }) + } }) // 标签页激活 chrome.tabs.onActivated.addListener(async (activeInfo) => { - const tab = await chrome.tabs.get(activeInfo.tabId) - await attachManagedTarget(tab) + scheduleManagedTargetResync({ + windowId: activeInfo.windowId, + reason: 'tab_activated', + delayMs: 40, + forceBroadcast: true, + }) }) } @@ -744,13 +940,16 @@ function setupTabListeners(): void { * 发送当前 Nine1Bot 标签组信息 */ async function sendInitialTargets(): Promise { - const tabs = await getTabsInActiveNine1Group() - for (const tab of tabs) { - await attachManagedTarget(tab) - } + await resyncManagedTargets({ + reason: 'relay_connected', + forceBroadcast: true, + }) } -export async function activateDedicatedNine1TabGroup(windowId?: number): Promise<{ groupId: number | null; tabId?: number }> { +export async function activateDedicatedNine1TabGroup( + windowId?: number, + options: { onlyIfMissing?: boolean; openNonce?: string } = {}, +): Promise<{ groupId: number | null; tabId?: number }> { const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: windowId === undefined, @@ -761,9 +960,17 @@ export async function activateDedicatedNine1TabGroup(windowId?: number): Promise return { groupId: null } } - const groupId = await addTabToNine1Group(activeTab.id) - detachAllActiveSessions() - await sendInitialTargets() + const groupId = await addTabToNine1Group(activeTab.id, undefined, { + onlyIfMissing: options.onlyIfMissing, + openNonce: options.openNonce, + }) + if (typeof activeTab.windowId === 'number') { + await resyncManagedTargets({ + windowId: activeTab.windowId, + reason: options.onlyIfMissing ? 'sidepanel_ensure' : 'sidepanel_activate', + forceBroadcast: true, + }) + } return { groupId, tabId: activeTab.id } } @@ -847,10 +1054,10 @@ export function connectToRelay(url?: string): void { } sendExtensionHello() - sendExtensionHealth() + void sendExtensionHealth() startHealthReporting() startAgentHeartbeat() - sendInitialTargets() + void sendInitialTargets() } ws.onmessage = (event) => { @@ -887,7 +1094,13 @@ function cleanup(): void { ws = null } + for (const timer of resyncTimers.values()) { + clearTimeout(timer) + } + resyncTimers.clear() + activeSessions.clear() + targetMetadataByTabId.clear() for (const [commandId, command] of runningCommands) { command.cancelReason = 'relay_disconnected' @@ -895,6 +1108,7 @@ function cleanup(): void { runningCommands.delete(commandId) } tabActiveCommandCount.clear() + tabStopRequestedAt.clear() } /** @@ -959,6 +1173,15 @@ export function initRelayClient(): void { chrome.debugger.onDetach.addListener((source) => { if (source.tabId) { attachedTabs.delete(source.tabId) + getLiveTab(source.tabId).then((tab) => { + if (typeof tab?.windowId === 'number') { + scheduleManagedTargetResync({ + windowId: tab.windowId, + reason: 'debugger_detached', + delayMs: 40, + }) + } + }).catch(() => undefined) } }) diff --git a/packages/browser-extension/src/background/tab-group-manager.ts b/packages/browser-extension/src/background/tab-group-manager.ts index 03efe1cc..8bc893ee 100644 --- a/packages/browser-extension/src/background/tab-group-manager.ts +++ b/packages/browser-extension/src/background/tab-group-manager.ts @@ -1,40 +1,126 @@ -import { ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY } from '../shared/tab-group' +import { + ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY, + ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY, + NINE1_TAB_GROUP_TITLE_PREFIX, + type ActiveNine1TabGroupBinding, + type ActiveNine1TabGroupBindings, + type TabGroupDiagnostics, + type TabGroupRecoverySource, + type TabGroupResolution, +} from '../shared/tab-group' -const NINE1_TAB_GROUP_TITLE = 'Nine1Bot' const NINE1_TAB_GROUP_COLOR: chrome.tabGroups.ColorEnum = 'blue' let cleanupInstalled = false -let activeNine1GroupId: number | null = null +let bindingsSyncInstalled = false +let bindingsLoaded = false +const activeNine1Groups = new Map() +const lastResolutionByWindow = new Map() +let lastError: TabGroupDiagnostics['lastError'] = null + +interface MatchingNine1Group { + groupId: number + windowId: number + title: string +} + +interface ResolveGroupOptions { + windowId?: number + persistRecovered?: boolean +} + +interface AddTabOptions { + onlyIfMissing?: boolean + openNonce?: string +} function formatGroupTitle(taskLabel?: string): string { - if (!taskLabel) return NINE1_TAB_GROUP_TITLE + if (!taskLabel) return NINE1_TAB_GROUP_TITLE_PREFIX const trimmed = taskLabel.trim() - if (!trimmed) return NINE1_TAB_GROUP_TITLE - return `${NINE1_TAB_GROUP_TITLE}: ${trimmed.slice(0, 32)}` + if (!trimmed) return NINE1_TAB_GROUP_TITLE_PREFIX + return `${NINE1_TAB_GROUP_TITLE_PREFIX}: ${trimmed.slice(0, 32)}` } -async function getGroupIdForTab(tabId: number): Promise { - try { - const tab = await chrome.tabs.get(tabId) - return typeof tab.groupId === 'number' && tab.groupId >= 0 ? tab.groupId : null - } catch { - return null +function isNine1GroupTitle(title?: string): boolean { + return typeof title === 'string' && title.startsWith(NINE1_TAB_GROUP_TITLE_PREFIX) +} + +function recordError(code: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + lastError = { + code, + message, + at: Date.now(), + } + console.warn(`[TabGroupManager] ${code}:`, message) +} + +function bindingToStorageRecord(): ActiveNine1TabGroupBindings { + return Object.fromEntries( + Array.from(activeNine1Groups.entries()).map(([windowId, binding]) => [String(windowId), { ...binding }]), + ) +} + +function setLastResolution(resolution: TabGroupResolution): void { + if (typeof resolution.windowId === 'number') { + lastResolutionByWindow.set(resolution.windowId, resolution) + } +} + +function replaceBindings(nextBindings: ActiveNine1TabGroupBindings): void { + activeNine1Groups.clear() + for (const binding of Object.values(nextBindings)) { + activeNine1Groups.set(binding.windowId, binding) } } async function getTab(tabId: number): Promise { try { return await chrome.tabs.get(tabId) - } catch { + } catch (error) { + recordError('tab_lookup_failed', error) + return null + } +} + +async function getWindowIdForTab(tabId: number): Promise { + const tab = await getTab(tabId) + return typeof tab?.windowId === 'number' ? tab.windowId : null +} + +async function getCurrentWindowId(): Promise { + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (typeof activeTab?.windowId === 'number') return activeTab.windowId + } catch (error) { + recordError('current_window_query_failed', error) + } + + try { + const lastFocused = await chrome.windows.getLastFocused() + return typeof lastFocused?.id === 'number' ? lastFocused.id : null + } catch (error) { + recordError('last_focused_window_failed', error) return null } } +async function resolveWindowId(windowId?: number): Promise { + if (typeof windowId === 'number') return windowId + return await getCurrentWindowId() +} + +async function getGroupIdForTab(tabId: number): Promise { + const tab = await getTab(tabId) + return typeof tab?.groupId === 'number' && tab.groupId >= 0 ? tab.groupId : null +} + async function getGroupWindowId(groupId: number): Promise { try { const group = await chrome.tabGroups.get(groupId) return typeof group.windowId === 'number' ? group.windowId : null - } catch { + } catch (error) { + recordError('group_window_lookup_failed', error) return null } } @@ -48,10 +134,13 @@ async function groupExists(groupId: number): Promise { } } -async function updateGroup(groupId: number, options: { - collapsed?: boolean - taskLabel?: string -}): Promise { +async function updateGroup( + groupId: number, + options: { + collapsed?: boolean + taskLabel?: string + }, +): Promise { await chrome.tabGroups.update(groupId, { title: formatGroupTitle(options.taskLabel), color: NINE1_TAB_GROUP_COLOR, @@ -59,112 +148,423 @@ async function updateGroup(groupId: number, options: { }) } -async function persistActiveGroup(groupId: number | null): Promise { - activeNine1GroupId = groupId +async function persistBindings(): Promise { try { - if (groupId === null) { - await chrome.storage.local.remove(ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY) - } else { - await chrome.storage.local.set({ [ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]: groupId }) + await chrome.storage.local.set({ + [ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY]: bindingToStorageRecord(), + }) + await chrome.storage.local.remove(ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY) + } catch (error) { + recordError('persist_bindings_failed', error) + } +} + +function installBindingsSyncListener(): void { + if (bindingsSyncInstalled) return + bindingsSyncInstalled = true + + chrome.storage.onChanged?.addListener((changes, areaName) => { + if (areaName !== 'local') return + + const bindingsChange = changes[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY] + if (bindingsChange) { + replaceBindings(normalizeStoredBindings(bindingsChange.newValue)) + return } - } catch { - // Storage is only a coordination convenience for side panel pages. + + const legacyChange = changes[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] + if (!legacyChange) return + + const nextLegacyValue = legacyChange.newValue + if (typeof nextLegacyValue !== 'number' || nextLegacyValue < 0) return + + getGroupWindowId(nextLegacyValue).then((windowId) => { + if (windowId === null) return + activeNine1Groups.set(windowId, { + groupId: nextLegacyValue, + windowId, + updatedAt: Date.now(), + }) + }).catch((error) => { + recordError('legacy_binding_sync_failed', error) + }) + }) +} + +function setBinding(binding: ActiveNine1TabGroupBinding | null): void { + if (!binding) return + activeNine1Groups.set(binding.windowId, binding) +} + +async function persistBinding( + windowId: number, + groupId: number | null, + options: { openNonce?: string } = {}, +): Promise { + if (groupId === null) { + activeNine1Groups.delete(windowId) + await persistBindings() + return } + + setBinding({ + groupId, + windowId, + openNonce: options.openNonce, + updatedAt: Date.now(), + }) + await persistBindings() } -export async function getActiveNine1GroupId(): Promise { - if (activeNine1GroupId !== null && await groupExists(activeNine1GroupId)) { - return activeNine1GroupId +function normalizeStoredBindings(value: unknown): ActiveNine1TabGroupBindings { + if (!value || typeof value !== 'object') return {} + const normalized: ActiveNine1TabGroupBindings = {} + + for (const [windowKey, candidate] of Object.entries(value as Record)) { + if (!candidate || typeof candidate !== 'object') continue + const record = candidate as Partial + if ( + typeof record.groupId !== 'number' + || record.groupId < 0 + || typeof record.windowId !== 'number' + || record.windowId < 0 + ) { + continue + } + normalized[windowKey] = { + groupId: record.groupId, + windowId: record.windowId, + openNonce: typeof record.openNonce === 'string' && record.openNonce.trim() ? record.openNonce : undefined, + updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : Date.now(), + } } + return normalized +} + +async function loadBindings(): Promise { + if (bindingsLoaded) return + bindingsLoaded = true + installBindingsSyncListener() + try { - const stored = await chrome.storage.local.get({ [ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]: -1 }) - const groupId = stored[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] - if (typeof groupId === 'number' && groupId >= 0 && await groupExists(groupId)) { - activeNine1GroupId = groupId - return groupId + const stored = await chrome.storage.local.get({ + [ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY]: {}, + [ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]: -1, + }) + + const nextBindings = normalizeStoredBindings(stored[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY]) + replaceBindings(nextBindings) + + if (activeNine1Groups.size > 0) return + + const legacyGroupId = stored[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] + if (typeof legacyGroupId !== 'number' || legacyGroupId < 0) return + if (!(await groupExists(legacyGroupId))) return + + const groupWindowId = await getGroupWindowId(legacyGroupId) + if (groupWindowId === null) return + + setBinding({ + groupId: legacyGroupId, + windowId: groupWindowId, + updatedAt: Date.now(), + }) + await persistBindings() + } catch (error) { + recordError('load_bindings_failed', error) + } +} + +export async function refreshBindingsFromStorage(): Promise { + try { + installBindingsSyncListener() + const stored = await chrome.storage.local.get({ + [ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY]: {}, + }) + replaceBindings(normalizeStoredBindings(stored[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY])) + } catch (error) { + recordError('refresh_bindings_from_storage_failed', error) + } +} + +async function findMatchingNine1Groups(windowId: number): Promise { + try { + const tabs = await chrome.tabs.query({ windowId }) + const groupIds = Array.from( + new Set( + tabs + .map((tab) => tab.groupId) + .filter((groupId): groupId is number => typeof groupId === 'number' && groupId >= 0), + ), + ) + + const groups = await Promise.all( + groupIds.map(async (groupId) => { + try { + const group = await chrome.tabGroups.get(groupId) + if (!isNine1GroupTitle(group.title)) return null + return { + groupId, + windowId: group.windowId, + title: group.title ?? '', + } satisfies MatchingNine1Group + } catch { + return null + } + }), + ) + + return groups.filter((group): group is MatchingNine1Group => group !== null) + } catch (error) { + recordError('matching_group_scan_failed', error) + return [] + } +} + +export function pickRecoverableNine1Group(options: { + activeTabGroupId: number | null + matchingGroupIds: number[] + hadStoredBinding: boolean +}): { + groupId: number | null + recoverySource: TabGroupRecoverySource + issueCode?: string +} { + const { activeTabGroupId, matchingGroupIds, hadStoredBinding } = options + + if ( + typeof activeTabGroupId === 'number' + && matchingGroupIds.includes(activeTabGroupId) + ) { + return { + groupId: activeTabGroupId, + recoverySource: 'active-tab-match', + } + } + + if (matchingGroupIds.length === 1) { + return { + groupId: matchingGroupIds[0] ?? null, + recoverySource: 'single-title-match', } - } catch { - // ignore storage failures } - await persistActiveGroup(null) - return null + if (matchingGroupIds.length > 1) { + return { + groupId: null, + recoverySource: 'none', + issueCode: 'ambiguous_matching_groups', + } + } + + return { + groupId: null, + recoverySource: 'none', + issueCode: hadStoredBinding ? 'group_binding_stale' : 'no_active_group', + } } -export async function createDedicatedNine1Group(tabId: number, taskLabel?: string): Promise { +async function resolveActiveGroup(options: ResolveGroupOptions = {}): Promise { + await loadBindings() + + const effectiveWindowId = await resolveWindowId(options.windowId) + if (effectiveWindowId === null) { + const resolution: TabGroupResolution = { + windowId: null, + groupId: null, + binding: null, + recoverySource: 'none', + issueCode: 'window_unavailable', + matchedGroupIds: [], + resolvedAt: Date.now(), + } + return resolution + } + + const storedBinding = activeNine1Groups.get(effectiveWindowId) ?? null + const storedBindingWindowId = storedBinding + ? await getGroupWindowId(storedBinding.groupId) + : null + if (storedBinding && storedBindingWindowId === effectiveWindowId) { + const resolution: TabGroupResolution = { + windowId: effectiveWindowId, + groupId: storedBinding.groupId, + binding: { ...storedBinding }, + recoverySource: 'stored', + matchedGroupIds: [storedBinding.groupId], + resolvedAt: Date.now(), + } + setLastResolution(resolution) + return resolution + } + + if (storedBinding) { + activeNine1Groups.delete(effectiveWindowId) + await persistBindings() + } + + const matchingGroups = await findMatchingNine1Groups(effectiveWindowId) + const [activeTab] = await chrome.tabs.query({ active: true, windowId: effectiveWindowId }).catch(() => []) + const selection = pickRecoverableNine1Group({ + activeTabGroupId: + typeof activeTab?.groupId === 'number' && activeTab.groupId >= 0 + ? activeTab.groupId + : null, + matchingGroupIds: matchingGroups.map((group) => group.groupId), + hadStoredBinding: Boolean(storedBinding), + }) + + if (selection.groupId !== null && options.persistRecovered !== false) { + await persistBinding(effectiveWindowId, selection.groupId) + } + + const binding = selection.groupId === null + ? null + : activeNine1Groups.get(effectiveWindowId) ?? { + groupId: selection.groupId, + windowId: effectiveWindowId, + updatedAt: Date.now(), + } + + const resolution: TabGroupResolution = { + windowId: effectiveWindowId, + groupId: selection.groupId, + binding, + recoverySource: selection.recoverySource, + issueCode: selection.issueCode, + matchedGroupIds: matchingGroups.map((group) => group.groupId), + resolvedAt: Date.now(), + } + setLastResolution(resolution) + return resolution +} + +export async function getActiveNine1GroupId(windowId?: number): Promise { + const resolution = await resolveActiveGroup({ windowId }) + return resolution.groupId +} + +export async function createDedicatedNine1Group( + tabId: number, + taskLabel?: string, + options: { openNonce?: string } = {}, +): Promise { + const tab = await getTab(tabId) + if (!tab?.id || typeof tab.windowId !== 'number') return null + try { const groupId = await chrome.tabs.group({ tabIds: [tabId] }) await updateGroup(groupId, { collapsed: false, taskLabel }) - await persistActiveGroup(groupId) + await persistBinding(tab.windowId, groupId, { openNonce: options.openNonce }) + setLastResolution({ + windowId: tab.windowId, + groupId, + binding: activeNine1Groups.get(tab.windowId) ?? null, + recoverySource: 'stored', + matchedGroupIds: [groupId], + resolvedAt: Date.now(), + }) return groupId - } catch { + } catch (error) { + recordError('create_dedicated_group_failed', error) return null } } -export async function addTabToNine1Group(tabId: number, taskLabel?: string): Promise { +export async function addTabToNine1Group( + tabId: number, + taskLabel?: string, + options: AddTabOptions = {}, +): Promise { + const tab = await getTab(tabId) + if (!tab?.id || typeof tab.windowId !== 'number') return null + try { - const tab = await getTab(tabId) - if (!tab) return null + const resolution = await resolveActiveGroup({ windowId: tab.windowId }) + let groupId = resolution.groupId - let groupId = await getActiveNine1GroupId() - if (groupId === null) { - return await createDedicatedNine1Group(tabId, taskLabel) + if (options.onlyIfMissing && groupId !== null) { + await updateGroup(groupId, { collapsed: false, taskLabel }) + return groupId } - const groupWindowId = await getGroupWindowId(groupId) - if (groupWindowId !== null && groupWindowId !== tab.windowId) { - return await createDedicatedNine1Group(tabId, taskLabel) + if (groupId === null) { + return await createDedicatedNine1Group(tabId, taskLabel, { openNonce: options.openNonce }) } const existingGroupId = await getGroupIdForTab(tabId) if (existingGroupId !== groupId) { groupId = await chrome.tabs.group({ tabIds: [tabId], groupId }) - await persistActiveGroup(groupId) + await persistBinding(tab.windowId, groupId, { openNonce: options.openNonce }) } await updateGroup(groupId, { collapsed: false, taskLabel }) return groupId - } catch { + } catch (error) { + recordError('add_tab_to_group_failed', error) return null } } -export async function isTabInActiveNine1Group(tabId: number): Promise { - const activeGroupId = await getActiveNine1GroupId() +export async function isTabInActiveNine1Group(tabId: number, windowId?: number): Promise { + const tab = await getTab(tabId) + if (!tab) return false + const activeGroupId = await getActiveNine1GroupId(windowId ?? tab.windowId) if (activeGroupId === null) return false - return await getGroupIdForTab(tabId) === activeGroupId + return tab.groupId === activeGroupId } -export async function getTabsInActiveNine1Group(): Promise { - const groupId = await getActiveNine1GroupId() - if (groupId === null) return [] +export async function getTabsInActiveNine1Group(windowId?: number): Promise { + const resolution = await resolveActiveGroup({ windowId }) + if (resolution.groupId === null) return [] try { - return await chrome.tabs.query({ groupId }) - } catch { + return await chrome.tabs.query({ groupId: resolution.groupId, windowId: resolution.windowId ?? undefined }) + } catch (error) { + recordError('active_group_tabs_query_failed', error) + return [] + } +} + +export async function getTabsInAllActiveNine1Groups(): Promise { + try { + const windows = await chrome.windows.getAll({ populate: false }) + const tabs = await Promise.all( + windows + .map((window) => window.id) + .filter((windowId): windowId is number => typeof windowId === 'number') + .map((windowId) => getTabsInActiveNine1Group(windowId)), + ) + return tabs.flat() + } catch (error) { + recordError('all_active_group_tabs_query_failed', error) return [] } } export async function getTabsInGroupByTab(tabId: number): Promise { + const tab = await getTab(tabId) + if (!tab?.id) return [tabId] + try { - if (!await isTabInActiveNine1Group(tabId)) return [tabId] - const tabs = await getTabsInActiveNine1Group() + if (!await isTabInActiveNine1Group(tabId, tab.windowId)) return [tabId] + const tabs = await getTabsInActiveNine1Group(tab.windowId) const tabIds = tabs - .map((tab) => tab.id) + .map((candidate) => candidate.id) .filter((candidate): candidate is number => typeof candidate === 'number') return tabIds.length > 0 ? tabIds : [tabId] - } catch { + } catch (error) { + recordError('group_tabs_by_tab_failed', error) return [tabId] } } export async function getDefaultNine1Tab(windowId?: number): Promise { - const tabs = await getTabsInActiveNine1Group() + const effectiveWindowId = await resolveWindowId(windowId) + const tabs = await getTabsInActiveNine1Group(effectiveWindowId ?? undefined) if (tabs.length === 0) return null - const activeInWindow = tabs.find((tab) => tab.active && (windowId === undefined || tab.windowId === windowId)) + const activeInWindow = tabs.find((tab) => tab.active && (effectiveWindowId === null || tab.windowId === effectiveWindowId)) if (activeInWindow) return activeInWindow const active = tabs.find((tab) => tab.active) @@ -173,32 +573,59 @@ export async function getDefaultNine1Tab(windowId?: number): Promise { try { - if (!await isTabInActiveNine1Group(tabId)) return - const groupId = await getActiveNine1GroupId() + const tab = await getTab(tabId) + if (!tab?.id || typeof tab.windowId !== 'number') return + if (!await isTabInActiveNine1Group(tabId, tab.windowId)) return + const groupId = await getActiveNine1GroupId(tab.windowId) if (groupId === null) return await updateGroup(groupId, { collapsed: false, taskLabel }) - } catch { - // ignore tab/group lifecycle races + } catch (error) { + recordError('set_group_active_failed', error) } } export async function setNine1GroupIdle(tabId: number): Promise { try { - if (!await isTabInActiveNine1Group(tabId)) return - const groupId = await getActiveNine1GroupId() + const tab = await getTab(tabId) + if (!tab?.id || typeof tab.windowId !== 'number') return + if (!await isTabInActiveNine1Group(tabId, tab.windowId)) return + const groupId = await getActiveNine1GroupId(tab.windowId) if (groupId === null) return await updateGroup(groupId, { collapsed: true }) - } catch { - // ignore tab/group lifecycle races + } catch (error) { + recordError('set_group_idle_failed', error) } } -async function cleanupMissingActiveGroup(): Promise { - const groupId = await getActiveNine1GroupId() - if (groupId === null) return - const tabs = await getTabsInActiveNine1Group() - if (tabs.length === 0) { - await persistActiveGroup(null) +async function cleanupMissingActiveGroups(): Promise { + await loadBindings() + const windowIds = Array.from(activeNine1Groups.keys()) + + for (const windowId of windowIds) { + const binding = activeNine1Groups.get(windowId) + if (!binding) continue + if (await groupExists(binding.groupId)) continue + await persistBinding(windowId, null) + } +} + +export async function getTabGroupDiagnostics(windowId?: number): Promise { + await loadBindings() + const currentWindowId = await resolveWindowId(windowId) + if (currentWindowId !== null && !lastResolutionByWindow.has(currentWindowId)) { + await resolveActiveGroup({ windowId: currentWindowId, persistRecovered: false }) + } + + return { + currentWindowId, + bindings: bindingToStorageRecord(), + lastResolutionByWindow: Object.fromEntries( + Array.from(lastResolutionByWindow.entries()).map(([resolvedWindowId, resolution]) => [ + String(resolvedWindowId), + { ...resolution }, + ]), + ), + lastError: lastError ? { ...lastError } : null, } } @@ -207,8 +634,16 @@ export function setupTabGroupCleanup(): void { cleanupInstalled = true chrome.tabs.onRemoved.addListener(() => { - cleanupMissingActiveGroup().catch(() => { - // Tab groups self-heal as tabs close; this keeps setup idempotent. + cleanupMissingActiveGroups().catch((error) => { + recordError('cleanup_missing_active_group_failed', error) }) }) } + +export function __resetTabGroupManagerStateForTests(): void { + cleanupInstalled = false + bindingsLoaded = false + activeNine1Groups.clear() + lastResolutionByWindow.clear() + lastError = null +} diff --git a/packages/browser-extension/src/shared/tab-group.ts b/packages/browser-extension/src/shared/tab-group.ts index e45e80f7..36d2f005 100644 --- a/packages/browser-extension/src/shared/tab-group.ts +++ b/packages/browser-extension/src/shared/tab-group.ts @@ -1 +1,39 @@ export const ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY = 'activeNine1TabGroupId' +export const ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY = 'activeNine1TabGroups' +export const NINE1_TAB_GROUP_TITLE_PREFIX = 'Nine1Bot' + +export interface ActiveNine1TabGroupBinding { + groupId: number + windowId: number + openNonce?: string + updatedAt: number +} + +export type ActiveNine1TabGroupBindings = Record + +export type TabGroupRecoverySource = + | 'stored' + | 'active-tab-match' + | 'single-title-match' + | 'none' + +export interface TabGroupResolution { + windowId: number | null + groupId: number | null + binding: ActiveNine1TabGroupBinding | null + recoverySource: TabGroupRecoverySource + issueCode?: string + matchedGroupIds: number[] + resolvedAt: number +} + +export interface TabGroupDiagnostics { + currentWindowId: number | null + bindings: ActiveNine1TabGroupBindings + lastResolutionByWindow: Record + lastError: { + code: string + message: string + at: number + } | null +} diff --git a/packages/browser-extension/src/sidepanel/index.ts b/packages/browser-extension/src/sidepanel/index.ts index b479ee98..270b1502 100644 --- a/packages/browser-extension/src/sidepanel/index.ts +++ b/packages/browser-extension/src/sidepanel/index.ts @@ -9,7 +9,11 @@ import { readStoredServerOrigin, writeStoredServerOrigin, } from '../shared/server-config' -import { ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY } from '../shared/tab-group' +import { + ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY, + ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY, +} from '../shared/tab-group' +import { getDefaultNine1Tab, refreshBindingsFromStorage } from '../background/tab-group-manager' let currentFrameOrigin = '' let currentServerOrigin = '' @@ -347,25 +351,15 @@ async function collectActiveTabPageContext(): Promise { } async function getActiveNine1GroupTab(): Promise { - try { - const stored = await chrome.storage.local.get({ [ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]: -1 }) - const groupId = stored[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] - if (typeof groupId !== 'number' || groupId < 0) return undefined - - const tabs = await chrome.tabs.query({ groupId }) - if (tabs.length === 0) return undefined - - const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }) - if (activeTab?.id && tabs.some((tab) => tab.id === activeTab.id)) return activeTab - - return tabs.find((tab) => tab.active) ?? tabs[0] - } catch { - return undefined - } + await refreshBindingsFromStorage() + return (await getDefaultNine1Tab()) ?? undefined } async function ensureDedicatedTabGroup(): Promise { - await chrome.runtime.sendMessage({ type: 'nine1bot-sidepanel-ensure-tab-group' }).catch(() => undefined) + await chrome.runtime.sendMessage({ + type: 'nine1bot-sidepanel-ensure-tab-group', + onlyIfMissing: true, + }).catch(() => undefined) } function notifyFramePageChanged(): void { @@ -394,7 +388,13 @@ function setupActivePageListener(): void { }) chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName === 'local' && changes[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]) { + if ( + areaName === 'local' + && ( + changes[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] + || changes[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY] + ) + ) { scheduleFramePageChanged() } }) diff --git a/packages/browser-extension/src/tools/tabs.ts b/packages/browser-extension/src/tools/tabs.ts index 85d9c18a..9da30b49 100644 --- a/packages/browser-extension/src/tools/tabs.ts +++ b/packages/browser-extension/src/tools/tabs.ts @@ -1,5 +1,5 @@ import type { ToolDefinition, ToolResult } from './index' -import { addTabToNine1Group, getDefaultNine1Tab, getTabsInActiveNine1Group } from '../background/tab-group-manager' +import { addTabToNine1Group, getDefaultNine1Tab, getTabGroupDiagnostics, getTabsInActiveNine1Group } from '../background/tab-group-manager' interface TabsContextArgs { createIfEmpty?: boolean @@ -82,6 +82,15 @@ export const tabsContextTool = { const activeTab = await getDefaultNine1Tab() const automatableTabs = managedTabs.filter((tab) => isAutomatableTabUrl(tab.url)) + const diagnostics = await getTabGroupDiagnostics(activeTab?.windowId) + const resolution = diagnostics.currentWindowId !== null + ? diagnostics.lastResolutionByWindow[String(diagnostics.currentWindowId)] ?? null + : null + const reasonCode = automatableTabs.length > 0 + ? 'ok' + : managedTabs.length > 0 + ? 'no_automatable_tabs' + : resolution?.issueCode ?? 'no_active_group' return { content: [ @@ -93,6 +102,15 @@ export const tabsContextTool = { activeTab: activeTab ? serializeTab(activeTab) : null, allTabs: includeAll ? automatableTabs.map(serializeTab) : undefined, totalMcpTabs: managedTabs.length, + totalAutomatableTabs: automatableTabs.length, + tabScanStatus: { + source: 'authoritative_group_scan', + reasonCode, + recoverySource: resolution?.recoverySource ?? 'none', + windowId: diagnostics.currentWindowId, + matchedGroupIds: resolution?.matchedGroupIds ?? [], + }, + diagnostics, }, null, 2 diff --git a/packages/browser-extension/test/tab-group-manager.test.ts b/packages/browser-extension/test/tab-group-manager.test.ts new file mode 100644 index 00000000..0a68eaca --- /dev/null +++ b/packages/browser-extension/test/tab-group-manager.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY, + ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY, + NINE1_TAB_GROUP_TITLE_PREFIX, +} from '../src/shared/tab-group' +import { + __resetTabGroupManagerStateForTests, + getActiveNine1GroupId, + getTabGroupDiagnostics, + refreshBindingsFromStorage, +} from '../src/background/tab-group-manager' + +interface MockTab { + id: number + windowId: number + groupId: number + active?: boolean + url?: string + title?: string +} + +interface MockGroup { + id: number + windowId: number + title?: string + color?: chrome.tabGroups.ColorEnum + collapsed?: boolean +} + +const state: { + tabs: Map + groups: Map + storage: Record + currentWindowId: number + storageListeners: Array<(changes: Record, areaName: string) => void> +} = { + tabs: new Map(), + groups: new Map(), + storage: {}, + currentWindowId: 1, + storageListeners: [], +} + +function resetChromeState(): void { + state.tabs.clear() + state.groups.clear() + state.storage = {} + state.currentWindowId = 1 + state.storageListeners = [] + __resetTabGroupManagerStateForTests() +} + +function setTabs(tabs: MockTab[]): void { + state.tabs = new Map(tabs.map((tab) => [tab.id, { ...tab }])) +} + +function setGroups(groups: MockGroup[]): void { + state.groups = new Map(groups.map((group) => [group.id, { ...group }])) +} + +beforeEach(() => { + resetChromeState() + + ;(globalThis as { chrome?: unknown }).chrome = { + storage: { + local: { + async get(defaults: Record) { + return { + ...defaults, + ...state.storage, + } + }, + async set(values: Record) { + const changes = Object.fromEntries( + Object.entries(values).map(([key, newValue]) => [ + key, + { oldValue: state.storage[key], newValue }, + ]), + ) + state.storage = { + ...state.storage, + ...values, + } + for (const listener of state.storageListeners) { + listener(changes, 'local') + } + }, + async remove(key: string) { + const oldValue = state.storage[key] + delete state.storage[key] + for (const listener of state.storageListeners) { + listener({ [key]: { oldValue, newValue: undefined } }, 'local') + } + }, + }, + onChanged: { + addListener(listener: (changes: Record, areaName: string) => void) { + state.storageListeners.push(listener) + }, + }, + }, + tabs: { + async get(tabId: number) { + const tab = state.tabs.get(tabId) + if (!tab) throw new Error(`Missing tab ${tabId}`) + return { ...tab } + }, + async query(queryInfo: { + active?: boolean + currentWindow?: boolean + windowId?: number + groupId?: number + }) { + return Array.from(state.tabs.values()).filter((tab) => { + if (typeof queryInfo.groupId === 'number' && tab.groupId !== queryInfo.groupId) return false + if (typeof queryInfo.windowId === 'number' && tab.windowId !== queryInfo.windowId) return false + if (queryInfo.currentWindow && tab.windowId !== state.currentWindowId) return false + if (queryInfo.active && !tab.active) return false + return true + }).map((tab) => ({ ...tab })) + }, + async group(options: { tabIds: number[]; groupId?: number }) { + const nextGroupId = options.groupId ?? Math.max(0, ...state.groups.keys()) + 1 + if (!state.groups.has(nextGroupId)) { + const firstTab = state.tabs.get(options.tabIds[0]!) + state.groups.set(nextGroupId, { + id: nextGroupId, + windowId: firstTab?.windowId ?? state.currentWindowId, + title: NINE1_TAB_GROUP_TITLE_PREFIX, + }) + } + for (const tabId of options.tabIds) { + const tab = state.tabs.get(tabId) + if (!tab) continue + state.tabs.set(tabId, { ...tab, groupId: nextGroupId }) + } + return nextGroupId + }, + onRemoved: { + addListener() { + return undefined + }, + }, + }, + tabGroups: { + async get(groupId: number) { + const group = state.groups.get(groupId) + if (!group) throw new Error(`Missing group ${groupId}`) + return { ...group } + }, + async update(groupId: number, updateInfo: Partial) { + const group = state.groups.get(groupId) + if (!group) throw new Error(`Missing group ${groupId}`) + const next = { ...group, ...updateInfo } + state.groups.set(groupId, next) + return { ...next } + }, + }, + windows: { + async getLastFocused() { + return { id: state.currentWindowId } + }, + async getAll() { + return Array.from( + new Set(Array.from(state.tabs.values()).map((tab) => tab.windowId)), + ).map((id) => ({ id })) + }, + }, + } +}) + +describe('tab group manager recovery', () => { + test('reuses a valid stored binding without rebuilding the group', async () => { + setGroups([{ id: 10, windowId: 1, title: NINE1_TAB_GROUP_TITLE_PREFIX }]) + setTabs([{ id: 1, windowId: 1, groupId: 10, active: true }]) + state.storage[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY] = { + '1': { groupId: 10, windowId: 1, updatedAt: 1 }, + } + + await expect(getActiveNine1GroupId(1)).resolves.toBe(10) + + const diagnostics = await getTabGroupDiagnostics(1) + expect(diagnostics.lastResolutionByWindow['1']?.recoverySource).toBe('stored') + }) + + test('recovers from a stale binding using the active tab matching group', async () => { + setGroups([{ id: 11, windowId: 1, title: `${NINE1_TAB_GROUP_TITLE_PREFIX}: task` }]) + setTabs([{ id: 2, windowId: 1, groupId: 11, active: true }]) + state.storage[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY] = { + '1': { groupId: 999, windowId: 1, updatedAt: 1 }, + } + + await expect(getActiveNine1GroupId(1)).resolves.toBe(11) + + const diagnostics = await getTabGroupDiagnostics(1) + expect(diagnostics.lastResolutionByWindow['1']?.recoverySource).toBe('active-tab-match') + }) + + test('rejects stored bindings when the group moved to another window', async () => { + setGroups([ + { id: 12, windowId: 2, title: `${NINE1_TAB_GROUP_TITLE_PREFIX}: moved` }, + { id: 13, windowId: 1, title: `${NINE1_TAB_GROUP_TITLE_PREFIX}: local` }, + ]) + setTabs([ + { id: 6, windowId: 1, groupId: 13, active: true }, + { id: 7, windowId: 2, groupId: 12, active: false }, + ]) + state.storage[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY] = { + '1': { groupId: 12, windowId: 1, updatedAt: 1 }, + } + + await expect(getActiveNine1GroupId(1)).resolves.toBe(13) + + const diagnostics = await getTabGroupDiagnostics(1) + expect(diagnostics.lastResolutionByWindow['1']?.recoverySource).toBe('active-tab-match') + expect(diagnostics.bindings['1']?.groupId).toBe(13) + }) + + test('prefers the active tab group when multiple matching groups exist', async () => { + setGroups([ + { id: 21, windowId: 1, title: `${NINE1_TAB_GROUP_TITLE_PREFIX}: first` }, + { id: 22, windowId: 1, title: `${NINE1_TAB_GROUP_TITLE_PREFIX}: second` }, + ]) + setTabs([ + { id: 3, windowId: 1, groupId: 21, active: false }, + { id: 4, windowId: 1, groupId: 22, active: true }, + ]) + + await expect(getActiveNine1GroupId(1)).resolves.toBe(22) + + const diagnostics = await getTabGroupDiagnostics(1) + expect(diagnostics.lastResolutionByWindow['1']?.matchedGroupIds).toEqual([21, 22]) + expect(diagnostics.lastResolutionByWindow['1']?.recoverySource).toBe('active-tab-match') + }) + + test('migrates the legacy single-value storage key to window-scoped bindings', async () => { + setGroups([{ id: 30, windowId: 5, title: NINE1_TAB_GROUP_TITLE_PREFIX }]) + setTabs([{ id: 5, windowId: 5, groupId: 30, active: true }]) + state.storage[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] = 30 + state.currentWindowId = 5 + + await expect(getActiveNine1GroupId(5)).resolves.toBe(30) + + const diagnostics = await getTabGroupDiagnostics(5) + expect(diagnostics.bindings['5']?.groupId).toBe(30) + expect(state.storage[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]).toBeUndefined() + }) + + test('refreshes cached bindings when another extension context updates storage', async () => { + setGroups([ + { id: 40, windowId: 1, title: `${NINE1_TAB_GROUP_TITLE_PREFIX}: first` }, + { id: 41, windowId: 1, title: `${NINE1_TAB_GROUP_TITLE_PREFIX}: second` }, + ]) + setTabs([ + { id: 8, windowId: 1, groupId: 40, active: true }, + { id: 9, windowId: 1, groupId: 41, active: false }, + ]) + state.storage[ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY] = { + '1': { groupId: 40, windowId: 1, updatedAt: 1 }, + } + + await expect(getActiveNine1GroupId(1)).resolves.toBe(40) + + await (globalThis.chrome as typeof chrome).storage.local.set({ + [ACTIVE_NINE1_TAB_GROUPS_STORAGE_KEY]: { + '1': { groupId: 41, windowId: 1, updatedAt: 2 }, + }, + }) + + await refreshBindingsFromStorage() + await expect(getActiveNine1GroupId(1)).resolves.toBe(41) + }) +}) diff --git a/packages/browser-mcp-server/src/bridge/relay-routes.ts b/packages/browser-mcp-server/src/bridge/relay-routes.ts index 3ac6b511..4f4e40c5 100644 --- a/packages/browser-mcp-server/src/bridge/relay-routes.ts +++ b/packages/browser-mcp-server/src/bridge/relay-routes.ts @@ -10,6 +10,7 @@ import { Hono } from 'hono' import { upgradeWebSocket } from 'hono/bun' import type { WSContext } from 'hono/ws' +import type { Tab, TabGroupDiagnostics } from '../core/types' export interface TargetInfo { targetId: string @@ -39,6 +40,28 @@ export interface ExtensionHealthPayload { lastPongAt?: number activeCommands?: number reconnectAttempt?: number + diagnostics?: { + authoritativeTabs: Array<{ + tabId: number + windowId: number + title: string + url: string + }> + activeSessions: Array<{ + tabId: number + sessionId: string + }> + tabGroups: TabGroupDiagnostics + lastResync: { + reason: string + windowId: number | null + attachedTabIds: number[] + detachedTabIds: number[] + updatedTabIds: number[] + authoritativeTabIds: number[] + at: number + } | null + } } export interface ExtensionAgentStatePayload { @@ -54,9 +77,11 @@ export interface ExtensionRelay { getTools: () => string[] getCapabilities: () => Record getHealth: () => ExtensionHealthPayload | null + getDiagnostics: () => ExtensionHealthPayload['diagnostics'] | null getHelloAt: () => number | null getHello: () => ExtensionHelloPayload | null getAgentStates: () => ExtensionAgentStatePayload[] + upsertTargetsFromTabs: (tabs: Tab[]) => ConnectedTarget[] sendCommand: (method: string, params?: unknown, targetId?: string) => Promise stop: () => Promise } @@ -140,6 +165,60 @@ function findSessionIdForTarget(targetId: string): string | undefined { return undefined } +function stableSessionIdForTarget(targetId: string): string { + return /^\d+$/.test(targetId) ? `tab_${targetId}` : `target_${targetId}` +} + +function upsertConnectedTarget(target: ConnectedTarget, broadcast = true): ConnectedTarget { + const previous = connectedTargets.get(target.sessionId) + connectedTargets.set(target.sessionId, target) + + if (!broadcast) { + return target + } + + if (!previous) { + broadcastToCdpClients({ + method: 'Target.attachedToTarget', + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false, + }, + sessionId: target.sessionId, + }) + return target + } + + if ( + previous.targetId !== target.targetId + || previous.targetInfo.title !== target.targetInfo.title + || previous.targetInfo.url !== target.targetInfo.url + ) { + broadcastToCdpClients({ + method: 'Target.targetInfoChanged', + params: { + targetInfo: { ...target.targetInfo, attached: true }, + }, + sessionId: target.sessionId, + }) + } + + return target +} + +function detachConnectedTarget(sessionId: string, targetId: string): void { + connectedTargets.delete(sessionId) + broadcastToCdpClients({ + method: 'Target.detachedFromTarget', + params: { + sessionId, + targetId, + }, + sessionId, + }) +} + async function routeCdpCommand(cmd: { id: number; method: string; params?: unknown; sessionId?: string }): Promise { switch (cmd.method) { case 'Browser.getVersion': @@ -284,6 +363,7 @@ function handleExtensionMessage(data: string): void { lastPongAt: params?.lastPongAt, activeCommands: params?.activeCommands, reconnectAttempt: params?.reconnectAttempt, + diagnostics: params?.diagnostics ?? undefined, } return } @@ -317,11 +397,11 @@ function handleExtensionMessage(data: string): void { const prevTargetId = prev?.targetId const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId) - connectedTargets.set(attached.sessionId, { + upsertConnectedTarget({ sessionId: attached.sessionId, targetId: nextTargetId, targetInfo: attached.targetInfo, - }) + }, false) if (changedTarget && prevTargetId) { broadcastToCdpClients({ @@ -516,9 +596,46 @@ export function getExtensionRelay(): ExtensionRelay { getTools: () => [...extensionTools], getCapabilities: () => ({ ...extensionCapabilities }), getHealth: () => (extensionHealth ? { ...extensionHealth } : null), + getDiagnostics: () => (extensionHealth?.diagnostics ? structuredClone(extensionHealth.diagnostics) : null), getHelloAt: () => extensionHelloAt, getHello: () => (extensionHello ? { ...extensionHello } : null), getAgentStates: () => Array.from(extensionAgentStates.values()).map((state) => ({ ...state })), + upsertTargetsFromTabs: (tabs: Tab[]) => { + const desiredSessionIds = new Set() + const desiredSessionIdByTargetId = new Map() + + for (const tab of tabs) { + const targetId = String(tab.id) + const sessionId = tab.sessionId ?? stableSessionIdForTarget(targetId) + desiredSessionIds.add(sessionId) + desiredSessionIdByTargetId.set(targetId, sessionId) + } + + for (const [sessionId, target] of Array.from(connectedTargets.entries())) { + const desiredSessionId = desiredSessionIdByTargetId.get(target.targetId) + if (desiredSessionId === sessionId) continue + if (!desiredSessionIds.has(sessionId) || desiredSessionId !== sessionId) { + detachConnectedTarget(sessionId, target.targetId) + } + } + + const nextTargets = tabs.map((tab) => { + const targetId = String(tab.id) + const sessionId = tab.sessionId ?? stableSessionIdForTarget(targetId) + return upsertConnectedTarget({ + sessionId, + targetId, + targetInfo: { + targetId, + type: 'page', + title: tab.title, + url: tab.url, + attached: true, + }, + }) + }) + return nextTargets + }, sendCommand: async (method: string, params?: unknown, targetId?: string) => { if (!extensionWs || extensionWs.readyState !== 1) { throw new Error('Chrome extension not connected') diff --git a/packages/browser-mcp-server/src/bridge/server.ts b/packages/browser-mcp-server/src/bridge/server.ts index 7f595b47..cca08fdc 100644 --- a/packages/browser-mcp-server/src/bridge/server.ts +++ b/packages/browser-mcp-server/src/bridge/server.ts @@ -105,6 +105,14 @@ function normalizeLocateResult(value: unknown, query: string): LocateResult { } } +type ExtensionTabListSource = 'authoritative_group_scan' | 'relay_cache_fallback' | 'none' + +interface RefreshExtensionTabsResult { + tabs: Tab[] + source: ExtensionTabListSource + issues: BrowserRuntimeStatus['issues'] +} + async function isLocalPortListening(host: string, port: number): Promise { return await new Promise((resolve) => { const socket = new Socket() @@ -217,22 +225,19 @@ export class BridgeServer { async getStatus(): Promise { let userStatus: BrowserStatus['user'] = null let botStatus: BrowserStatus['bot'] = null + const runtimeIssues: BrowserRuntimeStatus['issues'] = [] // User browser (Extension) if (this.isExtensionConnected) { - const targets = this.relay!.getTargets() const refreshedTabs = await this.refreshExtensionTabs() + runtimeIssues.push(...refreshedTabs.issues) userStatus = { connected: true, - tabs: refreshedTabs ?? targets.map(t => ({ - id: t.targetId, - sessionId: t.sessionId, - title: t.targetInfo.title, - url: t.targetInfo.url, - })), + tabs: refreshedTabs.tabs, + tabListSource: refreshedTabs.source, } } else { - userStatus = { connected: false, tabs: [] } + userStatus = { connected: false, tabs: [], tabListSource: 'none' } } // Bot browser (Direct CDP) @@ -256,14 +261,34 @@ export class BridgeServer { return { user: userStatus, bot: botStatus, - runtime: await this.getRuntimeStatus(), + runtime: await this.getRuntimeStatus(runtimeIssues), } } - private async refreshExtensionTabs(): Promise { - if (!this.relay?.extensionConnected()) return null + private async refreshExtensionTabs(): Promise { + if (!this.relay?.extensionConnected()) { + return { tabs: [], source: 'none', issues: [] } + } const supportedTools = this.relay.getTools() - if (supportedTools.length > 0 && !supportedTools.includes('tabs_context_mcp')) return null + if (supportedTools.length > 0 && !supportedTools.includes('tabs_context_mcp')) { + const targets = this.relay.getTargets() + return { + tabs: targets.map((target) => ({ + id: target.targetId, + sessionId: target.sessionId, + title: target.targetInfo.title, + url: target.targetInfo.url, + })), + source: targets.length > 0 ? 'relay_cache_fallback' : 'none', + issues: targets.length > 0 + ? [{ + code: 'relay_cache_fallback', + severity: 'warning', + message: 'Extension tabs_context_mcp is unavailable; using relay cache fallback.', + }] + : [], + } + } try { const result = await this.relay.sendCommand( @@ -276,40 +301,132 @@ export class BridgeServer { }, ) as ExtensionToolResult const text = result.content?.find(c => c.type === 'text')?.text ?? '{}' + if (result.isError) { + return { + tabs: [], + source: 'none', + issues: [ + { + code: 'tab_scan_failed', + severity: 'error', + message: `Extension tab scan failed: ${text}`, + }, + ], + } + } + + if (!text.trim().startsWith('{')) { + return { + tabs: [], + source: 'none', + issues: [ + { + code: 'tab_scan_failed', + severity: 'error', + message: `Extension tab scan returned a non-JSON payload: ${text}`, + }, + ], + } + } + const parsed = JSON.parse(text) as { allTabs?: Array<{ id?: number; title?: string; url?: string; active?: boolean; windowId?: number }> activeTab?: { id?: number; title?: string; url?: string; windowId?: number } | null + tabScanStatus?: { + source?: string + reasonCode?: string + recoverySource?: string + windowId?: number | null + matchedGroupIds?: number[] + } } const allTabs = Array.isArray(parsed.allTabs) ? parsed.allTabs : [] + const issues: BrowserRuntimeStatus['issues'] = [] if (allTabs.length > 0) { - return allTabs + const tabs = allTabs .filter(tab => typeof tab.id === 'number') .map(tab => ({ id: String(tab.id), + sessionId: `tab_${tab.id}`, title: tab.title ?? '', url: tab.url ?? '', active: tab.active, windowId: tab.windowId, })) + const beforeTargets = this.relay.getTargets() + this.relay.upsertTargetsFromTabs(tabs) + if (beforeTargets.length === 0) { + issues.push({ + code: 'relay_cache_empty', + severity: 'warning', + message: 'Relay target cache was empty; rebuilt it from authoritative extension tab scan.', + }) + } + return { + tabs, + source: 'authoritative_group_scan', + issues, + } } - if (parsed.activeTab?.id) { - return [{ - id: String(parsed.activeTab.id), - title: parsed.activeTab.title ?? '', - url: parsed.activeTab.url ?? '', - active: true, - windowId: parsed.activeTab.windowId, - }] + + const reasonCode = parsed.tabScanStatus?.reasonCode ?? 'no_active_group' + if (reasonCode !== 'ok') { + issues.push({ + code: reasonCode, + severity: reasonCode === 'tab_scan_failed' ? 'error' : 'warning', + message: `Extension tab scan reported ${reasonCode}.`, + }) + } + + return { + tabs: [], + source: 'none', + issues, } } catch { - return null + const cachedTargets = this.relay.getTargets() + if (cachedTargets.length > 0) { + return { + tabs: cachedTargets.map((target) => ({ + id: target.targetId, + sessionId: target.sessionId, + title: target.targetInfo.title, + url: target.targetInfo.url, + })), + source: 'relay_cache_fallback', + issues: [ + { + code: 'tab_scan_failed', + severity: 'error', + message: 'Extension tab scan failed; using relay cache fallback.', + }, + { + code: 'relay_cache_fallback', + severity: 'warning', + message: 'Using relay cache fallback because authoritative extension tab scan failed.', + }, + ], + } + } + + return { + tabs: [], + source: 'none', + issues: [ + { + code: 'tab_scan_failed', + severity: 'error', + message: 'Extension tab scan failed and relay cache is empty.', + }, + ], + } } - return null } - private async getRuntimeStatus(): Promise { + private async getRuntimeStatus(additionalIssues: BrowserRuntimeStatus['issues'] = []): Promise { const hello = this.relay?.getHello() ?? null const helloAt = this.relay?.getHelloAt() ?? null + const diagnostics = this.relay?.getDiagnostics() ?? null const conflicts = await this.detectLegacyPortConflicts() const issues: BrowserRuntimeStatus['issues'] = [] @@ -337,6 +454,8 @@ export class BridgeServer { }) } + issues.push(...additionalIssues) + return { mode: 'embedded', serverOrigin: this.options.serverOrigin, @@ -348,6 +467,7 @@ export class BridgeServer { protocolVersion: hello?.protocolVersion ?? null, serverOrigin: hello?.serverOrigin ?? null, pairedInstanceId: hello?.pairedInstanceId ?? null, + diagnostics, }, conflicts, issues, @@ -413,14 +533,7 @@ export class BridgeServer { if (channel === 'extension') { const refreshedTabs = await this.refreshExtensionTabs() - if (refreshedTabs) return refreshedTabs - const targets = this.relay!.getTargets() - return targets.map(t => ({ - id: t.targetId, - sessionId: t.sessionId, - title: t.targetInfo.title, - url: t.targetInfo.url, - })) + return refreshedTabs.tabs } const cdpUrl = await this.ensureBrowserAvailable() diff --git a/packages/browser-mcp-server/src/core/types.ts b/packages/browser-mcp-server/src/core/types.ts index 344b3042..f924aa95 100644 --- a/packages/browser-mcp-server/src/core/types.ts +++ b/packages/browser-mcp-server/src/core/types.ts @@ -22,6 +22,35 @@ export interface Tab { windowId?: number } +export interface TabGroupDiagnostics { + currentWindowId: number | null + bindings: Record + lastResolutionByWindow: Record + lastError: { + code: string + message: string + at: number + } | null +} + export interface PageContent { title: string url: string @@ -56,6 +85,7 @@ export interface BrowserStatus { user: { connected: boolean tabs: Tab[] + tabListSource?: 'authoritative_group_scan' | 'relay_cache_fallback' | 'none' } | null bot: { running: boolean @@ -87,6 +117,28 @@ export interface BrowserRuntimeStatus { protocolVersion: string | null serverOrigin: string | null pairedInstanceId: string | null + diagnostics?: { + authoritativeTabs: Array<{ + tabId: number + windowId: number + title: string + url: string + }> + activeSessions: Array<{ + tabId: number + sessionId: string + }> + tabGroups: TabGroupDiagnostics + lastResync: { + reason: string + windowId: number | null + attachedTabIds: number[] + detachedTabIds: number[] + updatedTabIds: number[] + authoritativeTabIds: number[] + at: number + } | null + } | null } conflicts: BrowserRuntimeConflict[] issues: BrowserStatusIssue[] diff --git a/packages/browser-mcp-server/test/bridge-server.test.ts b/packages/browser-mcp-server/test/bridge-server.test.ts index 0752a190..f598cc4f 100644 --- a/packages/browser-mcp-server/test/bridge-server.test.ts +++ b/packages/browser-mcp-server/test/bridge-server.test.ts @@ -49,4 +49,132 @@ describe('BridgeServer form fill', () => { await expect(bridge.clickElement('123', {}, 'bot')).rejects.toThrow('targetId') }) + + test('rebuilds stable extension targets from authoritative tab scans', async () => { + const bridge = new BridgeServer() + let relayTargets: Array<{ + sessionId: string + targetId: string + targetInfo: { targetId: string; title: string; url: string; type: string } + }> = [] + const bridgeWithRelay = bridge as any + + bridgeWithRelay.relay = { + extensionConnected: () => true, + getTools: () => ['tabs_context_mcp'], + getTargets: () => relayTargets, + upsertTargetsFromTabs: (tabs: Array<{ id: string; title: string; url: string; sessionId?: string }>) => { + relayTargets = tabs.map((tab) => ({ + sessionId: tab.sessionId ?? `tab_${tab.id}`, + targetId: String(tab.id), + targetInfo: { + targetId: String(tab.id), + title: tab.title, + url: tab.url, + type: 'page', + }, + })) + return relayTargets + }, + sendCommand: async () => ({ + content: [{ + type: 'text', + text: JSON.stringify({ + allTabs: [{ + id: 12, + title: 'Authoritative Tab', + url: 'https://example.com', + active: true, + windowId: 7, + }], + tabScanStatus: { + source: 'authoritative_group_scan', + reasonCode: 'ok', + }, + }), + }], + }), + getHealth: () => null, + getDiagnostics: () => null, + getHelloAt: () => null, + getHello: () => null, + getAgentStates: () => [], + } + + const status = await bridge.getStatus() + + expect(status.user?.tabs[0]?.sessionId).toBe('tab_12') + expect(status.user?.tabListSource).toBe('authoritative_group_scan') + expect(status.runtime?.issues.map((issue) => issue.code)).toContain('relay_cache_empty') + }) + + test('falls back to relay cache when authoritative tab scans fail', async () => { + const bridge = new BridgeServer() + const bridgeWithRelay = bridge as any + + bridgeWithRelay.relay = { + extensionConnected: () => true, + getTools: () => ['tabs_context_mcp'], + getTargets: () => [{ + sessionId: 'tab_44', + targetId: '44', + targetInfo: { + targetId: '44', + title: 'Cached Tab', + url: 'https://cached.example.com', + type: 'page', + }, + }], + upsertTargetsFromTabs: () => [], + sendCommand: async () => { + throw new Error('boom') + }, + getHealth: () => null, + getDiagnostics: () => null, + getHelloAt: () => null, + getHello: () => null, + getAgentStates: () => [], + } + + const status = await bridge.getStatus() + const issueCodes = status.runtime?.issues.map((issue) => issue.code) ?? [] + + expect(status.user?.tabs[0]?.id).toBe('44') + expect(status.user?.tabListSource).toBe('relay_cache_fallback') + expect(issueCodes).toContain('tab_scan_failed') + expect(issueCodes).toContain('relay_cache_fallback') + }) + + test('surfaces extension tool errors from authoritative tab scans', async () => { + const bridge = new BridgeServer() + const bridgeWithRelay = bridge as any + + bridgeWithRelay.relay = { + extensionConnected: () => true, + getTools: () => ['tabs_context_mcp'], + getTargets: () => [], + upsertTargetsFromTabs: () => [], + sendCommand: async () => ({ + isError: true, + content: [{ + type: 'text', + text: 'Error getting tabs context: no active group', + }], + }), + getHealth: () => null, + getDiagnostics: () => null, + getHelloAt: () => null, + getHello: () => null, + getAgentStates: () => [], + } + + const status = await bridge.getStatus() + + expect(status.user?.tabs).toEqual([]) + expect(status.runtime?.issues).toContainEqual({ + code: 'tab_scan_failed', + severity: 'error', + message: 'Extension tab scan failed: Error getting tabs context: no active group', + }) + }) }) diff --git a/packages/browser-mcp-server/test/relay-routes.test.ts b/packages/browser-mcp-server/test/relay-routes.test.ts index e110d6fd..60fdff30 100644 --- a/packages/browser-mcp-server/test/relay-routes.test.ts +++ b/packages/browser-mcp-server/test/relay-routes.test.ts @@ -7,4 +7,21 @@ describe('extension relay target validation', () => { getExtensionRelay().sendCommand('Runtime.evaluate', { expression: '1 + 1' }, 'missing-target'), ).rejects.toThrow('Chrome extension not connected') }) + + test('prunes stale cached targets when authoritative tabs are rebuilt', async () => { + const relay = getExtensionRelay() + await relay.stop() + + relay.upsertTargetsFromTabs([ + { id: '1', sessionId: 'tab_1', title: 'First', url: 'https://first.example.com' }, + { id: '2', sessionId: 'tab_2', title: 'Second', url: 'https://second.example.com' }, + ]) + expect(relay.getTargets().map((target) => target.targetId)).toEqual(['1', '2']) + + relay.upsertTargetsFromTabs([ + { id: '2', sessionId: 'tab_2', title: 'Second', url: 'https://second.example.com' }, + ]) + + expect(relay.getTargets().map((target) => target.targetId)).toEqual(['2']) + }) }) diff --git a/packages/nine1bot/src/launcher/server.ts b/packages/nine1bot/src/launcher/server.ts index 6dc7c852..10a8c443 100644 --- a/packages/nine1bot/src/launcher/server.ts +++ b/packages/nine1bot/src/launcher/server.ts @@ -212,11 +212,25 @@ export async function startServer(options: StartServerOptions): Promise> | undefined + try { + serverInstance = await OpencodeServer.listen({ + port: server.port, + hostname: server.hostname, + cors: [], + }) + } catch (error) { + if (bridgeServer) { + try { + await bridgeServer.stop() + } catch { + // Best-effort cleanup when the main server fails to start. + } finally { + clearBridgeServer() + } + } + throw error + } return { url: serverInstance.url.toString(),