@houwert/conductor 0.7.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], {
@@ -33,6 +33,14 @@ const sessionName = process.argv[2] ?? 'default';
33
33
  * Set by the host IDE (Stagehand) via the agent subprocess environment.
34
34
  */
35
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;
36
44
  const SOCKET_PATH = (0, protocol_js_1.socketPath)(sessionName);
37
45
  const PID_FILE = (0, protocol_js_1.pidFile)(sessionName);
38
46
  const LOG_FILE = (0, protocol_js_1.logFile)(sessionName);
@@ -83,7 +91,7 @@ async function ensureDriverRunning() {
83
91
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
84
92
  }
85
93
  else if (driverPlatform === 'web') {
86
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
94
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
87
95
  }
88
96
  else {
89
97
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -104,6 +112,7 @@ async function main() {
104
112
  fs_1.default.mkdirSync(path_1.default.dirname(PID_FILE), { recursive: true });
105
113
  fs_1.default.writeFileSync(PID_FILE, String(process.pid));
106
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
107
116
  // Remove stale socket
108
117
  try {
109
118
  fs_1.default.unlinkSync(SOCKET_PATH);
@@ -220,7 +229,13 @@ async function main() {
220
229
  resetIdleTimer();
221
230
  const parsed = url_1.default.parse(req.url ?? '/', true);
222
231
  if (req.method === 'GET' && parsed.pathname === '/status') {
223
- 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
+ });
224
239
  return;
225
240
  }
226
241
  if (req.method === 'GET' && parsed.pathname === '/logs') {
@@ -301,7 +316,7 @@ async function main() {
301
316
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
302
317
  }
303
318
  else if (platform === 'web') {
304
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
319
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
305
320
  }
306
321
  else {
307
322
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -432,33 +432,81 @@ let _server = null;
432
432
  * — we only disconnect.
433
433
  */
434
434
  let _cdpMode = false;
435
- async function startWebServer(port, browserName = 'chromium', dlog = () => { }, cdpUrl) {
435
+ async function startWebServer(port, browserName = 'chromium', dlog = () => { }, cdpUrl, cdpTargetId) {
436
436
  if (cdpUrl) {
437
437
  // ── CDP mode: attach to an existing browser (e.g. Electron webview) ───
438
438
  dlog(`Connecting to existing browser via CDP: ${cdpUrl}`);
439
439
  _browser = await playwright_core_1.chromium.connectOverCDP(cdpUrl);
440
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.
441
+ // Use an existing context and page. The host app (e.g. Stagehand) already
442
+ // created them — we just take a handle.
443
443
  const contexts = _browser.contexts();
444
444
  if (contexts.length === 0) {
445
445
  throw new Error('No browser contexts found via CDP — is the webview loaded?');
446
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;
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
+ }
455
475
  }
456
476
  }
457
- // Fallback: just use the first context's first page.
458
477
  if (!_page) {
459
- _context = contexts[0];
460
- const pages = _context.pages();
461
- _page = pages[0] ?? (await _context.newPage());
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)`));
462
510
  }
463
511
  attachConsoleListeners(_page);
464
512
  dlog(`CDP connected — page: ${_page.url()}`);
@@ -709,6 +757,10 @@ async function handleRequest(req, res, dlog) {
709
757
  jsonResponse(res, { url: (await getPage(dlog)).url() });
710
758
  return;
711
759
  }
760
+ case '/runningApp': {
761
+ jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
762
+ return;
763
+ }
712
764
  case '/title': {
713
765
  jsonResponse(res, { title: await (await getPage(dlog)).title() });
714
766
  return;
@@ -859,10 +911,6 @@ async function handleRequest(req, res, dlog) {
859
911
  jsonResponse(res, { ok: true });
860
912
  return;
861
913
  }
862
- case '/runningApp': {
863
- jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
864
- return;
865
- }
866
914
  case '/eraseText': {
867
915
  const count = body['count'] ?? 50;
868
916
  const p = await getPage(dlog);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.7.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.7.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.7.0
6
+ version: 0.7.1
7
7
  requires:
8
8
  bins: [conductor]