@agentrhq/webcmd 0.2.5 → 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 (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -0,0 +1,106 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile, rm } from 'node:fs/promises';
3
+ import { posix as path } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { promisify } from 'node:util';
6
+ import { buildLaunchOptions, humanizeBrowser } from 'cloakbrowser';
7
+ import { chromium } from 'playwright-core';
8
+ const execFileAsync = promisify(execFile);
9
+ async function openApplication(appPath, args) {
10
+ await execFileAsync('/usr/bin/open', ['-g', '-n', appPath, '--args', ...args]);
11
+ }
12
+ export async function waitForDevToolsPort(portFile, timeoutMs = 10_000) {
13
+ const deadline = Date.now() + timeoutMs;
14
+ while (Date.now() < deadline) {
15
+ try {
16
+ const port = Number.parseInt((await readFile(portFile, 'utf8')).split('\n')[0], 10);
17
+ if (Number.isInteger(port) && port > 0)
18
+ return port;
19
+ }
20
+ catch (error) {
21
+ if (error.code !== 'ENOENT')
22
+ throw error;
23
+ }
24
+ await delay(50);
25
+ }
26
+ throw new Error('Timed out waiting for background Chromium CDP endpoint');
27
+ }
28
+ async function terminateProfile(userDataDir) {
29
+ const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,command=']);
30
+ const needle = `--user-data-dir=${userDataDir}`;
31
+ for (const line of stdout.split('\n')) {
32
+ if (!line.includes(needle))
33
+ continue;
34
+ const pid = Number.parseInt(line.trim().split(/\s+/, 1)[0], 10);
35
+ if (Number.isInteger(pid) && pid !== process.pid)
36
+ process.kill(pid, 'SIGTERM');
37
+ }
38
+ }
39
+ const defaultDependencies = {
40
+ buildLaunchOptions,
41
+ humanizeBrowser,
42
+ openApplication,
43
+ activateApplication: async (appPath) => {
44
+ await execFileAsync('/usr/bin/open', [appPath]);
45
+ },
46
+ readPort: waitForDevToolsPort,
47
+ connectOverCDP: endpoint => chromium.connectOverCDP(endpoint),
48
+ terminateProfile,
49
+ removePortFile: portFile => rm(portFile, { force: true }),
50
+ };
51
+ const contextActivators = new WeakMap();
52
+ export async function activateDarwinBackgroundContext(context) {
53
+ await contextActivators.get(context)?.();
54
+ }
55
+ function appPathFor(executablePath) {
56
+ const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
57
+ const index = executablePath.lastIndexOf(marker);
58
+ if (index < 0)
59
+ throw new Error(`Cloak Chromium executable is not inside a macOS app bundle: ${executablePath}`);
60
+ return executablePath.slice(0, index);
61
+ }
62
+ export async function launchDarwinBackgroundPersistentContext(options, deps = defaultDependencies) {
63
+ const portFile = path.join(options.userDataDir, 'DevToolsActivePort');
64
+ await deps.removePortFile(portFile);
65
+ const launchOptions = await deps.buildLaunchOptions(options);
66
+ if (!launchOptions.executablePath)
67
+ throw new Error('Cloak Chromium executable path is missing');
68
+ const appPath = appPathFor(launchOptions.executablePath);
69
+ let browser;
70
+ let launched = false;
71
+ try {
72
+ await deps.openApplication(appPath, [
73
+ ...(launchOptions.args ?? []),
74
+ '--password-store=basic',
75
+ '--use-mock-keychain',
76
+ `--user-data-dir=${options.userDataDir}`,
77
+ '--remote-debugging-address=127.0.0.1',
78
+ '--remote-debugging-port=0',
79
+ 'about:blank',
80
+ ]);
81
+ launched = true;
82
+ const port = await deps.readPort(portFile);
83
+ browser = await deps.connectOverCDP(`http://127.0.0.1:${port}`);
84
+ await deps.humanizeBrowser(browser, options);
85
+ const context = browser.contexts()[0];
86
+ if (!context)
87
+ throw new Error('Background Chromium did not expose a persistent context');
88
+ contextActivators.set(context, () => deps.activateApplication(appPath));
89
+ context.close = async () => {
90
+ try {
91
+ await browser.close();
92
+ }
93
+ finally {
94
+ contextActivators.delete(context);
95
+ await deps.terminateProfile(options.userDataDir);
96
+ }
97
+ };
98
+ return context;
99
+ }
100
+ catch (error) {
101
+ await browser?.close().catch(() => { });
102
+ if (launched)
103
+ await deps.terminateProfile(options.userDataDir).catch(() => { });
104
+ throw error;
105
+ }
106
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext, waitForDevToolsPort } from './darwin-background-launch.js';
3
+ const options = {
4
+ userDataDir: '/tmp/cloak profile',
5
+ headless: false,
6
+ humanize: true,
7
+ };
8
+ function fakeRuntime() {
9
+ const context = { close: vi.fn() };
10
+ const browser = {
11
+ contexts: vi.fn(() => [context]),
12
+ close: vi.fn().mockResolvedValue(undefined),
13
+ };
14
+ return { browser, context };
15
+ }
16
+ function fakeDependencies(browser) {
17
+ return {
18
+ buildLaunchOptions: vi.fn().mockResolvedValue({
19
+ executablePath: '/Applications/Cloak Chromium.app/Contents/MacOS/Chromium',
20
+ args: ['--fingerprint=123'],
21
+ }),
22
+ humanizeBrowser: vi.fn().mockResolvedValue(undefined),
23
+ openApplication: vi.fn().mockResolvedValue(undefined),
24
+ activateApplication: vi.fn().mockResolvedValue(undefined),
25
+ readPort: vi.fn().mockResolvedValue(43123),
26
+ connectOverCDP: vi.fn().mockResolvedValue(browser),
27
+ terminateProfile: vi.fn().mockResolvedValue(undefined),
28
+ removePortFile: vi.fn().mockResolvedValue(undefined),
29
+ };
30
+ }
31
+ describe('launchDarwinBackgroundPersistentContext', () => {
32
+ it('launches the Chromium app without activation and connects through loopback CDP', async () => {
33
+ const { browser, context } = fakeRuntime();
34
+ const deps = fakeDependencies(browser);
35
+ const result = await launchDarwinBackgroundPersistentContext(options, deps);
36
+ expect(deps.removePortFile).toHaveBeenCalledWith('/tmp/cloak profile/DevToolsActivePort');
37
+ expect(deps.openApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app', [
38
+ '--fingerprint=123',
39
+ '--password-store=basic',
40
+ '--use-mock-keychain',
41
+ '--user-data-dir=/tmp/cloak profile',
42
+ '--remote-debugging-address=127.0.0.1',
43
+ '--remote-debugging-port=0',
44
+ 'about:blank',
45
+ ]);
46
+ expect(deps.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:43123');
47
+ expect(deps.humanizeBrowser).toHaveBeenCalledWith(browser, expect.objectContaining({ humanize: true }));
48
+ expect(result).toBe(context);
49
+ await activateDarwinBackgroundContext(result);
50
+ expect(deps.activateApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app');
51
+ await result.close();
52
+ expect(browser.close).toHaveBeenCalledOnce();
53
+ expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir);
54
+ });
55
+ it('fails immediately when the CDP port file misses its deadline', async () => {
56
+ await expect(waitForDevToolsPort('/missing/DevToolsActivePort', 0)).rejects.toThrow('Timed out waiting for background Chromium CDP endpoint');
57
+ });
58
+ it('terminates the launched profile when CDP connection fails', async () => {
59
+ const { browser } = fakeRuntime();
60
+ const deps = fakeDependencies(browser);
61
+ deps.connectOverCDP.mockRejectedValueOnce(new Error('connect failed'));
62
+ await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow('connect failed');
63
+ expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir);
64
+ });
65
+ });
@@ -231,6 +231,42 @@ describe('LocalCloakRuntimeProvider', () => {
231
231
  .resolves.toMatchObject({ id: 'close', ok: true, data: { closed: created.page } });
232
232
  expect(pages[1].close).toHaveBeenCalled();
233
233
  });
234
+ it('does not bring selected tabs to front in background window mode', async () => {
235
+ const { provider, pages } = makeProviderWithFakePage();
236
+ const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'work', surface: 'browser', url: 'https://second.example/', profileId: 'default' });
237
+ await expect(provider.dispatch({
238
+ id: 'select',
239
+ action: 'tabs',
240
+ op: 'select',
241
+ session: 'work',
242
+ surface: 'browser',
243
+ page: created.page,
244
+ profileId: 'default',
245
+ windowMode: 'background',
246
+ })).resolves.toMatchObject({ id: 'select', ok: true });
247
+ expect(pages[1].bringToFront).not.toHaveBeenCalled();
248
+ });
249
+ it('does not bring bound tabs to front in background window mode', async () => {
250
+ const { provider, pages } = makeProviderWithFakePage();
251
+ const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' });
252
+ await expect(provider.dispatch({
253
+ id: 'bind',
254
+ action: 'bind',
255
+ session: 'target',
256
+ surface: 'browser',
257
+ page: created.page,
258
+ profileId: 'default',
259
+ windowMode: 'background',
260
+ })).resolves.toMatchObject({ id: 'bind', ok: true });
261
+ expect(pages[1].bringToFront).not.toHaveBeenCalled();
262
+ });
263
+ it('brings bound tabs to front by default', async () => {
264
+ const { provider, pages } = makeProviderWithFakePage();
265
+ const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' });
266
+ await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'target', surface: 'browser', page: created.page, profileId: 'default' }))
267
+ .resolves.toMatchObject({ id: 'bind', ok: true });
268
+ expect(pages[1].bringToFront).toHaveBeenCalledOnce();
269
+ });
234
270
  it('closes a window by page identity when command.page is provided', async () => {
235
271
  const { provider, pages } = makeProviderWithFakePage();
236
272
  const first = await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' });
@@ -1,6 +1,7 @@
1
1
  import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core';
2
2
  import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
3
- import type { BrowserSurface, SiteSessionMode } from '../../protocol.js';
3
+ import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js';
4
+ import { activateDarwinBackgroundContext } from './darwin-background-launch.js';
4
5
  import { CloakNetworkCapture } from './network.js';
5
6
  export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
6
7
  export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
@@ -10,6 +11,7 @@ export interface SessionKeyInput {
10
11
  surface?: BrowserSurface;
11
12
  siteSession?: SiteSessionMode;
12
13
  idleTimeout?: number;
14
+ windowMode?: BrowserWindowMode;
13
15
  /** Discard the existing leased page (if any) and create a new one under the same lease. */
14
16
  freshPage?: boolean;
15
17
  }
@@ -34,13 +36,19 @@ export interface CloakTabInfo {
34
36
  export interface CloakSessionManagerOptions {
35
37
  baseDir?: string;
36
38
  launchPersistentContext?: LaunchPersistentContext;
39
+ launchBackgroundPersistentContext?: LaunchPersistentContext;
40
+ activateBackgroundContext?: typeof activateDarwinBackgroundContext;
37
41
  recoverLockedProfile?: RecoverLockedProfile;
42
+ platform?: NodeJS.Platform;
38
43
  }
39
44
  export declare function resolveLeaseKey(input: SessionKeyInput): string;
40
45
  export declare class CloakSessionManager {
41
46
  private readonly opts;
42
47
  readonly networkCapture: CloakNetworkCapture;
43
48
  private readonly launchPersistentContext;
49
+ private readonly launchBackgroundPersistentContext;
50
+ private readonly activateBackgroundContext;
51
+ private readonly platform;
44
52
  private readonly recoverLockedProfile;
45
53
  private readonly profiles;
46
54
  private readonly profileLaunches;
@@ -61,7 +69,7 @@ export declare class CloakSessionManager {
61
69
  newPage(input: SessionKeyInput & {
62
70
  url?: string;
63
71
  }): Promise<CloakPageLease>;
64
- selectPage(input: Pick<SessionKeyInput, 'profileId'> & {
72
+ selectPage(input: Pick<SessionKeyInput, 'profileId' | 'windowMode'> & {
65
73
  pageId?: string;
66
74
  index?: number;
67
75
  }): Promise<CloakPageLease | null>;
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
4
+ import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js';
4
5
  import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js';
5
6
  import { CloakNetworkCapture } from './network.js';
6
7
  let pageCounter = 0;
@@ -18,12 +19,18 @@ export class CloakSessionManager {
18
19
  opts;
19
20
  networkCapture = new CloakNetworkCapture();
20
21
  launchPersistentContext;
22
+ launchBackgroundPersistentContext;
23
+ activateBackgroundContext;
24
+ platform;
21
25
  recoverLockedProfile;
22
26
  profiles = new Map();
23
27
  profileLaunches = new Map();
24
28
  constructor(opts = {}) {
25
29
  this.opts = opts;
26
30
  this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext;
31
+ this.launchBackgroundPersistentContext = opts.launchBackgroundPersistentContext ?? launchDarwinBackgroundPersistentContext;
32
+ this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext;
33
+ this.platform = opts.platform ?? process.platform;
27
34
  this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile;
28
35
  }
29
36
  profileStatuses() {
@@ -43,7 +50,7 @@ export class CloakSessionManager {
43
50
  const session = requireSession(input.session);
44
51
  const surface = normalizeSurface(input.surface);
45
52
  const leaseKey = resolveLeaseKey(input);
46
- const runtime = await this.getProfileRuntime(profileId);
53
+ const runtime = await this.getProfileRuntime(profileId, input.windowMode);
47
54
  const freshPage = input.freshPage === true;
48
55
  const existing = runtime.pages.get(leaseKey);
49
56
  if (existing && !pageIsClosed(existing.page) && !freshPage) {
@@ -130,7 +137,7 @@ export class CloakSessionManager {
130
137
  const profileId = normalizeProfileId(input.profileId);
131
138
  const session = requireSession(input.session);
132
139
  const surface = normalizeSurface(input.surface);
133
- const runtime = await this.getProfileRuntime(profileId);
140
+ const runtime = await this.getProfileRuntime(profileId, input.windowMode);
134
141
  const page = await runtime.context.newPage();
135
142
  if (input.url) {
136
143
  await page.goto(input.url, { waitUntil: 'load' });
@@ -152,7 +159,10 @@ export class CloakSessionManager {
152
159
  if (!match)
153
160
  return null;
154
161
  const [leaseKey, entry] = match;
155
- await entry.page.bringToFront?.().catch(() => { });
162
+ if (input.windowMode !== 'background') {
163
+ await entry.page.bringToFront?.().catch(() => { });
164
+ await this.activateBackgroundContext(runtime.context);
165
+ }
156
166
  runtime.selectedPageId = entry.pageId;
157
167
  runtime.lastSeenAt = Date.now();
158
168
  return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId };
@@ -183,7 +193,10 @@ export class CloakSessionManager {
183
193
  entry.siteSession = input.siteSession;
184
194
  entry.idleTimeout = input.idleTimeout;
185
195
  runtime.pages.set(canonicalKey, entry);
186
- await entry.page.bringToFront?.().catch(() => { });
196
+ if (input.windowMode !== 'background') {
197
+ await entry.page.bringToFront?.().catch(() => { });
198
+ await this.activateBackgroundContext(runtime.context);
199
+ }
187
200
  this.refreshIdleTimer(runtime, canonicalKey, entry);
188
201
  runtime.selectedPageId = entry.pageId;
189
202
  runtime.lastSeenAt = Date.now();
@@ -235,14 +248,14 @@ export class CloakSessionManager {
235
248
  }
236
249
  this.profiles.clear();
237
250
  }
238
- async getProfileRuntime(profileId) {
251
+ async getProfileRuntime(profileId, windowMode) {
239
252
  const existing = this.profiles.get(profileId);
240
253
  if (existing)
241
254
  return existing;
242
255
  const pending = this.profileLaunches.get(profileId);
243
256
  if (pending)
244
257
  return pending;
245
- const launch = this.launchProfileRuntime(profileId);
258
+ const launch = this.launchProfileRuntime(profileId, windowMode);
246
259
  this.profileLaunches.set(profileId, launch);
247
260
  try {
248
261
  return await launch;
@@ -251,7 +264,7 @@ export class CloakSessionManager {
251
264
  this.profileLaunches.delete(profileId);
252
265
  }
253
266
  }
254
- async launchProfileRuntime(profileId) {
267
+ async launchProfileRuntime(profileId, windowMode) {
255
268
  const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir });
256
269
  fs.mkdirSync(userDataDir, { recursive: true });
257
270
  const launchOptions = {
@@ -259,14 +272,17 @@ export class CloakSessionManager {
259
272
  headless: false,
260
273
  humanize: true,
261
274
  };
275
+ const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background'
276
+ ? this.launchBackgroundPersistentContext
277
+ : this.launchPersistentContext;
262
278
  let context;
263
279
  try {
264
- context = await this.launchPersistentContext(launchOptions);
280
+ context = await launchPersistentContext(launchOptions);
265
281
  }
266
282
  catch (err) {
267
283
  if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir)))
268
284
  throw err;
269
- context = await this.launchPersistentContext(launchOptions);
285
+ context = await launchPersistentContext(launchOptions);
270
286
  }
271
287
  const runtime = { context, pages: new Map(), lastSeenAt: Date.now() };
272
288
  this.profiles.set(profileId, runtime);
@@ -42,6 +42,42 @@ describe('CloakSessionManager', () => {
42
42
  expect(launchPersistentContext).toHaveBeenCalledTimes(1);
43
43
  expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false });
44
44
  });
45
+ it.each([
46
+ { platform: 'darwin', windowMode: 'background', backgroundCalls: 1, normalCalls: 0 },
47
+ { platform: 'darwin', windowMode: 'foreground', backgroundCalls: 0, normalCalls: 1 },
48
+ { platform: 'linux', windowMode: 'background', backgroundCalls: 0, normalCalls: 1 },
49
+ ])('routes a cold $platform $windowMode launch', async ({ platform, windowMode, backgroundCalls, normalCalls }) => {
50
+ const launched = fakeContext();
51
+ const launchPersistentContext = vi.fn().mockResolvedValue(launched.context);
52
+ const launchBackgroundPersistentContext = vi.fn().mockResolvedValue(launched.context);
53
+ const manager = new CloakSessionManager({
54
+ baseDir: '/tmp/webcmd-test',
55
+ platform,
56
+ launchPersistentContext,
57
+ launchBackgroundPersistentContext,
58
+ });
59
+ await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', windowMode });
60
+ expect(launchBackgroundPersistentContext).toHaveBeenCalledTimes(backgroundCalls);
61
+ expect(launchPersistentContext).toHaveBeenCalledTimes(normalCalls);
62
+ });
63
+ it('reactivates a background-launched context for foreground tab selection', async () => {
64
+ const launched = fakeContext();
65
+ const activateBackgroundContext = vi.fn().mockResolvedValue(undefined);
66
+ const manager = new CloakSessionManager({
67
+ baseDir: '/tmp/webcmd-test',
68
+ platform: 'darwin',
69
+ launchBackgroundPersistentContext: vi.fn().mockResolvedValue(launched.context),
70
+ activateBackgroundContext,
71
+ });
72
+ const lease = await manager.getPage({
73
+ profileId: 'default',
74
+ session: 'work',
75
+ surface: 'browser',
76
+ windowMode: 'background',
77
+ });
78
+ await manager.selectPage({ profileId: 'default', pageId: lease.pageId, windowMode: 'foreground' });
79
+ expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context);
80
+ });
45
81
  it('coalesces concurrent persistent context launches for the same profile', async () => {
46
82
  const launched = fakeContext();
47
83
  let resolveLaunch;
@@ -77,6 +77,7 @@ export declare function resolveTargetJs(ref: string, opts?: ResolveOptions): str
77
77
  */
78
78
  export declare function boundingRectResolvedJs(opts?: {
79
79
  skipScroll?: boolean;
80
+ forClick?: boolean;
80
81
  }): string;
81
82
  /**
82
83
  * Generate JS for click that uses the unified resolver.
@@ -282,18 +282,113 @@ export function resolveTargetJs(ref, opts = {}) {
282
282
  */
283
283
  export function boundingRectResolvedJs(opts = {}) {
284
284
  const shouldScroll = opts.skipScroll ? 'false' : 'true';
285
+ const forClick = opts.forClick ? 'true' : 'false';
285
286
  return `
286
287
  (() => {
287
288
  const el = window.__resolved;
288
289
  if (!el) throw new Error('No resolved element');
289
290
  if (${shouldScroll}) el.scrollIntoView({ behavior: 'instant', block: 'center' });
290
- const rect = el.getBoundingClientRect();
291
+
292
+ const FOR_CLICK = ${forClick};
293
+ // hover()/dblClick() want the plain element centre — the retarget + hit-test
294
+ // below are click-only so those actions keep their original behaviour.
295
+ if (!FOR_CLICK) {
296
+ const r0 = el.getBoundingClientRect();
297
+ return {
298
+ x: Math.round(r0.left + r0.width / 2),
299
+ y: Math.round(r0.top + r0.height / 2),
300
+ w: Math.round(r0.width),
301
+ h: Math.round(r0.height),
302
+ visible: Math.round(r0.width) > 0 && Math.round(r0.height) > 0,
303
+ };
304
+ }
305
+
306
+ // Does this node OWN a click handler? Deliberately excludes cursor:pointer,
307
+ // which is an *inherited* CSS property — an <svg> icon inside a clickable
308
+ // <div> inherits the pointer cursor but owns no handler, so cursor can't
309
+ // decide whether a node is the real click target. See issue #2071.
310
+ const ownsClickHandler = (node) => {
311
+ if (!node || node.nodeType !== 1) return false;
312
+ const tag = node.tagName.toLowerCase();
313
+ if (['a','button','input','select','textarea','label','summary'].includes(tag)) return true;
314
+ const role = node.getAttribute && node.getAttribute('role');
315
+ if (role && ['button','link','menuitem','menuitemcheckbox','menuitemradio','tab','option','checkbox','radio','switch'].includes(role)) return true;
316
+ if (typeof node.onclick === 'function') return true;
317
+ if (node.hasAttribute && (node.hasAttribute('onclick') || node.hasAttribute('jsaction'))) return true;
318
+ try {
319
+ for (const k in node) {
320
+ if (k.charCodeAt(0) === 95 && (k.indexOf('__reactProps$') === 0 || k.indexOf('__reactEventHandlers$') === 0)) {
321
+ const p = node[k];
322
+ if (p && (p.onClick || p.onMouseDown || p.onMouseUp)) return true;
323
+ }
324
+ }
325
+ } catch (e) {}
326
+ return false;
327
+ };
328
+ // A retarget destination just needs to look clickable; here cursor:pointer
329
+ // IS a useful signal (the ancestor is where it was set).
330
+ const isClickableAncestor = (node) => {
331
+ if (ownsClickHandler(node)) return true;
332
+ try { return window.getComputedStyle(node).cursor === 'pointer'; } catch (e) { return false; }
333
+ };
334
+
335
+ // #2071: if the resolved node owns no click handler but a nearby ancestor
336
+ // is clickable, aim at that ancestor so the handler fires.
337
+ let target = el;
338
+ let retargeted = false;
339
+ if (!ownsClickHandler(el)) {
340
+ let a = el.parentElement, hops = 0;
341
+ while (a && hops < 4) {
342
+ if (isClickableAncestor(a)) { target = a; retargeted = true; break; }
343
+ a = a.parentElement; hops++;
344
+ }
345
+ }
346
+
347
+ const rect = target.getBoundingClientRect();
291
348
  const w = Math.round(rect.width);
292
349
  const h = Math.round(rect.height);
293
- const x = Math.round(rect.left + rect.width / 2);
294
- const y = Math.round(rect.top + rect.height / 2);
350
+ let x = Math.round(rect.left + rect.width / 2);
351
+ let y = Math.round(rect.top + rect.height / 2);
295
352
  const visible = w > 0 && h > 0;
296
- return { x, y, w, h, visible };
353
+
354
+ // #2076: verify the click point actually lands on the target. A trusted
355
+ // CDP click hit-tests at (x,y) and delivers the event to whatever is
356
+ // topmost there — an overlay/sibling can silently swallow it. Classify:
357
+ // 'target' — the point is the target or a light-DOM descendant.
358
+ // 'ancestor' — the point is an ANCESTOR of the target. This is the
359
+ // open-shadow-DOM case (elementFromPoint returns the shadow
360
+ // *host*, not the shadow content) and the "point sits over
361
+ // the target's own wrapper background" case. In both a CDP
362
+ // click at (x,y) still reaches the target — CDP hit-testing
363
+ // pierces shadow roots, and light-DOM clicks bubble up — so
364
+ // it is trustworthy, unlike an unrelated overlay.
365
+ // 'other' — an unrelated element covers the point (the real overlay
366
+ // bug); the caller then dispatches a direct DOM click.
367
+ const hitClass = (px, py) => {
368
+ let at = null;
369
+ try { at = document.elementFromPoint(px, py); } catch (e) { return 'none'; }
370
+ if (!at) return 'none';
371
+ if (at === target || target.contains(at)) return 'target';
372
+ if (at.contains && at.contains(target)) return 'ancestor';
373
+ return 'other';
374
+ };
375
+ let hit = visible ? hitClass(x, y) : 'none';
376
+ if (visible && hit !== 'target' && hit !== 'ancestor') {
377
+ const cands = [
378
+ [rect.left + rect.width * 0.5, rect.top + rect.height * 0.25],
379
+ [rect.left + rect.width * 0.25, rect.top + rect.height * 0.5],
380
+ [rect.left + rect.width * 0.75, rect.top + rect.height * 0.5],
381
+ [rect.left + rect.width * 0.5, rect.top + rect.height * 0.75],
382
+ [rect.left + 3, rect.top + 3],
383
+ [rect.right - 3, rect.bottom - 3],
384
+ ];
385
+ for (const c of cands) {
386
+ const px = Math.round(c[0]), py = Math.round(c[1]);
387
+ const hc = hitClass(px, py);
388
+ if (hc === 'target' || hc === 'ancestor') { x = px; y = py; hit = hc; break; }
389
+ }
390
+ }
391
+ return { x, y, w, h, visible, hit, retargeted };
297
392
  })()
298
393
  `;
299
394
  }