diff --git a/extension/dist/background.js b/extension/dist/background.js index 7a34be1..6849431 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -160,30 +160,35 @@ function scheduleReconnect() { connect(); }, delay); } -let automationWindowId = null; -let windowIdleTimer = null; +const automationSessions = /* @__PURE__ */ new Map(); const WINDOW_IDLE_TIMEOUT = 3e4; -function resetWindowIdleTimer() { - if (windowIdleTimer) clearTimeout(windowIdleTimer); - windowIdleTimer = setTimeout(async () => { - if (automationWindowId !== null) { - try { - await chrome.windows.remove(automationWindowId); - console.log(`[opencli] Automation window ${automationWindowId} closed (idle timeout)`); - } catch { - } - automationWindowId = null; +function getWorkspaceKey(workspace) { + return workspace?.trim() || "default"; +} +function resetWindowIdleTimer(workspace) { + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); + } catch { } - windowIdleTimer = null; + automationSessions.delete(workspace); }, WINDOW_IDLE_TIMEOUT); } -async function getAutomationWindow() { - if (automationWindowId !== null) { +async function getAutomationWindow(workspace) { + const existing = automationSessions.get(workspace); + if (existing) { try { - await chrome.windows.get(automationWindowId); - return automationWindowId; + await chrome.windows.get(existing.windowId); + return existing.windowId; } catch { - automationWindowId = null; + automationSessions.delete(workspace); } } const win = await chrome.windows.create({ @@ -193,17 +198,22 @@ async function getAutomationWindow() { height: 900, type: "normal" }); - automationWindowId = win.id; - console.log(`[opencli] Created automation window ${automationWindowId}`); - return automationWindowId; + const session = { + windowId: win.id, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); + resetWindowIdleTimer(workspace); + return session.windowId; } chrome.windows.onRemoved.addListener((windowId) => { - if (windowId === automationWindowId) { - console.log("[opencli] Automation window closed"); - automationWindowId = null; - if (windowIdleTimer) { - clearTimeout(windowIdleTimer); - windowIdleTimer = null; + for (const [workspace, session] of automationSessions.entries()) { + if (session.windowId === windowId) { + console.log(`[opencli] Automation window closed (${workspace})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); } } }); @@ -226,21 +236,24 @@ chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "keepalive") connect(); }); async function handleCommand(cmd) { - resetWindowIdleTimer(); + const workspace = getWorkspaceKey(cmd.workspace); + resetWindowIdleTimer(workspace); try { switch (cmd.action) { case "exec": - return await handleExec(cmd); + return await handleExec(cmd, workspace); case "navigate": - return await handleNavigate(cmd); + return await handleNavigate(cmd, workspace); case "tabs": - return await handleTabs(cmd); + return await handleTabs(cmd, workspace); case "cookies": return await handleCookies(cmd); case "screenshot": - return await handleScreenshot(cmd); + return await handleScreenshot(cmd, workspace); case "close-window": - return await handleCloseWindow(cmd); + return await handleCloseWindow(cmd, workspace); + case "sessions": + return await handleSessions(cmd); default: return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } @@ -256,9 +269,9 @@ function isWebUrl(url) { if (!url) return false; return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://"); } -async function resolveTabId(tabId) { +async function resolveTabId(tabId, workspace) { if (tabId !== void 0) return tabId; - const windowId = await getAutomationWindow(); + const windowId = await getAutomationWindow(workspace); const tabs = await chrome.tabs.query({ windowId }); const webTab = tabs.find((t) => t.id && isWebUrl(t.url)); if (webTab?.id) return webTab.id; @@ -267,22 +280,23 @@ async function resolveTabId(tabId) { if (!newTab.id) throw new Error("Failed to create tab in automation window"); return newTab.id; } -async function listAutomationTabs() { - if (automationWindowId === null) return []; +async function listAutomationTabs(workspace) { + const session = automationSessions.get(workspace); + if (!session) return []; try { - return await chrome.tabs.query({ windowId: automationWindowId }); + return await chrome.tabs.query({ windowId: session.windowId }); } catch { - automationWindowId = null; + automationSessions.delete(workspace); return []; } } -async function listAutomationWebTabs() { - const tabs = await listAutomationTabs(); +async function listAutomationWebTabs(workspace) { + const tabs = await listAutomationTabs(workspace); return tabs.filter((tab) => isWebUrl(tab.url)); } -async function handleExec(cmd) { +async function handleExec(cmd, workspace) { if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; - const tabId = await resolveTabId(cmd.tabId); + const tabId = await resolveTabId(cmd.tabId, workspace); try { const data = await evaluateAsync(tabId, cmd.code); return { id: cmd.id, ok: true, data }; @@ -290,9 +304,9 @@ async function handleExec(cmd) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function handleNavigate(cmd) { +async function handleNavigate(cmd, workspace) { if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; - const tabId = await resolveTabId(cmd.tabId); + const tabId = await resolveTabId(cmd.tabId, workspace); await chrome.tabs.update(tabId, { url: cmd.url }); await new Promise((resolve) => { chrome.tabs.get(tabId).then((tab2) => { @@ -316,10 +330,10 @@ async function handleNavigate(cmd) { const tab = await chrome.tabs.get(tabId); return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } }; } -async function handleTabs(cmd) { +async function handleTabs(cmd, workspace) { switch (cmd.op) { case "list": { - const tabs = await listAutomationWebTabs(); + const tabs = await listAutomationWebTabs(workspace); const data = tabs.map((t, i) => ({ index: i, tabId: t.id, @@ -330,20 +344,20 @@ async function handleTabs(cmd) { return { id: cmd.id, ok: true, data }; } case "new": { - const windowId = await getAutomationWindow(); + const windowId = await getAutomationWindow(workspace); const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "about:blank", active: true }); return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; } case "close": { if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(); + const tabs = await listAutomationWebTabs(workspace); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.remove(target.id); detach(target.id); return { id: cmd.id, ok: true, data: { closed: target.id } }; } - const tabId = await resolveTabId(cmd.tabId); + const tabId = await resolveTabId(cmd.tabId, workspace); await chrome.tabs.remove(tabId); detach(tabId); return { id: cmd.id, ok: true, data: { closed: tabId } }; @@ -355,7 +369,7 @@ async function handleTabs(cmd) { await chrome.tabs.update(cmd.tabId, { active: true }); return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; } - const tabs = await listAutomationWebTabs(); + const tabs = await listAutomationWebTabs(workspace); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.update(target.id, { active: true }); @@ -381,8 +395,8 @@ async function handleCookies(cmd) { })); return { id: cmd.id, ok: true, data }; } -async function handleScreenshot(cmd) { - const tabId = await resolveTabId(cmd.tabId); +async function handleScreenshot(cmd, workspace) { + const tabId = await resolveTabId(cmd.tabId, workspace); try { const data = await screenshot(tabId, { format: cmd.format, @@ -394,13 +408,25 @@ async function handleScreenshot(cmd) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function handleCloseWindow(cmd) { - if (automationWindowId !== null) { +async function handleCloseWindow(cmd, workspace) { + const session = automationSessions.get(workspace); + if (session) { try { - await chrome.windows.remove(automationWindowId); + await chrome.windows.remove(session.windowId); } catch { } - automationWindowId = null; + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); } return { id: cmd.id, ok: true, data: { closed: true } }; } +async function handleSessions(cmd) { + const now = Date.now(); + const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ + workspace, + windowId: session.windowId, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length, + idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) + }))); + return { id: cmd.id, ok: true, data }; +} diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 483e80f..0f1f39e 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -104,9 +104,9 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId(1); + mod.__test__.setAutomationWindowId('site:twitter', 1); - const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list' }); + const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list', workspace: 'site:twitter' }, 'site:twitter'); expect(result.ok).toBe(true); expect(result.data).toEqual([ @@ -125,11 +125,27 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId(1); + mod.__test__.setAutomationWindowId('site:twitter', 1); - const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example' }); + const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example', workspace: 'site:twitter' }, 'site:twitter'); expect(result.ok).toBe(true); expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true }); }); + + it('reports sessions per workspace', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId('site:zhihu', 2); + + const result = await mod.__test__.handleSessions({ id: '3', action: 'sessions' }); + expect(result.ok).toBe(true); + expect(result.data).toEqual(expect.arrayContaining([ + expect.objectContaining({ workspace: 'site:twitter', windowId: 1 }), + expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }), + ])); + }); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index c8b2e69..878c6bd 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -90,36 +90,48 @@ function scheduleReconnect(): void { // user's active browsing session is never touched. // The window auto-closes after 30s of idle (no commands). -let automationWindowId: number | null = null; -let windowIdleTimer: ReturnType | null = null; +type AutomationSession = { + windowId: number; + idleTimer: ReturnType | null; + idleDeadlineAt: number; +}; + +const automationSessions = new Map(); const WINDOW_IDLE_TIMEOUT = 30000; // 30s -function resetWindowIdleTimer(): void { - if (windowIdleTimer) clearTimeout(windowIdleTimer); - windowIdleTimer = setTimeout(async () => { - if (automationWindowId !== null) { - try { - await chrome.windows.remove(automationWindowId); - console.log(`[opencli] Automation window ${automationWindowId} closed (idle timeout)`); - } catch { - // Already gone - } - automationWindowId = null; +function getWorkspaceKey(workspace?: string): string { + return workspace?.trim() || 'default'; +} + +function resetWindowIdleTimer(workspace: string): void { + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); + } catch { + // Already gone } - windowIdleTimer = null; + automationSessions.delete(workspace); }, WINDOW_IDLE_TIMEOUT); } /** Get or create the dedicated automation window. */ -async function getAutomationWindow(): Promise { +async function getAutomationWindow(workspace: string): Promise { // Check if our window is still alive - if (automationWindowId !== null) { + const existing = automationSessions.get(workspace); + if (existing) { try { - await chrome.windows.get(automationWindowId); - return automationWindowId; + await chrome.windows.get(existing.windowId); + return existing.windowId; } catch { // Window was closed by user - automationWindowId = null; + automationSessions.delete(workspace); } } @@ -131,17 +143,25 @@ async function getAutomationWindow(): Promise { height: 900, type: 'normal', }); - automationWindowId = win.id!; - console.log(`[opencli] Created automation window ${automationWindowId}`); - return automationWindowId; + const session: AutomationSession = { + windowId: win.id!, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); + resetWindowIdleTimer(workspace); + return session.windowId; } // Clean up when the automation window is closed chrome.windows.onRemoved.addListener((windowId) => { - if (windowId === automationWindowId) { - console.log('[opencli] Automation window closed'); - automationWindowId = null; - if (windowIdleTimer) { clearTimeout(windowIdleTimer); windowIdleTimer = null; } + for (const [workspace, session] of automationSessions.entries()) { + if (session.windowId === windowId) { + console.log(`[opencli] Automation window closed (${workspace})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } } }); @@ -173,22 +193,25 @@ chrome.alarms.onAlarm.addListener((alarm) => { // ─── Command dispatcher ───────────────────────────────────────────── async function handleCommand(cmd: Command): Promise { + const workspace = getWorkspaceKey(cmd.workspace); // Reset idle timer on every command (window stays alive while active) - resetWindowIdleTimer(); + resetWindowIdleTimer(workspace); try { switch (cmd.action) { case 'exec': - return await handleExec(cmd); + return await handleExec(cmd, workspace); case 'navigate': - return await handleNavigate(cmd); + return await handleNavigate(cmd, workspace); case 'tabs': - return await handleTabs(cmd); + return await handleTabs(cmd, workspace); case 'cookies': return await handleCookies(cmd); case 'screenshot': - return await handleScreenshot(cmd); + return await handleScreenshot(cmd, workspace); case 'close-window': - return await handleCloseWindow(cmd); + return await handleCloseWindow(cmd, workspace); + case 'sessions': + return await handleSessions(cmd); default: return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } @@ -214,11 +237,11 @@ function isWebUrl(url?: string): boolean { * If explicit tabId is given, use that directly. * Otherwise, find or create a tab in the dedicated automation window. */ -async function resolveTabId(tabId?: number): Promise { +async function resolveTabId(tabId: number | undefined, workspace: string): Promise { if (tabId !== undefined) return tabId; // Get (or create) the automation window - const windowId = await getAutomationWindow(); + const windowId = await getAutomationWindow(workspace); // Find the active tab in our automation window const tabs = await chrome.tabs.query({ windowId }); @@ -234,24 +257,25 @@ async function resolveTabId(tabId?: number): Promise { return newTab.id; } -async function listAutomationTabs(): Promise { - if (automationWindowId === null) return []; +async function listAutomationTabs(workspace: string): Promise { + const session = automationSessions.get(workspace); + if (!session) return []; try { - return await chrome.tabs.query({ windowId: automationWindowId }); + return await chrome.tabs.query({ windowId: session.windowId }); } catch { - automationWindowId = null; + automationSessions.delete(workspace); return []; } } -async function listAutomationWebTabs(): Promise { - const tabs = await listAutomationTabs(); +async function listAutomationWebTabs(workspace: string): Promise { + const tabs = await listAutomationTabs(workspace); return tabs.filter((tab) => isWebUrl(tab.url)); } -async function handleExec(cmd: Command): Promise { +async function handleExec(cmd: Command, workspace: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; - const tabId = await resolveTabId(cmd.tabId); + const tabId = await resolveTabId(cmd.tabId, workspace); try { const data = await executor.evaluateAsync(tabId, cmd.code); return { id: cmd.id, ok: true, data }; @@ -260,9 +284,9 @@ async function handleExec(cmd: Command): Promise { } } -async function handleNavigate(cmd: Command): Promise { +async function handleNavigate(cmd: Command, workspace: string): Promise { if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' }; - const tabId = await resolveTabId(cmd.tabId); + const tabId = await resolveTabId(cmd.tabId, workspace); await chrome.tabs.update(tabId, { url: cmd.url }); // Wait for page to finish loading, checking current status first to avoid race @@ -290,10 +314,10 @@ async function handleNavigate(cmd: Command): Promise { return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } }; } -async function handleTabs(cmd: Command): Promise { +async function handleTabs(cmd: Command, workspace: string): Promise { switch (cmd.op) { case 'list': { - const tabs = await listAutomationWebTabs(); + const tabs = await listAutomationWebTabs(workspace); const data = tabs .map((t, i) => ({ index: i, @@ -305,20 +329,20 @@ async function handleTabs(cmd: Command): Promise { return { id: cmd.id, ok: true, data }; } case 'new': { - const windowId = await getAutomationWindow(); + const windowId = await getAutomationWindow(workspace); const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'about:blank', active: true }); return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; } case 'close': { if (cmd.index !== undefined) { - const tabs = await listAutomationWebTabs(); + const tabs = await listAutomationWebTabs(workspace); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.remove(target.id); executor.detach(target.id); return { id: cmd.id, ok: true, data: { closed: target.id } }; } - const tabId = await resolveTabId(cmd.tabId); + const tabId = await resolveTabId(cmd.tabId, workspace); await chrome.tabs.remove(tabId); executor.detach(tabId); return { id: cmd.id, ok: true, data: { closed: tabId } }; @@ -330,7 +354,7 @@ async function handleTabs(cmd: Command): Promise { await chrome.tabs.update(cmd.tabId, { active: true }); return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; } - const tabs = await listAutomationWebTabs(); + const tabs = await listAutomationWebTabs(workspace); const target = tabs[cmd.index!]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.update(target.id, { active: true }); @@ -358,8 +382,8 @@ async function handleCookies(cmd: Command): Promise { return { id: cmd.id, ok: true, data }; } -async function handleScreenshot(cmd: Command): Promise { - const tabId = await resolveTabId(cmd.tabId); +async function handleScreenshot(cmd: Command, workspace: string): Promise { + const tabId = await resolveTabId(cmd.tabId, workspace); try { const data = await executor.screenshot(tabId, { format: cmd.format, @@ -372,22 +396,46 @@ async function handleScreenshot(cmd: Command): Promise { } } -async function handleCloseWindow(cmd: Command): Promise { - if (automationWindowId !== null) { +async function handleCloseWindow(cmd: Command, workspace: string): Promise { + const session = automationSessions.get(workspace); + if (session) { try { - await chrome.windows.remove(automationWindowId); + await chrome.windows.remove(session.windowId); } catch { // Window may already be closed } - automationWindowId = null; + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); } return { id: cmd.id, ok: true, data: { closed: true } }; } +async function handleSessions(cmd: Command): Promise { + const now = Date.now(); + const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ + workspace, + windowId: session.windowId, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length, + idleMsRemaining: Math.max(0, session.idleDeadlineAt - now), + }))); + return { id: cmd.id, ok: true, data }; +} + export const __test__ = { handleTabs, - getAutomationWindowId: () => automationWindowId, - setAutomationWindowId: (windowId: number | null) => { - automationWindowId = windowId; + handleSessions, + getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null, + setAutomationWindowId: (workspace: string, windowId: number | null) => { + if (windowId === null) { + const session = automationSessions.get(workspace); + if (session?.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + return; + } + automationSessions.set(workspace, { + windowId, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + }); }, }; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 8e9f19c..0d05386 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -5,7 +5,7 @@ * Everything else is just JS code sent via 'exec'. */ -export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window'; +export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions'; export interface Command { /** Unique request ID */ @@ -16,6 +16,8 @@ export interface Command { tabId?: number; /** JS code to evaluate in page context (exec action) */ code?: string; + /** Logical workspace for automation session reuse */ + workspace?: string; /** URL to navigate to (navigate action) */ url?: string; /** Sub-operation for tabs: list, new, close, select */ diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 83d6eb3..4682329 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -18,7 +18,7 @@ export class CDPBridge { private _idCounter = 0; private _pending = new Map void; reject: (err: Error) => void }>(); - async connect(opts?: { timeout?: number }): Promise { + async connect(opts?: { timeout?: number; workspace?: string }): Promise { const endpoint = process.env.OPENCLI_CDP_ENDPOINT; if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set'); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 6239cb7..77d5ba8 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -15,9 +15,10 @@ function generateId(): string { export interface DaemonCommand { id: string; - action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window'; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions'; tabId?: number; code?: string; + workspace?: string; url?: string; op?: string; index?: number; @@ -111,3 +112,8 @@ export async function sendCommand( // Unreachable — the loop always returns or throws throw new Error('sendCommand: max retries exhausted'); } + +export async function listSessions(): Promise { + const result = await sendCommand('sessions'); + return Array.isArray(result) ? result : []; +} diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts index a42a744..655810a 100644 --- a/src/browser/mcp.ts +++ b/src/browser/mcp.ts @@ -26,7 +26,7 @@ export class BrowserBridge { return this._state; } - async connect(opts: { timeout?: number } = {}): Promise { + async connect(opts: { timeout?: number; workspace?: string } = {}): Promise { if (this._state === 'connected' && this._page) return this._page; if (this._state === 'connecting') throw new Error('Already connecting'); if (this._state === 'closing') throw new Error('Session is closing'); @@ -36,7 +36,7 @@ export class BrowserBridge { try { await this._ensureDaemon(opts.timeout); - this._page = new Page(); + this._page = new Page(opts.workspace); this._state = 'connected'; return this._page; } catch (err) { diff --git a/src/browser/page.ts b/src/browser/page.ts index a1623d0..7096be3 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -19,6 +19,8 @@ import { wrapForEval } from './utils.js'; * Page — implements IPage by talking to the daemon via HTTP. */ export class Page implements IPage { + constructor(private readonly workspace: string = 'default') {} + /** Active tab ID, set after navigate and used in all subsequent commands */ private _tabId: number | undefined; @@ -27,9 +29,14 @@ export class Page implements IPage { return this._tabId !== undefined ? { tabId: this._tabId } : {}; } + private _workspaceOpt(): { workspace: string } { + return { workspace: this.workspace }; + } + async goto(url: string): Promise { const result = await sendCommand('navigate', { url, + ...this._workspaceOpt(), ...this._tabOpt(), }) as { tabId?: number }; // Remember the tabId for subsequent exec calls @@ -41,7 +48,7 @@ export class Page implements IPage { /** Close the automation window in the extension */ async closeWindow(): Promise { try { - await sendCommand('close-window', {}); + await sendCommand('close-window', { ...this._workspaceOpt() }); } catch { // Window may already be closed or daemon may be down } @@ -49,11 +56,11 @@ export class Page implements IPage { async evaluate(js: string): Promise { const code = wrapForEval(js); - return sendCommand('exec', { code, ...this._tabOpt() }); + return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } async getCookies(opts: { domain?: string; url?: string } = {}): Promise { - const result = await sendCommand('cookies', opts); + const result = await sendCommand('cookies', { ...this._workspaceOpt(), ...opts }); return Array.isArray(result) ? result : []; } @@ -86,7 +93,7 @@ export class Page implements IPage { return buildTree(document.body, 0); })() `; - const raw = await sendCommand('exec', { code, ...this._tabOpt() }); + const raw = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); if (opts.raw) return raw; if (typeof raw === 'string') return formatSnapshot(raw, opts); return raw; @@ -105,7 +112,7 @@ export class Page implements IPage { return 'clicked'; })() `; - await sendCommand('exec', { code, ...this._tabOpt() }); + await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } async typeText(ref: string, text: string): Promise { @@ -124,7 +131,7 @@ export class Page implements IPage { return 'typed'; })() `; - await sendCommand('exec', { code, ...this._tabOpt() }); + await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } async pressKey(key: string): Promise { @@ -136,7 +143,7 @@ export class Page implements IPage { return 'pressed'; })() `; - await sendCommand('exec', { code, ...this._tabOpt() }); + await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise { @@ -161,24 +168,24 @@ export class Page implements IPage { check(); }) `; - await sendCommand('exec', { code, ...this._tabOpt() }); + await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } } async tabs(): Promise { - return sendCommand('tabs', { op: 'list' }); + return sendCommand('tabs', { op: 'list', ...this._workspaceOpt() }); } async closeTab(index?: number): Promise { - await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) }); + await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) }); } async newTab(): Promise { - await sendCommand('tabs', { op: 'new' }); + await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }); } async selectTab(index: number): Promise { - await sendCommand('tabs', { op: 'select', index }); + await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }); } async networkRequests(includeStatic: boolean = false): Promise { @@ -195,7 +202,7 @@ export class Page implements IPage { })); })() `; - return sendCommand('exec', { code, ...this._tabOpt() }); + return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } /** @@ -221,6 +228,7 @@ export class Page implements IPage { path?: string; } = {}): Promise { const base64 = await sendCommand('screenshot', { + ...this._workspaceOpt(), format: options.format, quality: options.quality, fullPage: options.fullPage, @@ -243,6 +251,7 @@ export class Page implements IPage { const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0; await sendCommand('exec', { code: `window.scrollBy(${dx}, ${dy})`, + ...this._workspaceOpt(), ...this._tabOpt(), }); } @@ -270,7 +279,7 @@ export class Page implements IPage { } })() `; - await sendCommand('exec', { code, ...this._tabOpt() }); + await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } async installInterceptor(pattern: string): Promise { diff --git a/src/cli.ts b/src/cli.ts index 92ee5fd..f2b8758 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -64,13 +64,13 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { }); program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('').option('--site ').option('--goal ').option('--wait ', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click ', 'Comma-separated labels to click before fuzzing (e.g. "字幕,CC,评论")') - .action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s: string) => s.trim()) : undefined; const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: BrowserFactory as any, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels }))); }); + .action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s: string) => s.trim()) : undefined; const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const workspace = `explore:${opts.site ?? (() => { try { return new URL(url).host; } catch { return 'default'; } })()}`; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: BrowserFactory as any, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels, workspace }))); }); program.command('synthesize').description('Synthesize CLIs from explore').argument('').option('--top ', '', '3') .action(async (target, opts) => { const { synthesizeFromExplore, renderSynthesizeSummary } = await import('./synthesize.js'); console.log(renderSynthesizeSummary(synthesizeFromExplore(target, { top: parseInt(opts.top) }))); }); program.command('generate').description('One-shot: explore → synthesize → register').argument('').option('--goal ').option('--site ') - .action(async (url, opts) => { const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const r = await generateCliFromUrl({ url, BrowserFactory: BrowserFactory as any, builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, goal: opts.goal, site: opts.site }); console.log(renderGenerateSummary(r)); process.exitCode = r.ok ? 0 : 1; }); + .action(async (url, opts) => { const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const workspace = `generate:${opts.site ?? (() => { try { return new URL(url).host; } catch { return 'default'; } })()}`; const r = await generateCliFromUrl({ url, BrowserFactory: BrowserFactory as any, builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, goal: opts.goal, site: opts.site, workspace }); console.log(renderGenerateSummary(r)); process.exitCode = r.ok ? 0 : 1; }); program.command('cascade').description('Strategy cascade: find simplest working strategy').argument('').option('--site ') .action(async (url, opts) => { @@ -80,16 +80,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { // Navigate to the site first for cookie context try { const siteUrl = new URL(url); await page.goto(`${siteUrl.protocol}//${siteUrl.host}`); await page.wait(2); } catch {} return cascadeProbe(page, url); - }); + }, { workspace: `cascade:${opts.site ?? (() => { try { return new URL(url).host; } catch { return 'default'; } })()}` }); console.log(renderCascadeResult(result)); }); program.command('doctor') .description('Diagnose opencli browser bridge connectivity') .option('--live', 'Test browser connectivity (requires Chrome running)', false) + .option('--sessions', 'Show active automation sessions', false) .action(async (opts) => { const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js'); - const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION }); + const report = await runBrowserDoctor({ live: opts.live, sessions: opts.sessions, cliVersion: PKG_VERSION }); console.log(renderBrowserDoctorReport(report)); }); @@ -161,7 +162,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; result = await browserSession(BrowserFactory as any, async (page) => { return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }); - }); + }, { workspace: `site:${cmd.site}` }); } else { result = await executeCommand(cmd, null, kwargs, actionOpts.verbose); } if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) { console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`)); diff --git a/src/doctor.ts b/src/doctor.ts index b01073e..733bbd5 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -8,12 +8,13 @@ import chalk from 'chalk'; import { checkDaemonStatus } from './browser/discover.js'; import { BrowserBridge } from './browser/index.js'; -import { browserSession } from './runtime.js'; +import { listSessions } from './browser/daemon-client.js'; export type DoctorOptions = { fix?: boolean; yes?: boolean; live?: boolean; + sessions?: boolean; cliVersion?: string; }; @@ -28,6 +29,7 @@ export type DoctorReport = { daemonRunning: boolean; extensionConnected: boolean; connectivity?: ConnectivityResult; + sessions?: Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>; issues: string[]; }; @@ -55,6 +57,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise + : undefined; const issues: string[] = []; if (!status.running) { @@ -78,6 +83,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise any; site?: string; goal?: string; authenticated?: boolean; outDir?: string; waitSeconds?: number; query?: string; - clickLabels?: string[]; auto?: boolean; + clickLabels?: string[]; auto?: boolean; workspace?: string; }, ): Promise> { const waitSeconds = opts.waitSeconds ?? 3.0; @@ -438,7 +438,7 @@ export async function exploreUrl( return { ...result, out_dir: targetDir }; })(), { timeout: exploreTimeout, label: `Explore ${url}` }); - }); + }, { workspace: opts.workspace }); } export function renderExploreSummary(result: Record): string { diff --git a/src/generate.ts b/src/generate.ts index cc669f4..103cfba 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -65,6 +65,7 @@ export async function generateCliFromUrl(opts: any): Promise { site: opts.site, goal: normalizeGoal(opts.goal) ?? opts.goal, waitSeconds: opts.waitSeconds ?? 3, + workspace: opts.workspace, }); // Step 2: Synthesize candidates diff --git a/src/runtime.ts b/src/runtime.ts index 642c988..5cf3eef 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -30,17 +30,18 @@ export function withTimeoutMs(promise: Promise, timeoutMs: number, message /** Interface for browser factory (BrowserBridge or test mocks) */ export interface IBrowserFactory { - connect(opts?: { timeout?: number }): Promise; + connect(opts?: { timeout?: number; workspace?: string }): Promise; close(): Promise; } export async function browserSession( BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise, + opts: { workspace?: string } = {}, ): Promise { const mcp = new BrowserFactory(); try { - const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT }); + const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, workspace: opts.workspace }); return await fn(page); } finally { await mcp.close().catch(() => {});