@houwert/conductor 0.6.0 → 0.7.1

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.
@@ -19,20 +19,36 @@ const verbose_js_1 = require("../verbose.js");
19
19
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
20
20
  const STARTUP_POLL_MS = 200;
21
21
  const STARTUP_MAX_WAIT_MS = 10000;
22
- async function socketExists(sessionName) {
22
+ async function fetchStatus(sessionName) {
23
23
  return new Promise((resolve) => {
24
24
  const req = http_1.default.get({ socketPath: (0, protocol_js_1.socketPath)(sessionName), path: '/status' }, (res) => {
25
- res.resume();
26
- resolve(res.statusCode === 200);
25
+ if (res.statusCode !== 200) {
26
+ res.resume();
27
+ resolve(null);
28
+ return;
29
+ }
30
+ const chunks = [];
31
+ res.on('data', (c) => chunks.push(c));
32
+ res.on('end', () => {
33
+ try {
34
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
35
+ }
36
+ catch {
37
+ resolve(null);
38
+ }
39
+ });
27
40
  });
28
41
  req.setTimeout(500);
29
42
  req.on('timeout', () => {
30
43
  req.destroy();
31
- resolve(false);
44
+ resolve(null);
32
45
  });
33
- req.on('error', () => resolve(false));
46
+ req.on('error', () => resolve(null));
34
47
  });
35
48
  }
49
+ async function socketExists(sessionName) {
50
+ return (await fetchStatus(sessionName)) !== null;
51
+ }
36
52
  async function waitForDaemon(sessionName) {
37
53
  const deadline = Date.now() + STARTUP_MAX_WAIT_MS;
38
54
  while (Date.now() < deadline) {
@@ -42,9 +58,61 @@ async function waitForDaemon(sessionName) {
42
58
  }
43
59
  return false;
44
60
  }
61
+ /**
62
+ * Wait until a process with the given PID is no longer running.
63
+ * Uses `process.kill(pid, 0)` which throws if the process is gone.
64
+ */
65
+ async function waitForProcessExit(pid, timeoutMs = 5000) {
66
+ const deadline = Date.now() + timeoutMs;
67
+ while (Date.now() < deadline) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ }
71
+ catch {
72
+ return;
73
+ }
74
+ await new Promise((r) => setTimeout(r, 50));
75
+ }
76
+ }
77
+ /**
78
+ * True when the running daemon's CDP attachment matches the current process's
79
+ * `CONDUCTOR_CDP_URL` / `CONDUCTOR_CDP_TARGET_ID` env. A mismatch means the
80
+ * daemon would control the wrong browser (e.g. a standalone Playwright
81
+ * instance when the caller expects to drive an embedded webview), so the
82
+ * daemon must be restarted with the correct env.
83
+ */
84
+ function daemonMatchesCdpEnv(status) {
85
+ const expectedCdpUrl = process.env.CONDUCTOR_CDP_URL ?? '';
86
+ const expectedCdpTargetId = process.env.CONDUCTOR_CDP_TARGET_ID ?? '';
87
+ const actualCdpUrl = status.cdpUrl ?? '';
88
+ const actualCdpTargetId = status.cdpTargetId ?? '';
89
+ return actualCdpUrl === expectedCdpUrl && actualCdpTargetId === expectedCdpTargetId;
90
+ }
45
91
  async function startDaemon(sessionName = 'default') {
46
- if (await socketExists(sessionName))
47
- return true;
92
+ const existing = await fetchStatus(sessionName);
93
+ if (existing) {
94
+ if (daemonMatchesCdpEnv(existing))
95
+ return true;
96
+ (0, verbose_js_1.log)(`daemon [${sessionName}] CDP env mismatch ` +
97
+ `(daemon cdpUrl="${existing.cdpUrl ?? ''}" targetId="${existing.cdpTargetId ?? ''}", ` +
98
+ `env cdpUrl="${process.env.CONDUCTOR_CDP_URL ?? ''}" targetId="${process.env.CONDUCTOR_CDP_TARGET_ID ?? ''}") — restarting`);
99
+ // Capture the PID before stopDaemon removes the pidfile so we can wait
100
+ // for the old process to actually exit before respawning. Otherwise the
101
+ // old daemon's cleanup handler may unlink the new daemon's socket.
102
+ let oldPid;
103
+ try {
104
+ const raw = fs_1.default.readFileSync((0, protocol_js_1.pidFile)(sessionName), 'utf-8').trim();
105
+ const n = parseInt(raw, 10);
106
+ if (!isNaN(n))
107
+ oldPid = n;
108
+ }
109
+ catch {
110
+ /* no pid — continue */
111
+ }
112
+ await stopDaemon(sessionName);
113
+ if (oldPid !== undefined)
114
+ await waitForProcessExit(oldPid);
115
+ }
48
116
  const serverScript = path_1.default.join(__dirname, 'server.js');
49
117
  (0, verbose_js_1.log)(`daemon [${sessionName}] not running — spawning ${serverScript}`);
50
118
  const child = (0, child_process_1.spawn)(process.execPath, [serverScript, sessionName], {
@@ -25,6 +25,22 @@ 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;
36
+ /**
37
+ * Optional CDP target ID to pick a specific page when the host app exposes
38
+ * multiple webviews over one CDP endpoint (e.g. one per workspace in
39
+ * Stagehand). When set, the web driver finds the page whose underlying
40
+ * `Target.targetId` matches and attaches to it, instead of falling back to
41
+ * URL heuristics.
42
+ */
43
+ const cdpTargetId = process.env.CONDUCTOR_CDP_TARGET_ID || undefined;
28
44
  const SOCKET_PATH = (0, protocol_js_1.socketPath)(sessionName);
29
45
  const PID_FILE = (0, protocol_js_1.pidFile)(sessionName);
30
46
  const LOG_FILE = (0, protocol_js_1.logFile)(sessionName);
@@ -75,7 +91,7 @@ async function ensureDriverRunning() {
75
91
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
76
92
  }
77
93
  else if (driverPlatform === 'web') {
78
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog);
94
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
79
95
  }
80
96
  else {
81
97
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -96,6 +112,7 @@ async function main() {
96
112
  fs_1.default.mkdirSync(path_1.default.dirname(PID_FILE), { recursive: true });
97
113
  fs_1.default.writeFileSync(PID_FILE, String(process.pid));
98
114
  dlog(`daemon started pid=${process.pid} session=${sessionName}`);
115
+ dlog(`env CONDUCTOR_CDP_URL=${cdpUrl ?? '<unset>'} CONDUCTOR_CDP_TARGET_ID=${cdpTargetId ?? '<unset>'}`); // kept intentionally — useful for future diagnosis of CDP attachment issues
99
116
  // Remove stale socket
100
117
  try {
101
118
  fs_1.default.unlinkSync(SOCKET_PATH);
@@ -212,7 +229,13 @@ async function main() {
212
229
  resetIdleTimer();
213
230
  const parsed = url_1.default.parse(req.url ?? '/', true);
214
231
  if (req.method === 'GET' && parsed.pathname === '/status') {
215
- jsonResponse(res, { ok: true, platform: driverPlatform, driverPort });
232
+ jsonResponse(res, {
233
+ ok: true,
234
+ platform: driverPlatform,
235
+ driverPort,
236
+ cdpUrl: cdpUrl ?? null,
237
+ cdpTargetId: cdpTargetId ?? null,
238
+ });
216
239
  return;
217
240
  }
218
241
  if (req.method === 'GET' && parsed.pathname === '/logs') {
@@ -277,7 +300,9 @@ async function main() {
277
300
  await (0, bootstrap_js_1.installDriver)(sessionName);
278
301
  dlog(`Driver installation complete`);
279
302
  }
280
- else if (platform === 'web') {
303
+ else if (platform === 'web' && !cdpUrl) {
304
+ // Only install Playwright browser when launching standalone.
305
+ // In CDP mode we attach to the host app's browser (e.g. Electron).
281
306
  const browser = (0, bootstrap_js_1.webBrowserName)(sessionName);
282
307
  await (0, bootstrap_js_1.ensurePlaywrightBrowser)(browser, dlog);
283
308
  }
@@ -291,7 +316,7 @@ async function main() {
291
316
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
292
317
  }
293
318
  else if (platform === 'web') {
294
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog);
319
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
295
320
  }
296
321
  else {
297
322
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -426,19 +426,107 @@ 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, cdpTargetId) {
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 an existing context and page. The host app (e.g. Stagehand) already
442
+ // 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
+ // Match the specific CDP target ID provided by the host app. Required —
448
+ // with multiple pages sharing one CDP port (Electron host window, webviews,
449
+ // DevTools), "guess the right one" is a foot-gun that ends in navigating
450
+ // the wrong window. If no targetId is supplied, or the supplied one can't
451
+ // be resolved to a Playwright Page, we throw rather than attaching to an
452
+ // arbitrary target.
453
+ if (!cdpTargetId) {
454
+ throw new Error('CDP mode requires CONDUCTOR_CDP_TARGET_ID — set it to the specific page target to control.');
455
+ }
456
+ dlog(`Selecting CDP target by ID: ${cdpTargetId}`);
457
+ const pageDiag = [];
458
+ outer: for (const ctx of contexts) {
459
+ for (const page of ctx.pages()) {
460
+ try {
461
+ const session = await ctx.newCDPSession(page);
462
+ const info = (await session.send('Target.getTargetInfo'));
463
+ await session.detach().catch(() => { });
464
+ const t = info.targetInfo;
465
+ pageDiag.push(`page targetId=${t?.targetId ?? '?'} type=${t?.type ?? '?'} url=${t?.url ?? page.url()}`);
466
+ if (t?.targetId === cdpTargetId) {
467
+ _context = ctx;
468
+ _page = page;
469
+ break outer;
470
+ }
471
+ }
472
+ catch (err) {
473
+ pageDiag.push(`page url=${page.url()} getTargetInfo failed: ${err instanceof Error ? err.message : String(err)}`);
474
+ }
475
+ }
476
+ }
477
+ if (!_page) {
478
+ // Enumerate all CDP targets to produce an actionable error — in
479
+ // particular so we can distinguish "wrong targetId" from "target exists
480
+ // but Playwright can't see it as a Page" (the latter happens when the
481
+ // host embeds content as a <webview> guest — type="webview" in CDP —
482
+ // rather than as a top-level page).
483
+ let diagExtra = '';
484
+ let expectedType;
485
+ try {
486
+ const browserSession = await _browser.newBrowserCDPSession();
487
+ const all = (await browserSession.send('Target.getTargets'));
488
+ await browserSession.detach().catch(() => { });
489
+ const enumerated = all.targetInfos
490
+ ?.map((t) => ` - ${t.type.padEnd(10)} ${t.targetId.slice(0, 8)} ${t.url}`)
491
+ .join('\n') ?? '(none)';
492
+ const expected = all.targetInfos?.find((t) => t.targetId === cdpTargetId);
493
+ expectedType = expected?.type;
494
+ diagExtra =
495
+ `\nPages visible to Playwright (${pageDiag.length}):\n` +
496
+ pageDiag.map((l) => ` - ${l}`).join('\n') +
497
+ `\nAll CDP targets (${all.targetInfos?.length ?? 0}):\n${enumerated}\n` +
498
+ (expected
499
+ ? `Requested target exists but type="${expected.type}" — Playwright only surfaces type="page".`
500
+ : `Requested target not present in Target.getTargets — stale or wrong ID.`);
501
+ }
502
+ catch (err) {
503
+ diagExtra = `\nTarget enumeration failed: ${err instanceof Error ? err.message : String(err)}`;
504
+ }
505
+ dlog(`CDP target id ${cdpTargetId} not matched by any Playwright Page.${diagExtra}`);
506
+ throw new Error(`CDP target ${cdpTargetId} not reachable as a Playwright Page` +
507
+ (expectedType
508
+ ? ` (exists as type="${expectedType}" — Playwright only surfaces type="page")`
509
+ : ` (not in target list)`));
510
+ }
511
+ attachConsoleListeners(_page);
512
+ dlog(`CDP connected — page: ${_page.url()}`);
513
+ }
514
+ else {
515
+ // ── Standalone mode: launch a fresh browser ───────────────────────────
516
+ const browserType = browserName === 'firefox' ? playwright_core_1.firefox : browserName === 'webkit' ? playwright_core_1.webkit : playwright_core_1.chromium;
517
+ dlog(`Launching ${browserName} browser...`);
518
+ _browser = await browserType.launch({
519
+ headless: false,
520
+ args: browserName === 'chromium' ? ['--disable-search-engine-choice-screen'] : undefined,
521
+ });
522
+ _context = await _browser.newContext({
523
+ viewport: DEFAULT_VIEWPORT,
524
+ });
525
+ _page = await _context.newPage();
526
+ attachConsoleListeners(_page);
527
+ dlog(`Browser ready, page created`);
528
+ _cdpMode = false;
529
+ }
442
530
  _server = http_1.default.createServer(async (req, res) => {
443
531
  try {
444
532
  await handleRequest(req, res, dlog);
@@ -462,18 +550,38 @@ async function stopWebServer() {
462
550
  _server.close();
463
551
  _server = null;
464
552
  }
465
- if (_page) {
466
- await _page.close().catch(() => { });
553
+ if (_cdpMode) {
554
+ // CDP mode: we don't own the browser — just release our handles.
555
+ // Do NOT close the page, context, or browser.
467
556
  _page = null;
468
- }
469
- if (_context) {
470
- await _context.close().catch(() => { });
471
557
  _context = null;
558
+ if (_browser) {
559
+ // Playwright's connectOverCDP browser supports disconnect() but not close().
560
+ try {
561
+ _browser.close().catch(() => { });
562
+ }
563
+ catch {
564
+ /* not all CDP browsers support close gracefully */
565
+ }
566
+ _browser = null;
567
+ }
472
568
  }
473
- if (_browser) {
474
- await _browser.close().catch(() => { });
475
- _browser = null;
569
+ else {
570
+ // Standalone mode: we launched the browser, so tear it all down.
571
+ if (_page) {
572
+ await _page.close().catch(() => { });
573
+ _page = null;
574
+ }
575
+ if (_context) {
576
+ await _context.close().catch(() => { });
577
+ _context = null;
578
+ }
579
+ if (_browser) {
580
+ await _browser.close().catch(() => { });
581
+ _browser = null;
582
+ }
476
583
  }
584
+ _cdpMode = false;
477
585
  }
478
586
  /** Playwright / CDP errors when the tab, context, or session died but our JS refs still exist. */
479
587
  function isClosedLikeError(err) {
@@ -483,20 +591,40 @@ function isClosedLikeError(err) {
483
591
  /**
484
592
  * Drop the context and open a fresh one. Used when newPage/goto fails after the user closed
485
593
  * the last tab (Chromium can quit the window) or the CDP target is gone while isConnected stays true.
594
+ *
595
+ * In CDP mode, we can't create new contexts — the host app owns the browser.
596
+ * Instead we try to re-acquire an existing context/page.
486
597
  */
487
598
  async function recreateBrowserContext(dlog) {
488
599
  dlog?.('Web driver: recreating browser context');
489
- if (_context) {
490
- await _context.close().catch(() => { });
491
- _context = null;
492
- }
493
- _page = null;
494
600
  if (!_browser) {
495
601
  throw new Error('No page available');
496
602
  }
497
603
  if (!_browser.isConnected()) {
498
604
  throw new Error('Browser has been closed. Restart the web driver (e.g. conductor daemon-start --device web).');
499
605
  }
606
+ if (_cdpMode) {
607
+ // In CDP mode, try to re-acquire a page from existing contexts.
608
+ _page = null;
609
+ _context = null;
610
+ const contexts = _browser.contexts();
611
+ for (const ctx of contexts) {
612
+ const pages = ctx.pages();
613
+ const candidate = pages.find((p) => !p.isClosed());
614
+ if (candidate) {
615
+ _context = ctx;
616
+ _page = candidate;
617
+ attachConsoleListeners(_page);
618
+ return;
619
+ }
620
+ }
621
+ throw new Error('No live pages found via CDP — is the webview still open?');
622
+ }
623
+ if (_context) {
624
+ await _context.close().catch(() => { });
625
+ _context = null;
626
+ }
627
+ _page = null;
500
628
  _context = await _browser.newContext({
501
629
  viewport: DEFAULT_VIEWPORT,
502
630
  });
@@ -629,6 +757,10 @@ async function handleRequest(req, res, dlog) {
629
757
  jsonResponse(res, { url: (await getPage(dlog)).url() });
630
758
  return;
631
759
  }
760
+ case '/runningApp': {
761
+ jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
762
+ return;
763
+ }
632
764
  case '/title': {
633
765
  jsonResponse(res, { title: await (await getPage(dlog)).title() });
634
766
  return;
@@ -779,10 +911,6 @@ async function handleRequest(req, res, dlog) {
779
911
  jsonResponse(res, { ok: true });
780
912
  return;
781
913
  }
782
- case '/runningApp': {
783
- jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
784
- return;
785
- }
786
914
  case '/eraseText': {
787
915
  const count = body['count'] ?? 50;
788
916
  const p = await getPage(dlog);
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.1",
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.1
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.1
7
7
  requires:
8
8
  bins: [conductor]