@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
package/README.md CHANGED
@@ -8,11 +8,14 @@
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-7C3AED.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>
14
17
  <a href="https://discord.gg/9YP2C9tvMp">
15
- <img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-7C3AED.svg?style=for-the-badge&logo=discord&logoColor=white&labelColor=000000&logoWidth=20">
18
+ <img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white&labelColor=000000&logoWidth=20">
16
19
  </a>
17
20
  <a href="https://x.com/agentrhq">
18
21
  <img alt="Follow AgentR on X" src="https://img.shields.io/badge/Built%20by%20%40agentrhq-000000.svg?style=for-the-badge&logo=x&logoColor=white&labelColor=000000&logoWidth=20">
@@ -123,15 +126,17 @@ Agents usually want `-f json`; humans usually want table (default) or yaml.
123
126
 
124
127
  Webcmd is designed to be driven by coding agents such as Codex, Claude Code, Cursor, and similar tools.
125
128
 
126
- ## Install skills (also refreshes existing installs)
129
+ ## Add skills (also refreshes existing links)
127
130
 
128
- Install Webcmd skills into your agent environment:
131
+ Add Webcmd skills to your agent environment:
129
132
 
130
133
  ```bash
131
- webcmd skills install
134
+ webcmd skills add
132
135
  ```
133
136
 
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`.
137
+ 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`.
138
+
139
+ 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
140
 
136
141
  ### Which skill to use
137
142
 
@@ -312,8 +317,8 @@ Common paths:
312
317
  | `~/.webcmd/clis/` | Private adapters. |
313
318
  | `~/.webcmd/cache/browser-network/` | Cached browser network captures. |
314
319
  | `~/.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. |
320
+ | `~/.agents/skills/` | Common global skills directory for agent skill managers. |
321
+ | `.agents/skills/` | Common workspace-local skills directory. |
317
322
 
318
323
  ## Troubleshooting
319
324
 
@@ -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',
@@ -21,6 +21,7 @@ cli({
21
21
  const count = Math.min(Number(args.limit) || 20, 50);
22
22
  await page.goto('https://www.producthunt.com');
23
23
  await page.wait(2);
24
+ await assertProductHuntAccessible(page);
24
25
  const domItems = await page.evaluate(`
25
26
  (() => {
26
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
  });
@@ -71,7 +71,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
71
71
  const rawNodes = Array.isArray(axTree?.nodes)
72
72
  ? axTree.nodes
73
73
  : [];
74
- const nodes = rawNodes.filter((node) => node && !node.ignored);
74
+ const nodes = rawNodes.filter((node) => node);
75
75
  const byId = new Map();
76
76
  const parentIds = new Set();
77
77
  for (const node of nodes) {
@@ -88,11 +88,21 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
88
88
  });
89
89
  const root = roots[0] ?? nodes[0];
90
90
  const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
91
+ const maxTraversalHops = 200;
91
92
  const roleNameCounts = countRoleNames(nodes);
92
93
  const roleNameSeen = new Map();
93
- function render(node, depth) {
94
- if (!node || depth > maxDepth)
94
+ function render(node, depth, hops, path) {
95
+ if (!node || depth > maxDepth || hops > maxTraversalHops || path.has(node))
95
96
  return false;
97
+ const nextPath = new Set(path).add(node);
98
+ if (node.ignored) {
99
+ let hasVisibleChild = false;
100
+ for (const childId of node.childIds ?? []) {
101
+ if (render(byId.get(childId), depth, hops + 1, nextPath))
102
+ hasVisibleChild = true;
103
+ }
104
+ return hasVisibleChild;
105
+ }
96
106
  const role = axString(node.role) || 'generic';
97
107
  const name = cleanText(axString(node.name));
98
108
  const value = cleanText(axString(node.value) || propertyValue(node, 'value'));
@@ -109,7 +119,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
109
119
  const childStart = lines.length;
110
120
  let hasVisibleChild = false;
111
121
  for (const childId of node.childIds ?? []) {
112
- if (render(byId.get(childId), depth + 1))
122
+ if (render(byId.get(childId), depth + 1, hops + 1, nextPath))
113
123
  hasVisibleChild = true;
114
124
  }
115
125
  if (!shouldShowSelf && !hasVisibleChild) {
@@ -151,7 +161,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
151
161
  }
152
162
  return true;
153
163
  }
154
- render(root, opts.baseDepth);
164
+ render(root, opts.baseDepth, 0, new Set());
155
165
  return nextRef;
156
166
  }
157
167
  export function findAxRefReplacement(axTree, ref) {
@@ -1,6 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import { buildAxSnapshot, buildAxSnapshotFromTrees, findAxRefReplacement } from './ax-snapshot.js';
3
4
  describe('AX snapshot prototype', () => {
5
+ it('publishes the AX snapshot builder as a package subpath', () => {
6
+ const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
7
+ expect(packageJson.exports?.['./browser/ax-snapshot'])
8
+ .toBe('./dist/src/browser/ax-snapshot.js');
9
+ });
4
10
  it('builds compact refs from Accessibility.getFullAXTree output', () => {
5
11
  const result = buildAxSnapshot({
6
12
  nodes: [
@@ -25,6 +31,49 @@ describe('AX snapshot prototype', () => {
25
31
  name: 'Category',
26
32
  });
27
33
  });
34
+ it('keeps actionable descendants of ignored AX nodes', () => {
35
+ const result = buildAxSnapshot({
36
+ nodes: [
37
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
38
+ { nodeId: '2', ignored: true, childIds: ['3', '4'] },
39
+ { nodeId: '3', role: { value: 'button' }, name: { value: 'First' }, backendDOMNodeId: 30 },
40
+ { nodeId: '4', role: { value: 'button' }, name: { value: 'Second' }, backendDOMNodeId: 40 },
41
+ ],
42
+ });
43
+ expect(result.text).toContain(' [1]button "First"\n [2]button "Second"');
44
+ expect(result.text).not.toContain('ignored');
45
+ expect([...result.refs.values()]).toEqual([
46
+ { ref: '1', backendNodeId: 30, role: 'button', name: 'First' },
47
+ { ref: '2', backendNodeId: 40, role: 'button', name: 'Second' },
48
+ ]);
49
+ });
50
+ it('stops ignored-node cycles without changing promoted indentation', () => {
51
+ const result = buildAxSnapshot({
52
+ nodes: [
53
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
54
+ { nodeId: '2', ignored: true, childIds: ['2', '3'] },
55
+ { nodeId: '3', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 30 },
56
+ ],
57
+ });
58
+ expect(result.text).toContain('\n [1]button "Save"\n');
59
+ expect(result.refs).toHaveLength(1);
60
+ });
61
+ it('bounds traversal through long ignored-node chains', () => {
62
+ const ignored = Array.from({ length: 201 }, (_, index) => ({
63
+ nodeId: String(index + 2),
64
+ ignored: true,
65
+ childIds: [String(index + 3)],
66
+ }));
67
+ const result = buildAxSnapshot({
68
+ nodes: [
69
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
70
+ ...ignored,
71
+ { nodeId: '203', role: { value: 'button' }, name: { value: 'Too deep' }, backendDOMNodeId: 203 },
72
+ ],
73
+ });
74
+ expect(result.text).not.toContain('Too deep');
75
+ expect(result.refs).toHaveLength(0);
76
+ });
28
77
  it('tracks nth only for duplicate role/name pairs', () => {
29
78
  const result = buildAxSnapshot({
30
79
  nodes: [
@@ -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>;
@@ -10,7 +10,7 @@ class CloakActionError extends Error {
10
10
  this.errorHint = errorHint;
11
11
  }
12
12
  }
13
- function commandProfileId(manager, command) {
13
+ export function resolveCloakCommandProfileId(manager, command) {
14
14
  const requested = command.profileId ?? command.contextId;
15
15
  if (requested?.trim())
16
16
  return requested.trim();
@@ -38,7 +38,7 @@ async function resolveLease(manager, command) {
38
38
  throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`);
39
39
  }
40
40
  return manager.getPage({
41
- profileId: commandProfileId(manager, command),
41
+ profileId: resolveCloakCommandProfileId(manager, command),
42
42
  session: command.session,
43
43
  surface: command.surface,
44
44
  siteSession: command.siteSession,
@@ -109,12 +109,12 @@ export async function dispatchCloakAction(manager, command) {
109
109
  }
110
110
  case 'close-window': {
111
111
  if (command.page) {
112
- const closed = await manager.closePage({ profileId: commandProfileId(manager, command), pageId: command.page });
112
+ const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page });
113
113
  return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } };
114
114
  }
115
115
  else {
116
116
  await manager.release({
117
- profileId: commandProfileId(manager, command),
117
+ profileId: resolveCloakCommandProfileId(manager, command),
118
118
  session: command.session,
119
119
  surface: command.surface,
120
120
  });
@@ -124,12 +124,12 @@ export async function dispatchCloakAction(manager, command) {
124
124
  case 'tabs': {
125
125
  switch (command.op ?? 'list') {
126
126
  case 'list': {
127
- const tabs = await manager.listPages({ profileId: commandProfileId(manager, command) });
127
+ const tabs = await manager.listPages({ profileId: resolveCloakCommandProfileId(manager, command) });
128
128
  return { id: command.id, ok: true, data: tabs };
129
129
  }
130
130
  case 'new': {
131
131
  const lease = await manager.newPage({
132
- profileId: commandProfileId(manager, command),
132
+ profileId: resolveCloakCommandProfileId(manager, command),
133
133
  session: command.session,
134
134
  surface: command.surface,
135
135
  siteSession: command.siteSession,
@@ -140,13 +140,13 @@ export async function dispatchCloakAction(manager, command) {
140
140
  return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId };
141
141
  }
142
142
  case 'select': {
143
- const lease = await manager.selectPage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode });
143
+ const lease = await manager.selectPage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode });
144
144
  if (!lease)
145
145
  return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
146
146
  return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId };
147
147
  }
148
148
  case 'close': {
149
- const closed = await manager.closePage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index });
149
+ const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index });
150
150
  if (!closed)
151
151
  return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
152
152
  return { id: command.id, ok: true, data: { closed } };
@@ -214,7 +214,7 @@ export async function dispatchCloakAction(manager, command) {
214
214
  }
215
215
  {
216
216
  const lease = await manager.bindPage({
217
- profileId: commandProfileId(manager, command),
217
+ profileId: resolveCloakCommandProfileId(manager, command),
218
218
  session: command.session,
219
219
  surface: command.surface,
220
220
  siteSession: command.siteSession,
@@ -10,6 +10,7 @@ export declare class LocalCloakRuntimeProvider implements BrowserRuntimeProvider
10
10
  private readonly manager;
11
11
  constructor(opts?: LocalCloakRuntimeProviderOptions);
12
12
  status(_opts?: RuntimeStatusOptions): Promise<BrowserRuntimeStatus>;
13
+ resolveProfileId(command: BrowserRuntimeCommand): string;
13
14
  dispatch(command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;
14
15
  shutdown(): Promise<void>;
15
16
  }