@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,8 +1,37 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js';
3
3
  import { buildCommandTimeoutFailure, getResponseCorsHeaders } from '../daemon-utils.js';
4
+ import { getSessionLeaseKey, isSessionLeaseCommand, SessionLeaseRegistry } from '../session-lease.js';
4
5
  const MAX_BODY = 1024 * 1024;
5
6
  const LOG_BUFFER_SIZE = 200;
7
+ function commandTimeoutMs(command) {
8
+ return typeof command.deadlineAt === 'number' && command.deadlineAt > 0
9
+ ? Math.max(1000, command.deadlineAt - Date.now())
10
+ : (typeof command.timeout === 'number' && command.timeout > 0 ? command.timeout * 1000 : 120_000);
11
+ }
12
+ function waitForCommandResult(command, providerPromise) {
13
+ const timeoutMs = commandTimeoutMs(command);
14
+ let timeoutId;
15
+ const responsePromise = Promise.race([
16
+ providerPromise,
17
+ new Promise((resolve) => {
18
+ timeoutId = setTimeout(() => {
19
+ const failure = buildCommandTimeoutFailure(command.action, timeoutMs);
20
+ resolve({
21
+ id: command.id,
22
+ ok: false,
23
+ errorCode: failure.errorCode,
24
+ error: failure.message,
25
+ errorHint: failure.errorHint,
26
+ });
27
+ }, timeoutMs);
28
+ }),
29
+ ]);
30
+ return responsePromise.finally(() => {
31
+ if (timeoutId)
32
+ clearTimeout(timeoutId);
33
+ });
34
+ }
6
35
  function readBody(req) {
7
36
  return new Promise((resolve, reject) => {
8
37
  const chunks = [];
@@ -37,6 +66,8 @@ export function createDaemonServer(provider, opts) {
37
66
  const host = opts.host ?? '127.0.0.1';
38
67
  const logBuffer = [];
39
68
  const pending = new Map();
69
+ const leases = new SessionLeaseRegistry();
70
+ const hasPendingWork = (runId) => [...pending.values()].some((entry) => entry.runId === runId);
40
71
  let shutdownStarted = false;
41
72
  function pushLog(level, msg) {
42
73
  logBuffer.push({ level, msg, ts: Date.now() });
@@ -82,6 +113,7 @@ export function createDaemonServer(provider, opts) {
82
113
  daemonVersion: opts.version,
83
114
  ...runtime,
84
115
  pending: pending.size + runtime.pending,
116
+ sessionLeases: leases.list(hasPendingWork).map(({ runId: _runId, ...lease }) => lease),
85
117
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
86
118
  port,
87
119
  });
@@ -117,35 +149,44 @@ export function createDaemonServer(provider, opts) {
117
149
  }
118
150
  const existing = pending.get(body.id);
119
151
  if (existing) {
120
- const result = await existing;
152
+ const result = await waitForCommandResult(body, existing.promise);
121
153
  jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result);
122
154
  return;
123
155
  }
124
- const timeoutMs = typeof body.deadlineAt === 'number' && body.deadlineAt > 0
125
- ? Math.max(1000, body.deadlineAt - Date.now())
126
- : (typeof body.timeout === 'number' && body.timeout > 0 ? body.timeout * 1000 : 120_000);
127
- let timeoutId;
128
- const commandPromise = Promise.race([
129
- provider.dispatch(body),
130
- new Promise((resolve) => {
131
- timeoutId = setTimeout(() => {
132
- const failure = buildCommandTimeoutFailure(body.action, timeoutMs);
133
- resolve({
134
- id: body.id,
135
- ok: false,
136
- errorCode: failure.errorCode,
137
- error: failure.message,
138
- errorHint: failure.errorHint,
139
- });
140
- }, timeoutMs);
141
- }),
142
- ]).finally(() => {
143
- if (timeoutId)
144
- clearTimeout(timeoutId);
156
+ if (body.action === 'lease-release') {
157
+ const released = typeof body.runId === 'string' ? leases.releaseByRunId(body.runId) : 0;
158
+ jsonResponse(res, 200, { id: body.id, ok: true, data: { released } });
159
+ return;
160
+ }
161
+ let leaseKey;
162
+ let runId;
163
+ if (isSessionLeaseCommand(body)) {
164
+ const profileId = provider.resolveProfileId?.(body)
165
+ ?? body.profileId
166
+ ?? body.contextId
167
+ ?? body.preferredContextId
168
+ ?? 'default';
169
+ leaseKey = getSessionLeaseKey(profileId, body.surface, body.session);
170
+ runId = body.runId;
171
+ const acquired = leases.acquire({
172
+ key: leaseKey,
173
+ runId,
174
+ command: body.command ?? body.action,
175
+ pid: body.pid,
176
+ }, hasPendingWork);
177
+ if (!acquired.acquired) {
178
+ const { key: _key, runId: _runId, ...holder } = acquired.holder;
179
+ jsonResponse(res, 409, { ok: false, code: 'session_busy', holder });
180
+ return;
181
+ }
182
+ }
183
+ const commandPromise = provider.dispatch(body).finally(() => {
184
+ if (leaseKey && runId)
185
+ leases.heartbeat(leaseKey, runId);
145
186
  pending.delete(body.id);
146
187
  });
147
- pending.set(body.id, commandPromise);
148
- const result = await commandPromise;
188
+ pending.set(body.id, { promise: commandPromise, runId, leaseKey });
189
+ const result = await waitForCommandResult(body, commandPromise);
149
190
  if (!result.ok)
150
191
  pushLog('warn', `Command ${body.id} failed: ${result.error ?? result.errorCode ?? 'unknown error'}`);
151
192
  jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result);
@@ -5,6 +5,11 @@ class FakeProvider {
5
5
  commands = [];
6
6
  shutdownCalled = false;
7
7
  delayMs = 0;
8
+ dispatchImpl;
9
+ resolveProfileId;
10
+ result(command) {
11
+ return { id: command.id, ok: true, data: { action: command.action }, page: 'page-1' };
12
+ }
8
13
  async status() {
9
14
  return {
10
15
  runtimeConnected: true,
@@ -16,10 +21,12 @@ class FakeProvider {
16
21
  };
17
22
  }
18
23
  async dispatch(command) {
24
+ this.commands.push(command);
25
+ if (this.dispatchImpl)
26
+ return this.dispatchImpl(command);
19
27
  if (this.delayMs > 0)
20
28
  await new Promise((resolve) => setTimeout(resolve, this.delayMs));
21
- this.commands.push(command);
22
- return { id: command.id, ok: true, data: { action: command.action }, page: 'page-1' };
29
+ return this.result(command);
23
30
  }
24
31
  async shutdown() {
25
32
  this.shutdownCalled = true;
@@ -30,6 +37,7 @@ describe('createDaemonServer', () => {
30
37
  afterEach(async () => {
31
38
  while (servers.length)
32
39
  await servers.pop().close();
40
+ vi.useRealTimers();
33
41
  });
34
42
  async function start(provider = new FakeProvider()) {
35
43
  const daemon = createDaemonServer(provider, { port: 0, host: '127.0.0.1', version: 'test' });
@@ -38,6 +46,28 @@ describe('createDaemonServer', () => {
38
46
  const address = daemon.server.address();
39
47
  return { provider, baseUrl: `http://127.0.0.1:${address.port}` };
40
48
  }
49
+ function postCommand(baseUrl, body) {
50
+ return fetch(`${baseUrl}/command`, {
51
+ method: 'POST',
52
+ headers: { [DAEMON_HEADER_NAME]: '1', 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(body),
54
+ });
55
+ }
56
+ function persistentWrite(id, runId, overrides = {}) {
57
+ return {
58
+ id,
59
+ action: 'exec',
60
+ code: '1',
61
+ surface: 'adapter',
62
+ siteSession: 'persistent',
63
+ access: 'write',
64
+ session: 'site:example',
65
+ runId,
66
+ command: 'example write',
67
+ pid: 4242,
68
+ ...overrides,
69
+ };
70
+ }
41
71
  it('returns runtime-named status fields without extension aliases', async () => {
42
72
  const { baseUrl } = await start();
43
73
  const res = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
@@ -145,6 +175,185 @@ describe('createDaemonServer', () => {
145
175
  await expect(second.json()).resolves.toMatchObject({ id: 'same-id', ok: true });
146
176
  expect(provider.commands).toHaveLength(1);
147
177
  });
178
+ it('rejects a second persistent adapter writer for the same resolved profile and session', async () => {
179
+ const provider = new FakeProvider();
180
+ provider.resolveProfileId = () => 'resolved-work';
181
+ const { baseUrl } = await start(provider);
182
+ const first = await postCommand(baseUrl, persistentWrite('first', 'run_100_1_1'));
183
+ expect(first.status).toBe(200);
184
+ const second = await postCommand(baseUrl, persistentWrite('second', 'run_200_2_2'));
185
+ expect(second.status).toBe(409);
186
+ await expect(second.json()).resolves.toMatchObject({
187
+ ok: false,
188
+ code: 'session_busy',
189
+ holder: {
190
+ command: 'example write',
191
+ pid: 4242,
192
+ acquiredAt: expect.any(Number),
193
+ heartbeatAt: expect.any(Number),
194
+ },
195
+ });
196
+ expect(provider.commands.map((command) => command.id)).toEqual(['first']);
197
+ });
198
+ it('lets one logical run issue multiple operations and heartbeat its lease', async () => {
199
+ let now = 1_000;
200
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
201
+ const { provider, baseUrl } = await start();
202
+ expect((await postCommand(baseUrl, persistentWrite('first', 'run_100_1_1'))).status).toBe(200);
203
+ now = 20_000;
204
+ expect((await postCommand(baseUrl, persistentWrite('second', 'run_100_1_1'))).status).toBe(200);
205
+ const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
206
+ await expect(status.json()).resolves.toMatchObject({
207
+ sessionLeases: [{
208
+ key: 'default␟adapter␟site%3Aexample',
209
+ command: 'example write',
210
+ acquiredAt: 1_000,
211
+ heartbeatAt: 20_000,
212
+ }],
213
+ });
214
+ expect(provider.commands.map((command) => command.id)).toEqual(['first', 'second']);
215
+ });
216
+ it.each([
217
+ ['read access', { access: 'read' }],
218
+ ['ephemeral sessions', { siteSession: 'ephemeral' }],
219
+ ['raw browser surface', { surface: 'browser' }],
220
+ ['different sites', { session: 'site:other' }],
221
+ ['different resolved profiles', { profileId: 'other' }],
222
+ ])('does not conflict across %s', async (_case, overrides) => {
223
+ const provider = new FakeProvider();
224
+ provider.resolveProfileId = (command) => command.profileId ?? 'default';
225
+ const { baseUrl } = await start(provider);
226
+ expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
227
+ expect((await postCommand(baseUrl, persistentWrite('other', 'run_200_2_2', overrides))).status).toBe(200);
228
+ expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'other']);
229
+ });
230
+ it('keeps pending holders live past the TTL and heartbeats them before pending removal', async () => {
231
+ let now = 1_000;
232
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
233
+ let settle;
234
+ const provider = new FakeProvider();
235
+ provider.dispatchImpl = (command) => new Promise((resolve) => {
236
+ settle = () => resolve({ id: command.id, ok: true, data: 'done' });
237
+ });
238
+ const { baseUrl } = await start(provider);
239
+ const pending = postCommand(baseUrl, persistentWrite('pending', 'run_100_1_1'));
240
+ try {
241
+ await vi.waitFor(() => expect(provider.commands).toHaveLength(1));
242
+ now = 46_001;
243
+ const whilePending = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
244
+ await expect(whilePending.json()).resolves.toMatchObject({
245
+ sessionLeases: [{ command: 'example write', heartbeatAt: 1_000 }],
246
+ });
247
+ const conflict = await postCommand(baseUrl, persistentWrite('conflict', 'run_200_2_2'));
248
+ expect(conflict.status).toBe(409);
249
+ expect(provider.commands).toHaveLength(1);
250
+ now = 50_000;
251
+ settle?.();
252
+ expect((await pending).status).toBe(200);
253
+ const afterSettle = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
254
+ const statusBody = await afterSettle.json();
255
+ expect(statusBody.sessionLeases).toEqual([expect.objectContaining({ heartbeatAt: 50_000 })]);
256
+ expect(statusBody.sessionLeases[0]).not.toHaveProperty('runId');
257
+ }
258
+ finally {
259
+ settle?.();
260
+ await pending.catch(() => undefined);
261
+ }
262
+ });
263
+ it('keeps timed-out provider work pending until provider settlement', async () => {
264
+ let now = 1_000;
265
+ const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => now);
266
+ let settleProvider;
267
+ let providerStarted;
268
+ const providerStartedPromise = new Promise((resolve) => {
269
+ providerStarted = resolve;
270
+ });
271
+ const provider = new FakeProvider();
272
+ provider.dispatchImpl = (command) => {
273
+ if (command.id !== 'timed-out') {
274
+ return Promise.resolve({ id: command.id, ok: true, data: 'done' });
275
+ }
276
+ if (provider.commands.filter(({ id }) => id === command.id).length > 1) {
277
+ return Promise.resolve({ id: command.id, ok: true, data: 'duplicate dispatch' });
278
+ }
279
+ providerStarted();
280
+ return new Promise((resolve) => {
281
+ settleProvider = () => resolve({ id: command.id, ok: true, data: 'done' });
282
+ });
283
+ };
284
+ const { baseUrl } = await start(provider);
285
+ const firstRequest = postCommand(baseUrl, persistentWrite('timed-out', 'run_100_1_1', { timeout: 0.01 }));
286
+ try {
287
+ await providerStartedPromise;
288
+ const timedOut = await firstRequest;
289
+ expect(timedOut.status).toBe(408);
290
+ await expect(timedOut.json()).resolves.toMatchObject({
291
+ id: 'timed-out',
292
+ ok: false,
293
+ errorCode: 'command_result_unknown',
294
+ });
295
+ now = 47_001;
296
+ const duplicateRequest = postCommand(baseUrl, persistentWrite('timed-out', 'run_100_1_1', { timeout: 120 }));
297
+ const whileProviderPending = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
298
+ await expect(whileProviderPending.json()).resolves.toMatchObject({
299
+ pending: 1,
300
+ sessionLeases: [{ command: 'example write', heartbeatAt: 1_000 }],
301
+ });
302
+ expect(provider.commands.map((command) => command.id)).toEqual(['timed-out']);
303
+ const conflict = await postCommand(baseUrl, persistentWrite('conflict', 'run_200_2_2'));
304
+ expect(conflict.status).toBe(409);
305
+ expect(provider.commands.map((command) => command.id)).toEqual(['timed-out']);
306
+ settleProvider?.();
307
+ const duplicate = await duplicateRequest;
308
+ expect(duplicate.status).toBe(200);
309
+ await expect(duplicate.json()).resolves.toMatchObject({ data: 'done' });
310
+ const afterProviderSettle = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
311
+ await expect(afterProviderSettle.json()).resolves.toMatchObject({
312
+ pending: 0,
313
+ sessionLeases: [{ command: 'example write', heartbeatAt: 47_001 }],
314
+ });
315
+ now = 92_002;
316
+ const reclaimed = await postCommand(baseUrl, persistentWrite('reclaimed', 'run_200_2_2'));
317
+ expect(reclaimed.status).toBe(200);
318
+ expect(provider.commands.map((command) => command.id)).toEqual(['timed-out', 'reclaimed']);
319
+ }
320
+ finally {
321
+ settleProvider?.();
322
+ await firstRequest.catch(() => undefined);
323
+ dateNow.mockRestore();
324
+ }
325
+ });
326
+ it('handles lease-release locally and permits a new owner', async () => {
327
+ const { provider, baseUrl } = await start();
328
+ expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
329
+ const released = await postCommand(baseUrl, {
330
+ id: 'release',
331
+ action: 'lease-release',
332
+ runId: 'run_100_1_1',
333
+ });
334
+ expect(released.status).toBe(200);
335
+ await expect(released.json()).resolves.toMatchObject({
336
+ id: 'release',
337
+ ok: true,
338
+ data: { released: 1 },
339
+ });
340
+ expect(provider.commands.map((command) => command.id)).toEqual(['owner']);
341
+ expect((await postCommand(baseUrl, persistentWrite('next-owner', 'run_200_2_2'))).status).toBe(200);
342
+ expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']);
343
+ });
344
+ it('returns only sanitized current holders from status', async () => {
345
+ let now = 1_000;
346
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
347
+ const { baseUrl } = await start();
348
+ expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
349
+ const current = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
350
+ const currentBody = await current.json();
351
+ expect(currentBody.sessionLeases).toHaveLength(1);
352
+ expect(currentBody.sessionLeases[0]).not.toHaveProperty('runId');
353
+ now = 46_001;
354
+ const expired = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
355
+ await expect(expired.json()).resolves.toMatchObject({ sessionLeases: [] });
356
+ });
148
357
  it('requires X-Webcmd on non-ping endpoints', async () => {
149
358
  const { baseUrl } = await start();
150
359
  const res = await fetch(`${baseUrl}/status`);
@@ -310,7 +310,7 @@ describe('documentation sync workflow', () => {
310
310
  expect(workflow).toContain(event);
311
311
  }
312
312
  expect(workflow).toContain('contents: read');
313
- expect(workflow).toContain('pull-requests: read');
313
+ expect(workflow).toContain('pull-requests: write');
314
314
  expect(workflow).toContain('issues: write');
315
315
  expect(workflow).toContain('ref: ${{ github.event.repository.default_branch }}');
316
316
  expect(workflow).toContain('GH_TOKEN: ${{ github.token }}');
@@ -20,6 +20,7 @@
20
20
  * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler)
21
21
  */
22
22
  import type { ObservationTraceReceipt } from './observation/events.js';
23
+ import { type SessionLeaseHolder } from './session-lease.js';
23
24
  export declare const EXIT_CODES: {
24
25
  readonly SUCCESS: 0;
25
26
  readonly GENERIC_ERROR: 1;
@@ -72,6 +73,10 @@ export declare class TimeoutError extends CliError {
72
73
  export declare class ArgumentError extends CliError {
73
74
  constructor(message: string, hint?: string);
74
75
  }
76
+ /** A persistent write session is temporarily owned by another logical run. */
77
+ export declare class SessionBusyError extends CliError {
78
+ constructor(holder: SessionLeaseHolder, platform?: string);
79
+ }
75
80
  export declare class EmptyResultError extends CliError {
76
81
  constructor(command: string, hint?: string);
77
82
  }
@@ -1,3 +1,4 @@
1
+ import { isActionablePid } from './session-lease.js';
1
2
  // ── Exit code table ──────────────────────────────────────────────────────────
2
3
  export const EXIT_CODES = {
3
4
  SUCCESS: 0,
@@ -81,6 +82,28 @@ export class ArgumentError extends CliError {
81
82
  super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
82
83
  }
83
84
  }
85
+ function formatBusyMessage(holder) {
86
+ const owner = !isActionablePid(holder.pid)
87
+ ? holder.command
88
+ : `${holder.command} (pid ${holder.pid})`;
89
+ return `Session is busy: ${owner} is already driving it.`;
90
+ }
91
+ function formatBusyHint(holder, platform) {
92
+ if (platform === 'win32') {
93
+ return !isActionablePid(holder.pid)
94
+ ? 'Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.'
95
+ : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`;
96
+ }
97
+ return !isActionablePid(holder.pid)
98
+ ? 'Wait for it to finish, or stop the owning process if it is stuck.'
99
+ : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`;
100
+ }
101
+ /** A persistent write session is temporarily owned by another logical run. */
102
+ export class SessionBusyError extends CliError {
103
+ constructor(holder, platform = process.platform) {
104
+ super('SESSION_BUSY', formatBusyMessage(holder), formatBusyHint(holder, platform), EXIT_CODES.TEMPFAIL);
105
+ }
106
+ }
84
107
  export class EmptyResultError extends CliError {
85
108
  constructor(command, hint) {
86
109
  super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in', EXIT_CODES.EMPTY_RESULT);
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { CliError, BrowserConnectError, adapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, selectorError, attachTraceReceipt, toEnvelope, } from './errors.js';
2
+ import { CliError, BrowserConnectError, adapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, selectorError, SessionBusyError, attachTraceReceipt, toEnvelope, } from './errors.js';
3
3
  describe('Error type hierarchy', () => {
4
4
  it('all error types extend CliError', () => {
5
5
  const errors = [
@@ -71,6 +71,23 @@ describe('toEnvelope', () => {
71
71
  },
72
72
  });
73
73
  });
74
+ it('serializes SessionBusyError with the public code and temporary-failure exit code', () => {
75
+ const err = new SessionBusyError({
76
+ command: 'chatgpt ask',
77
+ pid: 4242,
78
+ acquiredAt: 1_000,
79
+ heartbeatAt: 2_000,
80
+ });
81
+ expect(toEnvelope(err)).toEqual({
82
+ ok: false,
83
+ error: {
84
+ code: 'SESSION_BUSY',
85
+ message: expect.stringContaining('chatgpt ask'),
86
+ help: expect.any(String),
87
+ exitCode: 75,
88
+ },
89
+ });
90
+ });
74
91
  it('converts CliError without hint (omits help field)', () => {
75
92
  const err = new CommandExecutionError('Something broke');
76
93
  const envelope = toEnvelope(err);
@@ -120,3 +137,43 @@ describe('toEnvelope', () => {
120
137
  });
121
138
  });
122
139
  });
140
+ describe('SessionBusyError platform hints', () => {
141
+ const holder = {
142
+ command: 'chatgpt ask',
143
+ pid: 4242,
144
+ acquiredAt: 1_000,
145
+ heartbeatAt: 2_000,
146
+ };
147
+ it('uses PowerShell process guidance on Windows when the holder pid is known', () => {
148
+ const err = new SessionBusyError(holder, 'win32');
149
+ expect(err.hint).toContain('Stop-Process -Id 4242');
150
+ expect(err.hint).not.toContain('kill 4242');
151
+ });
152
+ it('uses Task Manager guidance on Windows when the holder pid is unavailable', () => {
153
+ const err = new SessionBusyError({ ...holder, pid: undefined }, 'win32');
154
+ expect(err.hint).toMatch(/wait/i);
155
+ expect(err.hint).toContain('Task Manager');
156
+ expect(err.hint).not.toContain('Stop-Process');
157
+ });
158
+ it('uses kill guidance on POSIX when the holder pid is known', () => {
159
+ const err = new SessionBusyError(holder, 'linux');
160
+ expect(err.hint).toContain('kill 4242');
161
+ expect(err.hint).not.toContain('Stop-Process');
162
+ });
163
+ it.each([
164
+ 0,
165
+ -1,
166
+ 1.5,
167
+ Number.NaN,
168
+ Number.POSITIVE_INFINITY,
169
+ Number.MAX_SAFE_INTEGER + 1,
170
+ ])('does not expose non-actionable pid %s in process guidance', (pid) => {
171
+ const windowsError = new SessionBusyError({ ...holder, pid }, 'win32');
172
+ expect(windowsError.message).not.toContain(`pid ${pid}`);
173
+ expect(windowsError.hint).toContain('Task Manager');
174
+ expect(windowsError.hint).not.toContain('Stop-Process');
175
+ const posixError = new SessionBusyError({ ...holder, pid }, 'linux');
176
+ expect(posixError.message).not.toContain(`pid ${pid}`);
177
+ expect(posixError.hint).not.toContain('kill');
178
+ });
179
+ });
@@ -15,11 +15,11 @@ import * as crypto from 'node:crypto';
15
15
  import * as fs from 'node:fs';
16
16
  import * as os from 'node:os';
17
17
  import { executePipeline } from './pipeline/index.js';
18
- import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
18
+ import { adapterLoadError, ArgumentError, CommandExecutionError, TimeoutError, attachTraceReceipt, getErrorMessage } from './errors.js';
19
19
  import { shouldUseBrowserSession } from './capabilityRouting.js';
20
20
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
21
21
  import { profileRouteParams, resolveProfileSelection } from './browser/profile.js';
22
- import { setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js';
22
+ import { releaseSiteSessionLease, setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js';
23
23
  import { emitHook } from './hooks.js';
24
24
  import { log } from './logger.js';
25
25
  import { isElectronApp } from './electron-apps.js';
@@ -27,10 +27,16 @@ import { probeCDP, resolveElectronEndpoint } from './launcher.js';
27
27
  import { ObservationSession, exportObservationSession } from './observation/index.js';
28
28
  import { resolveAdapterSourcePath } from './adapter-source.js';
29
29
  import { coerceCommandArguments, TRACE_MODES } from './command-surface.js';
30
+ import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, runWithDaemonRunContext } from './session-lease.js';
30
31
  const _loadedModules = new Map();
31
32
  /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */
32
33
  const _moduleMtimes = new Map();
33
34
  const _userClisDir = `${os.homedir()}/.webcmd/clis/`;
35
+ async function finalizeRun(runId, release) {
36
+ clearDaemonRunContext(runId);
37
+ if (release)
38
+ await releaseSiteSessionLease(runId).catch(() => undefined);
39
+ }
34
40
  function normalizeTraceMode(raw) {
35
41
  if (raw === undefined || raw === null || raw === '' || raw === 'off')
36
42
  return 'off';
@@ -178,7 +184,15 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
178
184
  const session = resolveAdapterBrowserSession(cmd, siteSession);
179
185
  const keepTab = resolveKeepTab(siteSession, opts.keepTab);
180
186
  const windowMode = resolveBrowserWindowMode(cmd.defaultWindowMode ?? 'background', opts.windowMode);
181
- result = await browserSession(BrowserFactory, async (page) => {
187
+ const surface = 'adapter';
188
+ const canonicalCommand = fullName(cmd);
189
+ const leaseEligible = surface === 'adapter'
190
+ && siteSession === 'persistent'
191
+ && cmd.access === 'write';
192
+ const runId = leaseEligible ? generateRunId() : undefined;
193
+ let releaseRun = true;
194
+ let deferRunFinalization = false;
195
+ const executeAdapter = async (page) => {
182
196
  const observation = traceMode === 'off'
183
197
  ? null
184
198
  : new ObservationSession({
@@ -223,6 +237,8 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
223
237
  });
224
238
  }
225
239
  catch (err) {
240
+ if (runId && isUnknownOutcomeError(err))
241
+ releaseRun = false;
226
242
  observation?.record({
227
243
  stream: 'action',
228
244
  name: 'pre_navigate',
@@ -244,13 +260,16 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
244
260
  throw wrapped;
245
261
  }
246
262
  }
263
+ const adapterPromise = Promise.resolve(runCommand(cmd, page, kwargs, debug));
264
+ let adapterSettled = false;
265
+ void adapterPromise.then(() => { adapterSettled = true; }, () => { adapterSettled = true; });
247
266
  try {
248
267
  const browserTimeout = userTimeoutSec !== null
249
268
  ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS
250
269
  : DEFAULT_BROWSER_COMMAND_TIMEOUT;
251
- const result = await runWithTimeout(runCommand(cmd, page, kwargs, debug), {
270
+ const result = await runWithTimeout(adapterPromise, {
252
271
  timeout: browserTimeout,
253
- label: fullName(cmd),
272
+ label: canonicalCommand,
254
273
  });
255
274
  observation?.record({
256
275
  stream: 'action',
@@ -269,6 +288,15 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
269
288
  return result;
270
289
  }
271
290
  catch (err) {
291
+ if (runId && isUnknownOutcomeError(err))
292
+ releaseRun = false;
293
+ if (runId && err instanceof TimeoutError && !adapterSettled) {
294
+ releaseRun = false;
295
+ deferRunFinalization = true;
296
+ void adapterPromise
297
+ .then(() => finalizeRun(runId, true), (lateError) => finalizeRun(runId, !isUnknownOutcomeError(lateError)))
298
+ .catch(() => undefined);
299
+ }
272
300
  if (observation) {
273
301
  observation.record({
274
302
  stream: 'action',
@@ -293,7 +321,30 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
293
321
  await page.closeWindow?.().catch(() => { });
294
322
  throw err;
295
323
  }
296
- }, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession, freshPage: cmd.freshPage === true && siteSession === 'persistent' });
324
+ };
325
+ const executeBrowser = () => browserSession(BrowserFactory, executeAdapter, {
326
+ session,
327
+ cdpEndpoint,
328
+ ...profileRouting,
329
+ windowMode,
330
+ surface,
331
+ siteSession,
332
+ freshPage: cmd.freshPage === true && siteSession === 'persistent',
333
+ });
334
+ try {
335
+ result = runId
336
+ ? await runWithDaemonRunContext({ runId, command: canonicalCommand, access: 'write' }, executeBrowser)
337
+ : await executeBrowser();
338
+ }
339
+ catch (err) {
340
+ if (runId && isUnknownOutcomeError(err))
341
+ releaseRun = false;
342
+ throw err;
343
+ }
344
+ finally {
345
+ if (runId && !deferRunFinalization)
346
+ await finalizeRun(runId, releaseRun);
347
+ }
297
348
  }
298
349
  else {
299
350
  // Non-browser commands: enforce a timeout only when the command exposes