@agentrhq/webcmd 0.2.0 → 0.2.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 (42) hide show
  1. package/README.md +23 -11
  2. package/cli-manifest.json +2 -0
  3. package/clis/twitter/delete.js +14 -6
  4. package/clis/twitter/delete.test.js +74 -1
  5. package/clis/twitter/timeline.js +3 -1
  6. package/clis/twitter/timeline.test.js +4 -0
  7. package/dist/src/browser/bridge-readiness.test.js +1 -1
  8. package/dist/src/browser/bridge.d.ts +1 -0
  9. package/dist/src/browser/bridge.js +6 -4
  10. package/dist/src/browser/cdp.d.ts +1 -0
  11. package/dist/src/browser/daemon-client.d.ts +1 -0
  12. package/dist/src/browser/daemon-client.js +7 -2
  13. package/dist/src/browser/daemon-client.test.js +33 -30
  14. package/dist/src/browser/daemon-transport.js +3 -19
  15. package/dist/src/browser/page.d.ts +2 -1
  16. package/dist/src/browser/page.js +5 -1
  17. package/dist/src/browser/profile.d.ts +9 -0
  18. package/dist/src/browser/profile.js +18 -7
  19. package/dist/src/browser/profile.test.d.ts +1 -0
  20. package/dist/src/browser/profile.test.js +44 -0
  21. package/dist/src/browser/protocol.d.ts +1 -0
  22. package/dist/src/browser/runtime/local-cloak/actions.js +24 -10
  23. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +1 -0
  24. package/dist/src/browser/runtime/local-cloak/session-manager.js +3 -0
  25. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +87 -0
  26. package/dist/src/cli.js +22 -17
  27. package/dist/src/cli.test.js +2 -2
  28. package/dist/src/commands/daemon.test.js +13 -13
  29. package/dist/src/constants.d.ts +2 -3
  30. package/dist/src/constants.js +3 -10
  31. package/dist/src/daemon.js +1 -5
  32. package/dist/src/doctor.js +12 -0
  33. package/dist/src/doctor.test.js +30 -3
  34. package/dist/src/execution.js +5 -3
  35. package/dist/src/external.js +19 -1
  36. package/dist/src/external.test.js +37 -1
  37. package/dist/src/main.js +0 -5
  38. package/dist/src/node-network.test.js +3 -3
  39. package/dist/src/runtime-identity.test.js +0 -8
  40. package/dist/src/runtime.d.ts +2 -0
  41. package/dist/src/runtime.js +1 -0
  42. package/package.json +2 -2
package/README.md CHANGED
@@ -1,26 +1,38 @@
1
1
  <img width="2054" height="766" alt="Webcmd banner" src="https://github.com/user-attachments/assets/3e5b9702-5ffd-43bd-ab1b-2319a8cc0e2a" />
2
2
 
3
- # Webcmd
4
-
5
3
  <p align="center">
6
4
  <a href="https://www.npmjs.com/package/@agentrhq/webcmd">
7
5
  <img alt="NPM version" src="https://img.shields.io/npm/v/@agentrhq/webcmd.svg?style=for-the-badge&color=1E88E5&labelColor=000000">
8
6
  </a>
7
+ <a href="https://www.npmjs.com/package/@agentrhq/webcmd">
8
+ <img alt="NPM downloads" src="https://img.shields.io/npm/dt/@agentrhq/webcmd.svg?style=for-the-badge&color=1E88E5&labelColor=000000">
9
+ </a>
9
10
  <a href="https://github.com/agentrhq/webcmd/blob/main/LICENSE">
10
11
  <img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-1E88E5.svg?style=for-the-badge&labelColor=000000">
11
12
  </a>
12
- <a href="https://github.com/agentrhq/webcmd/discussions">
13
- <img alt="Join the community on GitHub" src="https://img.shields.io/badge/Join%20the%20community-1E88E5.svg?style=for-the-badge&logo=github&labelColor=000000&logoWidth=20">
13
+ <a href="https://discord.gg/9YP2C9tvMp">
14
+ <img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-7C3AED.svg?style=for-the-badge&logo=discord&logoColor=white&labelColor=000000&logoWidth=20">
15
+ </a>
16
+ <a href="https://x.com/agentrhq">
17
+ <img alt="Follow AgentR on X" src="https://img.shields.io/badge/Follow%20%40agentrhq-000000.svg?style=for-the-badge&logo=x&logoColor=white&labelColor=000000&logoWidth=20">
14
18
  </a>
15
19
  </p>
16
20
 
17
- **Turn websites, browser sessions, desktop apps, and local tools into deterministic command-line surfaces for people and AI agents.**
21
+ # Webcmd
22
+
23
+ **Self-learning browser infra for AI agents.**
24
+
25
+ WebCMD learns the navigational context of websites as agents use them, then compiles that knowledge into deterministic commands for faster, cheaper, more reliable browser automation. The goal is simple: stop making agents rediscover the same sites on every run and cut browser-agent token spend by up to 90%.
26
+
27
+ On top of vanilla browser interactions, WebCMD adds 3 layers of learnings. Each layer collapses cost and variance for the layer above it.
18
28
 
19
- Webcmd gives you one command surface for three kinds of automation:
29
+ | Layer | Scenario | What Webcmd Helps With |
30
+ | --- | --- | --- |
31
+ | 1. Live browser control | The site is unfamiliar. | Use `webcmd browser` to inspect, click, type, extract, capture network calls, and complete the task in a real browser. |
32
+ | 2. Sitemap memory | The site is familiar, but the action space is not fully known. | Capture an agent-facing sitemap of observed pages, states, actions, workflows, APIs, pitfalls, and fallback paths. |
33
+ | 3. CLI authoring | The action space is known, but the path is still too variable for one fixed sequence. | Explicitly author a reusable `webcmd <site> <command>` adapter with structured output, so future agents spend tokens on the task instead of navigation. |
34
+ | 4. Custom CLI commands | The workflow is deterministic enough to stop browsing. | Extend the CLI with a tailored command so the workflow runs instantly with the least amount of tokens. |
20
35
 
21
- - **Use built-in adapters** for sites like Reddit, Hacker News, LinkedIn, Twitter/X, TikTok, Amazon, PubMed, ChatGPT, Claude, Gemini, NotebookLM, and many more.
22
- - **Let AI agents operate a real browser** with `webcmd browser <session> ...` primitives: open pages, inspect DOM snapshots, click, type, select, extract, capture network calls, and verify flows.
23
- - **Wrap local tools and desktop apps** so agents can discover and invoke `gh`, `docker`, `vercel`, Electron apps, and other command surfaces through the same `webcmd ...` entrypoint.
24
36
 
25
37
  ## Quick Start
26
38
 
@@ -139,7 +151,7 @@ webcmd <site> <command> -f json
139
151
  webcmd <site> <command> --trace retain-on-failure -f json
140
152
  ```
141
153
 
142
- Start with adapters. Fall back to `webcmd browser` only when no adapter covers the task or you are debugging/building one.
154
+ Start with adapters. Fall back to `webcmd browser` only when no adapter covers the task or you are teaching Webcmd a new site flow.
143
155
 
144
156
  ## Browser Automation
145
157
 
@@ -235,7 +247,7 @@ Use plugins for private company workflows, community adapters, or experiments th
235
247
 
236
248
  ## Writing Adapters
237
249
 
238
- When a site is not covered yet, author a reusable adapter instead of leaving an agent to click through the same browser flow every time.
250
+ When a site is not covered yet, author a reusable adapter instead of leaving an agent to spend tokens clicking through the same browser flow every time.
239
251
 
240
252
  ```bash
241
253
  webcmd browser init <site>/<command>
package/cli-manifest.json CHANGED
@@ -23638,6 +23638,8 @@
23638
23638
  "likes",
23639
23639
  "retweets",
23640
23640
  "replies",
23641
+ "quotes",
23642
+ "bookmarks",
23641
23643
  "views",
23642
23644
  "created_at",
23643
23645
  "url",
@@ -1,28 +1,36 @@
1
1
  import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
2
  import { CommandExecutionError } from '@agentrhq/webcmd/errors';
3
- import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
3
+ import { parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult } from './shared.js';
4
4
 
5
5
  function buildDeleteScript(tweetId) {
6
6
  return `(async () => {
7
7
  try {
8
8
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
9
9
  ${buildTwitterArticleScopeSource(tweetId)}
10
- const targetArticle = findTargetArticle();
10
+ let targetArticle = findTargetArticle();
11
+ for (let i = 0; i < 20 && !targetArticle; i++) {
12
+ await new Promise(r => setTimeout(r, 250));
13
+ targetArticle = findTargetArticle();
14
+ }
11
15
 
12
16
  if (!targetArticle) {
13
17
  return { ok: false, message: 'Could not find the tweet card matching the requested URL.' };
14
18
  }
15
19
 
16
- const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]'));
17
- const moreMenu = buttons.find((el) => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
20
+ const belongsToTargetArticle = (el) => el.closest('article') === targetArticle;
21
+ const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]')).filter(belongsToTargetArticle);
22
+ const moreMenu = Array.from(targetArticle.querySelectorAll('[data-testid="caret"]')).filter(belongsToTargetArticle).find(visible)
23
+ || buttons.find((el) => visible(el) && /^More/.test((el.getAttribute('aria-label') || '').trim()));
18
24
  if (!moreMenu) {
19
25
  return { ok: false, message: 'Could not find the "More" context menu on the matched tweet. Are you sure you are logged in and looking at a valid tweet?' };
20
26
  }
21
27
 
28
+ const beforeMenuItems = new Set(document.querySelectorAll('[role="menuitem"]'));
22
29
  moreMenu.click();
23
30
  await new Promise(r => setTimeout(r, 1000));
24
31
 
25
- const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
32
+ const items = Array.from(document.querySelectorAll('[role="menuitem"]'))
33
+ .filter((item) => visible(item) && !beforeMenuItems.has(item));
26
34
  const deleteBtn = items.find((item) => {
27
35
  const text = (item.textContent || '').trim();
28
36
  return text.includes('Delete') && !text.includes('List');
@@ -69,7 +77,7 @@ cli({
69
77
  const target = parseTweetUrl(kwargs.url);
70
78
  await page.goto(target.url);
71
79
  await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
72
- const result = await page.evaluate(buildDeleteScript(target.id));
80
+ const result = unwrapBrowserResult(await page.evaluate(buildDeleteScript(target.id)));
73
81
  if (result.ok) {
74
82
  // Wait for the deletion request to be processed
75
83
  await page.wait(2);
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
2
3
  import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
3
4
  import { getRegistry } from '@agentrhq/webcmd/registry';
4
- import './delete.js';
5
+ import { __test__ } from './delete.js';
5
6
  describe('twitter delete command', () => {
6
7
  it('targets the matched tweet article instead of the first More button on the page', async () => {
7
8
  const cmd = getRegistry().get('twitter/delete');
@@ -27,6 +28,12 @@ describe('twitter delete command', () => {
27
28
  expect(script).toContain('__twGetStatusIdFromHref');
28
29
  expect(script).toContain("document.querySelectorAll('article')");
29
30
  expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')");
31
+ expect(script).toContain("closest('article') === targetArticle");
32
+ expect(script).toContain(".filter(belongsToTargetArticle)");
33
+ expect(script).toContain('[data-testid="caret"]');
34
+ expect(script).toContain('/^More/');
35
+ expect(script).toContain('i < 20');
36
+ expect(script).toContain('beforeMenuItems');
30
37
  // Substring match must NOT appear — exact-id match only.
31
38
  expect(script).not.toContain("'/status/' + tweetId");
32
39
  expect(result).toEqual([
@@ -58,6 +65,72 @@ describe('twitter delete command', () => {
58
65
  ]);
59
66
  expect(page.wait).toHaveBeenCalledTimes(1);
60
67
  });
68
+ it('unwraps browser runtime evaluate envelopes before checking delete success', async () => {
69
+ const cmd = getRegistry().get('twitter/delete');
70
+ expect(cmd?.func).toBeTypeOf('function');
71
+ const page = {
72
+ goto: vi.fn().mockResolvedValue(undefined),
73
+ wait: vi.fn().mockResolvedValue(undefined),
74
+ evaluate: vi.fn().mockResolvedValue({
75
+ session: 'twitter',
76
+ data: { ok: true, message: 'Tweet successfully deleted.' },
77
+ }),
78
+ };
79
+ const result = await cmd.func(page, {
80
+ url: 'https://x.com/alice/status/2040254679301718161',
81
+ });
82
+ expect(result).toEqual([
83
+ {
84
+ status: 'success',
85
+ message: 'Tweet successfully deleted.',
86
+ },
87
+ ]);
88
+ expect(page.wait).toHaveBeenNthCalledWith(2, 2);
89
+ });
90
+ it('ignores stale non-target delete menu items that existed before opening the matched tweet menu', async () => {
91
+ const dom = new JSDOM(`
92
+ <body>
93
+ <div role="menuitem" data-stale-delete>Delete</div>
94
+ <article>
95
+ <a href="https://x.com/bob/status/999">wrong tweet</a>
96
+ <button data-testid="caret" aria-label="More"></button>
97
+ </article>
98
+ <article>
99
+ <a href="https://x.com/alice/status/2040254679301718161">target tweet</a>
100
+ <button data-testid="caret" aria-label="More" data-target-caret></button>
101
+ </article>
102
+ </body>
103
+ `, { runScripts: 'outside-only', url: 'https://x.com/alice/status/2040254679301718161' });
104
+ dom.window.setTimeout = (handler) => {
105
+ if (typeof handler === 'function') handler();
106
+ return 0;
107
+ };
108
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'getClientRects', {
109
+ configurable: true,
110
+ value() {
111
+ return [{ bottom: 1, height: 1, left: 0, right: 1, top: 0, width: 1 }];
112
+ },
113
+ });
114
+ let staleDeleteClicked = false;
115
+ let targetCaretClicked = false;
116
+ dom.window.document.querySelector('[data-stale-delete]')?.addEventListener('click', () => {
117
+ staleDeleteClicked = true;
118
+ });
119
+ dom.window.document.querySelector('[data-target-caret]')?.addEventListener('click', () => {
120
+ targetCaretClicked = true;
121
+ const item = dom.window.document.createElement('div');
122
+ item.setAttribute('role', 'menuitem');
123
+ item.textContent = 'Pin to your profile';
124
+ dom.window.document.body.appendChild(item);
125
+ });
126
+ const result = await dom.window.eval(__test__.buildDeleteScript('2040254679301718161'));
127
+ expect(targetCaretClicked).toBe(true);
128
+ expect(staleDeleteClicked).toBe(false);
129
+ expect(result).toEqual({
130
+ ok: false,
131
+ message: 'The matched tweet menu did not contain Delete. This tweet may not belong to you.',
132
+ });
133
+ });
61
134
  it('rejects malformed or off-domain URLs with ArgumentError before navigation', async () => {
62
135
  const cmd = getRegistry().get('twitter/delete');
63
136
  expect(cmd?.func).toBeTypeOf('function');
@@ -85,6 +85,8 @@ function extractTweet(result, seen) {
85
85
  likes: l.favorite_count || 0,
86
86
  retweets: l.retweet_count || 0,
87
87
  replies: l.reply_count || 0,
88
+ quotes: l.quote_count || 0,
89
+ bookmarks: l.bookmark_count || 0,
88
90
  views,
89
91
  created_at: l.created_at || '',
90
92
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
@@ -156,7 +158,7 @@ cli({
156
158
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of tweets to return (default 20).' },
157
159
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
158
160
  ],
159
- columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
161
+ columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'quotes', 'bookmarks', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
160
162
  func: async (page, kwargs) => {
161
163
  const limit = kwargs.limit || 20;
162
164
  const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
@@ -50,6 +50,8 @@ describe('twitter timeline helpers', () => {
50
50
  favorite_count: 3,
51
51
  retweet_count: 2,
52
52
  reply_count: 1,
53
+ quote_count: 4,
54
+ bookmark_count: 5,
53
55
  created_at: 'now',
54
56
  },
55
57
  core: {
@@ -96,6 +98,8 @@ describe('twitter timeline helpers', () => {
96
98
  likes: 3,
97
99
  retweets: 2,
98
100
  replies: 1,
101
+ quotes: 4,
102
+ bookmarks: 5,
99
103
  views: 9,
100
104
  created_at: 'now',
101
105
  url: 'https://x.com/alice/status/1',
@@ -9,7 +9,7 @@ function status(runtimeConnected) {
9
9
  runtimeName: 'fake',
10
10
  pending: 0,
11
11
  memoryMB: 0,
12
- port: 19825,
12
+ port: 9777,
13
13
  };
14
14
  }
15
15
  function readyHealth() {
@@ -17,6 +17,7 @@ export declare class BrowserBridge implements IBrowserFactory {
17
17
  session?: string;
18
18
  idleTimeout?: number;
19
19
  contextId?: string;
20
+ preferredContextId?: string;
20
21
  windowMode?: 'foreground' | 'background';
21
22
  surface?: 'browser' | 'adapter';
22
23
  siteSession?: 'ephemeral' | 'persistent';
@@ -2,7 +2,7 @@
2
2
  * Browser session manager — auto-spawns daemon and provides IPage.
3
3
  */
4
4
  import { Page } from './page.js';
5
- import { resolveProfileContextId } from './profile.js';
5
+ import { profileRouteParams, resolveProfileSelection } from './profile.js';
6
6
  import { ensureBrowserBridgeReady } from './daemon-lifecycle.js';
7
7
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
8
8
  /**
@@ -26,11 +26,13 @@ export class BrowserBridge {
26
26
  throw new Error('Session is closed');
27
27
  this._state = 'connecting';
28
28
  try {
29
- const contextId = opts.contextId ?? resolveProfileContextId();
30
- await this._ensureDaemon(opts.timeout, contextId);
29
+ const routing = opts.contextId || opts.preferredContextId
30
+ ? { contextId: opts.contextId, preferredContextId: opts.preferredContextId }
31
+ : profileRouteParams(resolveProfileSelection());
32
+ await this._ensureDaemon(opts.timeout, routing.contextId);
31
33
  if (!opts.session?.trim())
32
34
  throw new Error('Browser session is required');
33
- this._page = new Page(opts.session.trim(), opts.idleTimeout, contextId, opts.windowMode, opts.surface, opts.siteSession);
35
+ this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId);
34
36
  this._state = 'connected';
35
37
  return this._page;
36
38
  }
@@ -26,6 +26,7 @@ export declare class CDPBridge implements IBrowserFactory {
26
26
  session?: string;
27
27
  cdpEndpoint?: string;
28
28
  contextId?: string;
29
+ preferredContextId?: string;
29
30
  idleTimeout?: number;
30
31
  windowMode?: 'foreground' | 'background';
31
32
  surface?: 'browser' | 'adapter';
@@ -28,6 +28,7 @@ export declare function sendCommandFull(action: DaemonCommand['action'], params?
28
28
  }>;
29
29
  export declare function bindTab(session: string, opts?: {
30
30
  contextId?: string;
31
+ preferredContextId?: string;
31
32
  page?: string;
32
33
  index?: number;
33
34
  }): Promise<unknown>;
@@ -7,7 +7,7 @@ import { sleep } from '../utils.js';
7
7
  import { BrowserConnectError } from '../errors.js';
8
8
  import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js';
9
9
  import { classifyBrowserError } from './errors.js';
10
- import { resolveProfileContextId } from './profile.js';
10
+ import { profileRouteParams, resolveProfileSelection } from './profile.js';
11
11
  import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js';
12
12
  import { ensureBrowserBridgeReady } from './daemon-lifecycle.js';
13
13
  import { isPreDispatchError } from './bridge-readiness.js';
@@ -90,7 +90,11 @@ async function sendCommandRaw(action, params) {
90
90
  const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background'
91
91
  ? rawWindowMode
92
92
  : undefined;
93
- const contextId = params.contextId ?? resolveProfileContextId();
93
+ const routing = params.contextId || params.preferredContextId
94
+ ? { contextId: params.contextId, preferredContextId: params.preferredContextId }
95
+ : profileRouteParams(resolveProfileSelection());
96
+ const contextId = routing.contextId;
97
+ const preferredContextId = routing.preferredContextId;
94
98
  const windowMode = params.windowMode ?? envWindowMode;
95
99
  let id = generateId();
96
100
  let ensureUsed = false;
@@ -115,6 +119,7 @@ async function sendCommandRaw(action, params) {
115
119
  timeout: timeoutSeconds,
116
120
  deadlineAt,
117
121
  ...(contextId && { contextId }),
122
+ ...(preferredContextId && { preferredContextId }),
118
123
  ...(windowMode && { windowMode }),
119
124
  };
120
125
  try {
@@ -22,7 +22,7 @@ describe('daemon-client', () => {
22
22
  runtimeVersion: '1.2.3',
23
23
  pending: 0,
24
24
  memoryMB: 32,
25
- port: 19825,
25
+ port: 9777,
26
26
  };
27
27
  const fetchMock = vi.mocked(fetch);
28
28
  fetchMock.mockResolvedValue({
@@ -60,7 +60,7 @@ describe('daemon-client', () => {
60
60
  runtimeName: 'fake',
61
61
  pending: 0,
62
62
  memoryMB: 16,
63
- port: 19825,
63
+ port: 9777,
64
64
  };
65
65
  vi.mocked(fetch).mockResolvedValue({
66
66
  ok: true,
@@ -78,7 +78,7 @@ describe('daemon-client', () => {
78
78
  runtimeVersion: '1.2.3',
79
79
  pending: 0,
80
80
  memoryMB: 32,
81
- port: 19825,
81
+ port: 9777,
82
82
  };
83
83
  vi.mocked(fetch).mockResolvedValue({
84
84
  ok: true,
@@ -100,7 +100,7 @@ describe('daemon-client', () => {
100
100
  ],
101
101
  pending: 0,
102
102
  memoryMB: 32,
103
- port: 19825,
103
+ port: 9777,
104
104
  };
105
105
  vi.mocked(fetch).mockResolvedValue({
106
106
  ok: true,
@@ -119,32 +119,12 @@ describe('daemon-client', () => {
119
119
  runtimeName: 'fake',
120
120
  pending: 0,
121
121
  memoryMB: 1,
122
- port: 19825,
122
+ port: 9777,
123
123
  }),
124
124
  });
125
125
  await fetchDaemonStatus({ contextId: 'work' });
126
126
  expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work$/);
127
127
  });
128
- it('rejects WEBCMD_DAEMON_PORT so CLI and extension cannot split bridge ports', async () => {
129
- vi.resetModules();
130
- vi.stubEnv('WEBCMD_DAEMON_PORT', '19999');
131
- vi.mocked(fetch).mockResolvedValue({
132
- ok: true,
133
- json: () => Promise.resolve({
134
- ok: true,
135
- pid: 1,
136
- uptime: 0,
137
- runtimeConnected: true,
138
- runtimeName: 'fake',
139
- pending: 0,
140
- memoryMB: 1,
141
- port: 19825,
142
- }),
143
- });
144
- const freshClient = await import('./daemon-client.js');
145
- await expect(freshClient.fetchDaemonStatus()).rejects.toThrow('WEBCMD_DAEMON_PORT is no longer supported');
146
- expect(vi.mocked(fetch)).not.toHaveBeenCalled();
147
- });
148
128
  it('sendCommand includes the current pid in generated command ids', async () => {
149
129
  vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
150
130
  vi.mocked(fetch).mockResolvedValue({
@@ -162,7 +142,7 @@ describe('daemon-client', () => {
162
142
  expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
163
143
  expect(ids[0]).not.toBe(ids[1]);
164
144
  });
165
- it('sendCommand forwards WEBCMD_PROFILE as command contextId', async () => {
145
+ it('sendCommand forwards WEBCMD_PROFILE as a hard contextId requirement', async () => {
166
146
  vi.stubEnv('WEBCMD_PROFILE', 'work');
167
147
  vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
168
148
  vi.mocked(fetch).mockResolvedValue({
@@ -172,6 +152,29 @@ describe('daemon-client', () => {
172
152
  await sendCommand('exec', { code: '1 + 1' });
173
153
  const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
174
154
  expect(body.contextId).toBe('work');
155
+ expect(body.preferredContextId).toBeUndefined();
156
+ });
157
+ it('sendCommand forwards the config default as preferredContextId', async () => {
158
+ const fs = await import('node:fs');
159
+ const os = await import('node:os');
160
+ const path = await import('node:path');
161
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-dc-profile-'));
162
+ fs.writeFileSync(path.join(configDir, 'browser-profiles.json'), JSON.stringify({ version: 1, aliases: {}, defaultContextId: 'profile-default' }));
163
+ vi.stubEnv('WEBCMD_CONFIG_DIR', configDir);
164
+ vi.stubEnv('WEBCMD_PROFILE', '');
165
+ try {
166
+ vi.mocked(fetch).mockResolvedValue({
167
+ status: 200,
168
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
169
+ });
170
+ await sendCommand('exec', { code: '1 + 1' });
171
+ const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
172
+ expect(body.contextId).toBeUndefined();
173
+ expect(body.preferredContextId).toBe('profile-default');
174
+ }
175
+ finally {
176
+ fs.rmSync(configDir, { recursive: true, force: true });
177
+ }
175
178
  });
176
179
  it('sendCommand uses explicit windowMode before WEBCMD_WINDOW env fallback', async () => {
177
180
  vi.stubEnv('WEBCMD_WINDOW', 'foreground');
@@ -230,7 +233,7 @@ describe('daemon-client', () => {
230
233
  runtimeName: 'fake',
231
234
  pending: 0,
232
235
  memoryMB: 0,
233
- port: 19825,
236
+ port: 9777,
234
237
  },
235
238
  },
236
239
  spawnedProcess: null,
@@ -269,13 +272,13 @@ describe('daemon-client', () => {
269
272
  runtimeName: 'fake',
270
273
  pending: 0,
271
274
  memoryMB: 0,
272
- port: 19825,
275
+ port: 9777,
273
276
  },
274
277
  },
275
278
  spawnedProcess: null,
276
279
  });
277
280
  const refused = new TypeError('fetch failed');
278
- refused.cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:19825'), { code: 'ECONNREFUSED' });
281
+ refused.cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:9777'), { code: 'ECONNREFUSED' });
279
282
  const fetchMock = vi.mocked(fetch);
280
283
  fetchMock
281
284
  .mockRejectedValueOnce(refused)
@@ -300,7 +303,7 @@ describe('daemon-client', () => {
300
303
  runtimeName: 'fake',
301
304
  pending: 0,
302
305
  memoryMB: 0,
303
- port: 19825,
306
+ port: 9777,
304
307
  },
305
308
  },
306
309
  spawnedProcess: null,
@@ -1,20 +1,8 @@
1
- import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT, unsupportedDaemonPortEnvMessage } from '../constants.js';
1
+ import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js';
2
2
  const DAEMON_PORT = DEFAULT_DAEMON_PORT;
3
3
  const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
4
4
  const WEBCMD_HEADERS = { [DAEMON_HEADER_NAME]: '1' };
5
- class UnsupportedDaemonPortEnvError extends Error {
6
- constructor(value) {
7
- super(unsupportedDaemonPortEnvMessage(value));
8
- this.name = 'UnsupportedDaemonPortEnvError';
9
- }
10
- }
11
- function assertSupportedDaemonPortEnv() {
12
- const value = process.env.WEBCMD_DAEMON_PORT;
13
- if (value !== undefined && value !== '')
14
- throw new UnsupportedDaemonPortEnvError(value);
15
- }
16
5
  export async function requestDaemon(pathname, init) {
17
- assertSupportedDaemonPortEnv();
18
6
  const { timeout = 2000, headers, ...rest } = init ?? {};
19
7
  const controller = new AbortController();
20
8
  const timer = setTimeout(() => controller.abort(), timeout);
@@ -37,9 +25,7 @@ export async function fetchDaemonStatus(opts) {
37
25
  return null;
38
26
  return await res.json();
39
27
  }
40
- catch (err) {
41
- if (err instanceof UnsupportedDaemonPortEnvError)
42
- throw err;
28
+ catch {
43
29
  return null;
44
30
  }
45
31
  }
@@ -60,9 +46,7 @@ export async function requestDaemonShutdown(opts) {
60
46
  const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
61
47
  return res.ok;
62
48
  }
63
- catch (err) {
64
- if (err instanceof UnsupportedDaemonPortEnvError)
65
- throw err;
49
+ catch {
66
50
  return false;
67
51
  }
68
52
  }
@@ -19,8 +19,9 @@ export declare class Page extends BasePage {
19
19
  private readonly windowMode?;
20
20
  private readonly surface;
21
21
  private readonly siteSession?;
22
+ readonly preferredContextId?: string | undefined;
22
23
  private readonly _idleTimeout;
23
- constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined);
24
+ constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined, preferredContextId?: string | undefined);
24
25
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
25
26
  private _page;
26
27
  private _networkCaptureUnsupported;
@@ -40,14 +40,16 @@ export class Page extends BasePage {
40
40
  windowMode;
41
41
  surface;
42
42
  siteSession;
43
+ preferredContextId;
43
44
  _idleTimeout;
44
- constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession) {
45
+ constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession, preferredContextId) {
45
46
  super();
46
47
  this.session = session;
47
48
  this.contextId = contextId;
48
49
  this.windowMode = windowMode;
49
50
  this.surface = surface;
50
51
  this.siteSession = siteSession;
52
+ this.preferredContextId = preferredContextId;
51
53
  this._idleTimeout = idleTimeout;
52
54
  }
53
55
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
@@ -60,6 +62,7 @@ export class Page extends BasePage {
60
62
  session: this.session,
61
63
  surface: this.surface,
62
64
  ...(this.contextId && { contextId: this.contextId }),
65
+ ...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
63
66
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
64
67
  ...(this.windowMode && { windowMode: this.windowMode }),
65
68
  ...(this.siteSession && { siteSession: this.siteSession }),
@@ -71,6 +74,7 @@ export class Page extends BasePage {
71
74
  session: this.session,
72
75
  surface: this.surface,
73
76
  ...(this.contextId && { contextId: this.contextId }),
77
+ ...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
74
78
  ...(this._page !== undefined && { page: this._page }),
75
79
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
76
80
  ...(this.windowMode && { windowMode: this.windowMode }),
@@ -8,6 +8,15 @@ export declare function normalizeContextId(value: string | undefined | null): st
8
8
  export declare function emptyProfileConfig(): ProfileConfig;
9
9
  export declare function loadProfileConfig(): ProfileConfig;
10
10
  export declare function saveProfileConfig(config: ProfileConfig): void;
11
+ export type ProfileSelection = {
12
+ contextId: string;
13
+ source: 'explicit' | 'preferred';
14
+ };
15
+ export declare function resolveProfileSelection(profile?: string): ProfileSelection | undefined;
16
+ export declare function profileRouteParams(selection: ProfileSelection | undefined): {
17
+ contextId?: string;
18
+ preferredContextId?: string;
19
+ };
11
20
  export declare function resolveProfileContextId(profile?: string): string | undefined;
12
21
  export declare function aliasForContextId(config: ProfileConfig, contextId: string): string | undefined;
13
22
  export declare function renameProfile(contextId: string, alias: string): ProfileConfig;
@@ -42,14 +42,25 @@ export function saveProfileConfig(config) {
42
42
  fs.mkdirSync(path.dirname(target), { recursive: true });
43
43
  fs.writeFileSync(target, JSON.stringify(config, null, 2) + '\n', 'utf-8');
44
44
  }
45
- export function resolveProfileContextId(profile) {
45
+ export function resolveProfileSelection(profile) {
46
46
  const config = loadProfileConfig();
47
- const requested = normalizeContextId(profile)
48
- ?? normalizeContextId(process.env[`${ENV_PREFIX}_PROFILE`])
49
- ?? normalizeContextId(config.defaultContextId);
50
- if (!requested)
51
- return undefined;
52
- return config.aliases[requested] ?? requested;
47
+ const explicit = normalizeContextId(profile) ?? normalizeContextId(process.env[`${ENV_PREFIX}_PROFILE`]);
48
+ if (explicit)
49
+ return { contextId: config.aliases[explicit] ?? explicit, source: 'explicit' };
50
+ const preferred = normalizeContextId(config.defaultContextId);
51
+ if (preferred)
52
+ return { contextId: config.aliases[preferred] ?? preferred, source: 'preferred' };
53
+ return undefined;
54
+ }
55
+ export function profileRouteParams(selection) {
56
+ if (!selection)
57
+ return {};
58
+ return selection.source === 'explicit'
59
+ ? { contextId: selection.contextId }
60
+ : { preferredContextId: selection.contextId };
61
+ }
62
+ export function resolveProfileContextId(profile) {
63
+ return resolveProfileSelection(profile)?.contextId;
53
64
  }
54
65
  export function aliasForContextId(config, contextId) {
55
66
  for (const [alias, id] of Object.entries(config.aliases)) {
@@ -0,0 +1 @@
1
+ export {};