@sovovs/bycli 2.1.40 → 2.1.42

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 (42) hide show
  1. package/cli-manifest.json +10 -2
  2. package/clis/weixin/_wechat/article-artifact.js +55 -0
  3. package/clis/weixin/_wechat/article-identity.js +27 -0
  4. package/clis/weixin/_wechat/publish-analysis.js +8 -4
  5. package/clis/weixin/_wechat/publish-download.js +3 -1
  6. package/clis/weixin/download-publish-data.js +20 -0
  7. package/clis/weixin/download.js +15 -2
  8. package/dist/src/adapter-coordination.d.ts +26 -0
  9. package/dist/src/adapter-coordination.js +183 -0
  10. package/dist/src/adapter-coordination.test.d.ts +1 -0
  11. package/dist/src/adapter-execution-context.d.ts +6 -0
  12. package/dist/src/adapter-execution-context.js +8 -0
  13. package/dist/src/adapter-scheduler.d.ts +86 -0
  14. package/dist/src/adapter-scheduler.js +349 -0
  15. package/dist/src/adapter-scheduler.test.d.ts +1 -0
  16. package/dist/src/browser/daemon-client.d.ts +11 -0
  17. package/dist/src/browser/daemon-client.js +53 -1
  18. package/dist/src/browser/extension-capabilities.d.ts +1 -0
  19. package/dist/src/browser/extension-capabilities.js +18 -5
  20. package/dist/src/browser/page.d.ts +2 -1
  21. package/dist/src/browser/page.js +3 -0
  22. package/dist/src/build-manifest.js +1 -0
  23. package/dist/src/cli-argv-preprocess.d.ts +3 -0
  24. package/dist/src/cli-argv-preprocess.js +4 -0
  25. package/dist/src/commanderAdapter.js +11 -0
  26. package/dist/src/daemon.js +118 -0
  27. package/dist/src/discovery.js +1 -0
  28. package/dist/src/download/article-download.d.ts +2 -0
  29. package/dist/src/download/article-download.js +30 -4
  30. package/dist/src/errors.d.ts +3 -0
  31. package/dist/src/errors.js +5 -0
  32. package/dist/src/execution.d.ts +2 -0
  33. package/dist/src/execution.js +172 -105
  34. package/dist/src/help.d.ts +1 -0
  35. package/dist/src/help.js +40 -0
  36. package/dist/src/manifest-types.d.ts +4 -0
  37. package/dist/src/registry.d.ts +6 -0
  38. package/dist/src/registry.js +23 -0
  39. package/dist/src/serialization.d.ts +1 -0
  40. package/dist/src/serialization.js +1 -0
  41. package/dist/src/types.d.ts +2 -0
  42. package/package.json +3 -2
@@ -39,6 +39,7 @@ import { recordExtensionVersion } from './update-check.js';
39
39
  import { EXTENSION_CAPABILITY_MISSING_ERROR_CODE, EXTENSION_CAPABILITY_MISSING_HTTP_STATUS, extensionCapabilityHint, missingRequiredExtensionCapability, normalizeExtensionCapabilities, } from './browser/extension-capabilities.js';
40
40
  import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
41
41
  import { resolveDaemonHost } from './daemon-config.js';
42
+ import { AdapterScheduler, AdapterSchedulerError, } from './adapter-scheduler.js';
42
43
  const PORT = parseInt(process.env.BYCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
43
44
  const HOST = resolveDaemonHost();
44
45
  const BROWSER_RECOVERY_COMMAND = process.env.BYCLI_BROWSER_RECOVERY_COMMAND?.trim();
@@ -60,6 +61,9 @@ const logger = createRecorderLogger(LOG_LEVELS.includes(envLogLevel) ? envLogLev
60
61
  // runner counters surface on GET /metrics and runner logs share the daemon's level.
61
62
  setDefaultRunnerObservability(metrics, logger);
62
63
  const extensionProfiles = new Map();
64
+ const adapterScheduler = new AdapterScheduler();
65
+ const adapterSchedulerSweep = setInterval(() => adapterScheduler.sweepExpired(), 5_000);
66
+ adapterSchedulerSweep.unref?.();
63
67
  const pending = new Map();
64
68
  let commandResultUnknownCount = 0;
65
69
  const LOG_BUFFER_SIZE = 200;
@@ -386,6 +390,92 @@ async function handleRequest(req, res) {
386
390
  }
387
391
  return;
388
392
  }
393
+ if (req.method === 'POST' && pathname.startsWith('/v1/adapter-leases/')) {
394
+ try {
395
+ const body = JSON.parse(await readBody(req));
396
+ if (pathname === '/v1/adapter-leases/acquire') {
397
+ const request = body;
398
+ let clientGone = false;
399
+ const onClose = () => {
400
+ if (res.writableEnded)
401
+ return;
402
+ clientGone = true;
403
+ adapterScheduler.cancel(request.requestId);
404
+ };
405
+ res.once('close', onClose);
406
+ const lease = await adapterScheduler.acquire(request);
407
+ res.off('close', onClose);
408
+ if (clientGone || res.destroyed) {
409
+ adapterScheduler.release({ ...lease, reason: 'cancelled' });
410
+ return;
411
+ }
412
+ jsonResponse(res, 200, { ok: true, data: lease });
413
+ return;
414
+ }
415
+ if (pathname === '/v1/adapter-leases/heartbeat') {
416
+ const lease = adapterScheduler.heartbeat(body);
417
+ jsonResponse(res, 200, { ok: true, data: lease });
418
+ return;
419
+ }
420
+ if (pathname === '/v1/adapter-leases/release') {
421
+ const released = adapterScheduler.release(body);
422
+ jsonResponse(res, 200, { ok: true, data: { released } });
423
+ return;
424
+ }
425
+ if (pathname === '/v1/adapter-leases/cancel') {
426
+ const requestId = typeof body.requestId === 'string' ? body.requestId : '';
427
+ const cancelled = requestId ? adapterScheduler.cancel(requestId) : false;
428
+ jsonResponse(res, 200, { ok: true, data: { cancelled } });
429
+ return;
430
+ }
431
+ jsonResponse(res, 404, { ok: false, errorCode: 'ADAPTER_QUEUE_RESET', error: 'Unknown Adapter lease operation' });
432
+ }
433
+ catch (error) {
434
+ if (res.destroyed)
435
+ return;
436
+ const schedulerError = error instanceof AdapterSchedulerError ? error : null;
437
+ const status = schedulerError?.code === 'ADAPTER_QUEUE_TIMEOUT' ? 408
438
+ : schedulerError?.code === 'ADAPTER_LEASE_LOST' ? 409
439
+ : schedulerError?.code === 'ADAPTER_POOL_AUTH_GATE' || schedulerError?.code === 'ADAPTER_POOL_RATE_LIMITED' ? 409
440
+ : 400;
441
+ jsonResponse(res, status, {
442
+ ok: false,
443
+ errorCode: schedulerError?.code ?? 'ADAPTER_QUEUE_RESET',
444
+ error: error instanceof Error ? error.message : 'Adapter scheduler request failed',
445
+ });
446
+ }
447
+ return;
448
+ }
449
+ if (req.method === 'POST' && pathname.startsWith('/v1/adapter-resources/')) {
450
+ try {
451
+ const body = JSON.parse(await readBody(req));
452
+ const lease = body.lease;
453
+ if (pathname === '/v1/adapter-resources/acquire') {
454
+ const keys = Array.isArray(body.keys) ? body.keys.filter((key) => typeof key === 'string') : [];
455
+ const timeoutMs = typeof body.timeoutMs === 'number' ? body.timeoutMs : 0;
456
+ const grant = await adapterScheduler.acquireResources(lease, keys, timeoutMs);
457
+ jsonResponse(res, 200, { ok: true, data: grant });
458
+ return;
459
+ }
460
+ if (pathname === '/v1/adapter-resources/release') {
461
+ const grantId = typeof body.grantId === 'string' ? body.grantId : '';
462
+ const released = adapterScheduler.releaseResources(lease, grantId);
463
+ jsonResponse(res, 200, { ok: true, data: { released } });
464
+ return;
465
+ }
466
+ jsonResponse(res, 404, { ok: false, errorCode: 'ADAPTER_QUEUE_RESET', error: 'Unknown Adapter resource operation' });
467
+ }
468
+ catch (error) {
469
+ const schedulerError = error instanceof AdapterSchedulerError ? error : null;
470
+ const status = schedulerError?.code === 'ADAPTER_RESOURCE_TIMEOUT' ? 408 : 409;
471
+ jsonResponse(res, status, {
472
+ ok: false,
473
+ errorCode: schedulerError?.code ?? 'ADAPTER_LEASE_LOST',
474
+ error: error instanceof Error ? error.message : 'Adapter resource request failed',
475
+ });
476
+ }
477
+ return;
478
+ }
389
479
  if (req.method === 'GET' && pathname === '/status') {
390
480
  const uptime = process.uptime();
391
481
  const mem = process.memoryUsage();
@@ -415,6 +505,8 @@ async function handleRequest(req, res) {
415
505
  profileDisconnected: route.errorCode === 'profile_disconnected',
416
506
  profiles,
417
507
  pending: pending.size,
508
+ adapterLeases: adapterScheduler.snapshot(),
509
+ adapterResources: adapterScheduler.resourceSnapshot(),
418
510
  commandResultUnknown: commandResultUnknownCount,
419
511
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
420
512
  port: PORT,
@@ -454,6 +546,30 @@ async function handleRequest(req, res) {
454
546
  jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
455
547
  return;
456
548
  }
549
+ const namedAdapterSession = body.surface === 'adapter'
550
+ && body.siteSession === 'persistent'
551
+ && typeof body.session === 'string'
552
+ && /^site:[^:\s]+:[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(body.session);
553
+ if (namedAdapterSession && !body.adapterLease) {
554
+ throw new DaemonCommandFailure('Named Adapter browser commands require an active lease', 'ADAPTER_LEASE_LOST', undefined, 409);
555
+ }
556
+ if (body.adapterLease) {
557
+ try {
558
+ const lease = adapterScheduler.assertLease(body.adapterLease);
559
+ if (body.contextId !== lease.contextId
560
+ || body.surface !== 'adapter'
561
+ || body.siteSession !== 'persistent'
562
+ || body.session !== lease.sessionKey) {
563
+ throw new AdapterSchedulerError('ADAPTER_LEASE_LOST', 'Adapter browser command does not match its lease scope');
564
+ }
565
+ }
566
+ catch (error) {
567
+ if (error instanceof AdapterSchedulerError) {
568
+ throw new DaemonCommandFailure(error.message, error.code, undefined, 409);
569
+ }
570
+ throw error;
571
+ }
572
+ }
457
573
  const route = resolveExtensionConnection(typeof body.contextId === 'string' ? body.contextId : undefined);
458
574
  if (!route.connection) {
459
575
  jsonResponse(res, route.errorCode === 'profile_required' ? 409 : 503, {
@@ -722,6 +838,8 @@ function shutdown() {
722
838
  p.reject(new Error('Daemon shutting down'));
723
839
  }
724
840
  pending.clear();
841
+ clearInterval(adapterSchedulerSweep);
842
+ adapterScheduler.reset();
725
843
  for (const profile of extensionProfiles.values())
726
844
  profile.ws.close();
727
845
  httpServer.close();
@@ -137,6 +137,7 @@ export async function loadFromManifest(manifestPath, clisDir) {
137
137
  source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath,
138
138
  navigateBefore: entry.navigateBefore,
139
139
  siteSession: entry.siteSession,
140
+ adapterConcurrency: entry.adapterConcurrency,
140
141
  _lazy: true,
141
142
  _modulePath: modulePath,
142
143
  };
@@ -53,6 +53,8 @@ export interface ArticleDownloadOptions {
53
53
  stdout?: boolean;
54
54
  /** Opt-in hardened Markdown rules used by HTML-focused adapters. */
55
55
  secureMarkdown?: boolean;
56
+ /** Lease fencing check invoked immediately before the final Markdown write. */
57
+ beforePublish?: () => Promise<void>;
56
58
  }
57
59
  export interface ArticleDownloadResult {
58
60
  title: string;
@@ -325,7 +325,7 @@ async function downloadImages(imgUrls, imgDir, headers, detectExt) {
325
325
  * 6. File write
326
326
  */
327
327
  export async function downloadArticle(data, options) {
328
- const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, secureMarkdown = false, } = options;
328
+ const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, secureMarkdown = false, beforePublish, } = options;
329
329
  const labels = { ...DEFAULT_LABELS, ...frontmatterLabels };
330
330
  if (!data.title) {
331
331
  return [{
@@ -350,12 +350,14 @@ export async function downloadArticle(data, options) {
350
350
  // Convert HTML to Markdown
351
351
  let markdown = convertToMarkdown(data.contentHtml, data.codeBlocks || [], configureTurndown, cleanSelectors, secureMarkdown);
352
352
  const safeTitle = sanitizeFilename(data.title, maxTitleLength);
353
+ const stagingDir = !stdout && beforePublish
354
+ ? path.join(output, `.bycli-article-${crypto.randomUUID()}.tmp`)
355
+ : undefined;
353
356
  // Download images only when writing to disk. In stdout mode remote URLs
354
357
  // stay intact so the piped output is self-contained.
355
358
  if (!stdout && shouldDownloadImages && data.imageUrls && data.imageUrls.length > 0) {
356
359
  const articleDir = path.join(output, safeTitle);
357
- fs.mkdirSync(articleDir, { recursive: true });
358
- const imagesDir = path.join(articleDir, 'images');
360
+ const imagesDir = path.join(stagingDir ?? articleDir, 'images');
359
361
  fs.mkdirSync(imagesDir, { recursive: true });
360
362
  const urlMap = await downloadImages(data.imageUrls, imagesDir, imageHeaders, detectImageExt);
361
363
  markdown = replaceImageUrls(markdown, urlMap);
@@ -396,7 +398,31 @@ export async function downloadArticle(data, options) {
396
398
  fs.mkdirSync(articleDir, { recursive: true });
397
399
  const filename = `${safeTitle}.md`;
398
400
  const filePath = path.join(articleDir, filename);
399
- fs.writeFileSync(filePath, fullContent, 'utf-8');
401
+ if (stagingDir) {
402
+ fs.mkdirSync(stagingDir, { recursive: true });
403
+ const stagedMarkdown = path.join(stagingDir, filename);
404
+ fs.writeFileSync(stagedMarkdown, fullContent, { encoding: 'utf-8', flag: 'wx' });
405
+ try {
406
+ await beforePublish?.();
407
+ const stagedImages = path.join(stagingDir, 'images');
408
+ if (fs.existsSync(stagedImages)) {
409
+ const finalImages = path.join(articleDir, 'images');
410
+ fs.mkdirSync(finalImages, { recursive: true });
411
+ for (const image of fs.readdirSync(stagedImages)) {
412
+ fs.linkSync(path.join(stagedImages, image), path.join(finalImages, image));
413
+ }
414
+ }
415
+ // Same-filesystem rename makes the Markdown replacement atomic.
416
+ fs.renameSync(stagedMarkdown, filePath);
417
+ }
418
+ finally {
419
+ fs.rmSync(stagingDir, { recursive: true, force: true });
420
+ }
421
+ }
422
+ else {
423
+ await beforePublish?.();
424
+ fs.writeFileSync(filePath, fullContent, 'utf-8');
425
+ }
400
426
  return [{
401
427
  title: data.title,
402
428
  author: data.author || '-',
@@ -67,6 +67,9 @@ export declare class TimeoutError extends CliError {
67
67
  export declare class ArgumentError extends CliError {
68
68
  constructor(message: string, hint?: string);
69
69
  }
70
+ export declare class AdapterCoordinationError extends CliError {
71
+ constructor(code: string, message: string, temporary?: boolean, hint?: string);
72
+ }
70
73
  export declare class EmptyResultError extends CliError {
71
74
  constructor(command: string, hint?: string);
72
75
  }
@@ -86,6 +86,11 @@ export class ArgumentError extends CliError {
86
86
  super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
87
87
  }
88
88
  }
89
+ export class AdapterCoordinationError extends CliError {
90
+ constructor(code, message, temporary = false, hint) {
91
+ super(code, message, hint, temporary ? EXIT_CODES.TEMPFAIL : EXIT_CODES.USAGE_ERROR);
92
+ }
93
+ }
89
94
  export class EmptyResultError extends CliError {
90
95
  constructor(command, hint) {
91
96
  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);
@@ -22,6 +22,8 @@ export declare function executeCommand(cmd: CliCommand, rawKwargs: CommandArgs,
22
22
  keepTab?: string;
23
23
  windowMode?: string;
24
24
  siteSession?: string;
25
+ adapterSession?: string;
26
+ adapterQueueTimeout?: string;
25
27
  onTraceExport?: (trace: ObservationExportResult) => void;
26
28
  }): Promise<unknown>;
27
29
  export declare function prepareCommandArgs(cmd: CliCommand, rawKwargs: CommandArgs): CommandArgs;
@@ -16,10 +16,11 @@ import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import { getUserClisDir } from './config-paths.js';
18
18
  import { executePipeline } from './pipeline/index.js';
19
- import { adapterLoadError, ArgumentError, CliError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
19
+ import { AdapterCoordinationError, adapterLoadError, ArgumentError, CliError, CommandExecutionError, TimeoutError, attachTraceReceipt, getErrorMessage } from './errors.js';
20
20
  import { shouldUseBrowserSession } from './capabilityRouting.js';
21
21
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
22
22
  import { resolveProfileContextId } from './browser/profile.js';
23
+ import { resolveAdapterLeaseContextId } from './browser/daemon-client.js';
23
24
  import { emitHook } from './hooks.js';
24
25
  import { log } from './logger.js';
25
26
  import { isElectronApp } from './electron-apps.js';
@@ -27,6 +28,7 @@ import { probeCDP, resolveElectronEndpoint } from './launcher.js';
27
28
  import { ObservationSession, exportObservationSession } from './observation/index.js';
28
29
  import { resolveAdapterSourcePath } from './adapter-source.js';
29
30
  import { canonicalizeManifestArgSchema, ManifestSchemaError } from './manifest-schema.js';
31
+ import { settleAdapterOperationAfterTimeout, withAdapterCommandLease } from './adapter-coordination.js';
30
32
  import { capturedRegistryValues, closeRegistryTransaction, createRegistryTransaction, finalizeRegistryTransaction, resetRegistryTransactionStateForTests, rollbackRegistryTransaction, runRegistryTransaction, transactionGroupsForKey, } from './registry-transaction.js';
31
33
  const _loadedModules = new Map();
32
34
  /** Independent cache-busting generation; retained when an import promise is discarded. */
@@ -402,6 +404,8 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
402
404
  let result;
403
405
  try {
404
406
  const resolvedBrowser = resolveBrowserRequirement(cmd, kwargs);
407
+ const adapterSession = normalizeAdapterSession(cmd, resolvedBrowser, opts.adapterSession);
408
+ const adapterQueueTimeoutSeconds = normalizeAdapterQueueTimeout(opts.adapterQueueTimeout, adapterSession);
405
409
  const userTimeoutSec = readUserTimeoutSeconds(cmd, kwargs);
406
410
  if (shouldUseBrowserSession(cmd, resolvedBrowser)) {
407
411
  const electron = isElectronApp(cmd.site);
@@ -424,126 +428,154 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
424
428
  const contextId = resolveProfileContextId(opts.profile);
425
429
  const internal = cmd;
426
430
  const siteSession = resolveSiteSession(cmd, opts.siteSession);
427
- const session = resolveAdapterBrowserSession(cmd, siteSession);
431
+ assertAdapterSessionLifecycle(siteSession, adapterSession);
432
+ const session = resolveAdapterBrowserSession(cmd, siteSession, adapterSession);
428
433
  const keepTab = resolveKeepTab(siteSession, opts.keepTab);
429
434
  const windowMode = resolveBrowserWindowMode('background', opts.windowMode);
430
- result = await browserSession(BrowserFactory, async (page) => {
431
- const observation = traceMode === 'off'
432
- ? null
433
- : new ObservationSession({
434
- scope: {
435
- contextId,
436
- session,
437
- target: page.getActivePage?.(),
438
- site: cmd.site,
439
- command: fullName(cmd),
440
- adapterSourcePath: resolveAdapterSourcePath(internal)
441
- ?? resolveAdapterSourcePath(initialTraceCommand),
442
- },
443
- });
444
- if (observation) {
445
- observation.record({
446
- stream: 'action',
447
- name: 'command',
448
- phase: 'start',
449
- data: { args: kwargs },
450
- });
451
- await page.startNetworkCapture?.().catch(() => false);
452
- }
453
- const preNavUrl = resolvePreNav(cmd);
454
- if (preNavUrl && await shouldRunPreNav(cmd, page, siteSession, preNavUrl)) {
455
- observation?.record({
456
- stream: 'action',
457
- name: 'pre_navigate',
458
- phase: 'start',
459
- data: { url: preNavUrl },
460
- });
461
- // Navigate directly — the extension's handleNavigate already has a fast-path
462
- // that skips navigation if the tab is already at the target URL.
463
- // This avoids an extra exec round-trip (getCurrentUrl) on first command and
464
- // lets the extension create the automation window with the target URL directly
465
- // instead of about:blank.
466
- try {
467
- await page.goto(preNavUrl);
468
- observation?.record({
435
+ const executeInBrowser = () => browserSession(BrowserFactory, async (page) => {
436
+ // BrowserBridge.connect() has started/probed the daemon by this point. Resolve
437
+ // the daemon's canonical profile before acquiring a profile-scoped lease.
438
+ const leaseContextId = adapterSession
439
+ ? await resolveAdapterLeaseContextId(contextId)
440
+ : undefined;
441
+ if (leaseContextId)
442
+ page.setContextId?.(leaseContextId);
443
+ const executeOnPage = async () => {
444
+ const observation = traceMode === 'off'
445
+ ? null
446
+ : new ObservationSession({
447
+ scope: {
448
+ contextId,
449
+ session: adapterSession ? adapterSessionDiagnosticKey(cmd.site, adapterSession) : session,
450
+ target: page.getActivePage?.(),
451
+ site: cmd.site,
452
+ command: fullName(cmd),
453
+ adapterSourcePath: resolveAdapterSourcePath(internal)
454
+ ?? resolveAdapterSourcePath(initialTraceCommand),
455
+ },
456
+ });
457
+ if (observation) {
458
+ observation.record({
469
459
  stream: 'action',
470
- name: 'pre_navigate',
471
- phase: 'end',
472
- data: { url: preNavUrl },
460
+ name: 'command',
461
+ phase: 'start',
462
+ data: { args: kwargs },
473
463
  });
464
+ await page.startNetworkCapture?.().catch(() => false);
474
465
  }
475
- catch (err) {
466
+ const preNavUrl = resolvePreNav(cmd);
467
+ if (preNavUrl && await shouldRunPreNav(cmd, page, siteSession, preNavUrl)) {
476
468
  observation?.record({
477
469
  stream: 'action',
478
470
  name: 'pre_navigate',
479
- phase: 'error',
480
- data: { url: preNavUrl, error: err instanceof Error ? err.message : String(err) },
471
+ phase: 'start',
472
+ data: { url: preNavUrl },
481
473
  });
482
- const wrapped = new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
483
- if (observation && (traceMode === 'on' || traceMode === 'retain-on-failure')) {
484
- observation.record({
485
- stream: 'error',
486
- message: wrapped.message,
487
- stack: wrapped.stack,
488
- code: wrapped.code,
489
- hint: wrapped.hint,
474
+ // Navigate directly the extension's handleNavigate already has a fast-path
475
+ // that skips navigation if the tab is already at the target URL.
476
+ // This avoids an extra exec round-trip (getCurrentUrl) on first command and
477
+ // lets the extension create the automation window with the target URL directly
478
+ // instead of about:blank.
479
+ try {
480
+ await page.goto(preNavUrl);
481
+ observation?.record({
482
+ stream: 'action',
483
+ name: 'pre_navigate',
484
+ phase: 'end',
485
+ data: { url: preNavUrl },
490
486
  });
491
- await collectObservationEvidence(observation, page).catch(() => { });
492
- exportTraceArtifact(observation, 'failure', wrapped, opts.onTraceExport);
493
487
  }
494
- throw wrapped;
495
- }
496
- }
497
- try {
498
- const browserTimeout = userTimeoutSec !== null
499
- ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS
500
- : DEFAULT_BROWSER_COMMAND_TIMEOUT;
501
- const result = await runWithTimeout(runCommand(cmd, page, kwargs, debug), {
502
- timeout: browserTimeout,
503
- label: fullName(cmd),
504
- });
505
- observation?.record({
506
- stream: 'action',
507
- name: 'command',
508
- phase: 'end',
509
- });
510
- if (observation && traceMode === 'on') {
511
- await collectObservationEvidence(observation, page).catch(() => { });
512
- exportTraceArtifact(observation, 'success', undefined, opts.onTraceExport);
488
+ catch (err) {
489
+ observation?.record({
490
+ stream: 'action',
491
+ name: 'pre_navigate',
492
+ phase: 'error',
493
+ data: { url: preNavUrl, error: err instanceof Error ? err.message : String(err) },
494
+ });
495
+ const wrapped = new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
496
+ if (observation && (traceMode === 'on' || traceMode === 'retain-on-failure')) {
497
+ observation.record({
498
+ stream: 'error',
499
+ message: wrapped.message,
500
+ stack: wrapped.stack,
501
+ code: wrapped.code,
502
+ hint: wrapped.hint,
503
+ });
504
+ await collectObservationEvidence(observation, page).catch(() => { });
505
+ exportTraceArtifact(observation, 'failure', wrapped, opts.onTraceExport);
506
+ }
507
+ throw wrapped;
508
+ }
513
509
  }
514
- // Adapter commands are one-shot — release the current tab lease immediately
515
- // instead of waiting for the 30s idle timeout. The automation container
516
- // window stays open for reuse.
517
- if (!keepTab)
518
- await page.closeWindow?.().catch(() => { });
519
- return result;
520
- }
521
- catch (err) {
522
- if (observation) {
523
- observation.record({
510
+ try {
511
+ const browserTimeout = userTimeoutSec !== null
512
+ ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS
513
+ : DEFAULT_BROWSER_COMMAND_TIMEOUT;
514
+ const commandOperation = runCommand(cmd, page, kwargs, debug);
515
+ const result = adapterSession
516
+ ? await settleAdapterOperationAfterTimeout(commandOperation, browserTimeout * 1_000, new TimeoutError(fullName(cmd), browserTimeout), async () => { await page.closeWindow?.().catch(() => { }); })
517
+ : await runWithTimeout(commandOperation, {
518
+ timeout: browserTimeout,
519
+ label: fullName(cmd),
520
+ });
521
+ observation?.record({
524
522
  stream: 'action',
525
523
  name: 'command',
526
- phase: 'error',
527
- data: { error: err instanceof Error ? err.message : String(err) },
528
- });
529
- observation.record({
530
- stream: 'error',
531
- message: err instanceof Error ? err.message : String(err),
532
- stack: err instanceof Error ? err.stack : undefined,
524
+ phase: 'end',
533
525
  });
534
- if (traceMode === 'on' || traceMode === 'retain-on-failure') {
526
+ if (observation && traceMode === 'on') {
535
527
  await collectObservationEvidence(observation, page).catch(() => { });
536
- exportTraceArtifact(observation, 'failure', err, opts.onTraceExport);
528
+ exportTraceArtifact(observation, 'success', undefined, opts.onTraceExport);
537
529
  }
530
+ // Adapter commands are one-shot — release the current tab lease immediately
531
+ // instead of waiting for the 30s idle timeout. The automation container
532
+ // window stays open for reuse.
533
+ if (!keepTab)
534
+ await page.closeWindow?.().catch(() => { });
535
+ return result;
538
536
  }
539
- // Release the tab lease on failure too — without this, the lease lingers
540
- // until the extension's idle timer fires (unreliable on Windows where
541
- // MV3 service workers may be suspended before setTimeout triggers).
542
- if (!keepTab)
543
- await page.closeWindow?.().catch(() => { });
544
- throw err;
545
- }
537
+ catch (err) {
538
+ if (observation) {
539
+ observation.record({
540
+ stream: 'action',
541
+ name: 'command',
542
+ phase: 'error',
543
+ data: { error: err instanceof Error ? err.message : String(err) },
544
+ });
545
+ observation.record({
546
+ stream: 'error',
547
+ message: err instanceof Error ? err.message : String(err),
548
+ stack: err instanceof Error ? err.stack : undefined,
549
+ });
550
+ if (traceMode === 'on' || traceMode === 'retain-on-failure') {
551
+ await collectObservationEvidence(observation, page).catch(() => { });
552
+ exportTraceArtifact(observation, 'failure', err, opts.onTraceExport);
553
+ }
554
+ }
555
+ // Release the tab lease on failure too — without this, the lease lingers
556
+ // until the extension's idle timer fires (unreliable on Windows where
557
+ // MV3 service workers may be suspended before setTimeout triggers).
558
+ if (!keepTab)
559
+ await page.closeWindow?.().catch(() => { });
560
+ throw err;
561
+ }
562
+ };
563
+ if (!adapterSession)
564
+ return executeOnPage();
565
+ return withAdapterCommandLease({
566
+ requestId: crypto.randomUUID(),
567
+ contextId: leaseContextId,
568
+ surface: 'adapter',
569
+ site: cmd.site,
570
+ adapterSession,
571
+ sessionKey: session,
572
+ queueTimeoutMs: (adapterQueueTimeoutSeconds ?? 300) * 1_000,
573
+ maxParallel: cmd.adapterConcurrency?.maxParallel ?? 1,
574
+ }, executeOnPage, {
575
+ onLeaseLost: async () => { await page.closeWindow?.().catch(() => { }); },
576
+ });
546
577
  }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession });
578
+ result = await executeInBrowser();
547
579
  }
548
580
  else {
549
581
  // Non-browser commands: enforce a timeout only when the command exposes
@@ -674,11 +706,46 @@ function normalizeSiteSession(raw) {
674
706
  function resolveSiteSession(cmd, rawOption) {
675
707
  return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? 'ephemeral';
676
708
  }
677
- function resolveAdapterBrowserSession(cmd, siteSession) {
678
- if (siteSession === 'persistent')
679
- return `site:${cmd.site}`;
709
+ function normalizeAdapterSession(cmd, resolvedBrowser, rawOption) {
710
+ if (rawOption === undefined || rawOption === null || rawOption === '')
711
+ return undefined;
712
+ if (!resolvedBrowser || cmd.adapterConcurrency?.isolatedTabs !== true) {
713
+ throw new AdapterCoordinationError('ADAPTER_SESSION_NOT_SUPPORTED', `${fullName(cmd)} does not support named Adapter sessions`);
714
+ }
715
+ const value = String(rawOption);
716
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) {
717
+ throw new AdapterCoordinationError('INVALID_ADAPTER_SESSION', '--adapter-session must match [A-Za-z0-9][A-Za-z0-9_-]{0,63}');
718
+ }
719
+ return value;
720
+ }
721
+ function normalizeAdapterQueueTimeout(rawOption, adapterSession) {
722
+ if (rawOption === undefined || rawOption === null || rawOption === '') {
723
+ return adapterSession ? 300 : undefined;
724
+ }
725
+ if (!adapterSession) {
726
+ throw new AdapterCoordinationError('INVALID_ADAPTER_QUEUE_TIMEOUT', '--adapter-queue-timeout requires --adapter-session');
727
+ }
728
+ const value = typeof rawOption === 'string' && /^\d+$/.test(rawOption) ? Number(rawOption) : NaN;
729
+ if (!Number.isInteger(value) || value < 1 || value > 3600) {
730
+ throw new AdapterCoordinationError('INVALID_ADAPTER_QUEUE_TIMEOUT', '--adapter-queue-timeout must be an integer between 1 and 3600 seconds');
731
+ }
732
+ return value;
733
+ }
734
+ function assertAdapterSessionLifecycle(siteSession, adapterSession) {
735
+ if (adapterSession && siteSession !== 'persistent') {
736
+ throw new AdapterCoordinationError('ADAPTER_SESSION_REQUIRES_PERSISTENT', '--adapter-session requires --site-session persistent');
737
+ }
738
+ }
739
+ function resolveAdapterBrowserSession(cmd, siteSession, adapterSession) {
740
+ if (siteSession === 'persistent') {
741
+ return adapterSession ? `site:${cmd.site}:${adapterSession}` : `site:${cmd.site}`;
742
+ }
680
743
  return `site:${cmd.site}:${crypto.randomUUID()}`;
681
744
  }
745
+ function adapterSessionDiagnosticKey(site, adapterSession) {
746
+ const digest = crypto.createHash('sha256').update(adapterSession).digest('hex').slice(0, 12);
747
+ return `site:${site}:adapter-${digest}`;
748
+ }
682
749
  function normalizeBooleanOption(name, raw) {
683
750
  if (raw === undefined || raw === '')
684
751
  return null;
@@ -82,6 +82,7 @@ export declare function siteHelpData(site: string, commands: readonly CliCommand
82
82
  export declare function commandHelpData(cmd: CliCommand): Record<string, unknown>;
83
83
  export declare function formatCommonOptionsHelpText(): string;
84
84
  export declare function formatBrowserCommonOptionsHelpText(): string;
85
+ export declare function formatAdapterSessionOptionsHelpText(): string;
85
86
  export declare function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string;
86
87
  export declare function formatCommandHelpText(cmd: CliCommand): string;
87
88
  export declare function installStructuredHelp(command: Command, data: () => unknown, textSuffix?: string | (() => string)): void;