@agentrhq/webcmd 0.2.0 → 0.2.2

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 (78) hide show
  1. package/README.md +58 -31
  2. package/cli-manifest.json +506 -0
  3. package/clis/_shared/site-auth.js +6 -1
  4. package/clis/district/_lib.js +566 -0
  5. package/clis/district/auth.js +49 -0
  6. package/clis/district/checkout.js +278 -0
  7. package/clis/district/listings.js +158 -0
  8. package/clis/district/locations.js +211 -0
  9. package/clis/district/search.js +218 -0
  10. package/clis/district/seats.js +233 -0
  11. package/clis/district/set-location.js +82 -0
  12. package/clis/district/showtimes.js +433 -0
  13. package/clis/reddit/popular.js +12 -1
  14. package/clis/reddit/popular.test.js +12 -3
  15. package/clis/twitter/delete.js +14 -6
  16. package/clis/twitter/delete.test.js +74 -1
  17. package/clis/twitter/timeline.js +3 -1
  18. package/clis/twitter/timeline.test.js +4 -0
  19. package/dist/src/browser/bridge-readiness.test.js +1 -1
  20. package/dist/src/browser/bridge.d.ts +2 -0
  21. package/dist/src/browser/bridge.js +6 -4
  22. package/dist/src/browser/cdp.d.ts +1 -0
  23. package/dist/src/browser/daemon-client.d.ts +1 -0
  24. package/dist/src/browser/daemon-client.js +7 -2
  25. package/dist/src/browser/daemon-client.test.js +33 -30
  26. package/dist/src/browser/daemon-transport.js +3 -19
  27. package/dist/src/browser/page.d.ts +5 -1
  28. package/dist/src/browser/page.js +16 -1
  29. package/dist/src/browser/profile.d.ts +9 -0
  30. package/dist/src/browser/profile.js +18 -7
  31. package/dist/src/browser/profile.test.d.ts +1 -0
  32. package/dist/src/browser/profile.test.js +44 -0
  33. package/dist/src/browser/protocol.d.ts +3 -0
  34. package/dist/src/browser/runtime/local-cloak/actions.js +25 -10
  35. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +3 -0
  36. package/dist/src/browser/runtime/local-cloak/session-manager.js +15 -2
  37. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +145 -0
  38. package/dist/src/build-manifest.js +1 -0
  39. package/dist/src/build-manifest.test.js +34 -0
  40. package/dist/src/cli.js +133 -45
  41. package/dist/src/cli.test.js +2 -2
  42. package/dist/src/commands/daemon.test.js +13 -13
  43. package/dist/src/constants.d.ts +2 -3
  44. package/dist/src/constants.js +3 -10
  45. package/dist/src/daemon.js +1 -5
  46. package/dist/src/discovery.js +1 -0
  47. package/dist/src/doctor.js +12 -0
  48. package/dist/src/doctor.test.js +30 -3
  49. package/dist/src/engine.test.js +62 -0
  50. package/dist/src/execution.js +5 -3
  51. package/dist/src/external.js +19 -1
  52. package/dist/src/external.test.js +37 -1
  53. package/dist/src/main.js +0 -5
  54. package/dist/src/manifest-types.d.ts +2 -0
  55. package/dist/src/node-network.test.js +3 -3
  56. package/dist/src/registry.d.ts +10 -0
  57. package/dist/src/registry.js +5 -3
  58. package/dist/src/registry.test.js +25 -0
  59. package/dist/src/runtime-identity.test.js +0 -8
  60. package/dist/src/runtime.d.ts +4 -0
  61. package/dist/src/runtime.js +2 -0
  62. package/dist/src/skills.d.ts +23 -5
  63. package/dist/src/skills.js +87 -45
  64. package/dist/src/skills.test.js +80 -23
  65. package/package.json +3 -3
  66. package/skills/smart-search/SKILL.md +156 -0
  67. package/skills/smart-search/references/sources-ai.md +74 -0
  68. package/skills/smart-search/references/sources-info.md +43 -0
  69. package/skills/smart-search/references/sources-media.md +40 -0
  70. package/skills/smart-search/references/sources-other.md +32 -0
  71. package/skills/smart-search/references/sources-shopping.md +21 -0
  72. package/skills/smart-search/references/sources-social.md +36 -0
  73. package/skills/smart-search/references/sources-tech.md +38 -0
  74. package/skills/smart-search/references/sources-travel.md +26 -0
  75. package/skills/webcmd-adapter-author/SKILL.md +1 -0
  76. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  77. package/skills/webcmd-autofix/SKILL.md +8 -0
  78. package/skills/webcmd-sitemap-author/SKILL.md +1 -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,9 +17,11 @@ 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';
24
+ freshPage?: boolean;
23
25
  }): Promise<IPage>;
24
26
  close(): Promise<void>;
25
27
  private _ensureDaemon;
@@ -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, opts.freshPage);
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,12 @@ 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, freshPage?: boolean);
25
+ /** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
26
+ private _freshPagePending;
27
+ private _freshPageOpts;
24
28
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
25
29
  private _page;
26
30
  private _networkCaptureUnsupported;
@@ -40,15 +40,26 @@ 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, freshPage) {
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;
54
+ this._freshPagePending = freshPage === true;
55
+ }
56
+ /** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
57
+ _freshPagePending;
58
+ _freshPageOpts() {
59
+ if (!this._freshPagePending)
60
+ return {};
61
+ this._freshPagePending = false;
62
+ return { freshPage: true };
52
63
  }
53
64
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
54
65
  _page;
@@ -60,9 +71,11 @@ export class Page extends BasePage {
60
71
  session: this.session,
61
72
  surface: this.surface,
62
73
  ...(this.contextId && { contextId: this.contextId }),
74
+ ...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
63
75
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
64
76
  ...(this.windowMode && { windowMode: this.windowMode }),
65
77
  ...(this.siteSession && { siteSession: this.siteSession }),
78
+ ...this._freshPageOpts(),
66
79
  };
67
80
  }
68
81
  /** Helper: spread session + page identity into command params */
@@ -71,10 +84,12 @@ export class Page extends BasePage {
71
84
  session: this.session,
72
85
  surface: this.surface,
73
86
  ...(this.contextId && { contextId: this.contextId }),
87
+ ...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
74
88
  ...(this._page !== undefined && { page: this._page }),
75
89
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
76
90
  ...(this.windowMode && { windowMode: this.windowMode }),
77
91
  ...(this.siteSession && { siteSession: this.siteSession }),
92
+ ...this._freshPageOpts(),
78
93
  };
79
94
  }
80
95
  async goto(url, options) {
@@ -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 {};
@@ -0,0 +1,44 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { ENV_PREFIX } from '../brand.js';
6
+ import { profileRouteParams, resolveProfileSelection } from './profile.js';
7
+ describe('profile selection', () => {
8
+ let configDir;
9
+ beforeEach(() => {
10
+ configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-profile-test-'));
11
+ vi.stubEnv(`${ENV_PREFIX}_CONFIG_DIR`, configDir);
12
+ vi.stubEnv(`${ENV_PREFIX}_PROFILE`, '');
13
+ });
14
+ afterEach(() => {
15
+ vi.unstubAllEnvs();
16
+ fs.rmSync(configDir, { recursive: true, force: true });
17
+ });
18
+ function writeConfig(config) {
19
+ fs.writeFileSync(path.join(configDir, 'browser-profiles.json'), JSON.stringify(config));
20
+ }
21
+ it('tags an explicit profile argument as explicit and resolves aliases', () => {
22
+ writeConfig({ version: 1, aliases: { work: 'profile-work' } });
23
+ expect(resolveProfileSelection('work')).toEqual({ contextId: 'profile-work', source: 'explicit' });
24
+ });
25
+ it('tags WEBCMD_PROFILE as explicit', () => {
26
+ vi.stubEnv(`${ENV_PREFIX}_PROFILE`, 'profile-env');
27
+ expect(resolveProfileSelection()).toEqual({ contextId: 'profile-env', source: 'explicit' });
28
+ });
29
+ it('tags the persisted config default as preferred', () => {
30
+ writeConfig({ version: 1, aliases: {}, defaultContextId: 'profile-default' });
31
+ expect(resolveProfileSelection()).toEqual({ contextId: 'profile-default', source: 'preferred' });
32
+ });
33
+ it('explicit argument beats env, and env beats config default', () => {
34
+ vi.stubEnv(`${ENV_PREFIX}_PROFILE`, 'from-env');
35
+ writeConfig({ version: 1, aliases: {}, defaultContextId: 'from-config' });
36
+ expect(resolveProfileSelection('from-arg')).toEqual({ contextId: 'from-arg', source: 'explicit' });
37
+ expect(resolveProfileSelection()).toEqual({ contextId: 'from-env', source: 'explicit' });
38
+ });
39
+ it('maps explicit routes to contextId and preferred routes to preferredContextId', () => {
40
+ expect(profileRouteParams({ contextId: 'a', source: 'explicit' })).toEqual({ contextId: 'a' });
41
+ expect(profileRouteParams({ contextId: 'b', source: 'preferred' })).toEqual({ preferredContextId: 'b' });
42
+ expect(profileRouteParams(undefined)).toEqual({});
43
+ });
44
+ });
@@ -10,6 +10,8 @@ export interface BrowserRuntimeCommand {
10
10
  session?: string;
11
11
  surface?: BrowserSurface;
12
12
  siteSession?: SiteSessionMode;
13
+ /** Close any existing leased page and start on a new one (sent on the first action of a command run). */
14
+ freshPage?: boolean;
13
15
  url?: string;
14
16
  op?: string;
15
17
  index?: number;
@@ -34,6 +36,7 @@ export interface BrowserRuntimeCommand {
34
36
  idleTimeout?: number;
35
37
  frameIndex?: number;
36
38
  contextId?: string;
39
+ preferredContextId?: string;
37
40
  profileId?: string;
38
41
  }
39
42
  export interface BrowserRuntimeResult {
@@ -10,8 +10,22 @@ class CloakActionError extends Error {
10
10
  this.errorHint = errorHint;
11
11
  }
12
12
  }
13
- function commandProfileId(command) {
14
- return command.profileId ?? command.contextId ?? 'default';
13
+ function commandProfileId(manager, command) {
14
+ const requested = command.profileId ?? command.contextId;
15
+ if (requested?.trim())
16
+ return requested.trim();
17
+ const preferred = command.preferredContextId?.trim();
18
+ if (!preferred)
19
+ return 'default';
20
+ const active = manager.activeProfileIds();
21
+ if (active.includes(preferred))
22
+ return preferred;
23
+ if (active.length === 1)
24
+ return active[0];
25
+ if (active.length > 1) {
26
+ throw new CloakActionError('profile_required', `Default Cloak profile "${preferred}" is not active and multiple profiles are running; choose one with --profile.`, undefined, 'Run webcmd profile list, then update the default with webcmd profile use <name> or pass --profile <name>.');
27
+ }
28
+ return preferred;
15
29
  }
16
30
  function invalidRequest(command, error) {
17
31
  return { id: command.id, ok: false, errorCode: 'invalid_request', error };
@@ -24,11 +38,12 @@ async function resolveLease(manager, command) {
24
38
  throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`);
25
39
  }
26
40
  return manager.getPage({
27
- profileId: commandProfileId(command),
41
+ profileId: commandProfileId(manager, command),
28
42
  session: command.session,
29
43
  surface: command.surface,
30
44
  siteSession: command.siteSession,
31
45
  idleTimeout: command.idleTimeout,
46
+ freshPage: command.freshPage,
32
47
  });
33
48
  }
34
49
  function execTarget(page, frameIndex, pageId) {
@@ -93,12 +108,12 @@ export async function dispatchCloakAction(manager, command) {
93
108
  }
94
109
  case 'close-window': {
95
110
  if (command.page) {
96
- const closed = await manager.closePage({ profileId: commandProfileId(command), pageId: command.page });
111
+ const closed = await manager.closePage({ profileId: commandProfileId(manager, command), pageId: command.page });
97
112
  return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } };
98
113
  }
99
114
  else {
100
115
  await manager.release({
101
- profileId: commandProfileId(command),
116
+ profileId: commandProfileId(manager, command),
102
117
  session: command.session,
103
118
  surface: command.surface,
104
119
  });
@@ -108,12 +123,12 @@ export async function dispatchCloakAction(manager, command) {
108
123
  case 'tabs': {
109
124
  switch (command.op ?? 'list') {
110
125
  case 'list': {
111
- const tabs = await manager.listPages({ profileId: commandProfileId(command) });
126
+ const tabs = await manager.listPages({ profileId: commandProfileId(manager, command) });
112
127
  return { id: command.id, ok: true, data: tabs };
113
128
  }
114
129
  case 'new': {
115
130
  const lease = await manager.newPage({
116
- profileId: commandProfileId(command),
131
+ profileId: commandProfileId(manager, command),
117
132
  session: command.session,
118
133
  surface: command.surface,
119
134
  siteSession: command.siteSession,
@@ -123,13 +138,13 @@ export async function dispatchCloakAction(manager, command) {
123
138
  return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId };
124
139
  }
125
140
  case 'select': {
126
- const lease = await manager.selectPage({ profileId: commandProfileId(command), pageId: command.page, index: command.index });
141
+ const lease = await manager.selectPage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index });
127
142
  if (!lease)
128
143
  return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
129
144
  return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId };
130
145
  }
131
146
  case 'close': {
132
- const closed = await manager.closePage({ profileId: commandProfileId(command), pageId: command.page, index: command.index });
147
+ const closed = await manager.closePage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index });
133
148
  if (!closed)
134
149
  return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
135
150
  return { id: command.id, ok: true, data: { closed } };
@@ -197,7 +212,7 @@ export async function dispatchCloakAction(manager, command) {
197
212
  }
198
213
  {
199
214
  const lease = await manager.bindPage({
200
- profileId: commandProfileId(command),
215
+ profileId: commandProfileId(manager, command),
201
216
  session: command.session,
202
217
  surface: command.surface,
203
218
  siteSession: command.siteSession,
@@ -9,6 +9,8 @@ export interface SessionKeyInput {
9
9
  surface?: BrowserSurface;
10
10
  siteSession?: SiteSessionMode;
11
11
  idleTimeout?: number;
12
+ /** Discard the existing leased page (if any) and create a new one under the same lease. */
13
+ freshPage?: boolean;
12
14
  }
13
15
  export interface CloakPageLease {
14
16
  profileId: string;
@@ -46,6 +48,7 @@ export declare class CloakSessionManager {
46
48
  pending: number;
47
49
  lastSeenAt: number;
48
50
  }[];
51
+ activeProfileIds(): string[];
49
52
  getPage(input: SessionKeyInput): Promise<CloakPageLease>;
50
53
  findPageById(pageId: string, opts?: Pick<SessionKeyInput, 'idleTimeout'>): CloakPageLease | null;
51
54
  pageIdFor(page: PlaywrightPage): string | undefined;
@@ -31,21 +31,34 @@ export class CloakSessionManager {
31
31
  lastSeenAt: runtime.lastSeenAt,
32
32
  }));
33
33
  }
34
+ activeProfileIds() {
35
+ return [...this.profiles.keys()];
36
+ }
34
37
  async getPage(input) {
35
38
  const profileId = normalizeProfileId(input.profileId);
36
39
  const session = requireSession(input.session);
37
40
  const surface = normalizeSurface(input.surface);
38
41
  const leaseKey = resolveLeaseKey(input);
39
42
  const runtime = await this.getProfileRuntime(profileId);
43
+ const freshPage = input.freshPage === true;
40
44
  const existing = runtime.pages.get(leaseKey);
41
- if (existing && !pageIsClosed(existing.page)) {
45
+ if (existing && !pageIsClosed(existing.page) && !freshPage) {
42
46
  runtime.lastSeenAt = Date.now();
43
47
  existing.idleTimeout = input.idleTimeout;
44
48
  this.refreshIdleTimer(runtime, leaseKey, existing);
45
49
  return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId };
46
50
  }
51
+ if (existing && freshPage) {
52
+ runtime.pages.delete(leaseKey);
53
+ this.clearIdleTimer(existing);
54
+ if (runtime.selectedPageId === existing.pageId)
55
+ runtime.selectedPageId = undefined;
56
+ if (!pageIsClosed(existing.page))
57
+ await existing.page.close().catch(() => { });
58
+ }
47
59
  const existingPages = runtime.context.pages();
48
- const page = existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
60
+ // freshPage must never adopt a leftover tab its whole point is a clean DOM.
61
+ const page = !freshPage && existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
49
62
  const pageId = nextPageId();
50
63
  const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
51
64
  runtime.pages.set(leaseKey, entry);