@agentrhq/webcmd 0.3.1 → 0.3.3

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 (56) hide show
  1. package/README.md +13 -6
  2. package/cli-manifest.json +11 -11
  3. package/clis/producthunt/browse.js +9 -2
  4. package/clis/producthunt/browser-commands.test.js +66 -0
  5. package/clis/producthunt/hot.js +3 -3
  6. package/clis/producthunt/utils.js +27 -0
  7. package/clis/producthunt/utils.test.js +8 -0
  8. package/clis/spotify/spotify.js +11 -11
  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/availability.test.js +12 -1
  34. package/dist/src/hosted/client.js +3 -1
  35. package/dist/src/hosted/client.test.js +62 -0
  36. package/dist/src/hosted/main-lifecycle.test.js +66 -5
  37. package/dist/src/hosted/types.d.ts +1 -0
  38. package/dist/src/main.js +4 -0
  39. package/dist/src/package-bin-process.d.ts +13 -0
  40. package/dist/src/package-bin-process.js +24 -0
  41. package/dist/src/package-bin-process.test.d.ts +1 -0
  42. package/dist/src/package-bin-process.test.js +22 -0
  43. package/dist/src/session-lease.d.ts +82 -0
  44. package/dist/src/session-lease.js +163 -0
  45. package/dist/src/session-lease.test.d.ts +1 -0
  46. package/dist/src/session-lease.test.js +217 -0
  47. package/dist/src/skills.d.ts +8 -4
  48. package/dist/src/skills.js +30 -1
  49. package/dist/src/skills.test.js +40 -12
  50. package/hosted-contract.json +45 -34
  51. package/package.json +3 -2
  52. package/scripts/check-package-bin.mjs +7 -6
  53. package/scripts/collect-ci-diagnostics.ps1 +274 -0
  54. package/scripts/docs-sync-review.ts +1 -1
  55. package/skills/webcmd-adapter-author/SKILL.md +1 -1
  56. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
package/README.md CHANGED
@@ -8,6 +8,9 @@
8
8
  <a href="https://www.npmjs.com/package/@agentrhq/webcmd">
9
9
  <img alt="NPM downloads" src="https://img.shields.io/npm/dt/@agentrhq/webcmd.svg?style=for-the-badge&color=1E88E5&labelColor=000000">
10
10
  </a>
11
+ <a href="https://docs.webcmd.dev">
12
+ <img alt="Documentation" src="https://img.shields.io/badge/docs-webcmd.dev-1E88E5.svg?style=for-the-badge&labelColor=000000">
13
+ </a>
11
14
  <a href="https://github.com/agentrhq/webcmd/blob/main/LICENSE">
12
15
  <img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-1E88E5.svg?style=for-the-badge&labelColor=000000">
13
16
  </a>
@@ -25,6 +28,8 @@
25
28
 
26
29
  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%.
27
30
 
31
+ 📖 **Full documentation: [docs.webcmd.dev](https://docs.webcmd.dev)**
32
+
28
33
  On top of live browser control, WebCMD adds 3 layers of learnings. Each layer collapses cost and variance for the layer above it.
29
34
 
30
35
  | Layer | Scenario | What Webcmd Helps With |
@@ -123,15 +128,17 @@ Agents usually want `-f json`; humans usually want table (default) or yaml.
123
128
 
124
129
  Webcmd is designed to be driven by coding agents such as Codex, Claude Code, Cursor, and similar tools.
125
130
 
126
- ## Install skills (also refreshes existing installs)
131
+ ## Add skills (also refreshes existing links)
127
132
 
128
- Install Webcmd skills into your agent environment:
133
+ Add Webcmd skills to your agent environment:
129
134
 
130
135
  ```bash
131
- webcmd skills install
136
+ webcmd skills add
132
137
  ```
133
138
 
134
- The installer asks whether to install globally or locally, then asks for the coding agent (`agents`, `codex`, `claude`) or a custom skills path. For scripts, pass flags such as `--scope project --provider codex` or `--path ./my-skills`.
139
+ The command asks whether to add globally or locally, then asks for the coding agent (`agents`, `codex`, `claude`) or a custom skills path. For scripts, pass flags such as `--scope project --provider codex` or `--path ./my-skills`.
140
+
141
+ Remove Webcmd skill symlinks from all supported global and current-project locations with `webcmd skills remove`. Pass `--path ./my-skills` to include a custom directory.
135
142
 
136
143
  ### Which skill to use
137
144
 
@@ -312,8 +319,8 @@ Common paths:
312
319
  | `~/.webcmd/clis/` | Private adapters. |
313
320
  | `~/.webcmd/cache/browser-network/` | Cached browser network captures. |
314
321
  | `~/.webcmd/external-clis.yaml` | User external CLI registry entries. |
315
- | `~/.agents/skills/` | Common global skills install target for agent skill managers. |
316
- | `.agents/skills/` | Common workspace-local skills install target. |
322
+ | `~/.agents/skills/` | Common global skills directory for agent skill managers. |
323
+ | `.agents/skills/` | Common workspace-local skills directory. |
317
324
 
318
325
  ## Troubleshooting
319
326
 
package/cli-manifest.json CHANGED
@@ -21317,7 +21317,7 @@
21317
21317
  "name": "auth",
21318
21318
  "description": "Authenticate with Spotify (OAuth — run once)",
21319
21319
  "access": "write",
21320
- "strategy": "public",
21320
+ "strategy": "local",
21321
21321
  "browser": false,
21322
21322
  "args": [],
21323
21323
  "columns": [
@@ -21332,7 +21332,7 @@
21332
21332
  "name": "next",
21333
21333
  "description": "Skip to next track",
21334
21334
  "access": "write",
21335
- "strategy": "public",
21335
+ "strategy": "local",
21336
21336
  "browser": false,
21337
21337
  "args": [],
21338
21338
  "columns": [
@@ -21347,7 +21347,7 @@
21347
21347
  "name": "pause",
21348
21348
  "description": "Pause playback",
21349
21349
  "access": "write",
21350
- "strategy": "public",
21350
+ "strategy": "local",
21351
21351
  "browser": false,
21352
21352
  "args": [],
21353
21353
  "columns": [
@@ -21362,7 +21362,7 @@
21362
21362
  "name": "play",
21363
21363
  "description": "Resume playback or search and play a track/artist",
21364
21364
  "access": "write",
21365
- "strategy": "public",
21365
+ "strategy": "local",
21366
21366
  "browser": false,
21367
21367
  "args": [
21368
21368
  {
@@ -21388,7 +21388,7 @@
21388
21388
  "name": "prev",
21389
21389
  "description": "Skip to previous track",
21390
21390
  "access": "write",
21391
- "strategy": "public",
21391
+ "strategy": "local",
21392
21392
  "browser": false,
21393
21393
  "args": [],
21394
21394
  "columns": [
@@ -21403,7 +21403,7 @@
21403
21403
  "name": "queue",
21404
21404
  "description": "Add a track to the playback queue",
21405
21405
  "access": "write",
21406
- "strategy": "public",
21406
+ "strategy": "local",
21407
21407
  "browser": false,
21408
21408
  "args": [
21409
21409
  {
@@ -21428,7 +21428,7 @@
21428
21428
  "name": "repeat",
21429
21429
  "description": "Set repeat mode (off / track / context)",
21430
21430
  "access": "write",
21431
- "strategy": "public",
21431
+ "strategy": "local",
21432
21432
  "browser": false,
21433
21433
  "args": [
21434
21434
  {
@@ -21457,7 +21457,7 @@
21457
21457
  "name": "search",
21458
21458
  "description": "Search for tracks",
21459
21459
  "access": "read",
21460
- "strategy": "public",
21460
+ "strategy": "local",
21461
21461
  "browser": false,
21462
21462
  "args": [
21463
21463
  {
@@ -21490,7 +21490,7 @@
21490
21490
  "name": "shuffle",
21491
21491
  "description": "Toggle shuffle on/off",
21492
21492
  "access": "write",
21493
- "strategy": "public",
21493
+ "strategy": "local",
21494
21494
  "browser": false,
21495
21495
  "args": [
21496
21496
  {
@@ -21518,7 +21518,7 @@
21518
21518
  "name": "status",
21519
21519
  "description": "Show current playback status",
21520
21520
  "access": "read",
21521
- "strategy": "public",
21521
+ "strategy": "local",
21522
21522
  "browser": false,
21523
21523
  "args": [],
21524
21524
  "columns": [
@@ -21537,7 +21537,7 @@
21537
21537
  "name": "volume",
21538
21538
  "description": "Set playback volume (0-100)",
21539
21539
  "access": "write",
21540
- "strategy": "public",
21540
+ "strategy": "local",
21541
21541
  "browser": false,
21542
21542
  "args": [
21543
21543
  {
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { cli, Strategy } from '@agentrhq/webcmd/registry';
8
8
  import { CliError } from '@agentrhq/webcmd/errors';
9
- import { PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
9
+ import { assertProductHuntAccessible, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
10
10
  cli({
11
11
  site: 'producthunt',
12
12
  name: 'browse',
@@ -30,7 +30,14 @@ cli({
30
30
  const slug = String(args.category || '').trim().toLowerCase();
31
31
  await page.installInterceptor('producthunt.com');
32
32
  await page.goto(`https://www.producthunt.com/categories/${slug}`);
33
- await page.waitForCapture(5);
33
+ try {
34
+ await page.waitForCapture(5);
35
+ }
36
+ catch (error) {
37
+ await assertProductHuntAccessible(page);
38
+ throw error;
39
+ }
40
+ await assertProductHuntAccessible(page);
34
41
  const domItems = await page.evaluate(`
35
42
  (() => {
36
43
  const seen = new Set();
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@agentrhq/webcmd/registry';
3
+ import './hot.js';
4
+ import './browse.js';
5
+
6
+ function pageMock({ evaluations, captureError } = {}) {
7
+ const queue = [...(evaluations ?? [])];
8
+ return {
9
+ goto: async () => undefined,
10
+ wait: async () => undefined,
11
+ installInterceptor: async () => undefined,
12
+ waitForCapture: async () => {
13
+ if (captureError) throw captureError;
14
+ },
15
+ evaluate: async () => queue.shift(),
16
+ };
17
+ }
18
+
19
+ const challenge = {
20
+ title: 'Just a moment...',
21
+ body: 'Performing security verification before proceeding',
22
+ cloudflare: true,
23
+ };
24
+ const accessible = {
25
+ title: 'Product Hunt',
26
+ body: 'Discover your next favorite thing',
27
+ cloudflare: false,
28
+ };
29
+
30
+ describe('Product Hunt browser commands', () => {
31
+ it.each([
32
+ ['producthunt/hot', {}],
33
+ ['producthunt/browse', { category: 'developer-tools' }],
34
+ ])('%s reports verification pages as SITE_BLOCKED', async (name, args) => {
35
+ const command = getRegistry().get(name);
36
+
37
+ await expect(command.func(pageMock({ evaluations: [challenge] }), args)).rejects.toMatchObject({
38
+ code: 'SITE_BLOCKED',
39
+ exitCode: 69,
40
+ });
41
+ });
42
+
43
+ it('reports a challenge when category capture fails on the verification page', async () => {
44
+ const command = getRegistry().get('producthunt/browse');
45
+ const captureError = new Error('No network capture within 5s');
46
+
47
+ await expect(command.func(pageMock({ evaluations: [challenge], captureError }), {
48
+ category: 'developer-tools',
49
+ })).rejects.toMatchObject({
50
+ code: 'SITE_BLOCKED',
51
+ exitCode: 69,
52
+ });
53
+ });
54
+
55
+ it.each([
56
+ ['producthunt/hot', {}, 'Could not retrieve Product Hunt top posts'],
57
+ ['producthunt/browse', { category: 'developer-tools' }, 'No products found for category'],
58
+ ])('%s preserves NO_DATA for an accessible empty page', async (name, args, message) => {
59
+ const command = getRegistry().get(name);
60
+
61
+ await expect(command.func(pageMock({ evaluations: [accessible, []] }), args)).rejects.toMatchObject({
62
+ code: 'NO_DATA',
63
+ message: expect.stringContaining(message),
64
+ });
65
+ });
66
+ });
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { cli, Strategy } from '@agentrhq/webcmd/registry';
7
7
  import { CliError } from '@agentrhq/webcmd/errors';
8
- import { pickVoteCount } from './utils.js';
8
+ import { assertProductHuntAccessible, pickVoteCount } from './utils.js';
9
9
  cli({
10
10
  site: 'producthunt',
11
11
  name: 'hot',
@@ -19,9 +19,9 @@ cli({
19
19
  columns: ['rank', 'name', 'votes', 'url'],
20
20
  func: async (page, args) => {
21
21
  const count = Math.min(Number(args.limit) || 20, 50);
22
- await page.installInterceptor('producthunt.com');
23
22
  await page.goto('https://www.producthunt.com');
24
- await page.waitForCapture(5);
23
+ await page.wait(2);
24
+ await assertProductHuntAccessible(page);
25
25
  const domItems = await page.evaluate(`
26
26
  (() => {
27
27
  const seen = new Set();
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Product Hunt shared helpers.
3
3
  */
4
+ import { CliError, EXIT_CODES } from '@agentrhq/webcmd/errors';
5
+
4
6
  export const PRODUCTHUNT_CATEGORY_SLUGS = [
5
7
  'ai-agents',
6
8
  'ai-coding-agents',
@@ -19,6 +21,31 @@ export const PRODUCTHUNT_CATEGORY_SLUGS = [
19
21
  'engineering-development',
20
22
  ];
21
23
  const UA = 'Mozilla/5.0 (compatible; webcmd/1.0)';
24
+
25
+ export function isProductHuntVerificationPage(value) {
26
+ const title = String(value?.title ?? '');
27
+ const body = String(value?.body ?? '');
28
+ return value?.cloudflare === true
29
+ || /just a moment|security verification/i.test(title)
30
+ || /performing security verification|verify you are human|enable javascript and cookies/i.test(body);
31
+ }
32
+
33
+ export async function assertProductHuntAccessible(page) {
34
+ const state = await page.evaluate(`(() => ({
35
+ title: document.title || '',
36
+ body: (document.body?.innerText || '').slice(0, 2000),
37
+ cloudflare: !!document.querySelector('[id^="cf-"], [class*="cf-challenge"]')
38
+ }))()`);
39
+ if (!isProductHuntVerificationPage(state))
40
+ return;
41
+ throw new CliError(
42
+ 'SITE_BLOCKED',
43
+ 'Product Hunt served a security verification page',
44
+ 'Complete permitted verification in the active browser when available, then retry. Webcmd will not bypass site verification.',
45
+ EXIT_CODES.SERVICE_UNAVAIL,
46
+ );
47
+ }
48
+
22
49
  /**
23
50
  * Fetch Product Hunt Atom RSS feed.
24
51
  * @param category Optional category slug (e.g. "ai", "developer-tools")
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { parseFeed, pickVoteCount, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
3
+ import * as productHuntUtils from './utils.js';
3
4
  const SAMPLE_ATOM = `<?xml version="1.0" encoding="UTF-8"?>
4
5
  <feed xmlns="http://www.w3.org/2005/Atom">
5
6
  <title>Product Hunt</title>
@@ -61,4 +62,11 @@ describe('parseFeed', () => {
61
62
  expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('developer-tools');
62
63
  expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('ai-agents');
63
64
  });
65
+ it('recognizes explicit Product Hunt verification signals', () => {
66
+ expect(productHuntUtils.isProductHuntVerificationPage).toBeTypeOf('function');
67
+ expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Just a moment...', body: '', cloudflare: false })).toBe(true);
68
+ expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: 'Performing security verification', cloudflare: false })).toBe(true);
69
+ expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: '', cloudflare: true })).toBe(true);
70
+ expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: 'Discover your next favorite thing', cloudflare: false })).toBe(false);
71
+ });
64
72
  });
@@ -108,7 +108,7 @@ cli({
108
108
  name: 'auth',
109
109
  access: 'write',
110
110
  description: 'Authenticate with Spotify (OAuth — run once)',
111
- strategy: Strategy.PUBLIC,
111
+ strategy: Strategy.LOCAL,
112
112
  browser: false,
113
113
  args: [],
114
114
  columns: ['status'],
@@ -175,7 +175,7 @@ cli({
175
175
  name: 'status',
176
176
  access: 'read',
177
177
  description: 'Show current playback status',
178
- strategy: Strategy.PUBLIC,
178
+ strategy: Strategy.LOCAL,
179
179
  browser: false,
180
180
  args: [],
181
181
  columns: ['track', 'artist', 'album', 'status', 'progress'],
@@ -197,7 +197,7 @@ cli({
197
197
  name: 'play',
198
198
  access: 'write',
199
199
  description: 'Resume playback or search and play a track/artist',
200
- strategy: Strategy.PUBLIC,
200
+ strategy: Strategy.LOCAL,
201
201
  browser: false,
202
202
  args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }],
203
203
  columns: ['track', 'artist', 'status'],
@@ -216,7 +216,7 @@ cli({
216
216
  name: 'pause',
217
217
  access: 'write',
218
218
  description: 'Pause playback',
219
- strategy: Strategy.PUBLIC,
219
+ strategy: Strategy.LOCAL,
220
220
  browser: false,
221
221
  args: [],
222
222
  columns: ['status'],
@@ -227,7 +227,7 @@ cli({
227
227
  name: 'next',
228
228
  access: 'write',
229
229
  description: 'Skip to next track',
230
- strategy: Strategy.PUBLIC,
230
+ strategy: Strategy.LOCAL,
231
231
  browser: false,
232
232
  args: [],
233
233
  columns: ['status'],
@@ -238,7 +238,7 @@ cli({
238
238
  name: 'prev',
239
239
  access: 'write',
240
240
  description: 'Skip to previous track',
241
- strategy: Strategy.PUBLIC,
241
+ strategy: Strategy.LOCAL,
242
242
  browser: false,
243
243
  args: [],
244
244
  columns: ['status'],
@@ -249,7 +249,7 @@ cli({
249
249
  name: 'volume',
250
250
  access: 'write',
251
251
  description: 'Set playback volume (0-100)',
252
- strategy: Strategy.PUBLIC,
252
+ strategy: Strategy.LOCAL,
253
253
  browser: false,
254
254
  args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }],
255
255
  columns: ['volume'],
@@ -266,7 +266,7 @@ cli({
266
266
  name: 'search',
267
267
  access: 'read',
268
268
  description: 'Search for tracks',
269
- strategy: Strategy.PUBLIC,
269
+ strategy: Strategy.LOCAL,
270
270
  browser: false,
271
271
  args: [
272
272
  { name: 'query', type: 'str', required: true, positional: true, help: 'Search query' },
@@ -287,7 +287,7 @@ cli({
287
287
  name: 'queue',
288
288
  access: 'write',
289
289
  description: 'Add a track to the playback queue',
290
- strategy: Strategy.PUBLIC,
290
+ strategy: Strategy.LOCAL,
291
291
  browser: false,
292
292
  args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }],
293
293
  columns: ['track', 'artist', 'status'],
@@ -302,7 +302,7 @@ cli({
302
302
  name: 'shuffle',
303
303
  access: 'write',
304
304
  description: 'Toggle shuffle on/off',
305
- strategy: Strategy.PUBLIC,
305
+ strategy: Strategy.LOCAL,
306
306
  browser: false,
307
307
  args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }],
308
308
  columns: ['shuffle'],
@@ -316,7 +316,7 @@ cli({
316
316
  name: 'repeat',
317
317
  access: 'write',
318
318
  description: 'Set repeat mode (off / track / context)',
319
- strategy: Strategy.PUBLIC,
319
+ strategy: Strategy.LOCAL,
320
320
  browser: false,
321
321
  args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }],
322
322
  columns: ['repeat'],
@@ -26,6 +26,7 @@ export declare function sendCommandFull(action: DaemonCommand['action'], params?
26
26
  data: unknown;
27
27
  page?: string;
28
28
  }>;
29
+ export declare function releaseSiteSessionLease(runId: string): Promise<void>;
29
30
  export declare function bindTab(session: string, opts?: {
30
31
  contextId?: string;
31
32
  preferredContextId?: string;
@@ -4,8 +4,9 @@
4
4
  * Provides a typed send() function that posts a Command and returns a Result.
5
5
  */
6
6
  import { sleep } from '../utils.js';
7
- import { BrowserConnectError } from '../errors.js';
7
+ import { BrowserConnectError, SessionBusyError } from '../errors.js';
8
8
  import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js';
9
+ import { getDaemonRunContext } from '../session-lease.js';
9
10
  import { classifyBrowserError } from './errors.js';
10
11
  import { profileRouteParams, resolveProfileSelection } from './profile.js';
11
12
  import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js';
@@ -112,6 +113,7 @@ async function sendCommandRaw(action, params) {
112
113
  throw new BrowserCommandError('Browser command deadline exhausted across transport retries.', COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT);
113
114
  }
114
115
  const remainingMs = Math.max(1000, deadlineAt - Date.now());
116
+ const run = action === 'lease-release' ? undefined : getDaemonRunContext();
115
117
  const command = {
116
118
  id,
117
119
  action,
@@ -121,6 +123,12 @@ async function sendCommandRaw(action, params) {
121
123
  ...(contextId && { contextId }),
122
124
  ...(preferredContextId && { preferredContextId }),
123
125
  ...(windowMode && { windowMode }),
126
+ ...(run && {
127
+ runId: run.runId,
128
+ command: run.command,
129
+ access: run.access,
130
+ pid: process.pid,
131
+ }),
124
132
  };
125
133
  try {
126
134
  const res = await requestDaemon('/command', {
@@ -132,6 +140,9 @@ async function sendCommandRaw(action, params) {
132
140
  const result = (await res.json());
133
141
  if (result.ok)
134
142
  return result;
143
+ if (res.status === 409 && result.code === 'session_busy' && result.holder) {
144
+ throw new SessionBusyError(result.holder);
145
+ }
135
146
  if (result.errorCode && UNKNOWN_OUTCOME_CODES.has(result.errorCode)) {
136
147
  throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
137
148
  }
@@ -187,6 +198,19 @@ export async function sendCommandFull(action, params = {}) {
187
198
  const result = await sendCommandRaw(action, params);
188
199
  return { data: result.data, page: result.page };
189
200
  }
201
+ export async function releaseSiteSessionLease(runId) {
202
+ const command = {
203
+ id: generateId(),
204
+ action: 'lease-release',
205
+ runId,
206
+ };
207
+ await requestDaemon('/command', {
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify(command),
211
+ timeout: 2_000,
212
+ }).catch(() => undefined);
213
+ }
190
214
  export async function bindTab(session, opts = {}) {
191
215
  return sendCommand('bind', { session, surface: 'browser', ...opts });
192
216
  }
@@ -1,11 +1,15 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, sendCommand, setDaemonCommandTimeoutSeconds, } from './daemon-client.js';
2
+ import { fetchDaemonStatus, getDaemonHealth, releaseSiteSessionLease, requestDaemonShutdown, sendCommand, setDaemonCommandTimeoutSeconds, } from './daemon-client.js';
3
3
  import * as daemonLifecycle from './daemon-lifecycle.js';
4
+ import { clearDaemonRunContext, getDaemonRunContext, setDaemonRunContext } from '../session-lease.js';
4
5
  describe('daemon-client', () => {
5
6
  beforeEach(() => {
6
7
  vi.stubGlobal('fetch', vi.fn());
7
8
  });
8
9
  afterEach(() => {
10
+ const run = getDaemonRunContext();
11
+ if (run)
12
+ clearDaemonRunContext(run.runId);
9
13
  if (typeof setDaemonCommandTimeoutSeconds === 'function') {
10
14
  setDaemonCommandTimeoutSeconds(null);
11
15
  }
@@ -142,6 +146,25 @@ describe('daemon-client', () => {
142
146
  expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
143
147
  expect(ids[0]).not.toBe(ids[1]);
144
148
  });
149
+ it('sendCommand binds the active logical run metadata to each daemon operation', async () => {
150
+ setDaemonRunContext({
151
+ runId: 'run_4242_1000_1',
152
+ command: 'example write',
153
+ access: 'write',
154
+ });
155
+ vi.mocked(fetch).mockResolvedValue({
156
+ status: 200,
157
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
158
+ });
159
+ await sendCommand('exec', { code: '1' });
160
+ const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
161
+ expect(body).toMatchObject({
162
+ runId: 'run_4242_1000_1',
163
+ command: 'example write',
164
+ access: 'write',
165
+ pid: process.pid,
166
+ });
167
+ });
145
168
  it('sendCommand forwards WEBCMD_PROFILE as a hard contextId requirement', async () => {
146
169
  vi.stubEnv('WEBCMD_PROFILE', 'work');
147
170
  vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
@@ -200,6 +223,68 @@ describe('daemon-client', () => {
200
223
  });
201
224
  expect(fetchMock).toHaveBeenCalledTimes(1);
202
225
  });
226
+ it('maps daemon session_busy conflicts to SessionBusyError without retrying', async () => {
227
+ const fetchMock = vi.mocked(fetch);
228
+ fetchMock.mockResolvedValueOnce({
229
+ ok: false,
230
+ status: 409,
231
+ json: () => Promise.resolve({
232
+ ok: false,
233
+ code: 'session_busy',
234
+ holder: {
235
+ command: 'other write',
236
+ pid: 4242,
237
+ acquiredAt: 1_000,
238
+ heartbeatAt: 2_000,
239
+ },
240
+ }),
241
+ });
242
+ await expect(sendCommand('exec', { code: '1' })).rejects.toMatchObject({
243
+ name: 'SessionBusyError',
244
+ code: 'SESSION_BUSY',
245
+ message: expect.stringContaining('other write'),
246
+ });
247
+ expect(fetchMock).toHaveBeenCalledTimes(1);
248
+ });
249
+ it('releaseSiteSessionLease makes one best-effort POST without starting or retrying the daemon', async () => {
250
+ setDaemonRunContext({
251
+ runId: 'run_9999_newer_2',
252
+ command: 'newer write',
253
+ access: 'write',
254
+ });
255
+ const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady');
256
+ vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed'));
257
+ await expect(releaseSiteSessionLease('run_4242_1000_1')).resolves.toBeUndefined();
258
+ expect(fetch).toHaveBeenCalledTimes(1);
259
+ expect(ensureSpy).not.toHaveBeenCalled();
260
+ const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
261
+ expect(body).toMatchObject({
262
+ action: 'lease-release',
263
+ runId: 'run_4242_1000_1',
264
+ });
265
+ });
266
+ it('releaseSiteSessionLease aborts its best-effort POST after two seconds', async () => {
267
+ vi.useFakeTimers();
268
+ try {
269
+ let aborted = false;
270
+ vi.mocked(fetch).mockImplementationOnce((_url, init) => new Promise((_, reject) => {
271
+ init?.signal?.addEventListener('abort', () => {
272
+ aborted = true;
273
+ reject(Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }));
274
+ });
275
+ }));
276
+ const pending = releaseSiteSessionLease('run_4242_1000_1');
277
+ await vi.advanceTimersByTimeAsync(1_999);
278
+ expect(aborted).toBe(false);
279
+ await vi.advanceTimersByTimeAsync(1);
280
+ expect(aborted).toBe(true);
281
+ await expect(pending).resolves.toBeUndefined();
282
+ expect(fetch).toHaveBeenCalledTimes(1);
283
+ }
284
+ finally {
285
+ vi.useRealTimers();
286
+ }
287
+ });
203
288
  it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
204
289
  const fetchMock = vi.mocked(fetch);
205
290
  fetchMock.mockResolvedValue({
@@ -1,3 +1,4 @@
1
+ import type { SessionLeaseStatus } from '../session-lease.js';
1
2
  export interface DaemonStatus {
2
3
  ok: boolean;
3
4
  pid: number;
@@ -12,6 +13,7 @@ export interface DaemonStatus {
12
13
  profiles?: BrowserProfileStatus[];
13
14
  pending: number;
14
15
  commandResultUnknown?: number;
16
+ sessionLeases?: SessionLeaseStatus[];
15
17
  memoryMB: number;
16
18
  port: number;
17
19
  }
@@ -1,4 +1,5 @@
1
- export type BrowserRuntimeAction = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
1
+ import type { SessionLeaseStatus } from '../session-lease.js';
2
+ export type BrowserRuntimeAction = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames' | 'lease-release';
2
3
  export type BrowserSurface = 'browser' | 'adapter';
3
4
  export type SiteSessionMode = 'ephemeral' | 'persistent';
4
5
  export type BrowserWindowMode = 'foreground' | 'background';
@@ -38,6 +39,14 @@ export interface BrowserRuntimeCommand {
38
39
  contextId?: string;
39
40
  preferredContextId?: string;
40
41
  profileId?: string;
42
+ /** Stable identity for the complete logical CLI command run. */
43
+ runId?: string;
44
+ /** Human-readable canonical command that owns the logical run. */
45
+ command?: string;
46
+ /** Access classification used by daemon-local lease arbitration. */
47
+ access?: 'read' | 'write';
48
+ /** Originating CLI process, used only for actionable local busy guidance. */
49
+ pid?: number;
41
50
  }
42
51
  export interface BrowserRuntimeResult {
43
52
  id: string;
@@ -65,4 +74,6 @@ export interface BrowserRuntimeStatus {
65
74
  profiles: BrowserRuntimeProfileStatus[];
66
75
  pending: number;
67
76
  commandResultUnknown?: number;
77
+ /** Active local leases with internal run ownership tokens removed. */
78
+ sessionLeases?: SessionLeaseStatus[];
68
79
  }
@@ -1,3 +1,4 @@
1
1
  import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js';
2
2
  import type { CloakSessionManager } from './session-manager.js';
3
+ export declare function resolveCloakCommandProfileId(manager: CloakSessionManager, command: BrowserRuntimeCommand): string;
3
4
  export declare function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;