feat: add workspace-aware browser sessions

This commit is contained in:
jackwener committed 2026-03-20 13:41:04 +08:00
1 parent 0ea6e4a15c
commit 44f0bbe94d
13 files changed
+276 -150

No files matched your search

+83 -57
View File
@@ -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 };
}
+20 -4
View File
@@ -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 }),
]));
});
});
+107 -59
View File
@@ -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<typeof setTimeout> | null = null;
type AutomationSession = {
windowId: number;
idleTimer: ReturnType<typeof setTimeout> | null;
idleDeadlineAt: number;
};
const automationSessions = new Map<string, AutomationSession>();
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<number> {
async function getAutomationWindow(workspace: string): Promise<number> {
// 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<number> {
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<Result> {
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<number> {
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
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<number> {
return newTab.id;
}
async function listAutomationTabs(): Promise<chrome.tabs.Tab[]> {
if (automationWindowId === null) return [];
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
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<chrome.tabs.Tab[]> {
const tabs = await listAutomationTabs();
async function listAutomationWebTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
const tabs = await listAutomationTabs(workspace);
return tabs.filter((tab) => isWebUrl(tab.url));
}
async function handleExec(cmd: Command): Promise<Result> {
async function handleExec(cmd: Command, workspace: string): Promise<Result> {
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<Result> {
}
}
async function handleNavigate(cmd: Command): Promise<Result> {
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
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<Result> {
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
}
async function handleTabs(cmd: Command): Promise<Result> {
async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
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<Result> {
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<Result> {
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<Result> {
return { id: cmd.id, ok: true, data };
}
async function handleScreenshot(cmd: Command): Promise<Result> {
const tabId = await resolveTabId(cmd.tabId);
async function handleScreenshot(cmd: Command, workspace: string): Promise<Result> {
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<Result> {
}
}
async function handleCloseWindow(cmd: Command): Promise<Result> {
if (automationWindowId !== null) {
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
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<Result> {
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,
});
},
};
+3 -1
View File
@@ -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 */
+1 -1
View File
@@ -18,7 +18,7 @@ export class CDPBridge {
private _idCounter = 0;
private _pending = new Map<number, { resolve: (val: any) => void; reject: (err: Error) => void }>();
async connect(opts?: { timeout?: number }): Promise<IPage> {
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
+7 -1
View File
@@ -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<any[]> {
const result = await sendCommand('sessions');
return Array.isArray(result) ? result : [];
}
+2 -2
View File
@@ -26,7 +26,7 @@ export class BrowserBridge {
return this._state;
}
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
async connect(opts: { timeout?: number; workspace?: string } = {}): Promise<IPage> {
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) {
+23 -14
View File
@@ -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<void> {
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<void> {
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<any> {
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<any[]> {
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<void> {
@@ -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<void> {
@@ -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<void> {
@@ -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<any> {
return sendCommand('tabs', { op: 'list' });
return sendCommand('tabs', { op: 'list', ...this._workspaceOpt() });
}
async closeTab(index?: number): Promise<void> {
await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
}
async newTab(): Promise<void> {
await sendCommand('tabs', { op: 'new' });
await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
}
async selectTab(index: number): Promise<void> {
await sendCommand('tabs', { op: 'select', index });
await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
@@ -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<string> {
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<void> {
+6 -5
View File
@@ -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('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click <labels>', '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('<target>').option('--top <n>', '', '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('<url>').option('--goal <text>').option('--site <name>')
.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('<url>').option('--site <name>')
.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.`));
+18 -2
View File
@@ -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<Doctor
if (opts.live) {
connectivity = await checkConnectivity();
}
const sessions = opts.sessions && status.running && status.extensionConnected
? await listSessions() as Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>
: undefined;
const issues: string[] = [];
if (!status.running) {
@@ -78,6 +83,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
daemonRunning: status.running,
extensionConnected: status.extensionConnected,
connectivity,
sessions,
issues,
};
}
@@ -104,6 +110,17 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
}
if (report.sessions) {
lines.push('', chalk.bold('Sessions:'));
if (report.sessions.length === 0) {
lines.push(chalk.dim(' • no active automation sessions'));
} else {
for (const session of report.sessions) {
lines.push(chalk.dim(` • ${session.workspace} → window ${session.windowId}, tabs=${session.tabCount}, idle=${Math.ceil(session.idleMsRemaining / 1000)}s`));
}
}
}
if (report.issues.length) {
lines.push('', chalk.yellow('Issues:'));
for (const issue of report.issues) {
@@ -115,4 +132,3 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
return lines.join('\n');
}
+2 -2
View File
@@ -231,7 +231,7 @@ export async function exploreUrl(
BrowserFactory: new () => any;
site?: string; goal?: string; authenticated?: boolean;
outDir?: string; waitSeconds?: number; query?: string;
clickLabels?: string[]; auto?: boolean;
clickLabels?: string[]; auto?: boolean; workspace?: string;
},
): Promise<Record<string, any>> {
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, any>): string {
+1
View File
@@ -65,6 +65,7 @@ export async function generateCliFromUrl(opts: any): Promise<any> {
site: opts.site,
goal: normalizeGoal(opts.goal) ?? opts.goal,
waitSeconds: opts.waitSeconds ?? 3,
workspace: opts.workspace,
});
// Step 2: Synthesize candidates
+3 -2
View File
@@ -30,17 +30,18 @@ export function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message
/** Interface for browser factory (BrowserBridge or test mocks) */
export interface IBrowserFactory {
connect(opts?: { timeout?: number }): Promise<IPage>;
connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage>;
close(): Promise<void>;
}
export async function browserSession<T>(
BrowserFactory: new () => IBrowserFactory,
fn: (page: IPage) => Promise<T>,
opts: { workspace?: string } = {},
): Promise<T> {
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(() => {});