@houwert/conductor 0.6.0 → 0.7.0

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.
@@ -25,6 +25,14 @@ const web_server_js_1 = require("./web-server.js");
25
25
  const log_collector_js_1 = require("./log-collector.js");
26
26
  const session_js_1 = require("../session.js");
27
27
  const sessionName = process.argv[2] ?? 'default';
28
+ /**
29
+ * CDP URL for connecting to an external browser (e.g. Stagehand's embedded
30
+ * webview). When set, the web driver attaches via Playwright's connectOverCDP
31
+ * instead of launching its own browser.
32
+ *
33
+ * Set by the host IDE (Stagehand) via the agent subprocess environment.
34
+ */
35
+ const cdpUrl = process.env.CONDUCTOR_CDP_URL || undefined;
28
36
  const SOCKET_PATH = (0, protocol_js_1.socketPath)(sessionName);
29
37
  const PID_FILE = (0, protocol_js_1.pidFile)(sessionName);
30
38
  const LOG_FILE = (0, protocol_js_1.logFile)(sessionName);
@@ -75,7 +83,7 @@ async function ensureDriverRunning() {
75
83
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
76
84
  }
77
85
  else if (driverPlatform === 'web') {
78
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog);
86
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
79
87
  }
80
88
  else {
81
89
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -277,7 +285,9 @@ async function main() {
277
285
  await (0, bootstrap_js_1.installDriver)(sessionName);
278
286
  dlog(`Driver installation complete`);
279
287
  }
280
- else if (platform === 'web') {
288
+ else if (platform === 'web' && !cdpUrl) {
289
+ // Only install Playwright browser when launching standalone.
290
+ // In CDP mode we attach to the host app's browser (e.g. Electron).
281
291
  const browser = (0, bootstrap_js_1.webBrowserName)(sessionName);
282
292
  await (0, bootstrap_js_1.ensurePlaywrightBrowser)(browser, dlog);
283
293
  }
@@ -291,7 +301,7 @@ async function main() {
291
301
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
292
302
  }
293
303
  else if (platform === 'web') {
294
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog);
304
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
295
305
  }
296
306
  else {
297
307
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -426,19 +426,59 @@ let _browser = null;
426
426
  let _context = null;
427
427
  let _page = null;
428
428
  let _server = null;
429
- async function startWebServer(port, browserName = 'chromium', dlog = () => { }) {
430
- const browserType = browserName === 'firefox' ? playwright_core_1.firefox : browserName === 'webkit' ? playwright_core_1.webkit : playwright_core_1.chromium;
431
- dlog(`Launching ${browserName} browser...`);
432
- _browser = await browserType.launch({
433
- headless: false,
434
- args: browserName === 'chromium' ? ['--disable-search-engine-choice-screen'] : undefined,
435
- });
436
- _context = await _browser.newContext({
437
- viewport: DEFAULT_VIEWPORT,
438
- });
439
- _page = await _context.newPage();
440
- attachConsoleListeners(_page);
441
- dlog(`Browser ready, page created`);
429
+ /**
430
+ * True when connected to an external browser via CDP (e.g. Stagehand's
431
+ * embedded webview). In this mode we must NOT close the browser on shutdown
432
+ * we only disconnect.
433
+ */
434
+ let _cdpMode = false;
435
+ async function startWebServer(port, browserName = 'chromium', dlog = () => { }, cdpUrl) {
436
+ if (cdpUrl) {
437
+ // ── CDP mode: attach to an existing browser (e.g. Electron webview) ───
438
+ dlog(`Connecting to existing browser via CDP: ${cdpUrl}`);
439
+ _browser = await playwright_core_1.chromium.connectOverCDP(cdpUrl);
440
+ _cdpMode = true;
441
+ // Use the first existing context and page. The host app (e.g. Stagehand)
442
+ // already created them — we just take a handle.
443
+ const contexts = _browser.contexts();
444
+ if (contexts.length === 0) {
445
+ throw new Error('No browser contexts found via CDP — is the webview loaded?');
446
+ }
447
+ // Find the context with a real page (not about:blank, not the host app).
448
+ for (const ctx of contexts) {
449
+ const pages = ctx.pages();
450
+ const candidate = pages.find((p) => p.url() !== 'about:blank' && !p.url().startsWith('file://'));
451
+ if (candidate) {
452
+ _context = ctx;
453
+ _page = candidate;
454
+ break;
455
+ }
456
+ }
457
+ // Fallback: just use the first context's first page.
458
+ if (!_page) {
459
+ _context = contexts[0];
460
+ const pages = _context.pages();
461
+ _page = pages[0] ?? (await _context.newPage());
462
+ }
463
+ attachConsoleListeners(_page);
464
+ dlog(`CDP connected — page: ${_page.url()}`);
465
+ }
466
+ else {
467
+ // ── Standalone mode: launch a fresh browser ───────────────────────────
468
+ const browserType = browserName === 'firefox' ? playwright_core_1.firefox : browserName === 'webkit' ? playwright_core_1.webkit : playwright_core_1.chromium;
469
+ dlog(`Launching ${browserName} browser...`);
470
+ _browser = await browserType.launch({
471
+ headless: false,
472
+ args: browserName === 'chromium' ? ['--disable-search-engine-choice-screen'] : undefined,
473
+ });
474
+ _context = await _browser.newContext({
475
+ viewport: DEFAULT_VIEWPORT,
476
+ });
477
+ _page = await _context.newPage();
478
+ attachConsoleListeners(_page);
479
+ dlog(`Browser ready, page created`);
480
+ _cdpMode = false;
481
+ }
442
482
  _server = http_1.default.createServer(async (req, res) => {
443
483
  try {
444
484
  await handleRequest(req, res, dlog);
@@ -462,18 +502,38 @@ async function stopWebServer() {
462
502
  _server.close();
463
503
  _server = null;
464
504
  }
465
- if (_page) {
466
- await _page.close().catch(() => { });
505
+ if (_cdpMode) {
506
+ // CDP mode: we don't own the browser — just release our handles.
507
+ // Do NOT close the page, context, or browser.
467
508
  _page = null;
468
- }
469
- if (_context) {
470
- await _context.close().catch(() => { });
471
509
  _context = null;
510
+ if (_browser) {
511
+ // Playwright's connectOverCDP browser supports disconnect() but not close().
512
+ try {
513
+ _browser.close().catch(() => { });
514
+ }
515
+ catch {
516
+ /* not all CDP browsers support close gracefully */
517
+ }
518
+ _browser = null;
519
+ }
472
520
  }
473
- if (_browser) {
474
- await _browser.close().catch(() => { });
475
- _browser = null;
521
+ else {
522
+ // Standalone mode: we launched the browser, so tear it all down.
523
+ if (_page) {
524
+ await _page.close().catch(() => { });
525
+ _page = null;
526
+ }
527
+ if (_context) {
528
+ await _context.close().catch(() => { });
529
+ _context = null;
530
+ }
531
+ if (_browser) {
532
+ await _browser.close().catch(() => { });
533
+ _browser = null;
534
+ }
476
535
  }
536
+ _cdpMode = false;
477
537
  }
478
538
  /** Playwright / CDP errors when the tab, context, or session died but our JS refs still exist. */
479
539
  function isClosedLikeError(err) {
@@ -483,20 +543,40 @@ function isClosedLikeError(err) {
483
543
  /**
484
544
  * Drop the context and open a fresh one. Used when newPage/goto fails after the user closed
485
545
  * the last tab (Chromium can quit the window) or the CDP target is gone while isConnected stays true.
546
+ *
547
+ * In CDP mode, we can't create new contexts — the host app owns the browser.
548
+ * Instead we try to re-acquire an existing context/page.
486
549
  */
487
550
  async function recreateBrowserContext(dlog) {
488
551
  dlog?.('Web driver: recreating browser context');
489
- if (_context) {
490
- await _context.close().catch(() => { });
491
- _context = null;
492
- }
493
- _page = null;
494
552
  if (!_browser) {
495
553
  throw new Error('No page available');
496
554
  }
497
555
  if (!_browser.isConnected()) {
498
556
  throw new Error('Browser has been closed. Restart the web driver (e.g. conductor daemon-start --device web).');
499
557
  }
558
+ if (_cdpMode) {
559
+ // In CDP mode, try to re-acquire a page from existing contexts.
560
+ _page = null;
561
+ _context = null;
562
+ const contexts = _browser.contexts();
563
+ for (const ctx of contexts) {
564
+ const pages = ctx.pages();
565
+ const candidate = pages.find((p) => !p.isClosed());
566
+ if (candidate) {
567
+ _context = ctx;
568
+ _page = candidate;
569
+ attachConsoleListeners(_page);
570
+ return;
571
+ }
572
+ }
573
+ throw new Error('No live pages found via CDP — is the webview still open?');
574
+ }
575
+ if (_context) {
576
+ await _context.close().catch(() => { });
577
+ _context = null;
578
+ }
579
+ _page = null;
500
580
  _context = await _browser.newContext({
501
581
  viewport: DEFAULT_VIEWPORT,
502
582
  });
package/dist/index.js CHANGED
@@ -46,6 +46,9 @@ const delete_device_js_1 = require("./commands/delete-device.js");
46
46
  const logs_js_1 = require("./commands/logs.js");
47
47
  const device_picker_js_1 = require("./device-picker.js");
48
48
  const update_check_js_1 = require("./update-check.js");
49
+ const pkg_root_js_1 = require("./pkg-root.js");
50
+ const fs_1 = __importDefault(require("fs"));
51
+ const path_1 = __importDefault(require("path"));
49
52
  const COMMAND_HELP = {
50
53
  'start-device': start_device_js_1.HELP,
51
54
  'delete-device': delete_device_js_1.HELP,
@@ -96,6 +99,7 @@ const OPTIONS_HELP = `Options:
96
99
  --platform <p> Filter to devices of this platform (ios, android, tvos, web)
97
100
  --json Output as machine-readable JSON
98
101
  --verbose, -v Log daemon calls, fallbacks, and raw output
102
+ --version, -V Print version number
99
103
  --help, -h Show this help`;
100
104
  const HELP = `Usage: conductor <command> [args] [options]
101
105
 
@@ -109,6 +113,7 @@ async function main() {
109
113
  boolean: [
110
114
  'json',
111
115
  'help',
116
+ 'version',
112
117
  'clear',
113
118
  'list',
114
119
  'verbose',
@@ -155,12 +160,18 @@ async function main() {
155
160
  'metro-port',
156
161
  'level',
157
162
  ],
158
- alias: { h: 'help', v: 'verbose' },
163
+ alias: { h: 'help', v: 'verbose', V: 'version' },
159
164
  });
160
165
  if (argv['verbose'])
161
166
  (0, verbose_js_1.setVerbose)(true);
162
167
  const [command, ...rest] = argv._;
163
168
  const opts = { json: argv['json'] };
169
+ if (argv['version']) {
170
+ const pkgRoot = (0, pkg_root_js_1.findPkgRoot)(__dirname);
171
+ const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(pkgRoot, 'package.json'), 'utf-8'));
172
+ console.log(pkg.version);
173
+ process.exit(0);
174
+ }
164
175
  // Handle help and unknown commands before device resolution —
165
176
  // no point prompting for a device if we're just printing help or erroring out.
166
177
  if (!command || argv['help']) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conductor
3
- version: 0.6.0
3
+ version: 0.7.0
4
4
  description: "Token-efficient CLI for mobile UI testing (iOS simulator + Android emulator), designed for AI agents"
5
5
  metadata.openclaw:
6
6
  category: service
@@ -3,6 +3,6 @@ skills:
3
3
  path: conductor/SKILL.md
4
4
  description: "Token-efficient CLI for mobile UI testing, designed for AI agents"
5
5
  category: service
6
- version: 0.6.0
6
+ version: 0.7.0
7
7
  requires:
8
8
  bins: [conductor]