@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
@@ -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
@@ -1,14 +1,16 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- const { mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({
5
+ const { mockReleaseSiteSessionLease, mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({
6
+ mockReleaseSiteSessionLease: vi.fn().mockResolvedValue(undefined),
6
7
  mockSetDaemonCommandTimeoutSeconds: vi.fn(),
7
8
  }));
8
9
  vi.mock('./browser/daemon-client.js', async () => {
9
10
  const actual = await vi.importActual('./browser/daemon-client.js');
10
11
  return {
11
12
  ...actual,
13
+ releaseSiteSessionLease: mockReleaseSiteSessionLease,
12
14
  setDaemonCommandTimeoutSeconds: mockSetDaemonCommandTimeoutSeconds,
13
15
  };
14
16
  });
@@ -17,6 +19,17 @@ import { ArgumentError, TimeoutError, toEnvelope } from './errors.js';
17
19
  import { cli, Strategy } from './registry.js';
18
20
  import * as runtime from './runtime.js';
19
21
  import * as capRouting from './capabilityRouting.js';
22
+ import { clearDaemonRunContext, getDaemonRunContext } from './session-lease.js';
23
+ import { sendCommand } from './browser/daemon-client.js';
24
+ function deferred() {
25
+ let resolve;
26
+ let reject;
27
+ const promise = new Promise((res, rej) => {
28
+ resolve = res;
29
+ reject = rej;
30
+ });
31
+ return { promise, resolve, reject };
32
+ }
20
33
  describe('executeCommand — non-browser timeout', () => {
21
34
  it('applies the user --timeout arg as the ceiling for non-browser commands', async () => {
22
35
  const runWithTimeoutSpy = vi.spyOn(runtime, 'runWithTimeout');
@@ -148,6 +161,417 @@ describe('executeCommand — non-browser timeout', () => {
148
161
  });
149
162
  vi.restoreAllMocks();
150
163
  });
164
+ describe('persistent write run ownership', () => {
165
+ afterEach(() => {
166
+ const activeRun = getDaemonRunContext();
167
+ if (activeRun)
168
+ clearDaemonRunContext(activeRun.runId);
169
+ mockReleaseSiteSessionLease.mockReset().mockResolvedValue(undefined);
170
+ vi.restoreAllMocks();
171
+ vi.unstubAllGlobals();
172
+ });
173
+ it('binds a run only for browser-backed persistent writes', async () => {
174
+ const seen = new Map();
175
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
176
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockImplementation(cmd => cmd.browser !== false);
177
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
178
+ const makeCommand = (name, access, browser, siteSession) => cli({
179
+ site: 'test-execution',
180
+ name,
181
+ access,
182
+ description: 'test logical run eligibility',
183
+ browser,
184
+ strategy: Strategy.PUBLIC,
185
+ siteSession,
186
+ func: async () => {
187
+ seen.set(name, getDaemonRunContext());
188
+ return [{ ok: true }];
189
+ },
190
+ });
191
+ await executeCommand(makeCommand('run-eligible', 'write', true, 'persistent'), {});
192
+ await executeCommand(makeCommand('run-read', 'read', true, 'persistent'), {});
193
+ await executeCommand(makeCommand('run-ephemeral', 'write', true, 'ephemeral'), {});
194
+ await executeCommand(makeCommand('run-non-browser', 'write', false, 'persistent'), {});
195
+ const eligibleRun = seen.get('run-eligible');
196
+ expect(eligibleRun).toMatchObject({
197
+ runId: expect.stringMatching(/^run_/),
198
+ command: 'test-execution/run-eligible',
199
+ access: 'write',
200
+ });
201
+ expect(seen.get('run-read')).toBeUndefined();
202
+ expect(seen.get('run-ephemeral')).toBeUndefined();
203
+ expect(seen.get('run-non-browser')).toBeUndefined();
204
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
205
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(eligibleRun?.runId);
206
+ });
207
+ it('binds one canonical run before session setup, pre-navigation, and adapter execution', async () => {
208
+ const seen = [];
209
+ const capture = (stage) => seen.push({ stage, run: getDaemonRunContext() });
210
+ const mockPage = {
211
+ closeWindow: vi.fn().mockResolvedValue(undefined),
212
+ getCurrentUrl: vi.fn().mockImplementation(async () => {
213
+ capture('current-url');
214
+ return 'about:blank';
215
+ }),
216
+ goto: vi.fn().mockImplementation(async () => capture('pre-navigation')),
217
+ };
218
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
219
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => {
220
+ capture('session');
221
+ return fn(mockPage);
222
+ });
223
+ const cmd = cli({
224
+ site: 'test-execution',
225
+ name: 'run-bound-before-operations',
226
+ access: 'write',
227
+ description: 'test one run spans the complete adapter execution',
228
+ browser: true,
229
+ strategy: Strategy.COOKIE,
230
+ domain: 'example.com',
231
+ siteSession: 'persistent',
232
+ func: async () => {
233
+ capture('adapter');
234
+ return [{ ok: true }];
235
+ },
236
+ });
237
+ await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true }]);
238
+ expect(seen.map(entry => entry.stage)).toEqual([
239
+ 'session',
240
+ 'current-url',
241
+ 'pre-navigation',
242
+ 'adapter',
243
+ ]);
244
+ const runId = seen[0]?.run?.runId;
245
+ expect(runId).toMatch(/^run_/);
246
+ for (const entry of seen) {
247
+ expect(entry.run).toEqual({
248
+ runId,
249
+ command: 'test-execution/run-bound-before-operations',
250
+ access: 'write',
251
+ });
252
+ }
253
+ expect(getDaemonRunContext()).toBeUndefined();
254
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
255
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
256
+ });
257
+ it('clears and releases the run once after an ordinary adapter error', async () => {
258
+ let runId;
259
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
260
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
261
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
262
+ const cmd = cli({
263
+ site: 'test-execution',
264
+ name: 'run-ordinary-error',
265
+ access: 'write',
266
+ description: 'test ordinary errors release run ownership',
267
+ browser: true,
268
+ strategy: Strategy.PUBLIC,
269
+ siteSession: 'persistent',
270
+ func: async () => {
271
+ runId = getDaemonRunContext()?.runId;
272
+ throw new Error('ordinary adapter failure');
273
+ },
274
+ });
275
+ await expect(executeCommand(cmd, {})).rejects.toThrow('ordinary adapter failure');
276
+ expect(runId).toMatch(/^run_/);
277
+ expect(getDaemonRunContext()).toBeUndefined();
278
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
279
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
280
+ });
281
+ it('clears but does not release a run when an error cause has unknown outcome', async () => {
282
+ let runId;
283
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
284
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
285
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
286
+ const unknownOutcome = new Error('wrapped adapter failure');
287
+ unknownOutcome.cause = { code: 'COMMAND_RESULT_UNKNOWN' };
288
+ const cmd = cli({
289
+ site: 'test-execution',
290
+ name: 'run-unknown-outcome',
291
+ access: 'write',
292
+ description: 'test unknown outcomes retain the daemon lease',
293
+ browser: true,
294
+ strategy: Strategy.PUBLIC,
295
+ siteSession: 'persistent',
296
+ func: async () => {
297
+ runId = getDaemonRunContext()?.runId;
298
+ throw unknownOutcome;
299
+ },
300
+ });
301
+ await expect(executeCommand(cmd, {})).rejects.toBe(unknownOutcome);
302
+ expect(runId).toMatch(/^run_/);
303
+ expect(getDaemonRunContext()).toBeUndefined();
304
+ expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
305
+ });
306
+ it('does not release a run when pre-navigation has unknown outcome', async () => {
307
+ let runId;
308
+ const unknownOutcome = new Error('navigation result unknown');
309
+ unknownOutcome.cause = { errorCode: 'COMMAND_LOST' };
310
+ const mockPage = {
311
+ closeWindow: vi.fn().mockResolvedValue(undefined),
312
+ getCurrentUrl: vi.fn().mockResolvedValue('about:blank'),
313
+ goto: vi.fn().mockImplementation(async () => {
314
+ runId = getDaemonRunContext()?.runId;
315
+ throw unknownOutcome;
316
+ }),
317
+ };
318
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
319
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
320
+ const cmd = cli({
321
+ site: 'test-execution',
322
+ name: 'run-unknown-prenavigation',
323
+ access: 'write',
324
+ description: 'test unknown pre-navigation retains the daemon lease',
325
+ browser: true,
326
+ strategy: Strategy.COOKIE,
327
+ domain: 'example.com',
328
+ siteSession: 'persistent',
329
+ func: async () => [{ ok: true }],
330
+ });
331
+ await expect(executeCommand(cmd, {})).rejects.toThrow('Pre-navigation');
332
+ expect(runId).toMatch(/^run_/);
333
+ expect(getDaemonRunContext()).toBeUndefined();
334
+ expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
335
+ });
336
+ it('releases after a wrapper timeout when the adapter later succeeds', async () => {
337
+ const adapter = deferred();
338
+ let runId;
339
+ let settledRunId;
340
+ let adapterSettled = false;
341
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
342
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
343
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
344
+ vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
345
+ const cmd = cli({
346
+ site: 'test-execution',
347
+ name: 'run-timeout',
348
+ access: 'write',
349
+ description: 'test timeout defers local run cleanup',
350
+ browser: true,
351
+ strategy: Strategy.PUBLIC,
352
+ siteSession: 'persistent',
353
+ func: async () => {
354
+ runId = getDaemonRunContext()?.runId;
355
+ await adapter.promise;
356
+ settledRunId = getDaemonRunContext()?.runId;
357
+ adapterSettled = true;
358
+ },
359
+ });
360
+ await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
361
+ expect(runId).toMatch(/^run_/);
362
+ expect(adapterSettled).toBe(false);
363
+ expect(getDaemonRunContext()).toBeUndefined();
364
+ expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
365
+ adapter.resolve();
366
+ await vi.waitFor(() => expect(adapterSettled).toBe(true));
367
+ expect(settledRunId).toBe(runId);
368
+ expect(getDaemonRunContext()).toBeUndefined();
369
+ await vi.waitFor(() => expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce());
370
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
371
+ });
372
+ it('releases after a wrapper timeout when the adapter later fails ordinarily', async () => {
373
+ const adapter = deferred();
374
+ let runId;
375
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
376
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
377
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
378
+ vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
379
+ const cmd = cli({
380
+ site: 'test-execution',
381
+ name: 'run-timeout-late-ordinary-failure',
382
+ access: 'write',
383
+ description: 'test late ordinary failure releases run ownership',
384
+ browser: true,
385
+ strategy: Strategy.PUBLIC,
386
+ siteSession: 'persistent',
387
+ func: async () => {
388
+ runId = getDaemonRunContext()?.runId;
389
+ await adapter.promise;
390
+ },
391
+ });
392
+ await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
393
+ expect(runId).toMatch(/^run_/);
394
+ expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
395
+ adapter.reject(new Error('late ordinary failure'));
396
+ await vi.waitFor(() => expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce());
397
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
398
+ expect(getDaemonRunContext()).toBeUndefined();
399
+ });
400
+ it('retains ownership for TTL after a wrapper timeout when the adapter later has an unknown outcome', async () => {
401
+ const adapter = deferred();
402
+ let runId;
403
+ let adapterSettled = false;
404
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
405
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
406
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
407
+ vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
408
+ const cmd = cli({
409
+ site: 'test-execution',
410
+ name: 'run-timeout-late-unknown-failure',
411
+ access: 'write',
412
+ description: 'test late unknown failure retains ownership for TTL',
413
+ browser: true,
414
+ strategy: Strategy.PUBLIC,
415
+ siteSession: 'persistent',
416
+ func: async () => {
417
+ runId = getDaemonRunContext()?.runId;
418
+ try {
419
+ await adapter.promise;
420
+ }
421
+ finally {
422
+ adapterSettled = true;
423
+ }
424
+ },
425
+ });
426
+ await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
427
+ expect(runId).toMatch(/^run_/);
428
+ expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
429
+ const unknownOutcome = new Error('late result unknown');
430
+ unknownOutcome.cause = { code: 'COMMAND_RESULT_UNKNOWN' };
431
+ adapter.reject(unknownOutcome);
432
+ await vi.waitFor(() => expect(adapterSettled).toBe(true));
433
+ expect(getDaemonRunContext()).toBeUndefined();
434
+ expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
435
+ });
436
+ it('releases normally when the adapter itself rejects with TimeoutError', async () => {
437
+ let runId;
438
+ const adapterTimeout = new TimeoutError('adapter operation', 1);
439
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
440
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
441
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
442
+ const cmd = cli({
443
+ site: 'test-execution',
444
+ name: 'run-adapter-timeout',
445
+ access: 'write',
446
+ description: 'test a settled adapter timeout releases normally',
447
+ browser: true,
448
+ strategy: Strategy.PUBLIC,
449
+ siteSession: 'persistent',
450
+ func: async () => {
451
+ runId = getDaemonRunContext()?.runId;
452
+ throw adapterTimeout;
453
+ },
454
+ });
455
+ await expect(executeCommand(cmd, {})).rejects.toBe(adapterTimeout);
456
+ expect(runId).toMatch(/^run_/);
457
+ expect(getDaemonRunContext()).toBeUndefined();
458
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
459
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
460
+ });
461
+ it('does not let deferred timeout cleanup clear a later run', async () => {
462
+ const firstAdapter = deferred();
463
+ const secondAdapter = deferred();
464
+ let firstRunId;
465
+ let secondRunId;
466
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
467
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
468
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
469
+ vi.spyOn(runtime, 'runWithTimeout')
470
+ .mockRejectedValueOnce(new TimeoutError('first logical run', 1))
471
+ .mockImplementationOnce(async (promise) => promise);
472
+ const firstCommand = cli({
473
+ site: 'test-execution',
474
+ name: 'run-timeout-first',
475
+ access: 'write',
476
+ description: 'test deferred cleanup for an older run',
477
+ browser: true,
478
+ strategy: Strategy.PUBLIC,
479
+ siteSession: 'persistent',
480
+ func: () => {
481
+ firstRunId = getDaemonRunContext()?.runId;
482
+ return firstAdapter.promise;
483
+ },
484
+ });
485
+ const secondCommand = cli({
486
+ site: 'test-execution',
487
+ name: 'run-timeout-second',
488
+ access: 'write',
489
+ description: 'test a newer run survives old deferred cleanup',
490
+ browser: true,
491
+ strategy: Strategy.PUBLIC,
492
+ siteSession: 'persistent',
493
+ func: () => {
494
+ secondRunId = getDaemonRunContext()?.runId;
495
+ return secondAdapter.promise;
496
+ },
497
+ });
498
+ await expect(executeCommand(firstCommand, {})).rejects.toBeInstanceOf(TimeoutError);
499
+ const secondExecution = executeCommand(secondCommand, {});
500
+ await vi.waitFor(() => expect(secondRunId).toMatch(/^run_/));
501
+ expect(secondRunId).not.toBe(firstRunId);
502
+ firstAdapter.resolve();
503
+ await firstAdapter.promise;
504
+ await vi.waitFor(() => expect(getDaemonRunContext()).toBeUndefined());
505
+ secondAdapter.resolve();
506
+ await expect(secondExecution).resolves.toBeUndefined();
507
+ expect(getDaemonRunContext()).toBeUndefined();
508
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledTimes(2);
509
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(firstRunId);
510
+ expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(secondRunId);
511
+ });
512
+ it('keeps daemon metadata scoped to overlapping timed-out and active runs', async () => {
513
+ const resumeFirst = deferred();
514
+ const firstFinished = deferred();
515
+ const finishSecond = deferred();
516
+ const secondStarted = deferred();
517
+ let firstRunId;
518
+ let secondRunId;
519
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
520
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
521
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
522
+ vi.spyOn(runtime, 'runWithTimeout')
523
+ .mockRejectedValueOnce(new TimeoutError('first logical run', 1))
524
+ .mockImplementationOnce(async (promise) => promise);
525
+ vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_input, init) => {
526
+ const request = JSON.parse(String(init?.body));
527
+ return {
528
+ status: 200,
529
+ json: async () => ({ id: request.id, ok: true, data: 'ok' }),
530
+ };
531
+ }));
532
+ const firstCommand = cli({
533
+ site: 'test-execution',
534
+ name: 'run-overlap-first',
535
+ access: 'write',
536
+ description: 'test timed-out run keeps its daemon metadata',
537
+ browser: true,
538
+ strategy: Strategy.PUBLIC,
539
+ siteSession: 'persistent',
540
+ func: async () => {
541
+ firstRunId = getDaemonRunContext()?.runId;
542
+ await resumeFirst.promise;
543
+ await sendCommand('exec', { code: 'first-after-second' });
544
+ firstFinished.resolve();
545
+ },
546
+ });
547
+ const secondCommand = cli({
548
+ site: 'test-execution',
549
+ name: 'run-overlap-second',
550
+ access: 'write',
551
+ description: 'test active run keeps its daemon metadata',
552
+ browser: true,
553
+ strategy: Strategy.PUBLIC,
554
+ siteSession: 'persistent',
555
+ func: async () => {
556
+ secondRunId = getDaemonRunContext()?.runId;
557
+ await sendCommand('exec', { code: 'second-active' });
558
+ secondStarted.resolve();
559
+ await finishSecond.promise;
560
+ },
561
+ });
562
+ await expect(executeCommand(firstCommand, {})).rejects.toBeInstanceOf(TimeoutError);
563
+ const secondExecution = executeCommand(secondCommand, {});
564
+ await secondStarted.promise;
565
+ resumeFirst.resolve();
566
+ await firstFinished.promise;
567
+ const bodies = vi.mocked(fetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
568
+ expect(bodies.find(body => body.code === 'first-after-second')?.runId).toBe(firstRunId);
569
+ expect(bodies.find(body => body.code === 'second-active')?.runId).toBe(secondRunId);
570
+ expect(firstRunId).not.toBe(secondRunId);
571
+ finishSecond.resolve();
572
+ await expect(secondExecution).resolves.toBeUndefined();
573
+ });
574
+ });
151
575
  it('reuses a persistent site browser session and keeps the tab lease open', async () => {
152
576
  const closeWindow = vi.fn().mockResolvedValue(undefined);
153
577
  const mockPage = { closeWindow };
@@ -10,6 +10,17 @@ const EXPECTED_LOCAL_TOOLS = [
10
10
  'antigravity/state-keys',
11
11
  'antigravity/workspaces-list',
12
12
  'mercury/reimbursement-plan',
13
+ 'spotify/auth',
14
+ 'spotify/next',
15
+ 'spotify/pause',
16
+ 'spotify/play',
17
+ 'spotify/prev',
18
+ 'spotify/queue',
19
+ 'spotify/repeat',
20
+ 'spotify/search',
21
+ 'spotify/shuffle',
22
+ 'spotify/status',
23
+ 'spotify/volume',
13
24
  'trae-solo/extensions-list',
14
25
  'trae-solo/recent-workspaces',
15
26
  'trae-solo/settings-read',
@@ -174,7 +185,7 @@ describe('hosted availability', () => {
174
185
  const desktopApps = (byReason.get('desktop-app') ?? []).sort();
175
186
  expect(exceptionDiff(localTools, EXPECTED_LOCAL_TOOLS)).toEqual({ added: [], missing: [] });
176
187
  expect(exceptionDiff(desktopApps, EXPECTED_DESKTOP_APPS)).toEqual({ added: [], missing: [] });
177
- expect(localTools).toHaveLength(19);
188
+ expect(localTools).toHaveLength(30);
178
189
  expect(desktopApps).toHaveLength(111);
179
190
  });
180
191
  });
@@ -293,7 +293,7 @@ function isHostedManifestCommand(value) {
293
293
  if (!hasOnlyKeys(value, [
294
294
  'site', 'name', 'aliases', 'command', 'description', 'access', 'example', 'domain', 'strategy', 'browser',
295
295
  'args', 'columns', 'pipeline', 'defaultFormat', 'type', 'modulePath', 'sourceFile', 'navigateBefore',
296
- 'siteSession', 'defaultWindowMode', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion',
296
+ 'siteSession', 'freshPage', 'defaultWindowMode', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion',
297
297
  ]))
298
298
  return false;
299
299
  if (typeof value.site !== 'string' || typeof value.name !== 'string' || typeof value.command !== 'string')
@@ -318,6 +318,8 @@ function isHostedManifestCommand(value) {
318
318
  if (value[key] !== undefined && typeof value[key] !== 'string')
319
319
  return false;
320
320
  }
321
+ if (value.freshPage !== undefined && typeof value.freshPage !== 'boolean')
322
+ return false;
321
323
  return value.navigateBefore === undefined || typeof value.navigateBefore === 'boolean' || typeof value.navigateBefore === 'string';
322
324
  }
323
325
  function isHostedManifestArg(value) {