@jackwener/opencli 1.7.3 → 1.7.5

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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { extractJsonAssignmentFromHtml, prepareYoutubeApiPage } from './utils.js';
2
+ import { extractJsonAssignmentFromHtml, extractSubscriptionChannel, prepareYoutubeApiPage } from './utils.js';
3
3
  describe('youtube utils', () => {
4
4
  it('extractJsonAssignmentFromHtml parses bootstrap objects with nested braces in strings', () => {
5
5
  const html = `
@@ -34,4 +34,35 @@ describe('youtube utils', () => {
34
34
  expect(page.goto).toHaveBeenCalledWith('https://www.youtube.com', { waitUntil: 'none' });
35
35
  expect(page.wait).toHaveBeenCalledWith(2);
36
36
  });
37
+ it('extractSubscriptionChannel prefers explicit handle and subscriber count fields', () => {
38
+ expect(extractSubscriptionChannel({
39
+ title: { simpleText: 'OpenAI' },
40
+ channelHandleText: { runs: [{ text: '@openai' }] },
41
+ subscriberCountText: { simpleText: '1.23M subscribers' },
42
+ videoCountText: { simpleText: '123 videos' },
43
+ navigationEndpoint: { browseEndpoint: { canonicalBaseUrl: '/channel/UC123' } },
44
+ channelId: 'UC123',
45
+ })).toEqual({
46
+ name: 'OpenAI',
47
+ handle: '@openai',
48
+ subscribers: '1.23M subscribers',
49
+ url: 'https://www.youtube.com/channel/UC123',
50
+ });
51
+ });
52
+ it('extractSubscriptionChannel falls back when handle/count fields are overloaded', () => {
53
+ expect(extractSubscriptionChannel({
54
+ title: {
55
+ runs: [{ text: 'OpenAI' }],
56
+ },
57
+ subscriberCountText: { simpleText: '@openai' },
58
+ videoCountText: { simpleText: '1.23M subscribers' },
59
+ navigationEndpoint: { browseEndpoint: { canonicalBaseUrl: '/@openai' } },
60
+ channelId: 'UC123',
61
+ })).toEqual({
62
+ name: 'OpenAI',
63
+ handle: '@openai',
64
+ subscribers: '1.23M subscribers',
65
+ url: 'https://www.youtube.com/@openai',
66
+ });
67
+ });
37
68
  });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * YouTube watch-later — the user's Watch Later queue.
3
+ * Navigates to /playlist?list=WL and reads ytInitialData directly.
4
+ */
5
+ import { cli, Strategy } from '@jackwener/opencli/registry';
6
+ import { FETCH_BROWSE_FN, extractPlaylistVideos } from './utils.js';
7
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
8
+
9
+ cli({
10
+ site: 'youtube',
11
+ name: 'watch-later',
12
+ description: 'Get your YouTube Watch Later queue',
13
+ domain: 'www.youtube.com',
14
+ strategy: Strategy.COOKIE,
15
+ args: [
16
+ { name: 'limit', type: 'int', default: 50, help: 'Max videos to return (default 50, max 200)' },
17
+ ],
18
+ columns: ['rank', 'title', 'channel', 'duration', 'views', 'published', 'url'],
19
+ func: async (page, kwargs) => {
20
+ const limit = Math.min(kwargs.limit || 50, 200);
21
+ await page.goto('https://www.youtube.com/playlist?list=WL');
22
+ await page.wait(3);
23
+ const data = await page.evaluate(`
24
+ (async () => {
25
+ const d = window.ytInitialData;
26
+ if (!d) return { error: 'YouTube data not found — are you logged in?' };
27
+
28
+ const limit = ${limit};
29
+ const cfg = window.ytcfg?.data_ || {};
30
+ const apiKey = cfg.INNERTUBE_API_KEY;
31
+ const context = cfg.INNERTUBE_CONTEXT;
32
+
33
+ const header = d.header?.playlistHeaderRenderer;
34
+ const title = header?.title?.simpleText || 'Watch Later';
35
+ const stats = (header?.stats || [])
36
+ .map(s => s.runs?.map(r => r.text)?.join('') || s.simpleText || '')
37
+ .filter(Boolean);
38
+
39
+ const tabs = d.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
40
+ let listContents = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents || [];
41
+
42
+ ${FETCH_BROWSE_FN}
43
+
44
+ const extractVideos = ${extractPlaylistVideos.toString()};
45
+
46
+ let videos = extractVideos(listContents);
47
+
48
+ let contItem = listContents[listContents.length - 1];
49
+ while (videos.length < limit && contItem?.continuationItemRenderer && apiKey && context) {
50
+ const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
51
+ if (!token) break;
52
+ const contData = await fetchBrowse(apiKey, { context, continuation: token });
53
+ if (contData.error) break;
54
+ const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
55
+ if (!newItems.length) break;
56
+ videos = videos.concat(extractVideos(newItems));
57
+ contItem = newItems[newItems.length - 1];
58
+ }
59
+
60
+ return { title, stats, videos: videos.slice(0, limit) };
61
+ })()
62
+ `);
63
+ if (!data || typeof data !== 'object') {
64
+ throw new CommandExecutionError('Failed to fetch Watch Later — make sure you are logged into YouTube');
65
+ }
66
+ if (data.error) {
67
+ throw new CommandExecutionError(String(data.error));
68
+ }
69
+ if (!data.videos?.length) {
70
+ throw new EmptyResultError('youtube watch-later');
71
+ }
72
+ const statsStr = (data.stats || []).join(' | ');
73
+ process.stderr.write(`${data.title} ${statsStr}\n`);
74
+ return data.videos;
75
+ },
76
+ });
@@ -37,7 +37,7 @@ cli({
37
37
  time: d.create_time || topic.create_time || '',
38
38
  group: topic.group?.name || '',
39
39
  author: getTopicAuthor(topic),
40
- title: getTopicText(topic).slice(0, 120),
40
+ title: getTopicText(topic),
41
41
  comments: topic.comments_count ?? 0,
42
42
  likes: topic.likes_count ?? 0,
43
43
  url: getTopicUrl(topic.topic_id),
@@ -186,8 +186,11 @@ export function getTopicAuthor(topic) {
186
186
  '');
187
187
  }
188
188
  export function getTopicText(topic) {
189
+ const title = (topic.title || '').replace(/\s+/g, ' ').trim();
190
+ return title || getTopicContent(topic);
191
+ }
192
+ export function getTopicContent(topic) {
189
193
  const primary = [
190
- topic.title,
191
194
  topic.talk?.text,
192
195
  topic.question?.text,
193
196
  topic.answer?.text,
@@ -218,8 +221,8 @@ export function toTopicRow(topic) {
218
221
  type: topic.type || '',
219
222
  group: topic.group?.name || '',
220
223
  author: getTopicAuthor(topic),
221
- title: getTopicText(topic).slice(0, 120),
222
- content: getTopicText(topic),
224
+ title: getTopicText(topic),
225
+ content: getTopicContent(topic),
223
226
  comments: topic.comments_count ?? comments.length ?? 0,
224
227
  likes: topic.likes_count ?? 0,
225
228
  readers: topic.readers_count ?? topic.reading_count ?? 0,
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getTopicText, toTopicRow } from './utils.js';
3
+
4
+ describe('zsxq utils', () => {
5
+ it('keeps title and content separate when both fields exist', () => {
6
+ const topic = {
7
+ topic_id: '123',
8
+ title: 'A full title that should not be truncated',
9
+ talk: { text: 'This is the full body text.' },
10
+ };
11
+
12
+ expect(getTopicText(topic)).toBe('A full title that should not be truncated');
13
+ expect(toTopicRow(topic)).toMatchObject({
14
+ title: 'A full title that should not be truncated',
15
+ content: 'This is the full body text.',
16
+ });
17
+ });
18
+
19
+ it('falls back to body text for title when explicit title is absent', () => {
20
+ const topic = {
21
+ topic_id: '456',
22
+ talk: { text: 'Body-only topic text should still appear as the title preview.' },
23
+ };
24
+
25
+ expect(getTopicText(topic)).toBe('Body-only topic text should still appear as the title preview.');
26
+ expect(toTopicRow(topic)).toMatchObject({
27
+ title: 'Body-only topic text should still appear as the title preview.',
28
+ content: 'Body-only topic text should still appear as the title preview.',
29
+ });
30
+ });
31
+ });
@@ -33,7 +33,7 @@ export declare abstract class BasePage implements IPage {
33
33
  }): Promise<BrowserCookie[]>;
34
34
  abstract screenshot(options?: ScreenshotOptions): Promise<string>;
35
35
  abstract tabs(): Promise<unknown[]>;
36
- abstract selectTab(index: number): Promise<void>;
36
+ abstract selectTab(target: number | string): Promise<void>;
37
37
  click(ref: string): Promise<void>;
38
38
  /** Override in subclasses with CDP native click support */
39
39
  protected tryNativeClick(_x: number, _y: number): Promise<boolean>;
@@ -8,8 +8,10 @@
8
8
  * Subclasses implement the transport-specific methods: goto, evaluate,
9
9
  * getCookies, screenshot, tabs, etc.
10
10
  */
11
- import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
- import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
11
+ import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
+ import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
+ import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs } from './target-resolver.js';
14
+ import { TargetError } from './target-errors.js';
13
15
  import { formatSnapshot } from '../snapshotFormatter.js';
14
16
  export class BasePage {
15
17
  _lastUrl = null;
@@ -36,7 +38,13 @@ export class BasePage {
36
38
  }
37
39
  // ── Shared DOM helper implementations ──
38
40
  async click(ref) {
39
- const result = await this.evaluate(clickJs(ref));
41
+ // Phase 1: Resolve target with fingerprint verification
42
+ const resolution = await this.evaluate(resolveTargetJs(ref));
43
+ if (!resolution.ok) {
44
+ throw new TargetError(resolution);
45
+ }
46
+ // Phase 2: Execute click on resolved element
47
+ const result = await this.evaluate(clickResolvedJs());
40
48
  // Backwards compat: old format returned 'clicked' string
41
49
  if (typeof result === 'string' || result == null)
42
50
  return;
@@ -56,13 +64,25 @@ export class BasePage {
56
64
  return false;
57
65
  }
58
66
  async typeText(ref, text) {
59
- await this.evaluate(typeTextJs(ref, text));
67
+ // Phase 1: Resolve target with fingerprint verification
68
+ const resolution = await this.evaluate(resolveTargetJs(ref));
69
+ if (!resolution.ok) {
70
+ throw new TargetError(resolution);
71
+ }
72
+ // Phase 2: Execute type on resolved element
73
+ await this.evaluate(typeResolvedJs(text));
60
74
  }
61
75
  async pressKey(key) {
62
76
  await this.evaluate(pressKeyJs(key));
63
77
  }
64
78
  async scrollTo(ref) {
65
- return this.evaluate(scrollToRefJs(ref));
79
+ // Phase 1: Resolve target with fingerprint verification
80
+ const resolution = await this.evaluate(resolveTargetJs(ref));
81
+ if (!resolution.ok) {
82
+ throw new TargetError(resolution);
83
+ }
84
+ // Phase 2: Scroll to resolved element
85
+ return this.evaluate(scrollResolvedJs());
66
86
  }
67
87
  async getFormState() {
68
88
  return (await this.evaluate(getFormStateJs()));
@@ -15,9 +15,12 @@ export declare class BrowserBridge implements IBrowserFactory {
15
15
  connect(opts?: {
16
16
  timeout?: number;
17
17
  workspace?: string;
18
+ idleTimeout?: number;
18
19
  }): Promise<IPage>;
19
20
  close(): Promise<void>;
20
21
  private _ensureDaemon;
22
+ /** Poll until daemon is fully stopped (port released). */
23
+ private _waitForDaemonStop;
21
24
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
22
25
  private _pollUntilReady;
23
26
  }
@@ -6,9 +6,10 @@ import { fileURLToPath } from 'node:url';
6
6
  import * as path from 'node:path';
7
7
  import * as fs from 'node:fs';
8
8
  import { Page } from './page.js';
9
- import { getDaemonHealth } from './daemon-client.js';
9
+ import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
10
10
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
11
  import { BrowserConnectError } from '../errors.js';
12
+ import { PKG_VERSION } from '../version.js';
12
13
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
13
14
  /**
14
15
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -32,7 +33,7 @@ export class BrowserBridge {
32
33
  this._state = 'connecting';
33
34
  try {
34
35
  await this._ensureDaemon(opts.timeout);
35
- this._page = new Page(opts.workspace);
36
+ this._page = new Page(opts.workspace, opts.idleTimeout);
36
37
  this._state = 'connected';
37
38
  return this._page;
38
39
  }
@@ -57,18 +58,42 @@ export class BrowserBridge {
57
58
  // Fast path: everything ready
58
59
  if (health.state === 'ready')
59
60
  return;
60
- // Daemon running but no extension — wait for extension with progress
61
+ // Daemon running but no extension
61
62
  if (health.state === 'no-extension') {
62
- if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
63
- process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
64
- process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
63
+ // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
64
+ const daemonVersion = health.status?.daemonVersion;
65
+ const isStale = !daemonVersion || daemonVersion !== PKG_VERSION;
66
+ if (isStale) {
67
+ // Stale daemon — restart it so extension gets a fresh WebSocket endpoint
68
+ const reason = daemonVersion
69
+ ? `v${daemonVersion} ≠ v${PKG_VERSION}`
70
+ : `pre-version daemon, CLI is v${PKG_VERSION}`;
71
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
72
+ process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
73
+ }
74
+ const shutdownAccepted = await requestDaemonShutdown();
75
+ const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
76
+ if (!portReleased) {
77
+ // Stale daemon replacement failed — don't blindly spawn on an occupied port
78
+ throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
79
+ ' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
80
+ }
81
+ // Port released — fall through to spawn a fresh daemon
82
+ }
83
+ else {
84
+ // Same version — wait for extension to connect
85
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
86
+ process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
87
+ process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
88
+ }
89
+ if (await this._pollUntilReady(timeoutMs))
90
+ return;
91
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
92
+ 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
93
+ 'If not installed:\n' +
94
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
95
+ ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
65
96
  }
66
- if (await this._pollUntilReady(timeoutMs))
67
- return;
68
- throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
69
- ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
70
- ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
71
- ' Then run: opencli doctor', 'extension-not-connected');
72
97
  }
73
98
  // No daemon — spawn one
74
99
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -94,13 +119,25 @@ export class BrowserBridge {
94
119
  return;
95
120
  const finalHealth = await getDaemonHealth();
96
121
  if (finalHealth.state === 'no-extension') {
97
- throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
122
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
123
+ 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
124
+ 'If not installed:\n' +
98
125
  ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
99
- ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
100
- ' Then run: opencli doctor', 'extension-not-connected');
126
+ ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
101
127
  }
102
128
  throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
103
129
  }
130
+ /** Poll until daemon is fully stopped (port released). */
131
+ async _waitForDaemonStop(timeoutMs) {
132
+ const deadline = Date.now() + timeoutMs;
133
+ while (Date.now() < deadline) {
134
+ await new Promise(resolve => setTimeout(resolve, 200));
135
+ const h = await getDaemonHealth();
136
+ if (h.state === 'stopped')
137
+ return true;
138
+ }
139
+ return false;
140
+ }
104
141
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
105
142
  async _pollUntilReady(timeoutMs) {
106
143
  const deadline = Date.now() + timeoutMs;
@@ -255,6 +255,7 @@ class CDPPage extends BasePage {
255
255
  });
256
256
  this._networkCapturing = true;
257
257
  }
258
+ return true;
258
259
  }
259
260
  async readNetworkCapture() {
260
261
  // Await all in-flight body fetches so entries have responsePreview populated
@@ -295,7 +296,7 @@ class CDPPage extends BasePage {
295
296
  async tabs() {
296
297
  return [];
297
298
  }
298
- async selectTab(_index) {
299
+ async selectTab(_target) {
299
300
  // Not supported in direct CDP mode
300
301
  }
301
302
  }
@@ -6,11 +6,9 @@
6
6
  import type { BrowserSessionInfo } from '../types.js';
7
7
  export interface DaemonCommand {
8
8
  id: string;
9
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
10
- /** Target page identity (targetId). Cross-layer contract preferred over tabId. */
9
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
10
+ /** Target page identity (targetId). Cross-layer contract with the extension. */
11
11
  page?: string;
12
- /** @deprecated Legacy tab ID — use `page` (targetId) instead. */
13
- tabId?: number;
14
12
  code?: string;
15
13
  workspace?: string;
16
14
  url?: string;
@@ -34,6 +32,10 @@ export interface DaemonCommand {
34
32
  cdpParams?: Record<string, unknown>;
35
33
  /** When true, automation windows are created in the foreground */
36
34
  windowFocused?: boolean;
35
+ /** Custom idle timeout in seconds for this workspace session. Overrides the default. */
36
+ idleTimeout?: number;
37
+ /** Frame index for cross-frame operations (0-based, from 'frames' action) */
38
+ frameIndex?: number;
37
39
  }
38
40
  export interface DaemonResult {
39
41
  id: string;
@@ -47,6 +49,7 @@ export interface DaemonStatus {
47
49
  ok: boolean;
48
50
  pid: number;
49
51
  uptime: number;
52
+ daemonVersion?: string;
50
53
  extensionConnected: boolean;
51
54
  extensionVersion?: string;
52
55
  extensionCompatRange?: string;
@@ -11,7 +11,7 @@ const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
11
11
  const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
12
12
  let _idCounter = 0;
13
13
  function generateId() {
14
- return `cmd_${Date.now()}_${++_idCounter}`;
14
+ return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`;
15
15
  }
16
16
  async function requestDaemon(pathname, init) {
17
17
  const { timeout = 2000, headers, ...rest } = init ?? {};
@@ -85,6 +85,11 @@ async function sendCommandRaw(action, params) {
85
85
  });
86
86
  const result = (await res.json());
87
87
  if (!result.ok) {
88
+ const isDuplicateCommandId = res.status === 409
89
+ || (result.error ?? '').includes('Duplicate command id');
90
+ if (isDuplicateCommandId && attempt < maxRetries) {
91
+ continue;
92
+ }
88
93
  const advice = classifyBrowserError(new Error(result.error ?? ''));
89
94
  if (advice.retryable && attempt < maxRetries) {
90
95
  await sleep(advice.delayMs);
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, } from './daemon-client.js';
2
+ import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, sendCommand, } from './daemon-client.js';
3
3
  describe('daemon-client', () => {
4
4
  beforeEach(() => {
5
5
  vi.stubGlobal('fetch', vi.fn());
@@ -78,4 +78,43 @@ describe('daemon-client', () => {
78
78
  });
79
79
  await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
80
80
  });
81
+ it('sendCommand includes the current pid in generated command ids', async () => {
82
+ vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
83
+ vi.mocked(fetch).mockResolvedValue({
84
+ status: 200,
85
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
86
+ });
87
+ await expect(sendCommand('exec', { code: '1 + 1' })).resolves.toBe('ok');
88
+ await expect(sendCommand('exec', { code: '2 + 2' })).resolves.toBe('ok');
89
+ const ids = vi.mocked(fetch).mock.calls.map(([, init]) => {
90
+ const body = JSON.parse(String(init?.body));
91
+ return body.id;
92
+ });
93
+ expect(ids).toHaveLength(2);
94
+ expect(ids[0]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
95
+ expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
96
+ expect(ids[0]).not.toBe(ids[1]);
97
+ });
98
+ it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => {
99
+ vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123);
100
+ const fetchMock = vi.mocked(fetch);
101
+ fetchMock
102
+ .mockResolvedValueOnce({
103
+ ok: false,
104
+ status: 409,
105
+ json: () => Promise.resolve({ ok: false, error: 'Duplicate command id already pending; retry' }),
106
+ })
107
+ .mockResolvedValueOnce({
108
+ ok: true,
109
+ status: 200,
110
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 42 }),
111
+ });
112
+ await expect(sendCommand('exec', { code: '6 * 7' })).resolves.toBe(42);
113
+ expect(fetchMock).toHaveBeenCalledTimes(2);
114
+ const ids = fetchMock.mock.calls.map(([, init]) => {
115
+ const body = JSON.parse(String(init?.body));
116
+ return body.id;
117
+ });
118
+ expect(ids[0]).not.toBe(ids[1]);
119
+ });
81
120
  });
@@ -575,7 +575,9 @@ export function generateSnapshotJs(opts = {}) {
575
575
  const lines = [];
576
576
  const hiddenInteractives = [];
577
577
  const currentHashes = [];
578
+ const refIdentity = {};
578
579
  let iframeCount = 0;
580
+ let crossOriginIndex = 0;
579
581
 
580
582
  function walk(el, depth, parentPropagatingRect) {
581
583
  if (depth > MAX_DEPTH) return false;
@@ -709,11 +711,20 @@ export function generateSnapshotJs(opts = {}) {
709
711
  // Scroll marker
710
712
  if (isScrollable && !interactive) line += '|scroll|';
711
713
 
712
- // Interactive index + data-ref
714
+ // Interactive index + data-ref + fingerprint
713
715
  if (interactive) {
714
716
  interactiveIndex++;
715
717
  if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex);
716
718
  line += isScrollable ? '|scroll[' + interactiveIndex + ']|' : '[' + interactiveIndex + ']';
719
+ // Store fingerprint for stale-ref detection
720
+ refIdentity['' + interactiveIndex] = {
721
+ tag: tag,
722
+ role: el.getAttribute('role') || '',
723
+ text: (el.textContent || '').trim().slice(0, 30),
724
+ ariaLabel: el.getAttribute('aria-label') || '',
725
+ id: el.id || '',
726
+ testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
727
+ };
717
728
  }
718
729
 
719
730
  // Tag + attributes
@@ -747,7 +758,9 @@ export function generateSnapshotJs(opts = {}) {
747
758
  const doc = el.contentDocument;
748
759
  if (!doc || !doc.body) {
749
760
  const attrs = serializeAttrs(el);
750
- lines.push(indent + '|iframe|<iframe' + (attrs ? ' ' + attrs : '') + ' /> (cross-origin)');
761
+ const frameLabel = '[F' + crossOriginIndex + ']';
762
+ lines.push(indent + '|iframe|' + frameLabel + '<iframe' + (attrs ? ' ' + attrs : '') + ' /> (cross-origin, use: opencli browser frames + browser eval --frame <index>)');
763
+ crossOriginIndex++;
751
764
  return false;
752
765
  }
753
766
  iframeCount++;
@@ -760,7 +773,9 @@ export function generateSnapshotJs(opts = {}) {
760
773
  return has;
761
774
  } catch {
762
775
  const attrs = serializeAttrs(el);
763
- lines.push(indent + '|iframe|<iframe' + (attrs ? ' ' + attrs : '') + ' /> (blocked)');
776
+ const frameLabel = '[F' + crossOriginIndex + ']';
777
+ lines.push(indent + '|iframe|' + frameLabel + '<iframe' + (attrs ? ' ' + attrs : '') + ' /> (blocked, use: opencli browser frames + browser eval --frame <index>)');
778
+ crossOriginIndex++;
764
779
  return false;
765
780
  }
766
781
  }
@@ -797,6 +812,8 @@ export function generateSnapshotJs(opts = {}) {
797
812
 
798
813
  // Store hashes on window for next diff snapshot
799
814
  try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {}
815
+ // Store ref identity map for stale-ref detection by target resolver
816
+ try { window.__opencli_ref_identity = refIdentity; } catch {}
800
817
 
801
818
  return lines.join('\\n');
802
819
  })()
@@ -15,9 +15,12 @@ import { BasePage } from './base-page.js';
15
15
  */
16
16
  export declare class Page extends BasePage {
17
17
  private readonly workspace;
18
- constructor(workspace?: string);
18
+ private readonly _idleTimeout;
19
+ constructor(workspace?: string, idleTimeout?: number);
19
20
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
20
21
  private _page;
22
+ private _networkCaptureUnsupported;
23
+ private _networkCaptureWarned;
21
24
  /** Helper: spread workspace into command params */
22
25
  private _wsOpt;
23
26
  /** Helper: spread workspace + page identity into command params */
@@ -28,8 +31,9 @@ export declare class Page extends BasePage {
28
31
  }): Promise<void>;
29
32
  /** Get the active page identity (targetId) */
30
33
  getActivePage(): string | undefined;
31
- /** @deprecated Use getActivePage() instead */
32
- getActiveTabId(): number | undefined;
34
+ /** Bind this Page instance to a specific page identity (targetId). */
35
+ setActivePage(page?: string): void;
36
+ private _markUnsupportedNetworkCapture;
33
37
  evaluate(js: string): Promise<unknown>;
34
38
  getCookies(opts?: {
35
39
  domain?: string;
@@ -38,12 +42,14 @@ export declare class Page extends BasePage {
38
42
  /** Close the automation window in the extension */
39
43
  closeWindow(): Promise<void>;
40
44
  tabs(): Promise<unknown[]>;
41
- selectTab(index: number): Promise<void>;
45
+ newTab(url?: string): Promise<string | undefined>;
46
+ closeTab(target?: number | string): Promise<void>;
47
+ selectTab(target: number | string): Promise<void>;
42
48
  /**
43
49
  * Capture a screenshot via CDP Page.captureScreenshot.
44
50
  */
45
51
  screenshot(options?: ScreenshotOptions): Promise<string>;
46
- startNetworkCapture(pattern?: string): Promise<void>;
52
+ startNetworkCapture(pattern?: string): Promise<boolean>;
47
53
  readNetworkCapture(): Promise<unknown[]>;
48
54
  /**
49
55
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
@@ -52,6 +58,13 @@ export declare class Page extends BasePage {
52
58
  */
53
59
  setFileInput(files: string[], selector?: string): Promise<void>;
54
60
  insertText(text: string): Promise<void>;
61
+ frames(): Promise<Array<{
62
+ index: number;
63
+ frameId: string;
64
+ url: string;
65
+ name: string;
66
+ }>>;
67
+ evaluateInFrame(js: string, frameIndex: number): Promise<unknown>;
55
68
  cdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
56
69
  /** CDP native click fallback — called when JS el.click() fails */
57
70
  protected tryNativeClick(x: number, y: number): Promise<boolean>;