@agentrhq/webcmd 0.3.0 → 0.3.1

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 (181) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/chatgpt/ask.js +1 -1
  4. package/clis/chatgpt/ask.test.js +11 -0
  5. package/clis/chatgpt/commands.test.js +102 -1
  6. package/clis/chatgpt/deep-research-result.js +45 -3
  7. package/clis/chatgpt/image.js +22 -2
  8. package/clis/chatgpt/project-file-add.js +24 -1
  9. package/clis/chatgpt/utils.js +258 -52
  10. package/clis/chatgpt/utils.test.js +259 -1
  11. package/clis/claude/ask.js +21 -1
  12. package/clis/facebook/feed.js +110 -24
  13. package/clis/facebook/feed.test.js +62 -0
  14. package/clis/facebook/search.js +185 -38
  15. package/clis/facebook/search.test.js +95 -50
  16. package/clis/instagram/explore.js +30 -12
  17. package/clis/instagram/explore.test.js +77 -0
  18. package/clis/instagram/post.js +14 -1
  19. package/clis/instagram/reel.js +13 -1
  20. package/clis/linkedin/company.js +153 -0
  21. package/clis/linkedin/company.test.js +111 -0
  22. package/clis/linkedin/connections.js +135 -0
  23. package/clis/linkedin/connections.test.js +141 -0
  24. package/clis/mercury/reimbursement-draft.js +12 -1
  25. package/clis/twitter/article-evaluate.test.js +27 -0
  26. package/clis/twitter/article.js +73 -16
  27. package/clis/twitter/article.test.js +209 -0
  28. package/clis/twitter/download.js +6 -1
  29. package/clis/twitter/post.js +14 -1
  30. package/clis/twitter/profile.js +1 -1
  31. package/clis/twitter/profile.test.js +8 -0
  32. package/clis/twitter/quote.js +11 -1
  33. package/clis/twitter/reply.js +11 -1
  34. package/dist/src/browser/base-page.d.ts +26 -0
  35. package/dist/src/browser/base-page.js +23 -8
  36. package/dist/src/browser/base-page.test.js +45 -12
  37. package/dist/src/browser/command-catalog.d.ts +6 -0
  38. package/dist/src/browser/command-catalog.js +259 -0
  39. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  40. package/dist/src/browser/command-catalog.test.js +79 -0
  41. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  42. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  43. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  44. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  45. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
  46. package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
  47. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  48. package/dist/src/browser/target-resolver.d.ts +1 -0
  49. package/dist/src/browser/target-resolver.js +99 -4
  50. package/dist/src/browser/target-resolver.test.js +141 -1
  51. package/dist/src/build-manifest.d.ts +6 -0
  52. package/dist/src/build-manifest.js +18 -1
  53. package/dist/src/build-manifest.test.js +57 -1
  54. package/dist/src/builtin-command-surface.d.ts +9 -0
  55. package/dist/src/builtin-command-surface.js +16 -0
  56. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  57. package/dist/src/check-hosted-contract.test.js +56 -0
  58. package/dist/src/cli.js +58 -106
  59. package/dist/src/cli.test.js +71 -1
  60. package/dist/src/command-presentation.d.ts +95 -0
  61. package/dist/src/command-presentation.js +486 -0
  62. package/dist/src/command-presentation.test.d.ts +1 -0
  63. package/dist/src/command-presentation.test.js +97 -0
  64. package/dist/src/command-surface.d.ts +43 -0
  65. package/dist/src/command-surface.js +205 -0
  66. package/dist/src/command-surface.test.d.ts +1 -0
  67. package/dist/src/command-surface.test.js +406 -0
  68. package/dist/src/commanderAdapter.d.ts +5 -1
  69. package/dist/src/commanderAdapter.js +12 -55
  70. package/dist/src/commanderAdapter.test.js +15 -3
  71. package/dist/src/community-plugin-sync.d.ts +11 -0
  72. package/dist/src/community-plugin-sync.js +138 -0
  73. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  74. package/dist/src/community-plugin-sync.test.js +123 -0
  75. package/dist/src/completion-fast.d.ts +4 -4
  76. package/dist/src/completion-fast.js +15 -34
  77. package/dist/src/completion-shared.d.ts +4 -0
  78. package/dist/src/completion-shared.js +38 -0
  79. package/dist/src/completion.js +3 -27
  80. package/dist/src/completion.test.js +31 -2
  81. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  82. package/dist/src/docs-sync-review-cli.test.js +322 -0
  83. package/dist/src/docs-sync-review.d.ts +117 -0
  84. package/dist/src/docs-sync-review.js +475 -0
  85. package/dist/src/docs-sync-review.test.d.ts +1 -0
  86. package/dist/src/docs-sync-review.test.js +324 -0
  87. package/dist/src/errors.d.ts +12 -3
  88. package/dist/src/errors.js +11 -7
  89. package/dist/src/errors.test.js +14 -1
  90. package/dist/src/execution.d.ts +1 -2
  91. package/dist/src/execution.js +4 -45
  92. package/dist/src/generate-release-notes-cli.test.js +7 -2
  93. package/dist/src/help.d.ts +4 -0
  94. package/dist/src/help.js +50 -255
  95. package/dist/src/help.test.js +27 -1
  96. package/dist/src/hosted/args.d.ts +2 -7
  97. package/dist/src/hosted/args.js +2 -99
  98. package/dist/src/hosted/args.test.js +15 -1
  99. package/dist/src/hosted/availability.d.ts +13 -0
  100. package/dist/src/hosted/availability.js +16 -0
  101. package/dist/src/hosted/availability.test.d.ts +1 -0
  102. package/dist/src/hosted/availability.test.js +180 -0
  103. package/dist/src/hosted/browser-args.d.ts +18 -0
  104. package/dist/src/hosted/browser-args.js +152 -0
  105. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  106. package/dist/src/hosted/browser-args.test.js +182 -0
  107. package/dist/src/hosted/client.d.ts +31 -2
  108. package/dist/src/hosted/client.js +441 -53
  109. package/dist/src/hosted/client.test.js +651 -32
  110. package/dist/src/hosted/config.d.ts +7 -2
  111. package/dist/src/hosted/config.js +26 -4
  112. package/dist/src/hosted/config.test.js +34 -1
  113. package/dist/src/hosted/contract.d.ts +94 -0
  114. package/dist/src/hosted/contract.js +208 -0
  115. package/dist/src/hosted/contract.test.d.ts +1 -0
  116. package/dist/src/hosted/contract.test.js +361 -0
  117. package/dist/src/hosted/credentials.d.ts +38 -0
  118. package/dist/src/hosted/credentials.js +248 -0
  119. package/dist/src/hosted/credentials.test.d.ts +1 -0
  120. package/dist/src/hosted/credentials.test.js +93 -0
  121. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  122. package/dist/src/hosted/file-contract.test.js +174 -0
  123. package/dist/src/hosted/files.d.ts +29 -0
  124. package/dist/src/hosted/files.js +296 -0
  125. package/dist/src/hosted/files.test.d.ts +1 -0
  126. package/dist/src/hosted/files.test.js +231 -0
  127. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  128. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  129. package/dist/src/hosted/manifest.d.ts +5 -0
  130. package/dist/src/hosted/manifest.js +21 -66
  131. package/dist/src/hosted/manifest.test.js +120 -2
  132. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  133. package/dist/src/hosted/output-parity.test.js +108 -0
  134. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  135. package/dist/src/hosted/root-command-surface.test.js +646 -0
  136. package/dist/src/hosted/runner.d.ts +7 -0
  137. package/dist/src/hosted/runner.js +554 -253
  138. package/dist/src/hosted/runner.test.js +1471 -53
  139. package/dist/src/hosted/setup.d.ts +3 -2
  140. package/dist/src/hosted/setup.js +45 -15
  141. package/dist/src/hosted/setup.test.js +133 -6
  142. package/dist/src/hosted/types.d.ts +101 -23
  143. package/dist/src/main.js +120 -108
  144. package/dist/src/manifest-types.d.ts +2 -0
  145. package/dist/src/output.d.ts +17 -2
  146. package/dist/src/output.js +88 -81
  147. package/dist/src/output.test.js +141 -51
  148. package/dist/src/pipeline/executor.test.js +1 -0
  149. package/dist/src/pipeline/steps/download.test.js +1 -0
  150. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  151. package/dist/src/plugin-create-cli.test.js +37 -0
  152. package/dist/src/plugin-manifest.d.ts +12 -0
  153. package/dist/src/plugin-manifest.js +16 -0
  154. package/dist/src/plugin-manifest.test.js +19 -1
  155. package/dist/src/plugin-scaffold.d.ts +4 -1
  156. package/dist/src/plugin-scaffold.js +4 -1
  157. package/dist/src/plugin-scaffold.test.js +23 -8
  158. package/dist/src/plugin.js +4 -1
  159. package/dist/src/plugin.test.js +13 -0
  160. package/dist/src/registry.d.ts +2 -0
  161. package/dist/src/release-notes.js +12 -4
  162. package/dist/src/release-notes.test.js +27 -15
  163. package/dist/src/root-command-surface.d.ts +31 -0
  164. package/dist/src/root-command-surface.js +106 -0
  165. package/dist/src/serialization.d.ts +1 -16
  166. package/dist/src/serialization.js +5 -55
  167. package/dist/src/stream-write.d.ts +12 -0
  168. package/dist/src/stream-write.js +91 -0
  169. package/dist/src/stream-write.test.d.ts +1 -0
  170. package/dist/src/stream-write.test.js +186 -0
  171. package/dist/src/types.d.ts +5 -0
  172. package/dist/src/utils.d.ts +1 -1
  173. package/dist/src/utils.js +2 -8
  174. package/dist/src/utils.test.js +50 -0
  175. package/hosted-contract.json +36748 -0
  176. package/package.json +6 -1
  177. package/scripts/check-hosted-contract.mjs +108 -0
  178. package/scripts/docs-sync-review.ts +332 -0
  179. package/scripts/sync-community-plugins.ts +14 -0
  180. package/skills/webcmd-autofix/SKILL.md +26 -37
  181. package/skills/webcmd-usage/SKILL.md +7 -0
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { JSDOM } from 'jsdom';
5
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
6
6
  import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
7
- import { __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTResponsePairCounts, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, isGenerating, navigateToProject, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDetailRows, waitForChatGPTImages, waitForChatGPTResponse } from './utils.js';
7
+ import { __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTResponsePairCounts, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, getVisibleMessages, isGenerating, navigateToProject, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDeepResearchResult, waitForChatGPTDetailRows, waitForChatGPTImages, waitForChatGPTResponse } from './utils.js';
8
8
 
9
9
  const tempDirs = [];
10
10
 
@@ -20,6 +20,7 @@ function createPageMock({ location = '', generating = [], imageUrls = [] } = {})
20
20
  let imageIndex = 0;
21
21
  return {
22
22
  wait: vi.fn().mockResolvedValue(undefined),
23
+ sleep: vi.fn().mockResolvedValue(undefined),
23
24
  goto: vi.fn().mockResolvedValue(undefined),
24
25
  evaluate: vi.fn((script) => {
25
26
  if (script === 'window.location.href') return Promise.resolve(location);
@@ -51,6 +52,7 @@ function createDomEvaluatePage(html) {
51
52
  dom,
52
53
  goto: vi.fn().mockResolvedValue(undefined),
53
54
  wait: vi.fn().mockResolvedValue(undefined),
55
+ sleep: vi.fn().mockResolvedValue(undefined),
54
56
  evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(script))),
55
57
  };
56
58
  }
@@ -188,6 +190,37 @@ function makeDeepResearchPayload(report = makeDeepResearchReport(), { conversati
188
190
  return payload;
189
191
  }
190
192
 
193
+ function makeDeepResearchProgressPayload(status = 'waiting_for_user_response_on_plan') {
194
+ return {
195
+ mapping: {
196
+ progress_node: {
197
+ message: {
198
+ metadata: {
199
+ chatgpt_sdk: {
200
+ widget_state: JSON.stringify({
201
+ status,
202
+ waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z',
203
+ plan: {
204
+ plan_id: 'plan-demo',
205
+ title: 'Research plan',
206
+ steps: [{ id: 'step-1', title: 'Collect sources', status: 'pending' }],
207
+ },
208
+ step_statuses_by_plan: { 'step-1': 'pending' },
209
+ }),
210
+ response_metadata: {
211
+ async_task_conversation_id: 'async-conversation-123',
212
+ 'openai/widgetSessionId': 'widget-session-123',
213
+ 'openai/asyncStatus': 7,
214
+ venus_message_type: 'initial_loading_message',
215
+ },
216
+ },
217
+ },
218
+ },
219
+ },
220
+ },
221
+ };
222
+ }
223
+
191
224
  describe('chatgpt deep research result extraction', () => {
192
225
  it('extracts report markdown and sources from conversation widget_state', () => {
193
226
  const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload());
@@ -251,9 +284,150 @@ describe('chatgpt deep research result extraction', () => {
251
284
  .toThrow(CommandExecutionError);
252
285
  });
253
286
 
287
+ it('extracts waiting-for-user progress from widget metadata without a report', () => {
288
+ const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchProgressPayload());
289
+
290
+ expect(result).toMatchObject({
291
+ status: 'waiting_for_user',
292
+ method: 'conversation-widget-progress',
293
+ asyncTaskConversationId: 'async-conversation-123',
294
+ widgetSessionId: 'widget-session-123',
295
+ asyncStatus: 7,
296
+ venusMessageType: 'initial_loading_message',
297
+ venusStatus: 'waiting_for_user_response_on_plan',
298
+ waitingForUserUntil: '2026-07-02T02:29:48.298274Z',
299
+ planId: 'plan-demo',
300
+ planTitle: 'Research plan',
301
+ });
302
+ expect(result.report).toBe('');
303
+ expect(result.progress.planSteps).toEqual([
304
+ { id: 'step-1', title: 'Collect sources', status: 'pending' },
305
+ ]);
306
+ });
307
+
308
+ it('ignores unrelated SDK response metadata without Deep Research identity', () => {
309
+ const payload = {
310
+ mapping: {
311
+ app_widget: {
312
+ message: {
313
+ metadata: {
314
+ chatgpt_sdk: {
315
+ response_metadata: {
316
+ 'openai/widgetSessionId': 'unrelated-widget',
317
+ 'openai/asyncStatus': 1,
318
+ },
319
+ },
320
+ },
321
+ },
322
+ },
323
+ },
324
+ };
325
+
326
+ expect(__test__.extractDeepResearchFromConversationPayload(payload)).toBeNull();
327
+ });
328
+
329
+ it('prefers the current Deep Research progress over stale actionable progress', () => {
330
+ const stale = makeDeepResearchProgressPayload().mapping.progress_node;
331
+ stale.message.create_time = 1;
332
+ const current = makeDeepResearchProgressPayload('running').mapping.progress_node;
333
+ current.message.create_time = 2;
334
+ const payload = {
335
+ current_node: 'running_node',
336
+ mapping: {
337
+ waiting_node: { ...stale, parent: null },
338
+ running_node: { ...current, parent: 'waiting_node' },
339
+ },
340
+ };
341
+
342
+ expect(__test__.extractDeepResearchFromConversationPayload(payload)).toMatchObject({
343
+ status: 'running',
344
+ venusStatus: 'running',
345
+ });
346
+ });
347
+
254
348
  it('ignores short widget previews that are not completed reports', () => {
255
349
  expect(__test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload('short preview'))).toBeNull();
256
350
  });
351
+
352
+ it('stops waiting immediately when widget state needs user input', async () => {
353
+ const page = {
354
+ getCookies: vi.fn().mockResolvedValue([]),
355
+ wait: vi.fn().mockResolvedValue(undefined),
356
+ sleep: vi.fn().mockResolvedValue(undefined),
357
+ evaluate: vi.fn((script) => {
358
+ const source = String(script);
359
+ if (source.includes("document.querySelectorAll('iframe')")) {
360
+ return Promise.resolve({
361
+ url: 'https://chatgpt.com/c/requested123',
362
+ title: 'ChatGPT',
363
+ iframes: [],
364
+ deepResearchIframe: null,
365
+ });
366
+ }
367
+ if (source.includes('Stop generating') || source.includes('Thinking')) return Promise.resolve(false);
368
+ if (source.includes('/backend-api/conversation/')) {
369
+ return Promise.resolve({
370
+ ok: true,
371
+ status: 200,
372
+ contentType: 'application/json',
373
+ text: JSON.stringify(makeDeepResearchProgressPayload()),
374
+ });
375
+ }
376
+ return Promise.resolve(undefined);
377
+ }),
378
+ };
379
+
380
+ const result = await waitForChatGPTDeepResearchResult(page, {
381
+ conversationId: 'requested123',
382
+ timeoutSeconds: 180,
383
+ stableSeconds: 3,
384
+ });
385
+
386
+ expect(result.status).toBe('waiting_for_user');
387
+ expect(result.venusStatus).toBe('waiting_for_user_response_on_plan');
388
+ expect(page.sleep).not.toHaveBeenCalled();
389
+ });
390
+
391
+ it('continues waiting through a transient missing state before progress appears', async () => {
392
+ let conversationReads = 0;
393
+ const page = {
394
+ getCookies: vi.fn().mockResolvedValue([]),
395
+ wait: vi.fn().mockResolvedValue(undefined),
396
+ sleep: vi.fn().mockResolvedValue(undefined),
397
+ evaluate: vi.fn((script) => {
398
+ const source = String(script);
399
+ if (source.includes("document.querySelectorAll('iframe')")) {
400
+ return Promise.resolve({
401
+ url: 'https://chatgpt.com/c/requested123',
402
+ title: 'ChatGPT',
403
+ iframes: [],
404
+ deepResearchIframe: null,
405
+ });
406
+ }
407
+ if (source.includes('Stop generating') || source.includes('Thinking')) return Promise.resolve(false);
408
+ if (source.includes('/backend-api/conversation/')) {
409
+ conversationReads += 1;
410
+ return Promise.resolve({
411
+ ok: true,
412
+ status: 200,
413
+ contentType: 'application/json',
414
+ text: JSON.stringify(conversationReads === 1 ? { mapping: {} } : makeDeepResearchProgressPayload()),
415
+ });
416
+ }
417
+ return Promise.resolve(undefined);
418
+ }),
419
+ };
420
+
421
+ const result = await waitForChatGPTDeepResearchResult(page, {
422
+ conversationId: 'requested123',
423
+ timeoutSeconds: 180,
424
+ stableSeconds: 3,
425
+ });
426
+
427
+ expect(result.status).toBe('waiting_for_user');
428
+ expect(page.sleep).toHaveBeenCalledWith(3);
429
+ expect(conversationReads).toBe(2);
430
+ });
257
431
  });
258
432
 
259
433
  describe('chatgpt model selection validation', () => {
@@ -662,6 +836,7 @@ describe('chatgpt detail completion state', () => {
662
836
  function createDetailPageMock({ generating = false, messages = [] } = {}) {
663
837
  return {
664
838
  wait: vi.fn().mockResolvedValue(undefined),
839
+ sleep: vi.fn().mockResolvedValue(undefined),
665
840
  evaluate: vi.fn((script) => {
666
841
  if (script.includes('Stop generating') || script.includes('Thinking')) {
667
842
  return Promise.resolve(generating);
@@ -721,6 +896,7 @@ describe('chatgpt ask response extraction boundary', () => {
721
896
  let messageIndex = 0;
722
897
  return {
723
898
  wait: vi.fn().mockResolvedValue(undefined),
899
+ sleep: vi.fn().mockResolvedValue(undefined),
724
900
  evaluate: vi.fn((script) => {
725
901
  if (script === 'window.location.href') return Promise.resolve(url);
726
902
  if (script.includes('Stop generating') || script.includes('Thinking')) {
@@ -856,6 +1032,43 @@ describe('chatgpt ask response extraction boundary', () => {
856
1032
  conversationUrl: 'https://chatgpt.com/c/demo',
857
1033
  })).rejects.toThrow(/navigated away from the target conversation/);
858
1034
  });
1035
+
1036
+ it('polls with pure sleeps instead of DOM-stable numeric waits', async () => {
1037
+ mockAdvancingClock();
1038
+ const page = createResponseWaitPage([[], [], []]);
1039
+
1040
+ await expect(waitForChatGPTResponse(page, 0, 'unmatched prompt', 4, {}))
1041
+ .rejects.toThrow(/chatgpt ask timed out/);
1042
+
1043
+ expect(page.sleep).toHaveBeenCalled();
1044
+ expect(page.wait).not.toHaveBeenCalled();
1045
+ });
1046
+
1047
+ it('uses textContent instead of layout-triggering innerText in text-only polls', async () => {
1048
+ const page = createDomEvaluatePage(`
1049
+ <article data-testid="conversation-turn-2">
1050
+ <div data-message-author-role="assistant">
1051
+ <div class="markdown">done answer</div>
1052
+ </div>
1053
+ </article>
1054
+ `);
1055
+ for (const node of page.dom.window.document.querySelectorAll('*')) {
1056
+ node.getBoundingClientRect = () => ({ width: 120, height: 36 });
1057
+ }
1058
+ Object.defineProperty(page.dom.window.HTMLElement.prototype, 'innerText', {
1059
+ configurable: true,
1060
+ get() {
1061
+ throw new Error('innerText should not be read during text-only polls');
1062
+ },
1063
+ });
1064
+
1065
+ await expect(getVisibleMessages(page, { textOnly: true })).resolves.toEqual([{
1066
+ Index: 1,
1067
+ Role: 'Assistant',
1068
+ Text: 'done answer',
1069
+ Html: '',
1070
+ }]);
1071
+ });
859
1072
  });
860
1073
 
861
1074
  describe('chatgpt generation state', () => {
@@ -869,6 +1082,47 @@ describe('chatgpt generation state', () => {
869
1082
 
870
1083
  await expect(isGenerating(page)).resolves.toBe(true);
871
1084
  });
1085
+
1086
+ it('detects a plain-text Thinking pill in the latest message turn', async () => {
1087
+ const page = createDomEvaluatePage(`
1088
+ <div data-message-author-role="assistant">
1089
+ <div>partial answer</div>
1090
+ <div>Thinking</div>
1091
+ </div>
1092
+ `);
1093
+
1094
+ await expect(isGenerating(page)).resolves.toBe(true);
1095
+ });
1096
+
1097
+ it('stays idle when Thinking is only the selected composer model', async () => {
1098
+ const page = createDomEvaluatePage(`
1099
+ <article data-testid="conversation-turn-2">
1100
+ <div data-message-author-role="assistant"><div class="markdown">done answer</div></div>
1101
+ </article>
1102
+ <form>
1103
+ <div id="prompt-textarea" contenteditable="true"></div>
1104
+ <button aria-label="Thinking">Thinking</button>
1105
+ </form>
1106
+ `);
1107
+
1108
+ await expect(isGenerating(page)).resolves.toBe(false);
1109
+ });
1110
+
1111
+ it('ignores finished answer text that merely mentions Thinking', async () => {
1112
+ const page = createDomEvaluatePage(`
1113
+ <article data-testid="conversation-turn-2">
1114
+ <div data-message-author-role="assistant">
1115
+ <div class="markdown"><p>The answer discusses Thinking mode.</p></div>
1116
+ </div>
1117
+ </article>
1118
+ `);
1119
+ Object.defineProperty(page.dom.window.document.body, 'innerText', {
1120
+ configurable: true,
1121
+ get: () => 'The answer discusses Thinking mode.',
1122
+ });
1123
+
1124
+ await expect(isGenerating(page)).resolves.toBe(false);
1125
+ });
872
1126
  });
873
1127
 
874
1128
  describe('chatgpt current model detection', () => {
@@ -1203,6 +1457,7 @@ describe('chatgpt image upload helper', () => {
1203
1457
  const page = {
1204
1458
  setFileInput: vi.fn().mockResolvedValue(undefined),
1205
1459
  wait: vi.fn().mockResolvedValue(undefined),
1460
+ sleep: vi.fn().mockResolvedValue(undefined),
1206
1461
  evaluate: vi.fn().mockResolvedValue(true),
1207
1462
  };
1208
1463
 
@@ -1254,6 +1509,7 @@ describe('chatgpt image upload helper', () => {
1254
1509
  const page = {
1255
1510
  setFileInput: vi.fn().mockRejectedValue(new Error('No element found')),
1256
1511
  wait: vi.fn().mockResolvedValue(undefined),
1512
+ sleep: vi.fn().mockResolvedValue(undefined),
1257
1513
  evaluate: vi.fn((script) => {
1258
1514
  if (String(script).includes('new DataTransfer()')) {
1259
1515
  return Promise.resolve({ ok: true });
@@ -1289,6 +1545,7 @@ describe('chatgpt image upload helper', () => {
1289
1545
  const page = {
1290
1546
  setFileInput: vi.fn().mockResolvedValue(undefined),
1291
1547
  wait: vi.fn().mockResolvedValue(undefined),
1548
+ sleep: vi.fn().mockResolvedValue(undefined),
1292
1549
  evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
1293
1550
  };
1294
1551
 
@@ -1319,6 +1576,7 @@ describe('chatgpt image upload helper', () => {
1319
1576
  const page = {
1320
1577
  setFileInput: vi.fn().mockResolvedValue(undefined),
1321
1578
  wait: vi.fn().mockResolvedValue(undefined),
1579
+ sleep: vi.fn().mockResolvedValue(undefined),
1322
1580
  evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
1323
1581
  };
1324
1582
 
@@ -23,7 +23,27 @@ export const askCommand = cli({
23
23
  { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
24
24
  { name: 'model', default: 'sonnet', choices: ['sonnet', 'opus', 'haiku'], help: 'Model to use: sonnet, opus, or haiku' },
25
25
  { name: 'think', type: 'boolean', default: false, help: 'Enable Adaptive thinking' },
26
- { name: 'file', help: 'Attach a file (image, PDF, text) with the prompt' },
26
+ {
27
+ name: 'file',
28
+ help: 'Attach a file (image, PDF, text) with the prompt',
29
+ file: {
30
+ direction: 'input',
31
+ pathKind: 'file',
32
+ multiple: false,
33
+ contentTypes: [
34
+ 'application/pdf',
35
+ 'text/plain',
36
+ 'text/markdown',
37
+ 'text/csv',
38
+ 'application/json',
39
+ 'image/jpeg',
40
+ 'image/png',
41
+ 'image/gif',
42
+ 'image/webp',
43
+ ],
44
+ maxBytes: 26_214_400,
45
+ },
46
+ },
27
47
  ],
28
48
  columns: ['response'],
29
49
 
@@ -24,7 +24,26 @@ function buildFeedExtractScript(limit) {
24
24
  const limit = ${limit};
25
25
 
26
26
  function clean(value) {
27
- return String(value || '').replace(/\\s+/g, ' ').trim();
27
+ // Strip zero-width / bidi control chars first: Facebook injects them into
28
+ // decoy nodes to poison scrapers. See issue #2089.
29
+ return String(value || '')
30
+ .replace(/[\\u200b-\\u200f\\u202a-\\u202e\\u2060\\ufeff]/g, '')
31
+ .replace(/\\s+/g, ' ')
32
+ .trim();
33
+ }
34
+
35
+ // FB anti-scrape decoys: long spaceless digit runs, spaced single-char
36
+ // strings ("a b c d e"), and hidden-domain .com spam. Real author/content
37
+ // text never looks like this.
38
+ function isDecoyText(text) {
39
+ if (!text) return true;
40
+ if (/^\\d{8,}$/.test(text)) return true;
41
+ if (/^(?:\\S ){4,}\\S$/.test(text) && text.replace(/\\s/g, '').length <= 12) return true;
42
+ return false;
43
+ }
44
+
45
+ function isReelsOrCarouselChrome(text) {
46
+ return /^(Reels|Reels and short videos|People you may know)/i.test(text);
28
47
  }
29
48
 
30
49
  function textOf(el) {
@@ -39,36 +58,35 @@ function buildFeedExtractScript(limit) {
39
58
  const path = window.location && window.location.pathname ? window.location.pathname : '';
40
59
  const body = textOf(document.body);
41
60
  return /^\\/(login|checkpoint)(\\/|$|\\.php)/.test(path)
42
- || /^(Log in to Facebook|FacebookLog in|Log in Facebook)/i.test(body)
61
+ || /^(Log in to Facebook|Facebook Log in|Log in Facebook)/i.test(body)
43
62
  || /You must log in to continue/i.test(body);
44
63
  }
45
64
 
46
65
  function isExplicitEmptyFeed() {
47
66
  const body = textOf(document.body);
48
- return /No posts available|Nothing to show|No posts available|No more posts|No posts yet/i.test(body);
67
+ return /No posts available|Nothing to show|No more posts|No posts yet/i.test(body);
49
68
  }
50
69
 
51
70
  function isSuggestionOrChrome(text) {
52
- return /^(People you may know|People You May Know|People you may know?|People you may know?)/i.test(text)
53
- || /^(Suggested for you|Suggested Groups|Suggested groups|Suggested content)/i.test(text);
71
+ return /^(People you may know|People You May Know)/i.test(text)
72
+ || /^(Suggested for you|Suggested Groups|Suggested content)/i.test(text);
54
73
  }
55
74
 
56
75
  function isSponsored(text) {
57
- return /(^|\\s)(Sponsored|Sponsored|Advertisement)(\\s|$)/i.test(text);
76
+ return /(^|\\s)(Sponsored|Advertisement)(\\s|$)/i.test(text);
58
77
  }
59
78
 
60
79
  function isActionText(text) {
61
- return /^(Like|Comment|Share|Send|Follow|Like|Comment|Share|Send|Follow)$/i.test(text);
80
+ return /^(Like|Comment|Share|Send|Follow)$/i.test(text);
62
81
  }
63
82
 
64
83
  function isMetricText(text) {
65
84
  return /^(All:|All reactions:)/i.test(text)
66
85
  || /\\b(likes?|reactions?|comments?|shares?)\\b/i.test(text)
67
- || /(comments|shares)$/.test(text);
68
86
  }
69
87
 
70
88
  function isTimestampText(text) {
71
- return /^(\\d+\\s*(s|m|h|d|w|mo|yr|min|sec|second|minute|hour|day|week|month|year)s?|Just now|Yesterday|just now|yesterday|\\d+hours|\\d+days)(\\s*[\u00b7•.])?$/i.test(text);
89
+ return /^(\\d+\\s*(s|m|h|d|w|mo|yr|min|sec|second|minute|hour|day|week|month|year)s?|Just now|Yesterday)(\\s*[·•.])?$/i.test(text);
72
90
  }
73
91
 
74
92
  function postUrlFrom(root) {
@@ -86,9 +104,9 @@ function buildFeedExtractScript(limit) {
86
104
  const kinds = new Set();
87
105
  for (const el of root.querySelectorAll('[aria-label]')) {
88
106
  const label = labelOf(el);
89
- if (/^(Like|Like)$/i.test(label)) kinds.add('like');
90
- if (/^(Comment|Comment)$/i.test(label)) kinds.add('comment');
91
- if (/^(Share|Share)$/i.test(label)) kinds.add('share');
107
+ if (/^(Like)$/i.test(label)) kinds.add('like');
108
+ if (/^(Comment)$/i.test(label)) kinds.add('comment');
109
+ if (/^(Share)$/i.test(label)) kinds.add('share');
92
110
  }
93
111
  return kinds;
94
112
  }
@@ -116,6 +134,8 @@ function buildFeedExtractScript(limit) {
116
134
  && !isActionText(text)
117
135
  && !isMetricText(text)
118
136
  && !isTimestampText(text)
137
+ && !isDecoyText(text)
138
+ && !/^[\\d\\s.,-]+$/.test(text) // reject all-digit decoy names, but keep "Class of 2024" (#2089)
119
139
  && !/\\/groups\\/|\\/watch\\/|\\/reel\\/|\\/events\\/|\\/friends\\//i.test(href)) {
120
140
  return text;
121
141
  }
@@ -129,7 +149,8 @@ function buildFeedExtractScript(limit) {
129
149
  if (text.length <= 10) return false;
130
150
  if (isSuggestionOrChrome(text) || isSponsored(text)) return false;
131
151
  if (isActionText(text) || isMetricText(text) || isTimestampText(text)) return false;
132
- if (/^(See more|See more|More)$/i.test(text)) return false;
152
+ if (isDecoyText(text) || isReelsOrCarouselChrome(text)) return false;
153
+ if (/^(See more|More)$/i.test(text)) return false;
133
154
  return true;
134
155
  });
135
156
  }
@@ -147,13 +168,11 @@ function buildFeedExtractScript(limit) {
147
168
  if (!author && !content) return null;
148
169
  if (!content && !postUrl && kinds.size < 2) return null;
149
170
 
150
- const likesMatch = fullText.match(/All reactions:\\s*(\\d[\\d,.\\slocalized textKMk]*)/)
171
+ const likesMatch = fullText.match(/All reactions:\\s*(\\d[\\d,.\\sKMk]*)/)
151
172
  || fullText.match(/All:\\s*(\\d[\\d,.KMk]*)/)
152
173
  || fullText.match(/(\\d[\\d,.KMk]*)\\s*(?:likes?|reactions?)/i);
153
- const commentsMatch = fullText.match(/([\\d,.]+\\s*[localized text]?)\\s*comments/)
154
- || fullText.match(/(\\d[\\d,.KMk]*)\\s*comments?/i);
155
- const sharesMatch = fullText.match(/([\\d,.]+\\s*[localized text]?)\\s*shares/)
156
- || fullText.match(/(\\d[\\d,.KMk]*)\\s*shares?/i);
174
+ const commentsMatch = fullText.match(/(\\d[\\d,.KMk]*)\\s*comments?/i);
175
+ const sharesMatch = fullText.match(/(\\d[\\d,.KMk]*)\\s*shares?/i);
157
176
 
158
177
  return {
159
178
  index,
@@ -170,10 +189,51 @@ function buildFeedExtractScript(limit) {
170
189
  .filter((el) => textOf(el).length > 30);
171
190
  }
172
191
 
192
+ // Modern Facebook no longer wraps posts in [role="article"] nor exposes the
193
+ // Like/Comment/Share aria-labels the fallback keyed on. Every post still
194
+ // carries one "Actions for this post" control (the ⋯ menu). Anchor on it and
195
+ // walk up to the HIGHEST ancestor that still contains exactly one such
196
+ // control — that bounds the node to a single post. See issue #2089.
197
+ function actionMenuAnchors() {
198
+ // Post menus only — NOT "Actions for this comment", so the anchor set,
199
+ // countMenus, and loadFeedPosts all key on the same thing (#2089).
200
+ return Array.from(document.querySelectorAll('[aria-label]')).filter((el) =>
201
+ /^(Actions for this post)$/i.test(labelOf(el)));
202
+ }
203
+
204
+ function actionAnchoredContainers() {
205
+ const menus = actionMenuAnchors();
206
+ const seen = new WeakSet();
207
+ const containers = [];
208
+ const countMenus = (node) => {
209
+ let n = 0;
210
+ for (const el of node.querySelectorAll('[aria-label]')) {
211
+ if (/^(Actions for this post)$/i.test(labelOf(el))) n += 1;
212
+ }
213
+ return n;
214
+ };
215
+ const LANDMARK_ROLES = new Set(['main', 'feed', 'banner', 'navigation', 'complementary', 'contentinfo', 'region']);
216
+ for (const menu of menus) {
217
+ let node = menu.parentElement;
218
+ let best = null;
219
+ for (let depth = 0; depth < 22 && node && node !== document.body && node !== document.documentElement; depth += 1, node = node.parentElement) {
220
+ // Never let a single-post page climb the container up to a page
221
+ // landmark (role=main/feed/...) and emit the whole region as a post.
222
+ if (LANDMARK_ROLES.has(node.getAttribute('role') || '')) break;
223
+ if (countMenus(node) === 1) best = node; else break;
224
+ }
225
+ if (best && !seen.has(best) && textOf(best).length > 30) {
226
+ seen.add(best);
227
+ containers.push(best);
228
+ }
229
+ }
230
+ return containers;
231
+ }
232
+
173
233
  function fallbackContainers() {
174
234
  const main = document.querySelector('[role="main"]');
175
235
  if (!main) return [];
176
- const buttons = Array.from(main.querySelectorAll('[aria-label="Like"], [aria-label="Like"], [aria-label="Comment"], [aria-label="Comment"], [aria-label="Share"], [aria-label="Share"]'));
236
+ const buttons = Array.from(main.querySelectorAll('[aria-label="Like"], [aria-label="Comment"], [aria-label="Share"]'));
177
237
  const seen = new WeakSet();
178
238
  const containers = [];
179
239
  for (const button of buttons) {
@@ -210,7 +270,8 @@ function buildFeedExtractScript(limit) {
210
270
  if (isAuthPage()) return { status: 'auth', rows: [], diagnostics: {} };
211
271
 
212
272
  const primary = primaryContainers();
213
- const combined = dedupe([...primary, ...fallbackContainers()]);
273
+ const actionAnchored = actionAnchoredContainers();
274
+ const combined = dedupe([...primary, ...actionAnchored, ...fallbackContainers()]);
214
275
  const rows = [];
215
276
  for (const container of combined) {
216
277
  const row = extractPost(container, rows.length + 1);
@@ -224,13 +285,36 @@ function buildFeedExtractScript(limit) {
224
285
  diagnostics: {
225
286
  articleCount: document.querySelectorAll('[role="article"]').length,
226
287
  primaryCount: primary.length,
227
- fallbackActionCount: document.querySelectorAll('[role="main"] [aria-label="Like"], [role="main"] [aria-label="Like"], [role="main"] [aria-label="Comment"], [role="main"] [aria-label="Comment"]').length,
288
+ actionMenuCount: actionMenuAnchors().length,
289
+ fallbackActionCount: document.querySelectorAll('[role="main"] [aria-label="Like"], [role="main"] [aria-label="Comment"]').length,
228
290
  mainTextLength: textOf(document.querySelector('[role="main"]')).length,
229
291
  },
230
292
  };
231
293
  })()`;
232
294
  }
233
295
 
296
+ // Facebook streams feed posts in lazily as you scroll, so a single extraction
297
+ // off the initial viewport under-returns. Scroll a bounded number of times
298
+ // until enough post action-menus are present (or we stop growing). See #2089.
299
+ async function loadFeedPosts(page, limit) {
300
+ const scrollStep = `(async () => {
301
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
302
+ window.scrollTo(0, document.body.scrollHeight);
303
+ await sleep(800);
304
+ return document.querySelectorAll('[aria-label]').length
305
+ ? document.querySelectorAll('[role="article"]').length
306
+ + Array.from(document.querySelectorAll('[aria-label]')).filter((el) => /^(Actions for this post)$/i.test((el.getAttribute('aria-label') || '').trim())).length
307
+ : 0;
308
+ })()`;
309
+ let prev = -1;
310
+ for (let i = 0; i < 8; i += 1) {
311
+ let count = 0;
312
+ try { count = Number(unwrapBrowserResult(await page.evaluate(scrollStep))) || 0; } catch { break; }
313
+ if (count >= limit || count === prev) break;
314
+ prev = count;
315
+ }
316
+ }
317
+
234
318
  async function getFacebookFeed(page, kwargs) {
235
319
  const limit = requireLimit(kwargs.limit ?? 10);
236
320
  try {
@@ -238,10 +322,12 @@ async function getFacebookFeed(page, kwargs) {
238
322
  } catch (err) {
239
323
  throw new CommandExecutionError(
240
324
  `Failed to navigate to facebook feed: ${err instanceof Error ? err.message : err}`,
241
- 'Check that facebook.com is reachable and the browser extension is connected.',
325
+ 'Check that facebook.com is reachable and the Cloak browser session is running.',
242
326
  );
243
327
  }
244
328
 
329
+ await loadFeedPosts(page, limit);
330
+
245
331
  let payload;
246
332
  try {
247
333
  payload = unwrapBrowserResult(await page.evaluate(buildFeedExtractScript(limit)));
@@ -257,7 +343,7 @@ async function getFacebookFeed(page, kwargs) {
257
343
  }
258
344
 
259
345
  if (payload.status === 'auth') {
260
- throw new AuthRequiredError('www.facebook.com', 'Open Chrome and log in to Facebook before retrying.');
346
+ throw new AuthRequiredError('www.facebook.com', 'Log in to Facebook in the active Cloak browser session before retrying.');
261
347
  }
262
348
 
263
349
  if (payload.rows.length > 0) {
@@ -269,7 +355,7 @@ async function getFacebookFeed(page, kwargs) {
269
355
  }
270
356
 
271
357
  const diagnostics = payload.diagnostics || {};
272
- if (diagnostics.articleCount || diagnostics.fallbackActionCount || diagnostics.mainTextLength > 200) {
358
+ if (diagnostics.articleCount || diagnostics.actionMenuCount || diagnostics.fallbackActionCount || diagnostics.mainTextLength > 200) {
273
359
  throw new CommandExecutionError(
274
360
  'facebook feed page rendered but no feed rows could be extracted',
275
361
  `Diagnostics: articles=${diagnostics.articleCount || 0}, actions=${diagnostics.fallbackActionCount || 0}, mainTextLength=${diagnostics.mainTextLength || 0}.`,