@jackwener/opencli 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +35 -1
  3. package/README.zh-CN.md +17 -1
  4. package/SKILL.md +31 -851
  5. package/autoresearch/baseline-browse.txt +1 -0
  6. package/autoresearch/baseline-skill.txt +1 -0
  7. package/autoresearch/browse-tasks.json +688 -0
  8. package/autoresearch/eval-browse.ts +185 -0
  9. package/autoresearch/eval-skill.ts +248 -0
  10. package/autoresearch/run-browse.sh +9 -0
  11. package/autoresearch/run-skill.sh +9 -0
  12. package/dist/browser/base-page.d.ts +48 -0
  13. package/dist/browser/base-page.js +160 -0
  14. package/dist/browser/cdp.js +4 -106
  15. package/dist/browser/daemon-client.d.ts +20 -7
  16. package/dist/browser/daemon-client.js +39 -39
  17. package/dist/browser/daemon-client.test.js +77 -0
  18. package/dist/browser/discover.d.ts +1 -4
  19. package/dist/browser/discover.js +9 -23
  20. package/dist/browser/errors.d.ts +4 -0
  21. package/dist/browser/errors.js +20 -0
  22. package/dist/browser/index.d.ts +1 -1
  23. package/dist/browser/index.js +1 -1
  24. package/dist/browser/page.d.ts +10 -35
  25. package/dist/browser/page.js +55 -187
  26. package/dist/browser/tabs.js +5 -5
  27. package/dist/browser.test.js +15 -15
  28. package/dist/cli-manifest.json +294 -22
  29. package/dist/cli.js +392 -0
  30. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  31. package/dist/clis/amazon/bestsellers.js +130 -0
  32. package/dist/clis/amazon/bestsellers.test.js +20 -0
  33. package/dist/clis/amazon/discussion.d.ts +20 -0
  34. package/dist/clis/amazon/discussion.js +91 -0
  35. package/dist/clis/amazon/discussion.test.d.ts +1 -0
  36. package/dist/clis/amazon/discussion.test.js +36 -0
  37. package/dist/clis/amazon/offer.d.ts +23 -0
  38. package/dist/clis/amazon/offer.js +140 -0
  39. package/dist/clis/amazon/offer.test.d.ts +1 -0
  40. package/dist/clis/amazon/offer.test.js +29 -0
  41. package/dist/clis/amazon/product.d.ts +18 -0
  42. package/dist/clis/amazon/product.js +92 -0
  43. package/dist/clis/amazon/product.test.d.ts +1 -0
  44. package/dist/clis/amazon/product.test.js +24 -0
  45. package/dist/clis/amazon/search.d.ts +18 -0
  46. package/dist/clis/amazon/search.js +87 -0
  47. package/dist/clis/amazon/search.test.d.ts +1 -0
  48. package/dist/clis/amazon/search.test.js +22 -0
  49. package/dist/clis/amazon/shared.d.ts +64 -0
  50. package/dist/clis/amazon/shared.js +255 -0
  51. package/dist/clis/amazon/shared.test.d.ts +1 -0
  52. package/dist/clis/amazon/shared.test.js +33 -0
  53. package/dist/clis/gemini/ask.d.ts +1 -0
  54. package/dist/clis/gemini/ask.js +40 -0
  55. package/dist/clis/gemini/image.d.ts +1 -0
  56. package/dist/clis/gemini/image.js +105 -0
  57. package/dist/clis/gemini/new.d.ts +1 -0
  58. package/dist/clis/gemini/new.js +20 -0
  59. package/dist/clis/gemini/utils.d.ts +34 -0
  60. package/dist/clis/gemini/utils.js +463 -0
  61. package/dist/clis/gemini/utils.test.d.ts +1 -0
  62. package/dist/clis/gemini/utils.test.js +31 -0
  63. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  64. package/dist/clis/notebooklm/compat.test.js +3 -3
  65. package/dist/clis/notebooklm/current.js +2 -3
  66. package/dist/clis/notebooklm/get.js +2 -3
  67. package/dist/clis/notebooklm/history.js +2 -3
  68. package/dist/clis/notebooklm/note-list.js +2 -3
  69. package/dist/clis/notebooklm/notes-get.js +2 -3
  70. package/dist/clis/notebooklm/open.d.ts +1 -0
  71. package/dist/clis/notebooklm/open.js +41 -0
  72. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  73. package/dist/clis/notebooklm/open.test.js +63 -0
  74. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  75. package/dist/clis/notebooklm/source-get.js +2 -3
  76. package/dist/clis/notebooklm/source-guide.js +2 -3
  77. package/dist/clis/notebooklm/source-list.js +2 -3
  78. package/dist/clis/notebooklm/status.js +1 -2
  79. package/dist/clis/notebooklm/summary.js +2 -3
  80. package/dist/clis/notebooklm/utils.d.ts +2 -1
  81. package/dist/clis/notebooklm/utils.js +20 -21
  82. package/dist/clis/twitter/article.js +28 -1
  83. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  84. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  85. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  86. package/dist/clis/xiaohongshu/note.js +11 -0
  87. package/dist/clis/xiaohongshu/note.test.js +49 -0
  88. package/dist/commanderAdapter.js +7 -4
  89. package/dist/commanderAdapter.test.js +76 -0
  90. package/dist/commands/daemon.js +8 -47
  91. package/dist/commands/daemon.test.js +45 -70
  92. package/dist/discovery.js +27 -0
  93. package/dist/doctor.d.ts +1 -2
  94. package/dist/doctor.js +7 -8
  95. package/dist/explore.js +1 -1
  96. package/dist/output.js +28 -0
  97. package/dist/output.test.js +15 -0
  98. package/dist/pipeline/executor.js +2 -7
  99. package/dist/pipeline/steps/browser.js +1 -1
  100. package/dist/pipeline/template.js +25 -3
  101. package/dist/record.d.ts +50 -0
  102. package/dist/record.js +298 -57
  103. package/dist/record.test.d.ts +1 -0
  104. package/dist/record.test.js +293 -0
  105. package/dist/registry.d.ts +2 -0
  106. package/dist/registry.js +1 -0
  107. package/dist/registry.test.js +10 -0
  108. package/dist/runtime.js +3 -3
  109. package/dist/snapshotFormatter.d.ts +1 -1
  110. package/dist/snapshotFormatter.js +4 -4
  111. package/dist/snapshotFormatter.test.d.ts +1 -1
  112. package/dist/snapshotFormatter.test.js +2 -2
  113. package/dist/types.d.ts +11 -1
  114. package/dist/types.js +1 -1
  115. package/docs/.vitepress/config.mts +2 -0
  116. package/docs/adapters/browser/amazon.md +53 -0
  117. package/docs/adapters/browser/gemini.md +72 -0
  118. package/docs/adapters/browser/notebooklm.md +5 -5
  119. package/docs/adapters/index.md +3 -1
  120. package/docs/guide/getting-started.md +21 -0
  121. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  122. package/docs/zh/guide/getting-started.md +21 -0
  123. package/extension/package-lock.json +2 -2
  124. package/extension/src/background.test.ts +7 -163
  125. package/extension/src/background.ts +58 -161
  126. package/extension/src/cdp.ts +77 -124
  127. package/extension/src/protocol.ts +5 -5
  128. package/package.json +1 -1
  129. package/skills/opencli-explorer/SKILL.md +853 -0
  130. package/skills/opencli-oneshot/SKILL.md +222 -0
  131. package/skills/opencli-operate/SKILL.md +213 -0
  132. package/skills/opencli-usage/SKILL.md +152 -0
  133. package/skills/opencli-usage/browser.md +429 -0
  134. package/skills/opencli-usage/desktop.md +118 -0
  135. package/skills/opencli-usage/plugins.md +82 -0
  136. package/skills/opencli-usage/public-api.md +149 -0
  137. package/src/browser/base-page.ts +197 -0
  138. package/src/browser/cdp.ts +7 -131
  139. package/src/browser/daemon-client.test.ts +103 -0
  140. package/src/browser/daemon-client.ts +55 -43
  141. package/src/browser/discover.ts +9 -21
  142. package/src/browser/errors.ts +22 -0
  143. package/src/browser/index.ts +1 -1
  144. package/src/browser/page.ts +57 -209
  145. package/src/browser/tabs.ts +5 -5
  146. package/src/browser.test.ts +15 -15
  147. package/src/cli.ts +392 -0
  148. package/src/clis/amazon/bestsellers.test.ts +22 -0
  149. package/src/clis/amazon/bestsellers.ts +180 -0
  150. package/src/clis/amazon/discussion.test.ts +38 -0
  151. package/src/clis/amazon/discussion.ts +131 -0
  152. package/src/clis/amazon/offer.test.ts +35 -0
  153. package/src/clis/amazon/offer.ts +185 -0
  154. package/src/clis/amazon/product.test.ts +26 -0
  155. package/src/clis/amazon/product.ts +131 -0
  156. package/src/clis/amazon/search.test.ts +24 -0
  157. package/src/clis/amazon/search.ts +128 -0
  158. package/src/clis/amazon/shared.test.ts +37 -0
  159. package/src/clis/amazon/shared.ts +316 -0
  160. package/src/clis/gemini/ask.ts +46 -0
  161. package/src/clis/gemini/image.ts +115 -0
  162. package/src/clis/gemini/new.ts +22 -0
  163. package/src/clis/gemini/utils.test.ts +36 -0
  164. package/src/clis/gemini/utils.ts +523 -0
  165. package/src/clis/notebooklm/compat.test.ts +3 -3
  166. package/src/clis/notebooklm/current.ts +2 -3
  167. package/src/clis/notebooklm/get.ts +1 -3
  168. package/src/clis/notebooklm/history.ts +1 -3
  169. package/src/clis/notebooklm/note-list.ts +1 -3
  170. package/src/clis/notebooklm/notes-get.ts +1 -3
  171. package/src/clis/notebooklm/open.test.ts +78 -0
  172. package/src/clis/notebooklm/open.ts +61 -0
  173. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  174. package/src/clis/notebooklm/source-get.ts +1 -3
  175. package/src/clis/notebooklm/source-guide.ts +1 -3
  176. package/src/clis/notebooklm/source-list.ts +1 -3
  177. package/src/clis/notebooklm/status.ts +1 -2
  178. package/src/clis/notebooklm/summary.ts +1 -3
  179. package/src/clis/notebooklm/utils.ts +29 -20
  180. package/src/clis/twitter/article.ts +31 -1
  181. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  182. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  183. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  184. package/src/clis/xiaohongshu/note.test.ts +51 -0
  185. package/src/clis/xiaohongshu/note.ts +18 -0
  186. package/src/commanderAdapter.test.ts +109 -0
  187. package/src/commanderAdapter.ts +8 -4
  188. package/src/commands/daemon.test.ts +50 -84
  189. package/src/commands/daemon.ts +8 -56
  190. package/src/discovery.ts +22 -0
  191. package/src/doctor.ts +8 -9
  192. package/src/explore.ts +1 -1
  193. package/src/output.test.ts +17 -0
  194. package/src/output.ts +27 -0
  195. package/src/pipeline/executor.ts +2 -7
  196. package/src/pipeline/steps/browser.ts +1 -1
  197. package/src/pipeline/template.ts +27 -4
  198. package/src/record.test.ts +362 -0
  199. package/src/record.ts +341 -62
  200. package/src/registry.test.ts +12 -0
  201. package/src/registry.ts +3 -0
  202. package/src/runtime.ts +3 -3
  203. package/src/snapshotFormatter.test.ts +2 -2
  204. package/src/snapshotFormatter.ts +4 -4
  205. package/src/types.ts +11 -1
  206. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  207. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  208. package/dist/clis/notebooklm/bind-current.js +0 -29
  209. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  210. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  211. package/dist/clis/notebooklm/binding.test.js +0 -44
  212. package/extension/dist/background.js +0 -819
  213. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  214. package/src/clis/notebooklm/bind-current.ts +0 -36
  215. package/src/clis/notebooklm/binding.test.ts +0 -53
  216. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  217. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  218. /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
  219. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  220. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
@@ -117,8 +117,6 @@ type AutomationSession = {
117
117
  windowId: number;
118
118
  idleTimer: ReturnType<typeof setTimeout> | null;
119
119
  idleDeadlineAt: number;
120
- owned: boolean;
121
- preferredTabId: number | null;
122
120
  };
123
121
 
124
122
  const automationSessions = new Map<string, AutomationSession>();
@@ -136,11 +134,6 @@ function resetWindowIdleTimer(workspace: string): void {
136
134
  session.idleTimer = setTimeout(async () => {
137
135
  const current = automationSessions.get(workspace);
138
136
  if (!current) return;
139
- if (!current.owned) {
140
- console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
141
- automationSessions.delete(workspace);
142
- return;
143
- }
144
137
  try {
145
138
  await chrome.windows.remove(current.windowId);
146
139
  console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
@@ -180,8 +173,6 @@ async function getAutomationWindow(workspace: string): Promise<number> {
180
173
  windowId: win.id!,
181
174
  idleTimer: null,
182
175
  idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
183
- owned: true,
184
- preferredTabId: null,
185
176
  };
186
177
  automationSessions.set(workspace, session);
187
178
  console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
@@ -259,12 +250,12 @@ async function handleCommand(cmd: Command): Promise<Result> {
259
250
  return await handleScreenshot(cmd, workspace);
260
251
  case 'close-window':
261
252
  return await handleCloseWindow(cmd, workspace);
253
+ case 'cdp':
254
+ return await handleCdp(cmd, workspace);
262
255
  case 'sessions':
263
256
  return await handleSessions(cmd);
264
257
  case 'set-file-input':
265
258
  return await handleSetFileInput(cmd, workspace);
266
- case 'bind-current':
267
- return await handleBindCurrent(cmd, workspace);
268
259
  default:
269
260
  return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
270
261
  }
@@ -280,12 +271,12 @@ async function handleCommand(cmd: Command): Promise<Result> {
280
271
  // ─── Action handlers ─────────────────────────────────────────────────
281
272
 
282
273
  /** Internal blank page used when no user URL is provided. */
283
- const BLANK_PAGE = 'data:text/html,<html></html>';
274
+ const BLANK_PAGE = 'about:blank';
284
275
 
285
- /** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
276
+ /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
286
277
  function isDebuggableUrl(url?: string): boolean {
287
278
  if (!url) return true; // empty/undefined = tab still loading, allow it
288
- return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
279
+ return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
289
280
  }
290
281
 
291
282
  /** Check if a URL is safe for user-facing navigation (http/https only). */
@@ -312,57 +303,7 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean
312
303
  return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
313
304
  }
314
305
 
315
- function matchesDomain(url: string | undefined, domain: string): boolean {
316
- if (!url) return false;
317
- try {
318
- const parsed = new URL(url);
319
- return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
320
- } catch {
321
- return false;
322
- }
323
- }
324
-
325
- function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
326
- if (!tab.id || !isDebuggableUrl(tab.url)) return false;
327
- if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
328
- if (cmd.matchPathPrefix) {
329
- try {
330
- const parsed = new URL(tab.url!);
331
- if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
332
- } catch {
333
- return false;
334
- }
335
- }
336
- return true;
337
- }
338
-
339
- function isNotebooklmWorkspace(workspace: string): boolean {
340
- return workspace === 'site:notebooklm';
341
- }
342
-
343
- function classifyNotebooklmUrl(url?: string): 'notebook' | 'home' | 'other' {
344
- if (!url) return 'other';
345
- try {
346
- const parsed = new URL(url);
347
- if (parsed.hostname !== 'notebooklm.google.com') return 'other';
348
- return parsed.pathname.startsWith('/notebook/') ? 'notebook' : 'home';
349
- } catch {
350
- return 'other';
351
- }
352
- }
353
-
354
- function scoreWorkspaceTab(workspace: string, tab: chrome.tabs.Tab): number {
355
- if (!tab.id || !isDebuggableUrl(tab.url)) return -1;
356
- if (isNotebooklmWorkspace(workspace)) {
357
- const kind = classifyNotebooklmUrl(tab.url);
358
- if (kind === 'other') return -1;
359
- if (kind === 'notebook') return tab.active ? 400 : 300;
360
- return tab.active ? 200 : 100;
361
- }
362
- return -1;
363
- }
364
-
365
- function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
306
+ function setWorkspaceSession(workspace: string, session: Pick<AutomationSession, 'windowId'>): void {
366
307
  const existing = automationSessions.get(workspace);
367
308
  if (existing?.idleTimer) clearTimeout(existing.idleTimer);
368
309
  automationSessions.set(workspace, {
@@ -372,29 +313,6 @@ function setWorkspaceSession(workspace: string, session: Omit<AutomationSession,
372
313
  });
373
314
  }
374
315
 
375
- async function maybeBindWorkspaceToExistingTab(workspace: string): Promise<number | null> {
376
- if (!isNotebooklmWorkspace(workspace)) return null;
377
- const tabs = await chrome.tabs.query({});
378
- let bestTab: chrome.tabs.Tab | null = null;
379
- let bestScore = -1;
380
- for (const tab of tabs) {
381
- const score = scoreWorkspaceTab(workspace, tab);
382
- if (score > bestScore) {
383
- bestScore = score;
384
- bestTab = tab;
385
- }
386
- }
387
- if (!bestTab?.id || bestScore < 0) return null;
388
- setWorkspaceSession(workspace, {
389
- windowId: bestTab.windowId,
390
- owned: false,
391
- preferredTabId: bestTab.id,
392
- });
393
- console.log(`[opencli] Workspace ${workspace} bound to existing tab ${bestTab.id} in window ${bestTab.windowId}`);
394
- resetWindowIdleTimer(workspace);
395
- return bestTab.id;
396
- }
397
-
398
316
  /**
399
317
  * Resolve target tab in the automation window.
400
318
  * If explicit tabId is given, use that directly.
@@ -408,9 +326,7 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
408
326
  try {
409
327
  const tab = await chrome.tabs.get(tabId);
410
328
  const session = automationSessions.get(workspace);
411
- const matchesSession = session
412
- ? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
413
- : false;
329
+ const matchesSession = session ? tab.windowId === session.windowId : false;
414
330
  if (isDebuggableUrl(tab.url) && matchesSession) return tabId;
415
331
  if (session && !matchesSession) {
416
332
  console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
@@ -424,20 +340,6 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
424
340
  }
425
341
  }
426
342
 
427
- const adoptedTabId = await maybeBindWorkspaceToExistingTab(workspace);
428
- if (adoptedTabId !== null) return adoptedTabId;
429
-
430
- const existingSession = automationSessions.get(workspace);
431
- if (existingSession && existingSession.preferredTabId !== null) {
432
- try {
433
- const preferredTabId = existingSession.preferredTabId;
434
- const preferredTab = await chrome.tabs.get(preferredTabId);
435
- if (isDebuggableUrl(preferredTab.url)) return preferredTab.id!;
436
- } catch {
437
- automationSessions.delete(workspace);
438
- }
439
- }
440
-
441
343
  // Get (or create) the automation window
442
344
  const windowId = await getAutomationWindow(workspace);
443
345
 
@@ -470,14 +372,6 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
470
372
  async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
471
373
  const session = automationSessions.get(workspace);
472
374
  if (!session) return [];
473
- if (session.preferredTabId !== null) {
474
- try {
475
- return [await chrome.tabs.get(session.preferredTabId)];
476
- } catch {
477
- automationSessions.delete(workspace);
478
- return [];
479
- }
480
- }
481
375
  try {
482
376
  return await chrome.tabs.query({ windowId: session.windowId });
483
377
  } catch {
@@ -495,7 +389,8 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
495
389
  if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
496
390
  const tabId = await resolveTabId(cmd.tabId, workspace);
497
391
  try {
498
- const data = await executor.evaluateAsync(tabId, cmd.code);
392
+ const aggressive = workspace.startsWith('operate:');
393
+ const data = await executor.evaluateAsync(tabId, cmd.code, aggressive);
499
394
  return { id: cmd.id, ok: true, data };
500
395
  } catch (err) {
501
396
  return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -686,15 +581,57 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise<Result
686
581
  }
687
582
  }
688
583
 
584
+ /** CDP methods permitted via the 'cdp' passthrough action. */
585
+ const CDP_ALLOWLIST = new Set([
586
+ // Agent DOM context
587
+ 'Accessibility.getFullAXTree',
588
+ 'DOM.getDocument',
589
+ 'DOM.getBoxModel',
590
+ 'DOM.getContentQuads',
591
+ 'DOM.querySelectorAll',
592
+ 'DOM.scrollIntoViewIfNeeded',
593
+ 'DOMSnapshot.captureSnapshot',
594
+ // Native input events
595
+ 'Input.dispatchMouseEvent',
596
+ 'Input.dispatchKeyEvent',
597
+ 'Input.insertText',
598
+ // Page metrics & screenshots
599
+ 'Page.getLayoutMetrics',
600
+ 'Page.captureScreenshot',
601
+ // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
602
+ 'Runtime.enable',
603
+ // Emulation (used by screenshot full-page)
604
+ 'Emulation.setDeviceMetricsOverride',
605
+ 'Emulation.clearDeviceMetricsOverride',
606
+ ]);
607
+
608
+ async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
609
+ if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' };
610
+ if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
611
+ return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
612
+ }
613
+ const tabId = await resolveTabId(cmd.tabId, workspace);
614
+ try {
615
+ const aggressive = workspace.startsWith('operate:');
616
+ await executor.ensureAttached(tabId, aggressive);
617
+ const data = await chrome.debugger.sendCommand(
618
+ { tabId },
619
+ cmd.cdpMethod,
620
+ cmd.cdpParams ?? {},
621
+ );
622
+ return { id: cmd.id, ok: true, data };
623
+ } catch (err) {
624
+ return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
625
+ }
626
+ }
627
+
689
628
  async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
690
629
  const session = automationSessions.get(workspace);
691
630
  if (session) {
692
- if (session.owned) {
693
- try {
694
- await chrome.windows.remove(session.windowId);
695
- } catch {
696
- // Window may already be closed
697
- }
631
+ try {
632
+ await chrome.windows.remove(session.windowId);
633
+ } catch {
634
+ // Window may already be closed
698
635
  }
699
636
  if (session.idleTimer) clearTimeout(session.idleTimer);
700
637
  automationSessions.delete(workspace);
@@ -726,49 +663,11 @@ async function handleSessions(cmd: Command): Promise<Result> {
726
663
  return { id: cmd.id, ok: true, data };
727
664
  }
728
665
 
729
- async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
730
- const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
731
- const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
732
- const allTabs = await chrome.tabs.query({});
733
- const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
734
- ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
735
- ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
736
- if (!boundTab?.id) {
737
- return {
738
- id: cmd.id,
739
- ok: false,
740
- error: cmd.matchDomain || cmd.matchPathPrefix
741
- ? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
742
- : 'No active debuggable tab found',
743
- };
744
- }
745
-
746
- setWorkspaceSession(workspace, {
747
- windowId: boundTab.windowId,
748
- owned: false,
749
- preferredTabId: boundTab.id,
750
- });
751
- resetWindowIdleTimer(workspace);
752
- console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
753
- return {
754
- id: cmd.id,
755
- ok: true,
756
- data: {
757
- tabId: boundTab.id,
758
- windowId: boundTab.windowId,
759
- url: boundTab.url,
760
- title: boundTab.title,
761
- workspace,
762
- },
763
- };
764
- }
765
-
766
666
  export const __test__ = {
767
667
  handleNavigate,
768
668
  isTargetUrl,
769
669
  handleTabs,
770
670
  handleSessions,
771
- handleBindCurrent,
772
671
  resolveTabId,
773
672
  resetWindowIdleTimer,
774
673
  getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
@@ -782,11 +681,9 @@ export const __test__ = {
782
681
  }
783
682
  setWorkspaceSession(workspace, {
784
683
  windowId,
785
- owned: true,
786
- preferredTabId: null,
787
684
  });
788
685
  },
789
- setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => {
686
+ setSession: (workspace: string, session: { windowId: number }) => {
790
687
  setWorkspaceSession(workspace, session);
791
688
  },
792
689
  };
@@ -8,79 +8,13 @@
8
8
 
9
9
  const attached = new Set<number>();
10
10
 
11
- /** Internal blank page used when no user URL is provided. */
12
- const BLANK_PAGE = 'data:text/html,<html></html>';
13
- const FOREIGN_EXTENSION_URL_PREFIX = 'chrome-extension://';
14
- const ATTACH_RECOVERY_DELAY_MS = 120;
15
-
16
- /** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
11
+ /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
17
12
  function isDebuggableUrl(url?: string): boolean {
18
13
  if (!url) return true; // empty/undefined = tab still loading, allow it
19
- return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
14
+ return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
20
15
  }
21
16
 
22
- type CleanupResult = { removed: number };
23
-
24
- async function removeForeignExtensionEmbeds(tabId: number): Promise<CleanupResult> {
25
- const tab = await chrome.tabs.get(tabId);
26
- if (!tab.url || (!tab.url.startsWith('http://') && !tab.url.startsWith('https://'))) {
27
- return { removed: 0 };
28
- }
29
- if (!chrome.scripting?.executeScript) return { removed: 0 };
30
-
31
- try {
32
- const [result] = await chrome.scripting.executeScript({
33
- target: { tabId },
34
- args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`],
35
- func: (ownExtensionPrefix: string) => {
36
- const extensionPrefix = 'chrome-extension://';
37
- const selectors = ['iframe', 'frame', 'embed', 'object'];
38
- const visitedRoots = new Set<Document | ShadowRoot>();
39
- const roots: Array<Document | ShadowRoot> = [document];
40
- let removed = 0;
41
-
42
- while (roots.length > 0) {
43
- const root = roots.pop();
44
- if (!root || visitedRoots.has(root)) continue;
45
- visitedRoots.add(root);
46
-
47
- for (const selector of selectors) {
48
- const nodes = root.querySelectorAll(selector);
49
- for (const node of nodes) {
50
- const src = node.getAttribute('src') || node.getAttribute('data') || '';
51
- if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue;
52
- node.remove();
53
- removed++;
54
- }
55
- }
56
-
57
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
58
- let current = walker.nextNode();
59
- while (current) {
60
- const element = current as Element & { shadowRoot?: ShadowRoot | null };
61
- if (element.shadowRoot) roots.push(element.shadowRoot);
62
- current = walker.nextNode();
63
- }
64
- }
65
-
66
- return { removed };
67
- },
68
- });
69
- return result?.result ?? { removed: 0 };
70
- } catch {
71
- return { removed: 0 };
72
- }
73
- }
74
-
75
- function delay(ms: number): Promise<void> {
76
- return new Promise((resolve) => setTimeout(resolve, ms));
77
- }
78
-
79
- async function tryAttach(tabId: number): Promise<void> {
80
- await chrome.debugger.attach({ tabId }, '1.3');
81
- }
82
-
83
- async function ensureAttached(tabId: number): Promise<void> {
17
+ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> {
84
18
  // Verify the tab URL is debuggable before attempting attach
85
19
  try {
86
20
  const tab = await chrome.tabs.get(tabId);
@@ -109,35 +43,47 @@ async function ensureAttached(tabId: number): Promise<void> {
109
43
  }
110
44
  }
111
45
 
112
- try {
113
- await tryAttach(tabId);
114
- } catch (e: unknown) {
115
- const msg = e instanceof Error ? e.message : String(e);
116
- const hint = msg.includes('chrome-extension://')
117
- ? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
118
- : '';
119
- if (msg.includes('chrome-extension://')) {
120
- const recoveryCleanup = await removeForeignExtensionEmbeds(tabId);
121
- if (recoveryCleanup.removed > 0) {
122
- console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`);
123
- }
124
- await delay(ATTACH_RECOVERY_DELAY_MS);
125
- try {
126
- await tryAttach(tabId);
127
- } catch {
128
- throw new Error(`attach failed: ${msg}${hint}`);
129
- }
130
- } else if (msg.includes('Another debugger is already attached')) {
46
+ // Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge)
47
+ // can temporarily interfere with chrome.debugger. A short delay usually resolves it.
48
+ // Normal commands: 2 retries, 500ms delay (fast fail for non-operate use)
49
+ // Operate commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference)
50
+ const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
51
+ const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
52
+ let lastError = '';
53
+
54
+ for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
55
+ try {
56
+ // Force detach first to clear any stale state from other extensions
131
57
  try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
132
- try {
133
- await tryAttach(tabId);
134
- } catch {
135
- throw new Error(`attach failed: ${msg}${hint}`);
58
+ await chrome.debugger.attach({ tabId }, '1.3');
59
+ lastError = '';
60
+ break; // Success
61
+ } catch (e: unknown) {
62
+ lastError = e instanceof Error ? e.message : String(e);
63
+ if (attempt < MAX_ATTACH_RETRIES) {
64
+ console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
65
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
66
+ // Re-verify tab URL before retrying (it may have changed)
67
+ try {
68
+ const tab = await chrome.tabs.get(tabId);
69
+ if (!isDebuggableUrl(tab.url)) {
70
+ lastError = `Tab URL changed to ${tab.url} during retry`;
71
+ break; // Don't retry if URL became un-debuggable
72
+ }
73
+ } catch {
74
+ lastError = `Tab ${tabId} no longer exists`;
75
+ break;
76
+ }
136
77
  }
137
- } else {
138
- throw new Error(`attach failed: ${msg}${hint}`);
139
78
  }
140
79
  }
80
+
81
+ if (lastError) {
82
+ const hint = lastError.includes('chrome-extension://')
83
+ ? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
84
+ : '';
85
+ throw new Error(`attach failed: ${lastError}${hint}`);
86
+ }
141
87
  attached.add(tabId);
142
88
 
143
89
  try {
@@ -145,40 +91,47 @@ async function ensureAttached(tabId: number): Promise<void> {
145
91
  } catch {
146
92
  // Some pages may not need explicit enable
147
93
  }
148
-
149
- // Disable breakpoints so that `debugger;` statements in page code don't
150
- // pause execution. Anti-bot scripts use `debugger;` traps to detect CDP —
151
- // they measure the time gap caused by the pause. Deactivating breakpoints
152
- // makes the engine skip `debugger;` entirely, neutralising the timing
153
- // side-channel without patching page JS.
154
- try {
155
- await chrome.debugger.sendCommand({ tabId }, 'Debugger.enable');
156
- await chrome.debugger.sendCommand({ tabId }, 'Debugger.setBreakpointsActive', { active: false });
157
- } catch {
158
- // Non-fatal: best-effort hardening
159
- }
160
94
  }
161
95
 
162
- export async function evaluate(tabId: number, expression: string): Promise<unknown> {
163
- await ensureAttached(tabId);
164
-
165
- const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
166
- expression,
167
- returnByValue: true,
168
- awaitPromise: true,
169
- }) as {
170
- result?: { type: string; value?: unknown; description?: string; subtype?: string };
171
- exceptionDetails?: { exception?: { description?: string }; text?: string };
172
- };
96
+ export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> {
97
+ // Retry the entire evaluate (attach + command).
98
+ // Normal: 2 retries. Operate: 3 retries (tolerates extension interference).
99
+ const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
100
+ for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) {
101
+ try {
102
+ await ensureAttached(tabId, aggressiveRetry);
103
+
104
+ const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
105
+ expression,
106
+ returnByValue: true,
107
+ awaitPromise: true,
108
+ }) as {
109
+ result?: { type: string; value?: unknown; description?: string; subtype?: string };
110
+ exceptionDetails?: { exception?: { description?: string }; text?: string };
111
+ };
112
+
113
+ if (result.exceptionDetails) {
114
+ const errMsg = result.exceptionDetails.exception?.description
115
+ || result.exceptionDetails.text
116
+ || 'Eval error';
117
+ throw new Error(errMsg);
118
+ }
173
119
 
174
- if (result.exceptionDetails) {
175
- const errMsg = result.exceptionDetails.exception?.description
176
- || result.exceptionDetails.text
177
- || 'Eval error';
178
- throw new Error(errMsg);
120
+ return result.result?.value;
121
+ } catch (e) {
122
+ const msg = e instanceof Error ? e.message : String(e);
123
+ // Only retry on attach/debugger errors, not on JS eval errors
124
+ const isAttachError = msg.includes('attach failed') || msg.includes('Debugger is not attached')
125
+ || msg.includes('chrome-extension://') || msg.includes('Target closed');
126
+ if (isAttachError && attempt < MAX_EVAL_RETRIES) {
127
+ attached.delete(tabId); // Force re-attach on next attempt
128
+ await new Promise(resolve => setTimeout(resolve, 1000));
129
+ continue;
130
+ }
131
+ throw e;
132
+ }
179
133
  }
180
-
181
- return result.result?.value;
134
+ throw new Error('evaluate: max retries exhausted');
182
135
  }
183
136
 
184
137
  export const evaluateAsync = evaluate;
@@ -5,7 +5,7 @@
5
5
  * Everything else is just JS code sent via 'exec'.
6
6
  */
7
7
 
8
- export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'bind-current';
8
+ export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
9
9
 
10
10
  export interface Command {
11
11
  /** Unique request ID */
@@ -26,10 +26,6 @@ export interface Command {
26
26
  index?: number;
27
27
  /** Cookie domain filter */
28
28
  domain?: string;
29
- /** Optional hostname/domain to require for current-tab binding */
30
- matchDomain?: string;
31
- /** Optional pathname prefix to require for current-tab binding */
32
- matchPathPrefix?: string;
33
29
  /** Screenshot format: png (default) or jpeg */
34
30
  format?: 'png' | 'jpeg';
35
31
  /** JPEG quality (0-100), only for jpeg format */
@@ -40,6 +36,10 @@ export interface Command {
40
36
  files?: string[];
41
37
  /** CSS selector for file input element (set-file-input action) */
42
38
  selector?: string;
39
+ /** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */
40
+ cdpMethod?: string;
41
+ /** CDP method params for 'cdp' action */
42
+ cdpParams?: Record<string, unknown>;
43
43
  }
44
44
 
45
45
  export interface Result {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.8",
3
+ "version": "1.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },