@agentrhq/webcmd 0.3.2 → 0.3.4

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 (58) hide show
  1. package/README.md +12 -7
  2. package/clis/producthunt/browse.js +9 -2
  3. package/clis/producthunt/browser-commands.test.js +66 -0
  4. package/clis/producthunt/hot.js +2 -1
  5. package/clis/producthunt/utils.js +27 -0
  6. package/clis/producthunt/utils.test.js +8 -0
  7. package/dist/src/browser/ax-snapshot.js +15 -5
  8. package/dist/src/browser/ax-snapshot.test.js +49 -0
  9. package/dist/src/browser/daemon-client.d.ts +1 -0
  10. package/dist/src/browser/daemon-client.js +25 -1
  11. package/dist/src/browser/daemon-client.test.js +86 -1
  12. package/dist/src/browser/daemon-transport.d.ts +2 -0
  13. package/dist/src/browser/protocol.d.ts +12 -1
  14. package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
  15. package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
  16. package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
  17. package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
  18. package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
  19. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
  20. package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
  21. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
  22. package/dist/src/browser/runtime/provider.d.ts +1 -0
  23. package/dist/src/cli.js +36 -12
  24. package/dist/src/cli.test.js +5 -0
  25. package/dist/src/daemon/server.js +65 -24
  26. package/dist/src/daemon/server.test.js +211 -2
  27. package/dist/src/docs-sync-review-cli.test.js +1 -1
  28. package/dist/src/errors.d.ts +5 -0
  29. package/dist/src/errors.js +23 -0
  30. package/dist/src/errors.test.js +58 -1
  31. package/dist/src/execution.js +57 -6
  32. package/dist/src/execution.test.js +426 -2
  33. package/dist/src/hosted/client.js +3 -1
  34. package/dist/src/hosted/client.test.js +62 -0
  35. package/dist/src/hosted/main-lifecycle.test.js +66 -5
  36. package/dist/src/hosted/types.d.ts +1 -0
  37. package/dist/src/main.js +4 -0
  38. package/dist/src/package-bin-process.d.ts +13 -0
  39. package/dist/src/package-bin-process.js +24 -0
  40. package/dist/src/package-bin-process.test.d.ts +1 -0
  41. package/dist/src/package-bin-process.test.js +22 -0
  42. package/dist/src/session-lease.d.ts +82 -0
  43. package/dist/src/session-lease.js +163 -0
  44. package/dist/src/session-lease.test.d.ts +1 -0
  45. package/dist/src/session-lease.test.js +217 -0
  46. package/dist/src/skills.d.ts +8 -4
  47. package/dist/src/skills.js +30 -1
  48. package/dist/src/skills.test.js +40 -12
  49. package/hosted-contract.json +1 -1
  50. package/package.json +3 -1
  51. package/scripts/check-package-bin.mjs +7 -6
  52. package/scripts/collect-ci-diagnostics.ps1 +274 -0
  53. package/scripts/docs-sync-review.ts +1 -1
  54. package/skills/smart-search/SKILL.md +20 -6
  55. package/skills/webcmd-adapter-author/SKILL.md +1 -1
  56. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
  57. package/skills/webcmd-browser/SKILL.md +16 -2
  58. package/skills/webcmd-usage/SKILL.md +21 -6
@@ -1,5 +1,5 @@
1
- import { dispatchCloakAction } from './actions.js';
2
- import { CloakSessionManager } from './session-manager.js';
1
+ import { dispatchCloakAction, resolveCloakCommandProfileId } from './actions.js';
2
+ import { CloakSessionManager, resolveCloakBrowserVersion } from './session-manager.js';
3
3
  export class LocalCloakRuntimeProvider {
4
4
  opts;
5
5
  manager;
@@ -12,12 +12,15 @@ export class LocalCloakRuntimeProvider {
12
12
  return {
13
13
  runtimeConnected: true,
14
14
  runtimeName: 'cloak',
15
- runtimeVersion: undefined,
15
+ runtimeVersion: resolveCloakBrowserVersion(),
16
16
  profiles,
17
17
  pending: 0,
18
18
  commandResultUnknown: 0,
19
19
  };
20
20
  }
21
+ resolveProfileId(command) {
22
+ return resolveCloakCommandProfileId(this.manager, command);
23
+ }
21
24
  async dispatch(command) {
22
25
  return dispatchCloakAction(this.manager, command);
23
26
  }
@@ -27,6 +27,7 @@ function fakePage(url) {
27
27
  function makeProviderWithFakePage() {
28
28
  const pages = [fakePage('https://example.com/')];
29
29
  const context = {
30
+ on: vi.fn(),
30
31
  pages: vi.fn(() => pages.filter((page) => !page.isClosed())),
31
32
  newPage: vi.fn(async () => {
32
33
  const page = fakePage('about:blank');
@@ -3,6 +3,8 @@ import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbr
3
3
  import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js';
4
4
  import { activateDarwinBackgroundContext } from './darwin-background-launch.js';
5
5
  import { CloakNetworkCapture } from './network.js';
6
+ /** Installed `cloakbrowser` npm package version, for doctor/status display. */
7
+ export declare function resolveCloakBrowserVersion(): string | undefined;
6
8
  export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
7
9
  export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
8
10
  export interface SessionKeyInput {
@@ -85,6 +87,9 @@ export declare class CloakSessionManager {
85
87
  shutdown(): Promise<void>;
86
88
  private getProfileRuntime;
87
89
  private launchProfileRuntime;
90
+ private invalidateProfileRuntime;
91
+ private attachRuntimeLifecycle;
92
+ private createPageWithRecovery;
88
93
  private openEntries;
89
94
  private findEntryByPageId;
90
95
  private refreshIdleTimer;
@@ -1,9 +1,23 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
  import { execFile } from 'node:child_process';
3
5
  import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
4
6
  import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js';
5
7
  import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js';
6
8
  import { CloakNetworkCapture } from './network.js';
9
+ import { findPackageRoot } from '../../../package-paths.js';
10
+ /** Installed `cloakbrowser` npm package version, for doctor/status display. */
11
+ export function resolveCloakBrowserVersion() {
12
+ try {
13
+ const entryPath = fileURLToPath(import.meta.resolve('cloakbrowser'));
14
+ const pkg = JSON.parse(fs.readFileSync(path.join(findPackageRoot(entryPath), 'package.json'), 'utf-8'));
15
+ return typeof pkg.version === 'string' ? pkg.version : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
7
21
  let pageCounter = 0;
8
22
  export function resolveLeaseKey(input) {
9
23
  const surface = input.surface === 'adapter' ? 'adapter' : 'browser';
@@ -15,6 +29,10 @@ export function resolveLeaseKey(input) {
15
29
  function pageIsClosed(page) {
16
30
  return page.isClosed?.() === true;
17
31
  }
32
+ function isClosedContextError(error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ return /Target page, context or browser has been closed/i.test(message);
35
+ }
18
36
  export class CloakSessionManager {
19
37
  opts;
20
38
  networkCapture = new CloakNetworkCapture();
@@ -37,7 +55,7 @@ export class CloakSessionManager {
37
55
  return [...this.profiles.entries()].map(([contextId, runtime]) => ({
38
56
  contextId,
39
57
  runtimeConnected: true,
40
- runtimeVersion: runtime.runtimeVersion,
58
+ runtimeVersion: resolveCloakBrowserVersion(),
41
59
  pending: 0,
42
60
  lastSeenAt: runtime.lastSeenAt,
43
61
  }));
@@ -67,16 +85,21 @@ export class CloakSessionManager {
67
85
  if (!pageIsClosed(existing.page))
68
86
  await existing.page.close().catch(() => { });
69
87
  }
70
- const existingPages = runtime.context.pages();
71
- // freshPage must never adopt a leftover tab — its whole point is a clean DOM.
72
- const page = !freshPage && existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
73
- const pageId = nextPageId();
74
- const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
75
- runtime.pages.set(leaseKey, entry);
76
- this.refreshIdleTimer(runtime, leaseKey, entry);
77
- runtime.selectedPageId = pageId;
78
- runtime.lastSeenAt = Date.now();
79
- return { profileId, leaseKey, context: runtime.context, page, pageId };
88
+ return this.createPageWithRecovery(profileId, input.windowMode, (candidate) => {
89
+ const existingPages = candidate.context.pages();
90
+ // freshPage must never adopt a leftover tab its whole point is a clean DOM.
91
+ return !freshPage && existingPages[0] && candidate.pages.size === 0
92
+ ? existingPages[0]
93
+ : candidate.context.newPage();
94
+ }, (candidate, page) => {
95
+ const pageId = nextPageId();
96
+ const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
97
+ candidate.pages.set(leaseKey, entry);
98
+ this.refreshIdleTimer(candidate, leaseKey, entry);
99
+ candidate.selectedPageId = pageId;
100
+ candidate.lastSeenAt = Date.now();
101
+ return { profileId, leaseKey, context: candidate.context, page, pageId };
102
+ });
80
103
  }
81
104
  findPageById(pageId, opts = {}) {
82
105
  for (const [profileId, runtime] of this.profiles.entries()) {
@@ -137,18 +160,36 @@ export class CloakSessionManager {
137
160
  const profileId = normalizeProfileId(input.profileId);
138
161
  const session = requireSession(input.session);
139
162
  const surface = normalizeSurface(input.surface);
140
- const runtime = await this.getProfileRuntime(profileId, input.windowMode);
141
- const page = await runtime.context.newPage();
163
+ const acquired = await this.createPageWithRecovery(profileId, input.windowMode, (candidate) => candidate.context.newPage(), (runtime, page) => ({ runtime, page }));
142
164
  if (input.url) {
143
- await page.goto(input.url, { waitUntil: 'load' });
165
+ try {
166
+ await acquired.page.goto(input.url, { waitUntil: 'load' });
167
+ }
168
+ catch (error) {
169
+ if (!pageIsClosed(acquired.page))
170
+ await acquired.page.close().catch(() => { });
171
+ throw error;
172
+ }
173
+ }
174
+ if (this.profiles.get(profileId) !== acquired.runtime) {
175
+ if (!pageIsClosed(acquired.page))
176
+ await acquired.page.close().catch(() => { });
177
+ throw new Error('Target page, context or browser has been closed');
144
178
  }
145
179
  const pageId = nextPageId();
146
180
  const leaseKey = `${resolveLeaseKey(input)}\u0000${pageId}`;
147
- const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
148
- runtime.pages.set(leaseKey, entry);
149
- this.refreshIdleTimer(runtime, leaseKey, entry);
150
- runtime.lastSeenAt = Date.now();
151
- return { profileId, leaseKey, context: runtime.context, page, pageId };
181
+ const entry = {
182
+ page: acquired.page,
183
+ pageId,
184
+ session,
185
+ surface,
186
+ siteSession: input.siteSession,
187
+ idleTimeout: input.idleTimeout,
188
+ };
189
+ acquired.runtime.pages.set(leaseKey, entry);
190
+ this.refreshIdleTimer(acquired.runtime, leaseKey, entry);
191
+ acquired.runtime.lastSeenAt = Date.now();
192
+ return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId };
152
193
  }
153
194
  async selectPage(input) {
154
195
  const profileId = normalizeProfileId(input.profileId);
@@ -285,9 +326,44 @@ export class CloakSessionManager {
285
326
  context = await launchPersistentContext(launchOptions);
286
327
  }
287
328
  const runtime = { context, pages: new Map(), lastSeenAt: Date.now() };
329
+ this.attachRuntimeLifecycle(profileId, runtime);
288
330
  this.profiles.set(profileId, runtime);
289
331
  return runtime;
290
332
  }
333
+ invalidateProfileRuntime(profileId, runtime) {
334
+ if (this.profiles.get(profileId) !== runtime)
335
+ return;
336
+ this.profiles.delete(profileId);
337
+ for (const entry of runtime.pages.values()) {
338
+ if (entry.idleTimer)
339
+ clearTimeout(entry.idleTimer);
340
+ this.networkCapture.stop(entry.page);
341
+ }
342
+ runtime.pages.clear();
343
+ runtime.selectedPageId = undefined;
344
+ }
345
+ attachRuntimeLifecycle(profileId, runtime) {
346
+ runtime.context.on('close', () => this.invalidateProfileRuntime(profileId, runtime));
347
+ }
348
+ async createPageWithRecovery(profileId, windowMode, createPage, commitPage, attempt = 0) {
349
+ const runtime = await this.getProfileRuntime(profileId, windowMode);
350
+ let page;
351
+ try {
352
+ page = await createPage(runtime);
353
+ }
354
+ catch (error) {
355
+ if (attempt !== 0 || !isClosedContextError(error))
356
+ throw error;
357
+ this.invalidateProfileRuntime(profileId, runtime);
358
+ return this.createPageWithRecovery(profileId, windowMode, createPage, commitPage, 1);
359
+ }
360
+ if (this.profiles.get(profileId) !== runtime) {
361
+ if (!pageIsClosed(page))
362
+ await page.close().catch(() => { });
363
+ throw new Error('Target page, context or browser has been closed');
364
+ }
365
+ return commitPage(runtime, page);
366
+ }
291
367
  openEntries(runtime) {
292
368
  return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page));
293
369
  }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { CloakSessionManager } from './session-manager.js';
4
4
  import { dispatchCloakAction } from './actions.js';
5
5
  function fakeContext() {
6
+ const listeners = new Map();
6
7
  const page = {
7
8
  goto: vi.fn().mockResolvedValue(undefined),
8
9
  evaluate: vi.fn().mockResolvedValue('ok'),
@@ -14,6 +15,15 @@ function fakeContext() {
14
15
  };
15
16
  return {
16
17
  context: {
18
+ on(event, listener) {
19
+ const bucket = listeners.get(event) ?? new Set();
20
+ bucket.add(listener);
21
+ listeners.set(event, bucket);
22
+ },
23
+ emit(event) {
24
+ for (const listener of listeners.get(event) ?? [])
25
+ listener();
26
+ },
17
27
  pages: vi.fn().mockReturnValue([page]),
18
28
  newPage: vi.fn().mockResolvedValue(page),
19
29
  cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]),
@@ -98,6 +108,229 @@ describe('CloakSessionManager', () => {
98
108
  expect(second.context).toBe(launched.context);
99
109
  expect(first.page).toBe(second.page);
100
110
  });
111
+ it('evicts a closed runtime and clears every tracked page resource', async () => {
112
+ vi.useFakeTimers();
113
+ const launched = fakeContext();
114
+ const secondPage = fakeContext().page;
115
+ launched.context.newPage.mockResolvedValue(secondPage);
116
+ const manager = new CloakSessionManager({
117
+ baseDir: '/tmp/webcmd-test',
118
+ launchPersistentContext: vi.fn().mockResolvedValue(launched.context),
119
+ });
120
+ const stopCapture = vi.spyOn(manager.networkCapture, 'stop');
121
+ const first = await manager.getPage({ profileId: 'default', session: 'one', surface: 'browser', idleTimeout: 25 });
122
+ const second = await manager.newPage({ profileId: 'default', session: 'two', surface: 'browser', idleTimeout: 25 });
123
+ expect(manager.activeProfileIds()).toEqual(['default']);
124
+ expect(vi.getTimerCount()).toBe(2);
125
+ launched.context.emit('close');
126
+ expect(manager.activeProfileIds()).toEqual([]);
127
+ expect(manager.profileStatuses()).toEqual([]);
128
+ expect(vi.getTimerCount()).toBe(0);
129
+ expect(stopCapture).toHaveBeenCalledTimes(2);
130
+ expect(stopCapture).toHaveBeenCalledWith(first.page);
131
+ expect(stopCapture).toHaveBeenCalledWith(second.page);
132
+ await vi.advanceTimersByTimeAsync(25);
133
+ expect(first.page.close).not.toHaveBeenCalled();
134
+ expect(second.page.close).not.toHaveBeenCalled();
135
+ });
136
+ it('does not let a late close from an old runtime evict its replacement', async () => {
137
+ const first = fakeContext();
138
+ const replacement = fakeContext();
139
+ const launchPersistentContext = vi.fn()
140
+ .mockResolvedValueOnce(first.context)
141
+ .mockResolvedValueOnce(replacement.context);
142
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
143
+ await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' });
144
+ first.context.emit('close');
145
+ const replacementLease = await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' });
146
+ first.context.emit('close');
147
+ expect(manager.activeProfileIds()).toEqual(['default']);
148
+ expect(manager.profileStatuses()).toHaveLength(1);
149
+ expect((await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' })).context)
150
+ .toBe(replacementLease.context);
151
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
152
+ });
153
+ it('coalesces simultaneous replacement launches after a context closes', async () => {
154
+ const first = fakeContext();
155
+ const replacement = fakeContext();
156
+ let resolveReplacement;
157
+ const launchPersistentContext = vi.fn()
158
+ .mockResolvedValueOnce(first.context)
159
+ .mockImplementationOnce(() => new Promise((resolve) => {
160
+ resolveReplacement = resolve;
161
+ }));
162
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
163
+ await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' });
164
+ first.context.emit('close');
165
+ const one = manager.getPage({ profileId: 'default', session: 'one', surface: 'browser' });
166
+ const two = manager.getPage({ profileId: 'default', session: 'two', surface: 'browser' });
167
+ await Promise.resolve();
168
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
169
+ resolveReplacement(replacement.context);
170
+ const leases = await Promise.all([one, two]);
171
+ expect(leases[0].context).toBe(replacement.context);
172
+ expect(leases[1].context).toBe(replacement.context);
173
+ });
174
+ it('discards a page created after its runtime closes and defers recovery to the next command', async () => {
175
+ const first = fakeContext();
176
+ first.context.pages.mockReturnValue([]);
177
+ let resolveFirstPage;
178
+ let markPageCreationStarted;
179
+ const pageCreationStarted = new Promise((resolve) => {
180
+ markPageCreationStarted = resolve;
181
+ });
182
+ first.context.newPage.mockImplementation(() => {
183
+ markPageCreationStarted();
184
+ return new Promise((resolve) => {
185
+ resolveFirstPage = resolve;
186
+ });
187
+ });
188
+ const replacement = fakeContext();
189
+ replacement.context.pages.mockReturnValue([]);
190
+ const launchPersistentContext = vi.fn()
191
+ .mockResolvedValueOnce(first.context)
192
+ .mockResolvedValueOnce(replacement.context);
193
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
194
+ const pendingLease = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
195
+ await pageCreationStarted;
196
+ first.context.emit('close');
197
+ resolveFirstPage(first.page);
198
+ await expect(pendingLease).rejects.toThrow('Target page, context or browser has been closed');
199
+ expect(first.page.close).toHaveBeenCalled();
200
+ expect(manager.activeProfileIds()).toEqual([]);
201
+ expect(launchPersistentContext).toHaveBeenCalledTimes(1);
202
+ const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
203
+ expect(lease.context).toBe(replacement.context);
204
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
205
+ });
206
+ it('does not publish an orphaned page after acquisition validation', async () => {
207
+ vi.useFakeTimers();
208
+ const first = fakeContext();
209
+ first.context.pages.mockReturnValue([]);
210
+ first.context.newPage.mockImplementation(() => ({
211
+ then(resolve) {
212
+ resolve(first.page);
213
+ queueMicrotask(() => first.context.emit('close'));
214
+ },
215
+ }));
216
+ const replacement = fakeContext();
217
+ replacement.context.pages.mockReturnValue([]);
218
+ const launchPersistentContext = vi.fn()
219
+ .mockResolvedValueOnce(first.context)
220
+ .mockResolvedValueOnce(replacement.context);
221
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
222
+ await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser', idleTimeout: 25 });
223
+ expect(manager.activeProfileIds()).toEqual([]);
224
+ expect(vi.getTimerCount()).toBe(0);
225
+ const lease = await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' });
226
+ expect(lease.context).toBe(replacement.context);
227
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
228
+ });
229
+ it('retries getPage page creation once after a closed-context failure', async () => {
230
+ const closed = new Error('Target page, context or browser has been closed');
231
+ const first = fakeContext();
232
+ first.context.pages.mockReturnValue([]);
233
+ first.context.newPage.mockRejectedValue(closed);
234
+ const replacement = fakeContext();
235
+ replacement.context.pages.mockReturnValue([]);
236
+ const launchPersistentContext = vi.fn()
237
+ .mockResolvedValueOnce(first.context)
238
+ .mockResolvedValueOnce(replacement.context);
239
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
240
+ const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
241
+ expect(lease.context).toBe(replacement.context);
242
+ expect(first.context.newPage).toHaveBeenCalledTimes(1);
243
+ expect(replacement.context.newPage).toHaveBeenCalledTimes(1);
244
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
245
+ });
246
+ it('retries explicit newPage page creation once after a closed-context failure', async () => {
247
+ const closed = new Error('browserContext.newPage: Target page, context or browser has been closed');
248
+ const first = fakeContext();
249
+ first.context.newPage.mockRejectedValue(closed);
250
+ const replacement = fakeContext();
251
+ const launchPersistentContext = vi.fn()
252
+ .mockResolvedValueOnce(first.context)
253
+ .mockResolvedValueOnce(replacement.context);
254
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
255
+ const lease = await manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' });
256
+ expect(lease.context).toBe(replacement.context);
257
+ expect(first.context.newPage).toHaveBeenCalledTimes(1);
258
+ expect(replacement.context.newPage).toHaveBeenCalledTimes(1);
259
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
260
+ });
261
+ it('returns the second closed-context page creation failure without looping', async () => {
262
+ const firstFailure = new Error('Target page, context or browser has been closed');
263
+ const secondFailure = new Error('Target page, context or browser has been closed again');
264
+ const first = fakeContext();
265
+ first.context.newPage.mockRejectedValue(firstFailure);
266
+ const replacement = fakeContext();
267
+ replacement.context.newPage.mockRejectedValue(secondFailure);
268
+ const launchPersistentContext = vi.fn()
269
+ .mockResolvedValueOnce(first.context)
270
+ .mockResolvedValueOnce(replacement.context);
271
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
272
+ await expect(manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' }))
273
+ .rejects.toBe(secondFailure);
274
+ expect(first.context.newPage).toHaveBeenCalledTimes(1);
275
+ expect(replacement.context.newPage).toHaveBeenCalledTimes(1);
276
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
277
+ });
278
+ it('keeps an explicitly navigated page untracked until navigation succeeds', async () => {
279
+ vi.useFakeTimers();
280
+ const launched = fakeContext();
281
+ let resolveNavigation;
282
+ let markNavigationStarted;
283
+ const navigationStarted = new Promise((resolve) => {
284
+ markNavigationStarted = resolve;
285
+ });
286
+ launched.page.goto.mockImplementation(() => {
287
+ markNavigationStarted();
288
+ return new Promise((resolve) => {
289
+ resolveNavigation = resolve;
290
+ });
291
+ });
292
+ const manager = new CloakSessionManager({
293
+ baseDir: '/tmp/webcmd-test',
294
+ launchPersistentContext: vi.fn().mockResolvedValue(launched.context),
295
+ });
296
+ const pendingLease = manager.newPage({
297
+ profileId: 'default',
298
+ session: 'work',
299
+ surface: 'browser',
300
+ idleTimeout: 25,
301
+ url: 'https://example.com/',
302
+ });
303
+ await navigationStarted;
304
+ const pagesDuringNavigation = await manager.listPages({ profileId: 'default' });
305
+ const pageIdDuringNavigation = manager.pageIdFor(launched.page);
306
+ const timersDuringNavigation = vi.getTimerCount();
307
+ resolveNavigation();
308
+ const lease = await pendingLease;
309
+ expect(pagesDuringNavigation).toEqual([]);
310
+ expect(pageIdDuringNavigation).toBeUndefined();
311
+ expect(timersDuringNavigation).toBe(0);
312
+ expect(manager.pageIdFor(launched.page)).toBe(lease.pageId);
313
+ expect(await manager.listPages({ profileId: 'default' })).toHaveLength(1);
314
+ expect(vi.getTimerCount()).toBe(1);
315
+ });
316
+ it('does not retry or retain a page when navigation fails after creation', async () => {
317
+ const navigationFailure = new Error('Target page, context or browser has been closed');
318
+ const launched = fakeContext();
319
+ launched.page.goto.mockRejectedValue(navigationFailure);
320
+ const launchPersistentContext = vi.fn().mockResolvedValue(launched.context);
321
+ const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
322
+ await expect(manager.newPage({
323
+ profileId: 'default',
324
+ session: 'work',
325
+ surface: 'browser',
326
+ url: 'https://example.com/',
327
+ })).rejects.toBe(navigationFailure);
328
+ expect(launchPersistentContext).toHaveBeenCalledTimes(1);
329
+ expect(launched.context.newPage).toHaveBeenCalledTimes(1);
330
+ expect(launched.page.goto).toHaveBeenCalledTimes(1);
331
+ expect(launched.page.close).toHaveBeenCalledTimes(1);
332
+ expect(await manager.listPages({ profileId: 'default' })).toEqual([]);
333
+ });
101
334
  it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => {
102
335
  const launched = fakeContext();
103
336
  const launchPersistentContext = vi.fn()
@@ -125,6 +358,7 @@ describe('CloakSessionManager', () => {
125
358
  });
126
359
  const openPages = [];
127
360
  const context = {
361
+ on: vi.fn(),
128
362
  pages: vi.fn(() => openPages),
129
363
  newPage: vi.fn(async () => {
130
364
  const page = makePage();
@@ -159,6 +393,7 @@ describe('CloakSessionManager', () => {
159
393
  close: vi.fn().mockResolvedValue(undefined),
160
394
  };
161
395
  const context = {
396
+ on: vi.fn(),
162
397
  pages: vi.fn().mockReturnValue([leftover]),
163
398
  newPage: vi.fn().mockResolvedValue(created),
164
399
  cookies: vi.fn().mockResolvedValue([]),
@@ -4,6 +4,7 @@ export interface RuntimeStatusOptions {
4
4
  }
5
5
  export interface BrowserRuntimeProvider {
6
6
  status(opts?: RuntimeStatusOptions): Promise<BrowserRuntimeStatus>;
7
+ resolveProfileId?(command: BrowserRuntimeCommand): string;
7
8
  dispatch(command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;
8
9
  shutdown(): Promise<void>;
9
10
  }
package/dist/src/cli.js CHANGED
@@ -18,7 +18,7 @@ import { render as renderOutput } from './output.js';
18
18
  import { PKG_VERSION } from './version.js';
19
19
  import { printCompletionScript } from './completion.js';
20
20
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
21
- import { installWebcmdSkill, listWebcmdSkills, updateWebcmdSkill } from './skills.js';
21
+ import { addWebcmdSkills, listWebcmdSkills, removeWebcmdSkills, updateWebcmdSkill } from './skills.js';
22
22
  import { registerAllCommands } from './commanderAdapter.js';
23
23
  import { buildRootHelpPresentation, classifyAdapter, installCommanderNamespaceStructuredHelp, installRootPresentationHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
24
24
  import { EXIT_CODES, getErrorMessage, BrowserConnectError, CliError, ArgumentError } from './errors.js';
@@ -62,15 +62,15 @@ function parseDurationMs(raw, flagName) {
62
62
  function timestampFromRaw(value) {
63
63
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
64
64
  }
65
- function isInteractiveInstall(opts) {
65
+ function isInteractiveSkillAdd(opts) {
66
66
  return !opts.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
67
67
  }
68
- async function resolveSkillInstallOptions(opts) {
69
- if (!isInteractiveInstall(opts))
68
+ async function resolveSkillAddOptions(opts) {
69
+ if (!isInteractiveSkillAdd(opts))
70
70
  return opts;
71
71
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
72
72
  try {
73
- const scope = opts.scope ?? await choosePrompt(rl, 'Where should Webcmd install skills?', [
73
+ const scope = opts.scope ?? await choosePrompt(rl, 'Where should Webcmd add skills?', [
74
74
  { key: '1', label: 'Global', value: 'user', aliases: ['global', 'user', 'g'] },
75
75
  { key: '2', label: 'Local project', value: 'project', aliases: ['local', 'project', 'l'] },
76
76
  ], '1');
@@ -126,6 +126,24 @@ async function handleSkillLinkCommand(action, json, verb) {
126
126
  process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
127
127
  }
128
128
  }
129
+ function handleSkillRemoveCommand(customPath, json) {
130
+ try {
131
+ const result = removeWebcmdSkills({ customPath });
132
+ if (json) {
133
+ console.log(JSON.stringify(result, null, 2));
134
+ return;
135
+ }
136
+ console.log(`Webcmd skill links removed: ${result.removed.length}`);
137
+ for (const linkPath of result.removed)
138
+ console.log(linkPath);
139
+ }
140
+ catch (err) {
141
+ console.error(`Error: ${getErrorMessage(err)}`);
142
+ if (err instanceof CliError && err.hint)
143
+ console.error(`Hint: ${err.hint}`);
144
+ process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
145
+ }
146
+ }
129
147
  function toIsoTimestamp(timestamp) {
130
148
  if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0)
131
149
  return undefined;
@@ -706,7 +724,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
706
724
  });
707
725
  const skillsCmd = program
708
726
  .command('skills')
709
- .description('List, install, and update bundled Webcmd skills')
727
+ .description('List, add, update, and remove bundled Webcmd skills')
710
728
  .action(() => {
711
729
  const rows = listWebcmdSkills();
712
730
  renderOutput(rows, {
@@ -732,24 +750,24 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
732
750
  });
733
751
  });
734
752
  skillsCmd
735
- .command('install')
736
- .description('Install bundled Webcmd skills into an agent skills folder')
753
+ .command('add')
754
+ .description('Add bundled Webcmd skills to an agent skills folder')
737
755
  .option('-p, --provider <provider>', 'Agent provider: agents, codex, claude')
738
- .option('-s, --scope <scope>', 'Install scope: user/global or project/local')
756
+ .option('-s, --scope <scope>', 'Add scope: user/global or project/local')
739
757
  .option('--path <path>', 'Custom agent skills directory')
740
758
  .option('--json', 'Output a JSON envelope', false)
741
759
  .action(async (opts) => {
742
760
  await handleSkillLinkCommand(async () => {
743
- const resolved = await resolveSkillInstallOptions(opts);
761
+ const resolved = await resolveSkillAddOptions(opts);
744
762
  if (resolved.provider === 'custom' && !resolved.path) {
745
763
  throw new ArgumentError('Custom skill provider requires --path.', 'Pass --path <skills-dir> or run interactively.');
746
764
  }
747
- return installWebcmdSkill({
765
+ return addWebcmdSkills({
748
766
  provider: resolved.provider,
749
767
  scope: resolved.scope,
750
768
  customPath: resolved.path,
751
769
  });
752
- }, opts.json, 'installed');
770
+ }, opts.json, 'added');
753
771
  });
754
772
  skillsCmd
755
773
  .command('update')
@@ -765,6 +783,12 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
765
783
  customPath: opts.path,
766
784
  }), opts.json, 'updated');
767
785
  });
786
+ skillsCmd
787
+ .command('remove')
788
+ .description('Remove bundled Webcmd skill symlinks from supported locations')
789
+ .option('--path <path>', 'Also remove links from a custom agent skills directory')
790
+ .option('--json', 'Output a JSON envelope', false)
791
+ .action((opts) => handleSkillRemoveCommand(opts.path, opts.json));
768
792
  const authCmd = registerAuthCommands(program);
769
793
  program
770
794
  .command('convention-audit')
@@ -60,6 +60,11 @@ describe('createProgram root help descriptions', () => {
60
60
  expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
61
61
  expect(descriptionFor(program, 'external')).toBe('install, list, register');
62
62
  });
63
+ it('exposes add and remove without an install command', () => {
64
+ const skills = createProgram('', '').commands.find((command) => command.name() === 'skills');
65
+ expect(skills.commands.map((command) => command.name())).toEqual(['list', 'add', 'update', 'remove']);
66
+ expect(skills.commands.find((command) => command.name() === 'add')?.aliases()).toEqual([]);
67
+ });
63
68
  it('renders auth namespace structured help', () => {
64
69
  const argv = process.argv;
65
70
  try {