@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.
- package/dist/daemon/client.js +75 -7
- package/dist/daemon/server.js +18 -3
- package/dist/daemon/web-server.js +67 -19
- package/drivers/ios/conductor-driver-ios.zip +0 -0
- package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
- package/drivers/tvos/conductor-driver-tvos.zip +0 -0
- package/drivers/tvos/conductor-driver-tvosUITests-Runner.zip +0 -0
- package/package.json +1 -1
- package/skills/conductor/SKILL.md +1 -1
- package/skills/skills.yaml +1 -1
package/dist/daemon/client.js
CHANGED
|
@@ -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
|
|
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.
|
|
26
|
-
|
|
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(
|
|
44
|
+
resolve(null);
|
|
32
45
|
});
|
|
33
|
-
req.on('error', () => resolve(
|
|
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
|
-
|
|
47
|
-
|
|
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], {
|
package/dist/daemon/server.js
CHANGED
|
@@ -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, {
|
|
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
|
|
442
|
-
//
|
|
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
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED