@jackwener/opencli 1.0.1 → 1.0.4

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 (253) hide show
  1. package/.github/workflows/build-extension.yml +80 -0
  2. package/.github/workflows/ci.yml +6 -6
  3. package/.github/workflows/docs.yml +52 -0
  4. package/.github/workflows/e2e-headed.yml +2 -2
  5. package/.github/workflows/pkg-pr-new.yml +2 -2
  6. package/.github/workflows/release.yml +2 -5
  7. package/.github/workflows/security.yml +2 -2
  8. package/CDP.md +1 -1
  9. package/CDP.zh-CN.md +1 -1
  10. package/README.md +42 -34
  11. package/README.zh-CN.md +42 -34
  12. package/SKILL.md +3 -5
  13. package/dist/browser/cdp.d.ts +42 -0
  14. package/dist/browser/cdp.js +339 -0
  15. package/dist/browser/daemon-client.d.ts +3 -1
  16. package/dist/browser/daemon-client.js +4 -0
  17. package/dist/browser/dom-helpers.d.ts +20 -0
  18. package/dist/browser/dom-helpers.js +109 -0
  19. package/dist/browser/index.d.ts +3 -0
  20. package/dist/browser/index.js +4 -0
  21. package/dist/browser/mcp.d.ts +1 -0
  22. package/dist/browser/mcp.js +10 -5
  23. package/dist/browser/page.d.ts +7 -0
  24. package/dist/browser/page.js +39 -123
  25. package/dist/browser/utils.d.ts +10 -0
  26. package/dist/browser/utils.js +27 -0
  27. package/dist/browser.test.js +49 -1
  28. package/dist/build-manifest.js +3 -1
  29. package/dist/build-manifest.test.js +34 -0
  30. package/dist/capabilityRouting.d.ts +2 -0
  31. package/dist/capabilityRouting.js +30 -0
  32. package/dist/capabilityRouting.test.d.ts +1 -0
  33. package/dist/capabilityRouting.test.js +42 -0
  34. package/dist/chaoxing.d.ts +58 -0
  35. package/dist/chaoxing.js +225 -0
  36. package/dist/chaoxing.test.d.ts +1 -0
  37. package/dist/chaoxing.test.js +45 -0
  38. package/dist/cli-manifest.json +885 -48
  39. package/dist/cli.d.ts +1 -0
  40. package/dist/cli.js +234 -0
  41. package/dist/clis/antigravity/serve.d.ts +14 -0
  42. package/dist/clis/antigravity/serve.js +263 -0
  43. package/dist/clis/bilibili/download.js +4 -14
  44. package/dist/clis/boss/chatlist.d.ts +1 -0
  45. package/dist/clis/boss/chatlist.js +50 -0
  46. package/dist/clis/boss/chatmsg.d.ts +1 -0
  47. package/dist/clis/boss/chatmsg.js +73 -0
  48. package/dist/clis/boss/resume.d.ts +1 -0
  49. package/dist/clis/boss/resume.js +249 -0
  50. package/dist/clis/boss/send.d.ts +1 -0
  51. package/dist/clis/boss/send.js +176 -0
  52. package/dist/clis/chaoxing/assignments.d.ts +1 -0
  53. package/dist/clis/chaoxing/assignments.js +74 -0
  54. package/dist/clis/chaoxing/exams.d.ts +1 -0
  55. package/dist/clis/chaoxing/exams.js +74 -0
  56. package/dist/clis/chatgpt/ask.js +15 -14
  57. package/dist/clis/chatgpt/ax.d.ts +1 -0
  58. package/dist/clis/chatgpt/ax.js +78 -0
  59. package/dist/clis/chatgpt/read.js +5 -6
  60. package/dist/clis/hf/top.d.ts +1 -0
  61. package/dist/clis/hf/top.js +119 -0
  62. package/dist/clis/jike/comment.d.ts +1 -0
  63. package/dist/clis/jike/comment.js +107 -0
  64. package/dist/clis/jike/create.d.ts +1 -0
  65. package/dist/clis/jike/create.js +106 -0
  66. package/dist/clis/jike/feed.d.ts +1 -0
  67. package/dist/clis/jike/feed.js +67 -0
  68. package/dist/clis/jike/like.d.ts +1 -0
  69. package/dist/clis/jike/like.js +61 -0
  70. package/dist/clis/jike/notifications.d.ts +1 -0
  71. package/dist/clis/jike/notifications.js +169 -0
  72. package/dist/clis/jike/post.yaml +58 -0
  73. package/dist/clis/jike/repost.d.ts +1 -0
  74. package/dist/clis/jike/repost.js +103 -0
  75. package/dist/clis/jike/search.d.ts +1 -0
  76. package/dist/clis/jike/search.js +67 -0
  77. package/dist/clis/jike/shared.d.ts +19 -0
  78. package/dist/clis/jike/shared.js +25 -0
  79. package/dist/clis/jike/topic.yaml +52 -0
  80. package/dist/clis/jike/user.yaml +51 -0
  81. package/dist/clis/smzdm/search.js +28 -39
  82. package/dist/clis/stackoverflow/bounties.yaml +29 -0
  83. package/dist/clis/stackoverflow/hot.yaml +28 -0
  84. package/dist/clis/stackoverflow/search.yaml +32 -0
  85. package/dist/clis/stackoverflow/unanswered.yaml +28 -0
  86. package/dist/clis/twitter/download.js +6 -16
  87. package/dist/clis/twitter/post.js +9 -2
  88. package/dist/clis/twitter/search.js +14 -33
  89. package/dist/clis/xiaohongshu/download.d.ts +1 -1
  90. package/dist/clis/xiaohongshu/download.js +4 -4
  91. package/dist/clis/zhihu/download.js +3 -3
  92. package/dist/doctor.d.ts +7 -0
  93. package/dist/doctor.js +16 -0
  94. package/dist/download/index.d.ts +12 -8
  95. package/dist/download/index.js +11 -3
  96. package/dist/download/index.test.d.ts +1 -0
  97. package/dist/download/index.test.js +14 -0
  98. package/dist/engine.js +25 -14
  99. package/dist/explore.d.ts +1 -0
  100. package/dist/explore.js +48 -103
  101. package/dist/generate.js +1 -0
  102. package/dist/interceptor.js +3 -2
  103. package/dist/main.js +4 -193
  104. package/dist/output.d.ts +2 -1
  105. package/dist/output.js +3 -1
  106. package/dist/pipeline/executor.test.js +1 -0
  107. package/dist/pipeline/steps/download.js +14 -18
  108. package/dist/registry.d.ts +4 -3
  109. package/dist/registry.js +5 -2
  110. package/dist/runtime.d.ts +4 -1
  111. package/dist/runtime.js +2 -2
  112. package/dist/scripts/framework.d.ts +4 -0
  113. package/dist/scripts/framework.js +21 -0
  114. package/dist/scripts/interact.d.ts +4 -0
  115. package/dist/scripts/interact.js +20 -0
  116. package/dist/scripts/store.d.ts +9 -0
  117. package/dist/scripts/store.js +44 -0
  118. package/dist/synthesize.js +1 -1
  119. package/dist/types.d.ts +12 -0
  120. package/dist/verify.d.ts +6 -1
  121. package/dist/verify.js +54 -2
  122. package/docs/.vitepress/config.mts +193 -0
  123. package/docs/adapters/browser/apple-podcasts.md +28 -0
  124. package/docs/adapters/browser/bbc.md +26 -0
  125. package/docs/adapters/browser/bilibili.md +38 -0
  126. package/docs/adapters/browser/boss.md +28 -0
  127. package/docs/adapters/browser/coupang.md +28 -0
  128. package/docs/adapters/browser/ctrip.md +27 -0
  129. package/docs/adapters/browser/github.md +26 -0
  130. package/docs/adapters/browser/hackernews.md +26 -0
  131. package/docs/adapters/browser/linkedin.md +27 -0
  132. package/docs/adapters/browser/reddit.md +41 -0
  133. package/docs/adapters/browser/reuters.md +27 -0
  134. package/docs/adapters/browser/smzdm.md +27 -0
  135. package/docs/adapters/browser/twitter.md +47 -0
  136. package/docs/adapters/browser/v2ex.md +32 -0
  137. package/docs/adapters/browser/weibo.md +27 -0
  138. package/docs/adapters/browser/xiaohongshu.md +32 -0
  139. package/docs/adapters/browser/xiaoyuzhou.md +28 -0
  140. package/docs/adapters/browser/xueqiu.md +32 -0
  141. package/docs/adapters/browser/yahoo-finance.md +26 -0
  142. package/docs/adapters/browser/youtube.md +29 -0
  143. package/docs/adapters/browser/zhihu.md +30 -0
  144. package/docs/adapters/desktop/antigravity.md +46 -0
  145. package/docs/adapters/desktop/chatgpt.md +43 -0
  146. package/docs/adapters/desktop/chatwise.md +38 -0
  147. package/docs/adapters/desktop/codex.md +32 -0
  148. package/docs/adapters/desktop/cursor.md +33 -0
  149. package/docs/adapters/desktop/discord.md +28 -0
  150. package/docs/adapters/desktop/feishu.md +20 -0
  151. package/docs/adapters/desktop/neteasemusic.md +31 -0
  152. package/docs/adapters/desktop/notion.md +29 -0
  153. package/docs/adapters/desktop/wechat.md +28 -0
  154. package/docs/adapters/index.md +49 -0
  155. package/docs/advanced/cdp.md +103 -0
  156. package/docs/advanced/download.md +63 -0
  157. package/docs/advanced/electron.md +125 -0
  158. package/docs/advanced/remote-chrome.md +72 -0
  159. package/docs/developer/ai-workflow.md +66 -0
  160. package/docs/developer/architecture.md +90 -0
  161. package/docs/developer/contributing.md +136 -0
  162. package/docs/developer/testing.md +237 -0
  163. package/docs/developer/ts-adapter.md +87 -0
  164. package/docs/developer/yaml-adapter.md +108 -0
  165. package/docs/guide/browser-bridge.md +38 -0
  166. package/docs/guide/getting-started.md +56 -0
  167. package/docs/guide/installation.md +37 -0
  168. package/docs/guide/troubleshooting.md +56 -0
  169. package/docs/index.md +35 -0
  170. package/docs/zh/adapters/index.md +5 -0
  171. package/docs/zh/advanced/cdp.md +3 -0
  172. package/docs/zh/developer/contributing.md +24 -0
  173. package/docs/zh/guide/browser-bridge.md +25 -0
  174. package/docs/zh/guide/getting-started.md +40 -0
  175. package/docs/zh/guide/installation.md +37 -0
  176. package/docs/zh/index.md +29 -0
  177. package/extension/dist/background.js +386 -438
  178. package/extension/manifest.json +2 -2
  179. package/extension/package-lock.json +1156 -0
  180. package/extension/src/background.test.ts +151 -0
  181. package/extension/src/background.ts +124 -53
  182. package/extension/src/protocol.ts +3 -1
  183. package/package.json +7 -3
  184. package/src/browser/cdp.ts +367 -0
  185. package/src/browser/daemon-client.ts +7 -1
  186. package/src/browser/dom-helpers.ts +116 -0
  187. package/src/browser/index.ts +4 -0
  188. package/src/browser/mcp.ts +14 -6
  189. package/src/browser/page.ts +47 -124
  190. package/src/browser/utils.ts +27 -0
  191. package/src/browser.test.ts +56 -0
  192. package/src/build-manifest.test.ts +36 -0
  193. package/src/build-manifest.ts +2 -1
  194. package/src/capabilityRouting.test.ts +47 -0
  195. package/src/capabilityRouting.ts +28 -0
  196. package/src/chaoxing.test.ts +53 -0
  197. package/src/chaoxing.ts +268 -0
  198. package/src/cli.ts +205 -0
  199. package/src/clis/antigravity/SKILL.md +5 -0
  200. package/src/clis/antigravity/serve.ts +329 -0
  201. package/src/clis/bilibili/download.ts +4 -15
  202. package/src/clis/boss/chatlist.ts +50 -0
  203. package/src/clis/boss/chatmsg.ts +70 -0
  204. package/src/clis/boss/resume.ts +262 -0
  205. package/src/clis/boss/send.ts +193 -0
  206. package/src/clis/chaoxing/README.md +36 -0
  207. package/src/clis/chaoxing/README.zh-CN.md +35 -0
  208. package/src/clis/chaoxing/assignments.ts +88 -0
  209. package/src/clis/chaoxing/exams.ts +88 -0
  210. package/src/clis/chatgpt/ask.ts +14 -15
  211. package/src/clis/chatgpt/ax.ts +81 -0
  212. package/src/clis/chatgpt/read.ts +5 -7
  213. package/src/clis/hf/top.ts +141 -0
  214. package/src/clis/jike/comment.ts +113 -0
  215. package/src/clis/jike/create.ts +113 -0
  216. package/src/clis/jike/feed.ts +74 -0
  217. package/src/clis/jike/like.ts +65 -0
  218. package/src/clis/jike/notifications.ts +185 -0
  219. package/src/clis/jike/post.yaml +58 -0
  220. package/src/clis/jike/repost.ts +114 -0
  221. package/src/clis/jike/search.ts +74 -0
  222. package/src/clis/jike/shared.ts +36 -0
  223. package/src/clis/jike/topic.yaml +52 -0
  224. package/src/clis/jike/user.yaml +51 -0
  225. package/src/clis/smzdm/search.ts +30 -39
  226. package/src/clis/stackoverflow/bounties.yaml +29 -0
  227. package/src/clis/stackoverflow/hot.yaml +28 -0
  228. package/src/clis/stackoverflow/search.yaml +32 -0
  229. package/src/clis/stackoverflow/unanswered.yaml +28 -0
  230. package/src/clis/twitter/download.ts +6 -17
  231. package/src/clis/twitter/post.ts +9 -2
  232. package/src/clis/twitter/search.ts +15 -33
  233. package/src/clis/xiaohongshu/download.ts +4 -4
  234. package/src/clis/zhihu/download.ts +3 -3
  235. package/src/doctor.ts +18 -2
  236. package/src/download/index.test.ts +16 -0
  237. package/src/download/index.ts +22 -4
  238. package/src/engine.ts +20 -13
  239. package/src/explore.ts +54 -103
  240. package/src/generate.ts +1 -0
  241. package/src/interceptor.ts +3 -2
  242. package/src/main.ts +4 -180
  243. package/src/output.ts +15 -13
  244. package/src/pipeline/executor.test.ts +1 -0
  245. package/src/pipeline/steps/download.ts +14 -17
  246. package/src/registry.ts +9 -5
  247. package/src/runtime.ts +3 -2
  248. package/src/scripts/framework.ts +20 -0
  249. package/src/scripts/interact.ts +22 -0
  250. package/src/scripts/store.ts +40 -0
  251. package/src/synthesize.ts +1 -1
  252. package/src/types.ts +9 -0
  253. package/src/verify.ts +64 -3
@@ -0,0 +1,367 @@
1
+ /**
2
+ * CDP client — implements IPage by connecting directly to a Chrome/Electron CDP WebSocket.
3
+ *
4
+ * Fixes applied:
5
+ * - send() now has a 30s timeout guard (P0 #4)
6
+ * - goto() waits for Page.loadEventFired instead of hardcoded 1s sleep (P1 #3)
7
+ * - Implemented scroll, autoScroll, screenshot, networkRequests (P1 #2)
8
+ * - Shared DOM helper methods extracted to reduce duplication with Page (P1 #5)
9
+ */
10
+
11
+ import { WebSocket } from 'ws';
12
+ import type { IPage } from '../types.js';
13
+ import { wrapForEval } from './utils.js';
14
+ import {
15
+ clickJs,
16
+ typeTextJs,
17
+ pressKeyJs,
18
+ waitForTextJs,
19
+ scrollJs,
20
+ autoScrollJs,
21
+ networkRequestsJs,
22
+ } from './dom-helpers.js';
23
+
24
+ export interface CDPTarget {
25
+ type?: string;
26
+ url?: string;
27
+ title?: string;
28
+ webSocketDebuggerUrl?: string;
29
+ }
30
+
31
+ const CDP_SEND_TIMEOUT = 30_000; // 30s per command
32
+
33
+ export class CDPBridge {
34
+ private _ws: WebSocket | null = null;
35
+ private _idCounter = 0;
36
+ private _pending = new Map<number, { resolve: (val: any) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
37
+ private _eventListeners = new Map<string, Set<(params: any) => void>>();
38
+
39
+ async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
40
+ const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
41
+ if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
42
+
43
+ // If it's a direct ws:// URL, use it. Otherwise, fetch the /json endpoint to find a page.
44
+ let wsUrl = endpoint;
45
+ if (endpoint.startsWith('http')) {
46
+ const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`);
47
+ if (!res.ok) throw new Error(`Failed to fetch CDP targets: ${res.statusText}`);
48
+ const targets = await res.json() as CDPTarget[];
49
+ const target = selectCDPTarget(targets);
50
+ if (!target || !target.webSocketDebuggerUrl) {
51
+ throw new Error('No inspectable targets found at CDP endpoint');
52
+ }
53
+ wsUrl = target.webSocketDebuggerUrl;
54
+ }
55
+
56
+ return new Promise((resolve, reject) => {
57
+ const ws = new WebSocket(wsUrl);
58
+ const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), opts?.timeout ?? 10000);
59
+
60
+ ws.on('open', () => {
61
+ clearTimeout(timeout);
62
+ this._ws = ws;
63
+ resolve(new CDPPage(this));
64
+ });
65
+
66
+ ws.on('error', (err) => {
67
+ clearTimeout(timeout);
68
+ reject(err);
69
+ });
70
+
71
+ ws.on('message', (data) => {
72
+ try {
73
+ const msg = JSON.parse(data.toString());
74
+ // Handle command responses
75
+ if (msg.id && this._pending.has(msg.id)) {
76
+ const entry = this._pending.get(msg.id)!;
77
+ clearTimeout(entry.timer);
78
+ this._pending.delete(msg.id);
79
+ if (msg.error) {
80
+ entry.reject(new Error(msg.error.message));
81
+ } else {
82
+ entry.resolve(msg.result);
83
+ }
84
+ }
85
+ // Handle CDP events
86
+ if (msg.method) {
87
+ const listeners = this._eventListeners.get(msg.method);
88
+ if (listeners) {
89
+ for (const fn of listeners) fn(msg.params);
90
+ }
91
+ }
92
+ } catch {
93
+ // ignore parsing errors
94
+ }
95
+ });
96
+ });
97
+ }
98
+
99
+ async close(): Promise<void> {
100
+ if (this._ws) {
101
+ this._ws.close();
102
+ this._ws = null;
103
+ }
104
+ for (const p of this._pending.values()) {
105
+ clearTimeout(p.timer);
106
+ p.reject(new Error('CDP connection closed'));
107
+ }
108
+ this._pending.clear();
109
+ this._eventListeners.clear();
110
+ }
111
+
112
+ /** Send a CDP command with timeout guard (P0 fix #4) */
113
+ async send(method: string, params: any = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<any> {
114
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
115
+ throw new Error('CDP connection is not open');
116
+ }
117
+ const id = ++this._idCounter;
118
+ return new Promise((resolve, reject) => {
119
+ const timer = setTimeout(() => {
120
+ this._pending.delete(id);
121
+ reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`));
122
+ }, timeoutMs);
123
+ this._pending.set(id, { resolve, reject, timer });
124
+ this._ws!.send(JSON.stringify({ id, method, params }));
125
+ });
126
+ }
127
+
128
+ /** Listen for a CDP event */
129
+ on(event: string, handler: (params: any) => void): void {
130
+ let set = this._eventListeners.get(event);
131
+ if (!set) { set = new Set(); this._eventListeners.set(event, set); }
132
+ set.add(handler);
133
+ }
134
+
135
+ /** Remove a CDP event listener */
136
+ off(event: string, handler: (params: any) => void): void {
137
+ this._eventListeners.get(event)?.delete(handler);
138
+ }
139
+
140
+ /** Wait for a CDP event to fire (one-shot) */
141
+ waitForEvent(event: string, timeoutMs: number = 15_000): Promise<any> {
142
+ return new Promise((resolve, reject) => {
143
+ const timer = setTimeout(() => {
144
+ this.off(event, handler);
145
+ reject(new Error(`Timed out waiting for CDP event '${event}'`));
146
+ }, timeoutMs);
147
+ const handler = (params: any) => {
148
+ clearTimeout(timer);
149
+ this.off(event, handler);
150
+ resolve(params);
151
+ };
152
+ this.on(event, handler);
153
+ });
154
+ }
155
+ }
156
+
157
+ class CDPPage implements IPage {
158
+ constructor(private bridge: CDPBridge) {}
159
+
160
+ /** Navigate with proper load event waiting (P1 fix #3) */
161
+ async goto(url: string): Promise<void> {
162
+ await this.bridge.send('Page.enable');
163
+ const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
164
+ .catch(() => {}); // Don't fail if event times out
165
+ await this.bridge.send('Page.navigate', { url });
166
+ await loadPromise;
167
+ }
168
+
169
+ async evaluate(js: string): Promise<any> {
170
+ const expression = wrapForEval(js);
171
+ const result = await this.bridge.send('Runtime.evaluate', {
172
+ expression,
173
+ returnByValue: true,
174
+ awaitPromise: true
175
+ });
176
+ if (result.exceptionDetails) {
177
+ throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception'));
178
+ }
179
+ return result.result?.value;
180
+ }
181
+
182
+ async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
183
+ const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {});
184
+ const cookies = Array.isArray(result?.cookies) ? result.cookies : [];
185
+ return opts.domain
186
+ ? cookies.filter((cookie: any) => typeof cookie.domain === 'string' && cookie.domain.includes(opts.domain!))
187
+ : cookies;
188
+ }
189
+
190
+ async snapshot(_opts?: any): Promise<any> {
191
+ // CDP doesn't have a built-in accessibility tree equivalent without additional setup
192
+ return '(snapshot not available in CDP mode)';
193
+ }
194
+
195
+ // ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ──
196
+
197
+ async click(ref: string): Promise<void> {
198
+ await this.evaluate(clickJs(ref));
199
+ }
200
+
201
+ async typeText(ref: string, text: string): Promise<void> {
202
+ await this.evaluate(typeTextJs(ref, text));
203
+ }
204
+
205
+ async pressKey(key: string): Promise<void> {
206
+ await this.evaluate(pressKeyJs(key));
207
+ }
208
+
209
+ async wait(options: any): Promise<void> {
210
+ if (typeof options === 'number') {
211
+ await new Promise(resolve => setTimeout(resolve, options * 1000));
212
+ return;
213
+ }
214
+ if (options.time) {
215
+ await new Promise(resolve => setTimeout(resolve, options.time * 1000));
216
+ return;
217
+ }
218
+ if (options.text) {
219
+ const timeout = (options.timeout ?? 30) * 1000;
220
+ await this.evaluate(waitForTextJs(options.text, timeout));
221
+ }
222
+ }
223
+
224
+ // ── Implemented methods (P1 fix #2) ──
225
+
226
+ async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
227
+ await this.evaluate(scrollJs(direction, amount));
228
+ }
229
+
230
+ async autoScroll(options?: { times?: number; delayMs?: number }): Promise<void> {
231
+ const times = options?.times ?? 3;
232
+ const delayMs = options?.delayMs ?? 2000;
233
+ await this.evaluate(autoScrollJs(times, delayMs));
234
+ }
235
+
236
+ async screenshot(options: any = {}): Promise<string> {
237
+ const result = await this.bridge.send('Page.captureScreenshot', {
238
+ format: options.format ?? 'png',
239
+ quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined,
240
+ captureBeyondViewport: options.fullPage ?? false,
241
+ });
242
+ const base64 = result.data;
243
+ if (options.path) {
244
+ const fs = await import('node:fs');
245
+ const path = await import('node:path');
246
+ const dir = path.dirname(options.path);
247
+ await fs.promises.mkdir(dir, { recursive: true });
248
+ await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
249
+ }
250
+ return base64;
251
+ }
252
+
253
+ async networkRequests(includeStatic: boolean = false): Promise<any> {
254
+ return this.evaluate(networkRequestsJs(includeStatic));
255
+ }
256
+
257
+ async tabs(): Promise<any> {
258
+ return [];
259
+ }
260
+
261
+ async closeTab(_index?: number): Promise<void> {
262
+ // Not supported in direct CDP mode
263
+ }
264
+
265
+ async newTab(): Promise<void> {
266
+ await this.bridge.send('Target.createTarget', { url: 'about:blank' });
267
+ }
268
+
269
+ async selectTab(_index: number): Promise<void> {
270
+ // Not supported in direct CDP mode
271
+ }
272
+
273
+ async consoleMessages(_level?: string): Promise<any> {
274
+ return [];
275
+ }
276
+
277
+ async installInterceptor(pattern: string): Promise<void> {
278
+ const { generateInterceptorJs } = await import('../interceptor.js');
279
+ await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
280
+ arrayName: '__opencli_xhr',
281
+ patchGuard: '__opencli_interceptor_patched',
282
+ }));
283
+ }
284
+
285
+ async getInterceptedRequests(): Promise<any[]> {
286
+ const { generateReadInterceptedJs } = await import('../interceptor.js');
287
+ const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
288
+ return (result as any[]) || [];
289
+ }
290
+ }
291
+
292
+ // ── CDP target selection (unchanged) ──
293
+
294
+ function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
295
+ const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET);
296
+
297
+ const ranked = targets
298
+ .map((target, index) => ({ target, index, score: scoreCDPTarget(target, preferredPattern) }))
299
+ .filter(({ score }) => Number.isFinite(score))
300
+ .sort((a, b) => {
301
+ if (b.score !== a.score) return b.score - a.score;
302
+ return a.index - b.index;
303
+ });
304
+
305
+ return ranked[0]?.target;
306
+ }
307
+
308
+ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
309
+ if (!target.webSocketDebuggerUrl) return Number.NEGATIVE_INFINITY;
310
+
311
+ const type = (target.type ?? '').toLowerCase();
312
+ const url = (target.url ?? '').toLowerCase();
313
+ const title = (target.title ?? '').toLowerCase();
314
+ const haystack = `${title} ${url}`;
315
+
316
+ if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY;
317
+ if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY;
318
+
319
+ let score = 0;
320
+
321
+ if (preferredPattern && preferredPattern.test(haystack)) score += 1000;
322
+
323
+ if (type === 'app') score += 120;
324
+ else if (type === 'webview') score += 100;
325
+ else if (type === 'page') score += 80;
326
+ else if (type === 'iframe') score += 20;
327
+
328
+ if (url.startsWith('http://localhost') || url.startsWith('https://localhost')) score += 90;
329
+ if (url.startsWith('file://')) score += 60;
330
+ if (url.startsWith('http://127.0.0.1') || url.startsWith('https://127.0.0.1')) score += 50;
331
+ if (url.startsWith('about:blank')) score -= 120;
332
+ if (url === '' || url === 'about:blank') score -= 40;
333
+
334
+ if (title && title !== 'devtools') score += 25;
335
+ if (title.includes('antigravity')) score += 120;
336
+ if (title.includes('codex')) score += 120;
337
+ if (title.includes('cursor')) score += 120;
338
+ if (title.includes('chatwise')) score += 120;
339
+ if (title.includes('notion')) score += 120;
340
+ if (title.includes('discord')) score += 120;
341
+ if (title.includes('netease')) score += 120;
342
+
343
+ if (url.includes('antigravity')) score += 100;
344
+ if (url.includes('codex')) score += 100;
345
+ if (url.includes('cursor')) score += 100;
346
+ if (url.includes('chatwise')) score += 100;
347
+ if (url.includes('notion')) score += 100;
348
+ if (url.includes('discord')) score += 100;
349
+ if (url.includes('netease')) score += 100;
350
+
351
+ return score;
352
+ }
353
+
354
+ function compilePreferredPattern(raw: string | undefined): RegExp | undefined {
355
+ const value = raw?.trim();
356
+ if (!value) return undefined;
357
+ return new RegExp(escapeRegExp(value.toLowerCase()));
358
+ }
359
+
360
+ function escapeRegExp(value: string): string {
361
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
362
+ }
363
+
364
+ export const __test__ = {
365
+ selectCDPTarget,
366
+ scoreCDPTarget,
367
+ };
@@ -15,9 +15,10 @@ function generateId(): string {
15
15
 
16
16
  export interface DaemonCommand {
17
17
  id: string;
18
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
18
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
19
19
  tabId?: number;
20
20
  code?: string;
21
+ workspace?: string;
21
22
  url?: string;
22
23
  op?: string;
23
24
  index?: number;
@@ -111,3 +112,8 @@ export async function sendCommand(
111
112
  // Unreachable — the loop always returns or throws
112
113
  throw new Error('sendCommand: max retries exhausted');
113
114
  }
115
+
116
+ export async function listSessions(): Promise<any[]> {
117
+ const result = await sendCommand('sessions');
118
+ return Array.isArray(result) ? result : [];
119
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shared DOM operation JS generators.
3
+ *
4
+ * Used by both Page (daemon mode) and CDPPage (direct CDP mode)
5
+ * to eliminate code duplication for click, type, press, wait, scroll, etc.
6
+ */
7
+
8
+ /** Generate JS to click an element by ref */
9
+ export function clickJs(ref: string): string {
10
+ const safeRef = JSON.stringify(ref);
11
+ return `
12
+ (() => {
13
+ const ref = ${safeRef};
14
+ const el = document.querySelector('[data-ref="' + ref + '"]')
15
+ || document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
16
+ if (!el) throw new Error('Element not found: ' + ref);
17
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
18
+ el.click();
19
+ return 'clicked';
20
+ })()
21
+ `;
22
+ }
23
+
24
+ /** Generate JS to type text into an element by ref */
25
+ export function typeTextJs(ref: string, text: string): string {
26
+ const safeRef = JSON.stringify(ref);
27
+ const safeText = JSON.stringify(text);
28
+ return `
29
+ (() => {
30
+ const ref = ${safeRef};
31
+ const el = document.querySelector('[data-ref="' + ref + '"]')
32
+ || document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
33
+ if (!el) throw new Error('Element not found: ' + ref);
34
+ el.focus();
35
+ el.value = ${safeText};
36
+ el.dispatchEvent(new Event('input', { bubbles: true }));
37
+ el.dispatchEvent(new Event('change', { bubbles: true }));
38
+ return 'typed';
39
+ })()
40
+ `;
41
+ }
42
+
43
+ /** Generate JS to press a keyboard key */
44
+ export function pressKeyJs(key: string): string {
45
+ return `
46
+ (() => {
47
+ const el = document.activeElement || document.body;
48
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
49
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
50
+ return 'pressed';
51
+ })()
52
+ `;
53
+ }
54
+
55
+ /** Generate JS to wait for text to appear in the page */
56
+ export function waitForTextJs(text: string, timeoutMs: number): string {
57
+ return `
58
+ new Promise((resolve, reject) => {
59
+ const deadline = Date.now() + ${timeoutMs};
60
+ const check = () => {
61
+ if (document.body.innerText.includes(${JSON.stringify(text)})) return resolve('found');
62
+ if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(text)}));
63
+ setTimeout(check, 200);
64
+ };
65
+ check();
66
+ })
67
+ `;
68
+ }
69
+
70
+ /** Generate JS for scroll */
71
+ export function scrollJs(direction: string, amount: number): string {
72
+ const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
73
+ const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
74
+ return `window.scrollBy(${dx}, ${dy})`;
75
+ }
76
+
77
+ /** Generate JS for auto-scroll with lazy-load detection */
78
+ export function autoScrollJs(times: number, delayMs: number): string {
79
+ return `
80
+ (async () => {
81
+ for (let i = 0; i < ${times}; i++) {
82
+ const lastHeight = document.body.scrollHeight;
83
+ window.scrollTo(0, lastHeight);
84
+ await new Promise(resolve => {
85
+ let timeoutId;
86
+ const observer = new MutationObserver(() => {
87
+ if (document.body.scrollHeight > lastHeight) {
88
+ clearTimeout(timeoutId);
89
+ observer.disconnect();
90
+ setTimeout(resolve, 100);
91
+ }
92
+ });
93
+ observer.observe(document.body, { childList: true, subtree: true });
94
+ timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
95
+ });
96
+ }
97
+ })()
98
+ `;
99
+ }
100
+
101
+ /** Generate JS to read performance resource entries as network requests */
102
+ export function networkRequestsJs(includeStatic: boolean): string {
103
+ return `
104
+ (() => {
105
+ const entries = performance.getEntriesByType('resource');
106
+ return entries
107
+ ${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
108
+ .map(e => ({
109
+ url: e.name,
110
+ type: e.initiatorType,
111
+ duration: Math.round(e.duration),
112
+ size: e.transferSize || 0,
113
+ }));
114
+ })()
115
+ `;
116
+ }
@@ -7,9 +7,11 @@
7
7
 
8
8
  export { Page } from './page.js';
9
9
  export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
10
+ export { CDPBridge } from './cdp.js';
10
11
  export { isDaemonRunning } from './daemon-client.js';
11
12
 
12
13
  import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
14
+ import { __test__ as cdpTest } from './cdp.js';
13
15
  import { withTimeoutMs } from '../runtime.js';
14
16
 
15
17
  export const __test__ = {
@@ -17,4 +19,6 @@ export const __test__ = {
17
19
  diffTabIndexes,
18
20
  appendLimited,
19
21
  withTimeoutMs,
22
+ selectCDPTarget: cdpTest.selectCDPTarget,
23
+ scoreCDPTarget: cdpTest.scoreCDPTarget,
20
24
  };
@@ -26,7 +26,7 @@ export class BrowserBridge {
26
26
  return this._state;
27
27
  }
28
28
 
29
- async connect(opts: { timeout?: number } = {}): Promise<IPage> {
29
+ async connect(opts: { timeout?: number; workspace?: string } = {}): Promise<IPage> {
30
30
  if (this._state === 'connected' && this._page) return this._page;
31
31
  if (this._state === 'connecting') throw new Error('Already connecting');
32
32
  if (this._state === 'closing') throw new Error('Session is closing');
@@ -35,8 +35,8 @@ export class BrowserBridge {
35
35
  this._state = 'connecting';
36
36
 
37
37
  try {
38
- await this._ensureDaemon();
39
- this._page = new Page();
38
+ await this._ensureDaemon(opts.timeout);
39
+ this._page = new Page(opts.workspace);
40
40
  this._state = 'connected';
41
41
  return this._page;
42
42
  } catch (err) {
@@ -54,8 +54,16 @@ export class BrowserBridge {
54
54
  this._state = 'closed';
55
55
  }
56
56
 
57
- private async _ensureDaemon(): Promise<void> {
58
- if (await isDaemonRunning()) return;
57
+ private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
58
+ const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
59
+
60
+ if (await isExtensionConnected()) return;
61
+ if (await isDaemonRunning()) {
62
+ throw new Error(
63
+ 'Daemon is running but the Browser Extension is not connected.\n' +
64
+ 'Please install and enable the opencli Browser Bridge extension in Chrome.',
65
+ );
66
+ }
59
67
 
60
68
  // Find daemon relative to this file — works for both:
61
69
  // npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
@@ -85,7 +93,7 @@ export class BrowserBridge {
85
93
  this._daemonProc.unref();
86
94
 
87
95
  // Wait for daemon to be ready AND extension to connect
88
- const deadline = Date.now() + DAEMON_SPAWN_TIMEOUT;
96
+ const deadline = Date.now() + timeoutMs;
89
97
  while (Date.now() < deadline) {
90
98
  await new Promise(resolve => setTimeout(resolve, 300));
91
99
  if (await isExtensionConnected()) return;