@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
@@ -7,9 +7,11 @@
7
7
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
8
8
  import type { BrowserSessionInfo } from '../types.js';
9
9
  import { sleep } from '../utils.js';
10
+ import { isTransientBrowserError } from './errors.js';
10
11
 
11
12
  const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
12
13
  const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
14
+ const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
13
15
 
14
16
  let _idCounter = 0;
15
17
 
@@ -19,7 +21,7 @@ function generateId(): string {
19
21
 
20
22
  export interface DaemonCommand {
21
23
  id: string;
22
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'bind-current';
24
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
23
25
  tabId?: number;
24
26
  code?: string;
25
27
  workspace?: string;
@@ -27,15 +29,16 @@ export interface DaemonCommand {
27
29
  op?: string;
28
30
  index?: number;
29
31
  domain?: string;
30
- matchDomain?: string;
31
- matchPathPrefix?: string;
32
32
  format?: 'png' | 'jpeg';
33
33
  quality?: number;
34
34
  fullPage?: boolean;
35
+
35
36
  /** Local file paths for set-file-input action */
36
37
  files?: string[];
37
38
  /** CSS selector for file input element (set-file-input action) */
38
39
  selector?: string;
40
+ cdpMethod?: string;
41
+ cdpParams?: Record<string, unknown>;
39
42
  }
40
43
 
41
44
  export interface DaemonResult {
@@ -45,42 +48,65 @@ export interface DaemonResult {
45
48
  error?: string;
46
49
  }
47
50
 
48
- /**
49
- * Check if daemon is running.
50
- */
51
- export async function isDaemonRunning(): Promise<boolean> {
51
+ export interface DaemonStatus {
52
+ ok: boolean;
53
+ pid: number;
54
+ uptime: number;
55
+ extensionConnected: boolean;
56
+ extensionVersion?: string;
57
+ pending: number;
58
+ lastCliRequestTime: number;
59
+ memoryMB: number;
60
+ port: number;
61
+ }
62
+
63
+ async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise<Response> {
64
+ const { timeout = 2000, headers, ...rest } = init ?? {};
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), timeout);
52
67
  try {
53
- const controller = new AbortController();
54
- const timer = setTimeout(() => controller.abort(), 2000);
55
- const res = await fetch(`${DAEMON_URL}/status`, {
56
- headers: { 'X-OpenCLI': '1' },
68
+ return await fetch(`${DAEMON_URL}${pathname}`, {
69
+ ...rest,
70
+ headers: { ...OPENCLI_HEADERS, ...headers },
57
71
  signal: controller.signal,
58
72
  });
73
+ } finally {
59
74
  clearTimeout(timer);
75
+ }
76
+ }
77
+
78
+ export async function fetchDaemonStatus(opts?: { timeout?: number }): Promise<DaemonStatus | null> {
79
+ try {
80
+ const res = await requestDaemon('/status', { timeout: opts?.timeout ?? 2000 });
81
+ if (!res.ok) return null;
82
+ return await res.json() as DaemonStatus;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise<boolean> {
89
+ try {
90
+ const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
60
91
  return res.ok;
61
92
  } catch {
62
93
  return false;
63
94
  }
64
95
  }
65
96
 
97
+ /**
98
+ * Check if daemon is running.
99
+ */
100
+ export async function isDaemonRunning(): Promise<boolean> {
101
+ return (await fetchDaemonStatus()) !== null;
102
+ }
103
+
66
104
  /**
67
105
  * Check if daemon is running AND the extension is connected.
68
106
  */
69
107
  export async function isExtensionConnected(): Promise<boolean> {
70
- try {
71
- const controller = new AbortController();
72
- const timer = setTimeout(() => controller.abort(), 2000);
73
- const res = await fetch(`${DAEMON_URL}/status`, {
74
- headers: { 'X-OpenCLI': '1' },
75
- signal: controller.signal,
76
- });
77
- clearTimeout(timer);
78
- if (!res.ok) return false;
79
- const data = await res.json() as { extensionConnected?: boolean };
80
- return !!data.extensionConnected;
81
- } catch {
82
- return false;
83
- }
108
+ const status = await fetchDaemonStatus();
109
+ return !!status?.extensionConnected;
84
110
  }
85
111
 
86
112
  /**
@@ -99,27 +125,18 @@ export async function sendCommand(
99
125
  const id = generateId();
100
126
  const command: DaemonCommand = { id, action, ...params };
101
127
  try {
102
- const controller = new AbortController();
103
- const timer = setTimeout(() => controller.abort(), 30000);
104
-
105
- const res = await fetch(`${DAEMON_URL}/command`, {
128
+ const res = await requestDaemon('/command', {
106
129
  method: 'POST',
107
- headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
130
+ headers: { 'Content-Type': 'application/json' },
108
131
  body: JSON.stringify(command),
109
- signal: controller.signal,
132
+ timeout: 30000,
110
133
  });
111
- clearTimeout(timer);
112
134
 
113
135
  const result = (await res.json()) as DaemonResult;
114
136
 
115
137
  if (!result.ok) {
116
138
  // Check if error is a transient extension issue worth retrying
117
- const errMsg = result.error ?? '';
118
- const isTransient = errMsg.includes('Extension disconnected')
119
- || errMsg.includes('Extension not connected')
120
- || errMsg.includes('attach failed')
121
- || errMsg.includes('no longer exists');
122
- if (isTransient && attempt < maxRetries) {
139
+ if (isTransientBrowserError(new Error(result.error ?? '')) && attempt < maxRetries) {
123
140
  // Longer delay for extension recovery (service worker restart)
124
141
  await sleep(1500);
125
142
  continue;
@@ -146,8 +163,3 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
146
163
  const result = await sendCommand('sessions');
147
164
  return Array.isArray(result) ? result : [];
148
165
  }
149
-
150
- export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise<unknown> {
151
- return sendCommand('bind-current', { workspace, ...opts });
152
- }
153
-
@@ -1,12 +1,8 @@
1
1
  /**
2
- * Daemon discovery — simplified from MCP server path discovery.
3
- *
4
- * Only needs to check if the daemon is running. No more file system
5
- * scanning for @playwright/mcp locations.
2
+ * Daemon discovery — checks if the daemon is running.
6
3
  */
7
4
 
8
- import { DEFAULT_DAEMON_PORT } from '../constants.js';
9
- import { isDaemonRunning } from './daemon-client.js';
5
+ import { fetchDaemonStatus, isDaemonRunning } from './daemon-client.js';
10
6
 
11
7
  export { isDaemonRunning };
12
8
 
@@ -18,21 +14,13 @@ export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{
18
14
  extensionConnected: boolean;
19
15
  extensionVersion?: string;
20
16
  }> {
21
- try {
22
- const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
23
- const controller = new AbortController();
24
- const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
25
- try {
26
- const res = await fetch(`http://127.0.0.1:${port}/status`, {
27
- headers: { 'X-OpenCLI': '1' },
28
- signal: controller.signal,
29
- });
30
- const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
31
- return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
32
- } finally {
33
- clearTimeout(timer);
34
- }
35
- } catch {
17
+ const status = await fetchDaemonStatus({ timeout: opts?.timeout ?? 2000 });
18
+ if (!status) {
36
19
  return { running: false, extensionConnected: false };
37
20
  }
21
+ return {
22
+ running: true,
23
+ extensionConnected: status.extensionConnected,
24
+ extensionVersion: status.extensionVersion,
25
+ };
38
26
  }
@@ -8,6 +8,28 @@
8
8
  import { BrowserConnectError, type BrowserConnectKind } from '../errors.js';
9
9
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
10
10
 
11
+ /**
12
+ * Transient browser error patterns — shared across daemon-client, pipeline executor,
13
+ * and page retry logic. These errors indicate temporary conditions (extension restart,
14
+ * service worker cycle, tab navigation) that are worth retrying.
15
+ */
16
+ const TRANSIENT_ERROR_PATTERNS = [
17
+ 'Extension disconnected',
18
+ 'Extension not connected',
19
+ 'attach failed',
20
+ 'no longer exists',
21
+ 'CDP connection',
22
+ 'Daemon command failed',
23
+ ] as const;
24
+
25
+ /**
26
+ * Check if an error message indicates a transient browser error worth retrying.
27
+ */
28
+ export function isTransientBrowserError(err: unknown): boolean {
29
+ const msg = err instanceof Error ? err.message : String(err);
30
+ return TRANSIENT_ERROR_PATTERNS.some(pattern => msg.includes(pattern));
31
+ }
32
+
11
33
  // Re-export so callers don't need to import from two places
12
34
  export type ConnectFailureKind = BrowserConnectKind;
13
35
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  export { Page } from './page.js';
9
- export { BrowserBridge } from './mcp.js';
9
+ export { BrowserBridge } from './bridge.js';
10
10
  export { CDPBridge } from './cdp.js';
11
11
  export { isDaemonRunning } from './daemon-client.js';
12
12
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
@@ -10,25 +10,13 @@
10
10
  * chrome-extension:// tab that can't be debugged.
11
11
  */
12
12
 
13
- import { formatSnapshot } from '../snapshotFormatter.js';
14
- import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
13
+ import type { BrowserCookie, ScreenshotOptions } from '../types.js';
15
14
  import { sendCommand } from './daemon-client.js';
16
15
  import { wrapForEval } from './utils.js';
17
16
  import { saveBase64ToFile } from '../utils.js';
18
- import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
19
17
  import { generateStealthJs } from './stealth.js';
20
- import {
21
- clickJs,
22
- typeTextJs,
23
- pressKeyJs,
24
- waitForTextJs,
25
- waitForCaptureJs,
26
- waitForSelectorJs,
27
- scrollJs,
28
- autoScrollJs,
29
- networkRequestsJs,
30
- waitForDomStableJs,
31
- } from './dom-helpers.js';
18
+ import { waitForDomStableJs } from './dom-helpers.js';
19
+ import { BasePage } from './base-page.js';
32
20
 
33
21
  export function isRetryableSettleError(err: unknown): boolean {
34
22
  const message = err instanceof Error ? err.message : String(err);
@@ -39,13 +27,13 @@ export function isRetryableSettleError(err: unknown): boolean {
39
27
  /**
40
28
  * Page — implements IPage by talking to the daemon via HTTP.
41
29
  */
42
- export class Page implements IPage {
43
- constructor(private readonly workspace: string = 'default') {}
30
+ export class Page extends BasePage {
31
+ constructor(private readonly workspace: string = 'default') {
32
+ super();
33
+ }
44
34
 
45
35
  /** Active tab ID, set after navigate and used in all subsequent commands */
46
36
  private _tabId: number | undefined;
47
- /** Last navigated URL, tracked in-memory to avoid extra round-trips */
48
- private _lastUrl: string | null = null;
49
37
 
50
38
  /** Helper: spread workspace into command params */
51
39
  private _wsOpt(): { workspace: string } {
@@ -107,27 +95,8 @@ export class Page implements IPage {
107
95
  }
108
96
  }
109
97
 
110
- async getCurrentUrl(): Promise<string | null> {
111
- if (this._lastUrl) return this._lastUrl;
112
- try {
113
- const current = await this.evaluate('window.location.href');
114
- if (typeof current === 'string' && current) {
115
- this._lastUrl = current;
116
- return current;
117
- }
118
- } catch {
119
- // Best-effort: some commands may run before a debuggable tab is ready.
120
- }
121
- return null;
122
- }
123
-
124
- /** Close the automation window in the extension */
125
- async closeWindow(): Promise<void> {
126
- try {
127
- await sendCommand('close-window', { ...this._wsOpt() });
128
- } catch {
129
- // Window may already be closed or daemon may be down
130
- }
98
+ getActiveTabId(): number | undefined {
99
+ return this._tabId;
131
100
  }
132
101
 
133
102
  async evaluate(js: string): Promise<unknown> {
@@ -146,124 +115,12 @@ export class Page implements IPage {
146
115
  return Array.isArray(result) ? result : [];
147
116
  }
148
117
 
149
- async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
150
- // Primary: use the advanced DOM snapshot engine with multi-layer pruning
151
- const snapshotJs = generateSnapshotJs({
152
- viewportExpand: opts.viewportExpand ?? 800,
153
- maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
154
- interactiveOnly: opts.interactive ?? false,
155
- maxTextLength: opts.maxTextLength ?? 120,
156
- includeScrollInfo: true,
157
- bboxDedup: true,
158
- });
159
-
118
+ /** Close the automation window in the extension */
119
+ async closeWindow(): Promise<void> {
160
120
  try {
161
- const result = await sendCommand('exec', { code: snapshotJs, ...this._cmdOpts() });
162
- // The advanced engine already produces a clean, pruned, LLM-friendly output.
163
- // Do NOT pass through formatSnapshot — its format is incompatible.
164
- return result;
121
+ await sendCommand('close-window', { ...this._wsOpt() });
165
122
  } catch {
166
- // Fallback: basic DOM snapshot (original implementation)
167
- return this._basicSnapshot(opts);
168
- }
169
- }
170
-
171
- /** Fallback basic snapshot — original buildTree approach */
172
- private async _basicSnapshot(opts: Pick<SnapshotOptions, 'interactive' | 'compact' | 'maxDepth' | 'raw'> = {}): Promise<unknown> {
173
- const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
174
- const code = `
175
- (async () => {
176
- function buildTree(node, depth) {
177
- if (depth > ${maxDepth}) return '';
178
- const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
179
- const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
180
- const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
181
-
182
- ${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
183
-
184
- let indent = ' '.repeat(depth);
185
- let line = indent + role;
186
- if (name) line += ' "' + name.replace(/"/g, '\\\\\\"') + '"';
187
- if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
188
- if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
189
-
190
- let result = line + '\\n';
191
- if (node.children) {
192
- for (const child of node.children) {
193
- result += buildTree(child, depth + 1);
194
- }
195
- }
196
- return result;
197
- }
198
- return buildTree(document.body, 0);
199
- })()
200
- `;
201
- const raw = await sendCommand('exec', { code, ...this._cmdOpts() });
202
- if (opts.raw) return raw;
203
- if (typeof raw === 'string') return formatSnapshot(raw, opts);
204
- return raw;
205
- }
206
-
207
- async click(ref: string): Promise<void> {
208
- const code = clickJs(ref);
209
- await sendCommand('exec', { code, ...this._cmdOpts() });
210
- }
211
-
212
- async typeText(ref: string, text: string): Promise<void> {
213
- const code = typeTextJs(ref, text);
214
- await sendCommand('exec', { code, ...this._cmdOpts() });
215
- }
216
-
217
- async pressKey(key: string): Promise<void> {
218
- const code = pressKeyJs(key);
219
- await sendCommand('exec', { code, ...this._cmdOpts() });
220
- }
221
-
222
- async scrollTo(ref: string): Promise<unknown> {
223
- const code = scrollToRefJs(ref);
224
- return sendCommand('exec', { code, ...this._cmdOpts() });
225
- }
226
-
227
- async getFormState(): Promise<Record<string, unknown>> {
228
- const code = getFormStateJs();
229
- return (await sendCommand('exec', { code, ...this._cmdOpts() })) as Record<string, unknown>;
230
- }
231
-
232
- async wait(options: number | WaitOptions): Promise<void> {
233
- if (typeof options === 'number') {
234
- if (options >= 1) {
235
- // For waits >= 1s, use DOM-stable check: return early when the page
236
- // stops mutating, with the original wait time as the hard cap.
237
- // This turns e.g. `page.wait(5)` from a fixed 5s sleep into
238
- // "wait until DOM is stable, max 5s" — often completing in <1s.
239
- try {
240
- const maxMs = options * 1000;
241
- await sendCommand('exec', {
242
- code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
243
- ...this._cmdOpts(),
244
- });
245
- return;
246
- } catch {
247
- // Fallback: fixed sleep (e.g. if page has no DOM yet)
248
- }
249
- }
250
- await new Promise(resolve => setTimeout(resolve, options * 1000));
251
- return;
252
- }
253
- if (typeof options.time === 'number') {
254
- await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
255
- return;
256
- }
257
- if (options.selector) {
258
- const timeout = (options.timeout ?? 10) * 1000;
259
- const code = waitForSelectorJs(options.selector, timeout);
260
- await sendCommand('exec', { code, ...this._cmdOpts() });
261
- return;
262
- }
263
- if (options.text) {
264
- const timeout = (options.timeout ?? 30) * 1000;
265
- const code = waitForTextJs(options.text, timeout);
266
- await sendCommand('exec', { code, ...this._cmdOpts() });
123
+ // Window may already be closed or daemon may be down
267
124
  }
268
125
  }
269
126
 
@@ -289,27 +146,8 @@ export class Page implements IPage {
289
146
  if (result?.selected) this._tabId = result.selected;
290
147
  }
291
148
 
292
- async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
293
- const code = networkRequestsJs(includeStatic);
294
- const result = await sendCommand('exec', { code, ...this._cmdOpts() });
295
- return Array.isArray(result) ? result : [];
296
- }
297
-
298
- /**
299
- * Console messages are not available in lightweight daemon mode.
300
- * Would require CDP Runtime.consoleAPICalled event listener.
301
- * @returns Always returns empty array.
302
- */
303
- async consoleMessages(_level: string = 'info'): Promise<unknown[]> {
304
- return [];
305
- }
306
-
307
149
  /**
308
150
  * Capture a screenshot via CDP Page.captureScreenshot.
309
- * @param options.format - 'png' (default) or 'jpeg'
310
- * @param options.quality - JPEG quality 0-100
311
- * @param options.fullPage - capture full scrollable page
312
- * @param options.path - save to file path (returns base64 if omitted)
313
151
  */
314
152
  async screenshot(options: ScreenshotOptions = {}): Promise<string> {
315
153
  const base64 = await sendCommand('screenshot', {
@@ -326,35 +164,6 @@ export class Page implements IPage {
326
164
  return base64;
327
165
  }
328
166
 
329
- async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
330
- const code = scrollJs(direction, amount);
331
- await sendCommand('exec', { code, ...this._cmdOpts() });
332
- }
333
-
334
- async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
335
- const times = options.times ?? 3;
336
- const delayMs = options.delayMs ?? 2000;
337
- const code = autoScrollJs(times, delayMs);
338
- await sendCommand('exec', { code, ...this._cmdOpts() });
339
- }
340
-
341
- async installInterceptor(pattern: string): Promise<void> {
342
- const { generateInterceptorJs } = await import('../interceptor.js');
343
- // Must use evaluate() so wrapForEval() converts the arrow function into an IIFE;
344
- // sendCommand('exec') sends the code as-is, and CDP never executes a bare arrow.
345
- await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
346
- arrayName: '__opencli_xhr',
347
- patchGuard: '__opencli_interceptor_patched',
348
- }));
349
- }
350
-
351
- async getInterceptedRequests(): Promise<unknown[]> {
352
- const { generateReadInterceptedJs } = await import('../interceptor.js');
353
- // Same as installInterceptor: must go through evaluate() for IIFE wrapping
354
- const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
355
- return Array.isArray(result) ? result : [];
356
- }
357
-
358
167
  /**
359
168
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
360
169
  * Chrome reads the files directly from the local filesystem, avoiding the
@@ -371,13 +180,52 @@ export class Page implements IPage {
371
180
  }
372
181
  }
373
182
 
374
- async waitForCapture(timeout: number = 10): Promise<void> {
375
- const maxMs = timeout * 1000;
376
- await sendCommand('exec', {
377
- code: waitForCaptureJs(maxMs),
183
+ async cdp(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
184
+ return sendCommand('cdp', {
185
+ cdpMethod: method,
186
+ cdpParams: params,
378
187
  ...this._cmdOpts(),
379
188
  });
380
189
  }
190
+
191
+ async nativeClick(x: number, y: number): Promise<void> {
192
+ await this.cdp('Input.dispatchMouseEvent', {
193
+ type: 'mousePressed',
194
+ x, y,
195
+ button: 'left',
196
+ clickCount: 1,
197
+ });
198
+ await this.cdp('Input.dispatchMouseEvent', {
199
+ type: 'mouseReleased',
200
+ x, y,
201
+ button: 'left',
202
+ clickCount: 1,
203
+ });
204
+ }
205
+
206
+ async nativeType(text: string): Promise<void> {
207
+ // Use Input.insertText for reliable Unicode/CJK text insertion
208
+ await this.cdp('Input.insertText', { text });
209
+ }
210
+
211
+ async nativeKeyPress(key: string, modifiers: string[] = []): Promise<void> {
212
+ let modifierFlags = 0;
213
+ for (const mod of modifiers) {
214
+ if (mod === 'Alt') modifierFlags |= 1;
215
+ if (mod === 'Ctrl') modifierFlags |= 2;
216
+ if (mod === 'Meta') modifierFlags |= 4;
217
+ if (mod === 'Shift') modifierFlags |= 8;
218
+ }
219
+ await this.cdp('Input.dispatchKeyEvent', {
220
+ type: 'keyDown',
221
+ key,
222
+ modifiers: modifierFlags,
223
+ });
224
+ await this.cdp('Input.dispatchKeyEvent', {
225
+ type: 'keyUp',
226
+ key,
227
+ modifiers: modifierFlags,
228
+ });
229
+ }
381
230
  }
382
231
 
383
- // (End of file)
@@ -21,12 +21,12 @@ export function extractTabEntries(raw: unknown): Array<{ index: number; identity
21
21
  .map(line => line.trim())
22
22
  .filter(Boolean)
23
23
  .map(line => {
24
- // Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
25
- const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
26
- if (mcpMatch) {
24
+ // Match tab list format: "- 0: (current) [title](url)" or "- 1: [title](url)"
25
+ const tabMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
26
+ if (tabMatch) {
27
27
  return {
28
- index: parseInt(mcpMatch[1], 10),
29
- identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
28
+ index: parseInt(tabMatch[1], 10),
29
+ identity: tabMatch[2].trim() || `tab-${tabMatch[1]}`,
30
30
  };
31
31
  }
32
32
  // Legacy format: "Tab 0 ..."
@@ -104,43 +104,43 @@ describe('browser helpers', () => {
104
104
 
105
105
  describe('BrowserBridge state', () => {
106
106
  it('transitions to closed after close()', async () => {
107
- const mcp = new BrowserBridge();
107
+ const bridge = new BrowserBridge();
108
108
 
109
- expect(mcp.state).toBe('idle');
109
+ expect(bridge.state).toBe('idle');
110
110
 
111
- await mcp.close();
111
+ await bridge.close();
112
112
 
113
- expect(mcp.state).toBe('closed');
113
+ expect(bridge.state).toBe('closed');
114
114
  });
115
115
 
116
116
  it('rejects connect() after the session has been closed', async () => {
117
- const mcp = new BrowserBridge();
118
- await mcp.close();
117
+ const bridge = new BrowserBridge();
118
+ await bridge.close();
119
119
 
120
- await expect(mcp.connect()).rejects.toThrow('Session is closed');
120
+ await expect(bridge.connect()).rejects.toThrow('Session is closed');
121
121
  });
122
122
 
123
123
  it('rejects connect() while already connecting', async () => {
124
- const mcp = new BrowserBridge();
125
- (mcp as any)._state = 'connecting';
124
+ const bridge = new BrowserBridge();
125
+ (bridge as any)._state = 'connecting';
126
126
 
127
- await expect(mcp.connect()).rejects.toThrow('Already connecting');
127
+ await expect(bridge.connect()).rejects.toThrow('Already connecting');
128
128
  });
129
129
 
130
130
  it('rejects connect() while closing', async () => {
131
- const mcp = new BrowserBridge();
132
- (mcp as any)._state = 'closing';
131
+ const bridge = new BrowserBridge();
132
+ (bridge as any)._state = 'closing';
133
133
 
134
- await expect(mcp.connect()).rejects.toThrow('Session is closing');
134
+ await expect(bridge.connect()).rejects.toThrow('Session is closing');
135
135
  });
136
136
 
137
137
  it('fails fast when daemon is running but extension is disconnected', async () => {
138
138
  vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
139
139
  vi.spyOn(daemonClient, 'isDaemonRunning').mockResolvedValue(true);
140
140
 
141
- const mcp = new BrowserBridge();
141
+ const bridge = new BrowserBridge();
142
142
 
143
- await expect(mcp.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
143
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
144
144
  });
145
145
  });
146
146