@houwert/conductor 0.7.0 → 0.8.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.
@@ -1,12 +1,19 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.deleteDevice = deleteDevice;
5
8
  exports.HELP = ` delete-device <name-or-id>
6
- --platform <ios|tvos|android> Scope to a single platform
7
- --all Delete all shutdown simulators / non-running AVDs`;
9
+ --platform <ios|tvos|android|web> Scope to a single platform
10
+ --all Delete all shutdown simulators / non-running AVDs / web sessions`;
11
+ const fs_1 = __importDefault(require("fs"));
8
12
  const runner_js_1 = require("../runner.js");
9
13
  const output_js_1 = require("../output.js");
14
+ const client_js_1 = require("../daemon/client.js");
15
+ const protocol_js_1 = require("../daemon/protocol.js");
16
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
10
17
  async function listSimulators() {
11
18
  const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'list', 'devices', '--json']);
12
19
  if (!result.success)
@@ -89,6 +96,7 @@ async function deleteDevice(nameOrId, opts, flags) {
89
96
  const includeIOS = !platform || platform === 'ios';
90
97
  const includeTvOS = !platform || platform === 'tvos';
91
98
  const includeAndroid = !platform || platform === 'android';
99
+ const includeWeb = !platform || platform === 'web';
92
100
  const deleted = [];
93
101
  // ── --all mode ───────────────────────────────────────────────────────────
94
102
  if (flags.all) {
@@ -135,6 +143,26 @@ async function deleteDevice(nameOrId, opts, flags) {
135
143
  }
136
144
  }
137
145
  }
146
+ // Web: stop all running web daemon sessions
147
+ if (includeWeb) {
148
+ const sessions = (0, client_js_1.listDaemonSessions)();
149
+ for (const session of sessions) {
150
+ if (!(session === 'web' || session.startsWith('web:')))
151
+ continue;
152
+ const status = await (0, client_js_1.daemonStatus)(session);
153
+ if (!status.running)
154
+ continue;
155
+ try {
156
+ await (0, client_js_1.stopDaemon)(session);
157
+ const browser = (0, bootstrap_js_1.webBrowserName)(session);
158
+ const label = browser.charAt(0).toUpperCase() + browser.slice(1);
159
+ deleted.push({ id: session, name: label, platform: 'web' });
160
+ }
161
+ catch (e) {
162
+ (0, output_js_1.printError)(`Failed to stop web session ${session}: ${e instanceof Error ? e.message : String(e)}`, opts);
163
+ }
164
+ }
165
+ }
138
166
  if (deleted.length === 0) {
139
167
  (0, output_js_1.printError)('No devices to delete.', opts);
140
168
  return 1;
@@ -217,6 +245,40 @@ async function deleteDevice(nameOrId, opts, flags) {
217
245
  return 0;
218
246
  }
219
247
  }
248
+ // Try Web session (match by session ID or persisted name)
249
+ if (includeWeb && nameOrId) {
250
+ const sessions = (0, client_js_1.listDaemonSessions)();
251
+ const match = sessions.find((s) => {
252
+ if (!(s === 'web' || s.startsWith('web:')))
253
+ return false;
254
+ if (s === nameOrId)
255
+ return true;
256
+ try {
257
+ return fs_1.default.readFileSync((0, protocol_js_1.nameFile)(s), 'utf-8').trim() === nameOrId;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ });
263
+ if (match) {
264
+ try {
265
+ await (0, client_js_1.stopDaemon)(match);
266
+ }
267
+ catch (e) {
268
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
269
+ return 1;
270
+ }
271
+ const browser = (0, bootstrap_js_1.webBrowserName)(match);
272
+ const label = browser.charAt(0).toUpperCase() + browser.slice(1);
273
+ if (opts.json) {
274
+ (0, output_js_1.printData)({ status: 'ok', deleted: [{ id: match, name: label, platform: 'web' }] }, opts);
275
+ }
276
+ else {
277
+ (0, output_js_1.printSuccess)(`Stopped web session: ${label} (${match})`, opts);
278
+ }
279
+ return 0;
280
+ }
281
+ }
220
282
  (0, output_js_1.printError)(`Device "${nameOrId}" not found.`, opts);
221
283
  return 1;
222
284
  }
@@ -28,6 +28,10 @@ async function downloadApp(appId, output, opts = {}, sessionName = 'default') {
28
28
  return 1;
29
29
  }
30
30
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
31
+ if (platform === 'web') {
32
+ (0, output_js_1.printError)('download-app is not supported on web.', opts);
33
+ return 1;
34
+ }
31
35
  if (platform === 'ios' || platform === 'tvos') {
32
36
  // Get the .app bundle path from the simulator
33
37
  const getPath = await (0, runner_js_1.spawnCommand)('xcrun', [
@@ -24,7 +24,11 @@ async function installApp(appPath, opts = {}, sessionName = 'default') {
24
24
  return 1;
25
25
  }
26
26
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
27
- if (platform === 'ios' || platform === 'tvos') {
27
+ if (platform === 'web') {
28
+ (0, output_js_1.printError)('install-app is not supported on web. Use launch-app with a URL instead.', opts);
29
+ return 1;
30
+ }
31
+ else if (platform === 'ios' || platform === 'tvos') {
28
32
  const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'install', deviceId, appPath]);
29
33
  if (!result.success) {
30
34
  (0, output_js_1.printError)(`install-app failed: ${result.stderr}`, opts);
@@ -21,7 +21,11 @@ async function listApps(opts = {}, sessionName = 'default') {
21
21
  }
22
22
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
23
23
  let appIds;
24
- if (platform === 'ios' || platform === 'tvos') {
24
+ if (platform === 'web') {
25
+ (0, output_js_1.printError)('list-apps is not supported on web. Use foreground-app to get the current URL.', opts);
26
+ return 1;
27
+ }
28
+ else if (platform === 'ios' || platform === 'tvos') {
25
29
  const result = await (0, runner_js_1.spawnCommand)('bash', [
26
30
  '-c',
27
31
  `xcrun simctl listapps ${deviceId} | plutil -convert json - -o -`,
@@ -1,14 +1,19 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.discoverBootedDevices = discoverBootedDevices;
5
8
  exports.discoverAvailableDevices = discoverAvailableDevices;
6
9
  exports.listDevices = listDevices;
7
10
  exports.HELP = ` list-devices List booted and available devices/simulators`;
11
+ const fs_1 = __importDefault(require("fs"));
8
12
  const runner_js_1 = require("../runner.js");
9
13
  const output_js_1 = require("../output.js");
10
14
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
11
15
  const client_js_1 = require("../daemon/client.js");
16
+ const protocol_js_1 = require("../daemon/protocol.js");
12
17
  async function discoverBootedDevices() {
13
18
  const devices = [];
14
19
  // Try adb devices (Android)
@@ -58,9 +63,15 @@ async function discoverBootedDevices() {
58
63
  const status = await (0, client_js_1.daemonStatus)(session);
59
64
  if (status.running) {
60
65
  const browser = (0, bootstrap_js_1.webBrowserName)(session);
61
- const parts = session.split(':');
62
66
  const label = browser.charAt(0).toUpperCase() + browser.slice(1);
63
- const name = parts.length > 2 ? `${label} (${parts[2]})` : label;
67
+ let name;
68
+ try {
69
+ const saved = fs_1.default.readFileSync((0, protocol_js_1.nameFile)(session), 'utf-8').trim();
70
+ name = saved || label;
71
+ }
72
+ catch {
73
+ name = label;
74
+ }
64
75
  devices.push({
65
76
  id: session,
66
77
  name,
@@ -106,6 +117,19 @@ async function discoverAvailableDevices() {
106
117
  }
107
118
  }
108
119
  }
120
+ // Web: installed Playwright browsers that aren't currently running
121
+ const runningSessions = (0, client_js_1.listDaemonSessions)();
122
+ const runningBrowsers = new Set(runningSessions.filter((s) => s === 'web' || s.startsWith('web:')).map((s) => (0, bootstrap_js_1.webBrowserName)(s)));
123
+ for (const browser of ['chromium', 'firefox', 'webkit']) {
124
+ if ((0, bootstrap_js_1.isPlaywrightBrowserInstalled)(browser)) {
125
+ devices.push({
126
+ id: browser === 'chromium' ? 'web' : `web:${browser}`,
127
+ name: browser.charAt(0).toUpperCase() + browser.slice(1),
128
+ platform: 'web',
129
+ status: runningBrowsers.has(browser) ? 'installed' : 'available',
130
+ });
131
+ }
132
+ }
109
133
  return devices;
110
134
  }
111
135
  async function listDevices(opts) {
@@ -113,20 +137,12 @@ async function listDevices(opts) {
113
137
  discoverBootedDevices(),
114
138
  discoverAvailableDevices(),
115
139
  ]);
116
- // Detect installed Playwright browsers for web support
117
- const webBrowsers = ['chromium', 'firefox', 'webkit'].filter((b) => (0, bootstrap_js_1.isPlaywrightBrowserInstalled)(b));
118
- if (devices.length === 0 && availableDevices.length === 0 && webBrowsers.length === 0) {
140
+ if (devices.length === 0 && availableDevices.length === 0) {
119
141
  (0, output_js_1.printError)('No devices found. Start an emulator or simulator first.', opts);
120
142
  return 1;
121
143
  }
122
144
  if (opts.json) {
123
- const webDevices = webBrowsers.map((b) => ({
124
- id: b === 'chromium' ? 'web' : `web:${b}`,
125
- name: b.charAt(0).toUpperCase() + b.slice(1),
126
- platform: 'web',
127
- status: 'available',
128
- }));
129
- (0, output_js_1.printData)({ status: 'ok', devices, availableDevices: [...availableDevices, ...webDevices] }, opts);
145
+ (0, output_js_1.printData)({ status: 'ok', devices, availableDevices }, opts);
130
146
  }
131
147
  else {
132
148
  if (devices.length > 0) {
@@ -148,14 +164,6 @@ async function listDevices(opts) {
148
164
  else {
149
165
  console.log('No available devices.');
150
166
  }
151
- if (webBrowsers.length > 0) {
152
- console.log('');
153
- console.log('Web browsers:');
154
- for (const b of webBrowsers) {
155
- const deviceId = b === 'chromium' ? 'web' : `web:${b}`;
156
- console.log(` web available ${deviceId.padEnd(16)} ${b.charAt(0).toUpperCase() + b.slice(1)}`);
157
- }
158
- }
159
167
  }
160
168
  return 0;
161
169
  }
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.startDevice = startDevice;
@@ -6,12 +9,15 @@ exports.HELP = ` start-device
6
9
  --platform <ios|android|tvos|web> Boot a simulator/emulator, or start the web driver (Playwright)
7
10
  --os-version <n> iOS/tvOS version (e.g. 18) or Android API level (e.g. 33)
8
11
  --avd <name> Android AVD name (default: first available)
9
- --name <name> Set a custom name on the simulator after boot (iOS/tvOS only)
12
+ --name <name> Set a custom name on the device after creation (iOS/tvOS/web)
10
13
  --device-type <name> iOS/tvOS device type (e.g. "iPhone 16 Pro", "Apple TV 4K"); creates if needed
11
14
  --browser <chromium|firefox|webkit> Web only: which Playwright browser to launch (default: chromium)`;
15
+ const fs_1 = __importDefault(require("fs"));
12
16
  const child_process_1 = require("child_process");
13
17
  const runner_js_1 = require("../runner.js");
14
18
  const client_js_1 = require("../daemon/client.js");
19
+ const protocol_js_1 = require("../daemon/protocol.js");
20
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
15
21
  const output_js_1 = require("../output.js");
16
22
  const utils_js_1 = require("../utils.js");
17
23
  const IOS_BOOT_TIMEOUT_MS = 120000;
@@ -462,34 +468,50 @@ async function startAndroid(avdName, opts) {
462
468
  (0, output_js_1.printSuccess)(`Emulator ready: ${target} (${deviceId})`, opts);
463
469
  return 0;
464
470
  }
465
- function webSessionIdForBrowser(browserArg) {
471
+ function resolveWebBrowser(browserArg) {
466
472
  const b = (browserArg ?? 'chromium').toLowerCase();
467
473
  switch (b) {
468
474
  case 'chromium':
469
- return { session: 'web' };
470
475
  case 'firefox':
471
- return { session: 'web:firefox' };
472
476
  case 'webkit':
473
- return { session: 'web:webkit' };
477
+ return { browser: b };
474
478
  default:
475
479
  return {
476
480
  error: `Unknown web browser "${browserArg}". Use chromium, firefox, or webkit.`,
477
481
  };
478
482
  }
479
483
  }
480
- async function startWebDriver(opts, browser) {
481
- const resolved = webSessionIdForBrowser(browser);
484
+ async function startWebDriver(opts, browser, name) {
485
+ const resolved = resolveWebBrowser(browser);
482
486
  if ('error' in resolved) {
483
487
  (0, output_js_1.printError)(resolved.error, opts);
484
488
  return 1;
485
489
  }
486
- const ready = await (0, client_js_1.startDaemon)(resolved.session);
490
+ // Check for an existing running session of this browser type
491
+ const existing = await (0, client_js_1.findRunningWebSession)(resolved.browser);
492
+ if (existing && !name) {
493
+ (0, output_js_1.printSuccess)(`Web driver already running: ${existing}`, opts);
494
+ return 0;
495
+ }
496
+ // Generate a unique session ID for this instance
497
+ const session = (0, bootstrap_js_1.generateWebSessionId)(resolved.browser);
498
+ const ready = await (0, client_js_1.startDaemon)(session);
487
499
  if (!ready) {
488
- (0, output_js_1.printError)(`Web driver did not become ready for session ${resolved.session}. ` +
500
+ (0, output_js_1.printError)(`Web driver did not become ready for session ${session}. ` +
489
501
  'Install a browser with `conductor install-web` if needed, then retry.', opts);
490
502
  return 1;
491
503
  }
492
- (0, output_js_1.printSuccess)(`Web driver ready (${resolved.session})`, opts);
504
+ if (name) {
505
+ try {
506
+ fs_1.default.writeFileSync((0, protocol_js_1.nameFile)(session), name, 'utf-8');
507
+ }
508
+ catch {
509
+ /* best-effort — display name won't persist but the session works */
510
+ }
511
+ }
512
+ const label = resolved.browser.charAt(0).toUpperCase() + resolved.browser.slice(1);
513
+ const displayName = name ? `${name} (${label})` : label;
514
+ (0, output_js_1.printSuccess)(`Web driver ready: ${displayName} (${session})`, opts);
493
515
  return 0;
494
516
  }
495
517
  // ── Entry point ───────────────────────────────────────────────────────────────
@@ -506,7 +528,7 @@ async function startDevice(platform, opts, flags) {
506
528
  case 'android':
507
529
  return startAndroid(flags.avd, opts);
508
530
  case 'web':
509
- return startWebDriver(opts, flags.browser);
531
+ return startWebDriver(opts, flags.browser, flags.name);
510
532
  default:
511
533
  (0, output_js_1.printError)(`Unknown platform "${platform}". Use ios, android, tvos, or web.`, opts);
512
534
  return 1;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.stopDevice = stopDevice;
5
+ exports.HELP = ` stop-device [<name-or-id>]
6
+ --platform <ios|tvos|android|web> Scope to a single platform
7
+ --all Stop all booted simulators / running emulators / web sessions`;
8
+ const runner_js_1 = require("../runner.js");
9
+ const output_js_1 = require("../output.js");
10
+ const client_js_1 = require("../daemon/client.js");
11
+ const list_devices_js_1 = require("./list-devices.js");
12
+ // ── iOS / tvOS ───────────────────────────────────────────────────────────────
13
+ async function shutdownSimulator(udid) {
14
+ const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'shutdown', udid]);
15
+ if (!result.success && !result.stderr.includes('current state: Shutdown')) {
16
+ throw new Error(`Failed to shutdown simulator: ${result.stderr.trim()}`);
17
+ }
18
+ }
19
+ // ── Android ──────────────────────────────────────────────────────────────────
20
+ async function killEmulator(serial) {
21
+ const result = await (0, runner_js_1.spawnCommand)('adb', ['-s', serial, 'emu', 'kill']);
22
+ if (!result.success) {
23
+ throw new Error(`Failed to kill emulator ${serial}: ${result.stderr.trim()}`);
24
+ }
25
+ }
26
+ // ── Entry point ──────────────────────────────────────────────────────────────
27
+ async function stopDevice(nameOrId, opts, flags) {
28
+ if (!nameOrId && !flags.all) {
29
+ (0, output_js_1.printError)('stop-device requires a device name/ID, or --all', opts);
30
+ return 1;
31
+ }
32
+ const platform = flags.platform?.toLowerCase();
33
+ const includeIOS = !platform || platform === 'ios';
34
+ const includeTvOS = !platform || platform === 'tvos';
35
+ const includeAndroid = !platform || platform === 'android';
36
+ const includeWeb = !platform || platform === 'web';
37
+ const stopped = [];
38
+ // ── --all mode ───────────────────────────────────────────────────────────
39
+ if (flags.all) {
40
+ const devices = await (0, list_devices_js_1.discoverBootedDevices)();
41
+ for (const d of devices) {
42
+ if (d.platform === 'ios' && !includeIOS)
43
+ continue;
44
+ if (d.platform === 'tvos' && !includeTvOS)
45
+ continue;
46
+ if (d.platform === 'android' && !includeAndroid)
47
+ continue;
48
+ if (d.platform === 'web' && !includeWeb)
49
+ continue;
50
+ try {
51
+ if (d.platform === 'ios' || d.platform === 'tvos') {
52
+ await shutdownSimulator(d.id);
53
+ }
54
+ else if (d.platform === 'android') {
55
+ await killEmulator(d.id);
56
+ }
57
+ else if (d.platform === 'web') {
58
+ await (0, client_js_1.stopDaemon)(d.id);
59
+ }
60
+ stopped.push({ id: d.id, name: d.name, platform: d.platform });
61
+ }
62
+ catch (e) {
63
+ (0, output_js_1.printError)(`Failed to stop ${d.name} (${d.id}): ${e instanceof Error ? e.message : String(e)}`, opts);
64
+ }
65
+ }
66
+ if (stopped.length === 0) {
67
+ (0, output_js_1.printError)('No running devices to stop.', opts);
68
+ return 1;
69
+ }
70
+ if (opts.json) {
71
+ (0, output_js_1.printData)({ status: 'ok', stopped }, opts);
72
+ }
73
+ else {
74
+ for (const d of stopped) {
75
+ (0, output_js_1.printSuccess)(`Stopped ${d.platform} device: ${d.name} (${d.id})`, opts);
76
+ }
77
+ }
78
+ return 0;
79
+ }
80
+ // ── Single device mode ─────────────────────────────────────────────────
81
+ const devices = await (0, list_devices_js_1.discoverBootedDevices)();
82
+ const match = devices.find((d) => {
83
+ if (platform && d.platform !== platform)
84
+ return false;
85
+ return d.id === nameOrId || d.name === nameOrId;
86
+ });
87
+ if (!match) {
88
+ (0, output_js_1.printError)(`No running device found matching "${nameOrId}".`, opts);
89
+ return 1;
90
+ }
91
+ try {
92
+ if (match.platform === 'ios' || match.platform === 'tvos') {
93
+ await shutdownSimulator(match.id);
94
+ }
95
+ else if (match.platform === 'android') {
96
+ await killEmulator(match.id);
97
+ }
98
+ else if (match.platform === 'web') {
99
+ await (0, client_js_1.stopDaemon)(match.id);
100
+ }
101
+ }
102
+ catch (e) {
103
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
104
+ return 1;
105
+ }
106
+ if (opts.json) {
107
+ (0, output_js_1.printData)({ status: 'ok', stopped: [{ id: match.id, name: match.name, platform: match.platform }] }, opts);
108
+ }
109
+ else {
110
+ (0, output_js_1.printSuccess)(`Stopped ${match.platform} device: ${match.name} (${match.id})`, opts);
111
+ }
112
+ return 0;
113
+ }
@@ -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], {
@@ -73,7 +141,12 @@ async function stopDaemon(sessionName = 'default') {
73
141
  }
74
142
  // Clean up the daemon directory regardless — removes stale dirs from crashed daemons
75
143
  const dir = path_1.default.join(os_1.default.homedir(), '.conductor', 'daemons', sessionName);
76
- for (const file of [(0, protocol_js_1.socketPath)(sessionName), (0, protocol_js_1.pidFile)(sessionName), (0, protocol_js_1.logFile)(sessionName)]) {
144
+ for (const file of [
145
+ (0, protocol_js_1.socketPath)(sessionName),
146
+ (0, protocol_js_1.pidFile)(sessionName),
147
+ (0, protocol_js_1.logFile)(sessionName),
148
+ (0, protocol_js_1.nameFile)(sessionName),
149
+ ]) {
77
150
  try {
78
151
  fs_1.default.unlinkSync(file);
79
152
  }
@@ -7,6 +7,7 @@ exports.IDLE_TIMEOUT_MS = void 0;
7
7
  exports.socketPath = socketPath;
8
8
  exports.pidFile = pidFile;
9
9
  exports.logFile = logFile;
10
+ exports.nameFile = nameFile;
10
11
  const os_1 = __importDefault(require("os"));
11
12
  const path_1 = __importDefault(require("path"));
12
13
  const DIR = path_1.default.join(os_1.default.homedir(), '.conductor');
@@ -22,4 +23,7 @@ function pidFile(sessionName = 'default') {
22
23
  function logFile(sessionName = 'default') {
23
24
  return path_1.default.join(daemonDir(sessionName), 'daemon.log');
24
25
  }
26
+ function nameFile(sessionName = 'default') {
27
+ return path_1.default.join(daemonDir(sessionName), 'name');
28
+ }
25
29
  exports.IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -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);
@@ -52,6 +60,7 @@ let logCollector = null;
52
60
  const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
53
61
  let _restartInProgress = false;
54
62
  let _driverStarted = false;
63
+ let _driverStartError = null;
55
64
  async function ensureDriverRunning() {
56
65
  if (_restartInProgress || !_driverStarted)
57
66
  return;
@@ -83,7 +92,7 @@ async function ensureDriverRunning() {
83
92
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
84
93
  }
85
94
  else if (driverPlatform === 'web') {
86
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
95
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
87
96
  }
88
97
  else {
89
98
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -104,6 +113,7 @@ async function main() {
104
113
  fs_1.default.mkdirSync(path_1.default.dirname(PID_FILE), { recursive: true });
105
114
  fs_1.default.writeFileSync(PID_FILE, String(process.pid));
106
115
  dlog(`daemon started pid=${process.pid} session=${sessionName}`);
116
+ dlog(`env CONDUCTOR_CDP_URL=${cdpUrl ?? '<unset>'} CONDUCTOR_CDP_TARGET_ID=${cdpTargetId ?? '<unset>'}`); // kept intentionally — useful for future diagnosis of CDP attachment issues
107
117
  // Remove stale socket
108
118
  try {
109
119
  fs_1.default.unlinkSync(SOCKET_PATH);
@@ -220,7 +230,16 @@ async function main() {
220
230
  resetIdleTimer();
221
231
  const parsed = url_1.default.parse(req.url ?? '/', true);
222
232
  if (req.method === 'GET' && parsed.pathname === '/status') {
223
- jsonResponse(res, { ok: true, platform: driverPlatform, driverPort });
233
+ jsonResponse(res, {
234
+ ok: true,
235
+ platform: driverPlatform,
236
+ driverPort,
237
+ cdpUrl: cdpUrl ?? null,
238
+ cdpTargetId: cdpTargetId ?? null,
239
+ chromiumCdpPort: driverPlatform === 'web' ? (0, web_server_js_1.getCdpPort)() : null,
240
+ pageTargetId: driverPlatform === 'web' ? (0, web_server_js_1.getPageTargetId)() : null,
241
+ driverStartError: _driverStartError,
242
+ });
224
243
  return;
225
244
  }
226
245
  if (req.method === 'GET' && parsed.pathname === '/logs') {
@@ -301,7 +320,7 @@ async function main() {
301
320
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
302
321
  }
303
322
  else if (platform === 'web') {
304
- await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
323
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
305
324
  }
306
325
  else {
307
326
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
@@ -310,7 +329,8 @@ async function main() {
310
329
  dlog(`Driver started successfully`);
311
330
  }
312
331
  catch (err) {
313
- dlog(`Driver startup error: ${err instanceof Error ? err.message : String(err)}`);
332
+ _driverStartError = err instanceof Error ? err.message : String(err);
333
+ dlog(`Driver startup error: ${_driverStartError}`);
314
334
  }
315
335
  }
316
336
  // Start collecting logs once the driver is (or was already) running.
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseAriaSnapshot = parseAriaSnapshot;
7
7
  exports.startWebServer = startWebServer;
8
8
  exports.stopWebServer = stopWebServer;
9
+ exports.getCdpPort = getCdpPort;
10
+ exports.getPageTargetId = getPageTargetId;
9
11
  /**
10
12
  * Daemon-embedded HTTP server wrapping Playwright for web browser control.
11
13
  *
@@ -17,6 +19,7 @@ exports.stopWebServer = stopWebServer;
17
19
  * happen here so the CLI remains a thin HTTP client.
18
20
  */
19
21
  const http_1 = __importDefault(require("http"));
22
+ const net_1 = require("net");
20
23
  const url_1 = __importDefault(require("url"));
21
24
  const playwright_core_1 = require("playwright-core");
22
25
  const MAX_CONSOLE_BUFFER = 1000;
@@ -432,33 +435,109 @@ let _server = null;
432
435
  * — we only disconnect.
433
436
  */
434
437
  let _cdpMode = false;
435
- async function startWebServer(port, browserName = 'chromium', dlog = () => { }, cdpUrl) {
438
+ let _cdpPort = 0;
439
+ let _pageTargetId = null;
440
+ /** Find a free TCP port by briefly binding to port 0. */
441
+ function findFreePort() {
442
+ return new Promise((resolve, reject) => {
443
+ const srv = (0, net_1.createServer)();
444
+ srv.listen(0, '127.0.0.1', () => {
445
+ const addr = srv.address();
446
+ const port = typeof addr === 'object' ? (addr?.port ?? 0) : 0;
447
+ srv.close(() => resolve(port));
448
+ });
449
+ srv.on('error', reject);
450
+ });
451
+ }
452
+ /** Resolve the CDP target ID of the current page via a temporary CDP session. */
453
+ async function resolvePageTargetId() {
454
+ if (!_context || !_page)
455
+ return null;
456
+ try {
457
+ const cdpSession = await _context.newCDPSession(_page);
458
+ const info = (await cdpSession.send('Target.getTargetInfo'));
459
+ await cdpSession.detach();
460
+ return info.targetInfo?.targetId ?? null;
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ async function startWebServer(port, browserName = 'chromium', dlog = () => { }, cdpUrl, cdpTargetId) {
436
467
  if (cdpUrl) {
437
468
  // ── CDP mode: attach to an existing browser (e.g. Electron webview) ───
438
469
  dlog(`Connecting to existing browser via CDP: ${cdpUrl}`);
439
470
  _browser = await playwright_core_1.chromium.connectOverCDP(cdpUrl);
440
471
  _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.
472
+ // Use an existing context and page. The host app (e.g. Stagehand) already
473
+ // created them — we just take a handle.
443
474
  const contexts = _browser.contexts();
444
475
  if (contexts.length === 0) {
445
476
  throw new Error('No browser contexts found via CDP — is the webview loaded?');
446
477
  }
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;
478
+ // Match the specific CDP target ID provided by the host app. Required —
479
+ // with multiple pages sharing one CDP port (Electron host window, webviews,
480
+ // DevTools), "guess the right one" is a foot-gun that ends in navigating
481
+ // the wrong window. If no targetId is supplied, or the supplied one can't
482
+ // be resolved to a Playwright Page, we throw rather than attaching to an
483
+ // arbitrary target.
484
+ if (!cdpTargetId) {
485
+ throw new Error('CDP mode requires CONDUCTOR_CDP_TARGET_ID — set it to the specific page target to control.');
486
+ }
487
+ dlog(`Selecting CDP target by ID: ${cdpTargetId}`);
488
+ const pageDiag = [];
489
+ outer: for (const ctx of contexts) {
490
+ for (const page of ctx.pages()) {
491
+ try {
492
+ const session = await ctx.newCDPSession(page);
493
+ const info = (await session.send('Target.getTargetInfo'));
494
+ await session.detach().catch(() => { });
495
+ const t = info.targetInfo;
496
+ pageDiag.push(`page targetId=${t?.targetId ?? '?'} type=${t?.type ?? '?'} url=${t?.url ?? page.url()}`);
497
+ if (t?.targetId === cdpTargetId) {
498
+ _context = ctx;
499
+ _page = page;
500
+ break outer;
501
+ }
502
+ }
503
+ catch (err) {
504
+ pageDiag.push(`page url=${page.url()} getTargetInfo failed: ${err instanceof Error ? err.message : String(err)}`);
505
+ }
455
506
  }
456
507
  }
457
- // Fallback: just use the first context's first page.
458
508
  if (!_page) {
459
- _context = contexts[0];
460
- const pages = _context.pages();
461
- _page = pages[0] ?? (await _context.newPage());
509
+ // Enumerate all CDP targets to produce an actionable error — in
510
+ // particular so we can distinguish "wrong targetId" from "target exists
511
+ // but Playwright can't see it as a Page" (the latter happens when the
512
+ // host embeds content as a <webview> guest — type="webview" in CDP —
513
+ // rather than as a top-level page).
514
+ let diagExtra = '';
515
+ let expectedType;
516
+ try {
517
+ const browserSession = await _browser.newBrowserCDPSession();
518
+ const all = (await browserSession.send('Target.getTargets'));
519
+ await browserSession.detach().catch(() => { });
520
+ const enumerated = all.targetInfos
521
+ ?.map((t) => ` - ${t.type.padEnd(10)} ${t.targetId.slice(0, 8)} ${t.url}`)
522
+ .join('\n') ?? '(none)';
523
+ const expected = all.targetInfos?.find((t) => t.targetId === cdpTargetId);
524
+ expectedType = expected?.type;
525
+ diagExtra =
526
+ `\nPages visible to Playwright (${pageDiag.length}):\n` +
527
+ pageDiag.map((l) => ` - ${l}`).join('\n') +
528
+ `\nAll CDP targets (${all.targetInfos?.length ?? 0}):\n${enumerated}\n` +
529
+ (expected
530
+ ? `Requested target exists but type="${expected.type}" — Playwright only surfaces type="page".`
531
+ : `Requested target not present in Target.getTargets — stale or wrong ID.`);
532
+ }
533
+ catch (err) {
534
+ diagExtra = `\nTarget enumeration failed: ${err instanceof Error ? err.message : String(err)}`;
535
+ }
536
+ dlog(`CDP target id ${cdpTargetId} not matched by any Playwright Page.${diagExtra}`);
537
+ throw new Error(`CDP target ${cdpTargetId} not reachable as a Playwright Page` +
538
+ (expectedType
539
+ ? ` (exists as type="${expectedType}" — Playwright only surfaces type="page")`
540
+ : ` (not in target list)`));
462
541
  }
463
542
  attachConsoleListeners(_page);
464
543
  dlog(`CDP connected — page: ${_page.url()}`);
@@ -466,17 +545,25 @@ async function startWebServer(port, browserName = 'chromium', dlog = () => { },
466
545
  else {
467
546
  // ── Standalone mode: launch a fresh browser ───────────────────────────
468
547
  const browserType = browserName === 'firefox' ? playwright_core_1.firefox : browserName === 'webkit' ? playwright_core_1.webkit : playwright_core_1.chromium;
469
- dlog(`Launching ${browserName} browser...`);
548
+ if (browserName === 'chromium') {
549
+ _cdpPort = await findFreePort();
550
+ dlog(`Allocated CDP port ${_cdpPort} for remote debugging`);
551
+ }
552
+ const headless = process.env.CONDUCTOR_HEADLESS === '1' || process.env.CONDUCTOR_HEADLESS === 'true';
553
+ dlog(`Launching ${browserName} browser (headless=${headless})...`);
470
554
  _browser = await browserType.launch({
471
- headless: false,
472
- args: browserName === 'chromium' ? ['--disable-search-engine-choice-screen'] : undefined,
555
+ headless,
556
+ args: browserName === 'chromium'
557
+ ? [`--remote-debugging-port=${_cdpPort}`, '--disable-search-engine-choice-screen']
558
+ : undefined,
473
559
  });
474
560
  _context = await _browser.newContext({
475
561
  viewport: DEFAULT_VIEWPORT,
476
562
  });
477
563
  _page = await _context.newPage();
478
564
  attachConsoleListeners(_page);
479
- dlog(`Browser ready, page created`);
565
+ _pageTargetId = await resolvePageTargetId();
566
+ dlog(`Browser ready, page created (targetId=${_pageTargetId ?? 'unknown'})`);
480
567
  _cdpMode = false;
481
568
  }
482
569
  _server = http_1.default.createServer(async (req, res) => {
@@ -534,6 +621,32 @@ async function stopWebServer() {
534
621
  }
535
622
  }
536
623
  _cdpMode = false;
624
+ _cdpPort = 0;
625
+ _pageTargetId = null;
626
+ }
627
+ /** Get the CDP remote debugging port (0 if not running or non-Chromium). */
628
+ function getCdpPort() {
629
+ return _cdpPort;
630
+ }
631
+ /** Get the CDP target ID for the current page (null if unavailable). */
632
+ function getPageTargetId() {
633
+ return _pageTargetId;
634
+ }
635
+ /** Query the page's navigation history via CDP to determine back/forward state. */
636
+ async function getNavState(page) {
637
+ try {
638
+ const session = await page.context().newCDPSession(page);
639
+ const { currentIndex, entries } = await session.send('Page.getNavigationHistory');
640
+ await session.detach().catch(() => { });
641
+ return {
642
+ url: page.url(),
643
+ canGoBack: currentIndex > 0,
644
+ canGoForward: currentIndex < entries.length - 1,
645
+ };
646
+ }
647
+ catch {
648
+ return { url: page.url(), canGoBack: false, canGoForward: false };
649
+ }
537
650
  }
538
651
  /** Playwright / CDP errors when the tab, context, or session died but our JS refs still exist. */
539
652
  function isClosedLikeError(err) {
@@ -709,6 +822,10 @@ async function handleRequest(req, res, dlog) {
709
822
  jsonResponse(res, { url: (await getPage(dlog)).url() });
710
823
  return;
711
824
  }
825
+ case '/runningApp': {
826
+ jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
827
+ return;
828
+ }
712
829
  case '/title': {
713
830
  jsonResponse(res, { title: await (await getPage(dlog)).title() });
714
831
  return;
@@ -806,26 +923,29 @@ async function handleRequest(req, res, dlog) {
806
923
  return;
807
924
  }
808
925
  await gotoWithRecovery(targetUrl, dlog);
809
- jsonResponse(res, { ok: true });
926
+ const navState = await getNavState(await getPage(dlog));
927
+ jsonResponse(res, { ok: true, ...navState });
810
928
  return;
811
929
  }
812
930
  case '/goBack': {
813
- await (await getPage(dlog))
814
- .goBack({ waitUntil: 'domcontentloaded', timeout: 10000 })
815
- .catch(() => { });
816
- jsonResponse(res, { ok: true });
931
+ const p = await getPage(dlog);
932
+ await p.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => { });
933
+ const navState = await getNavState(p);
934
+ jsonResponse(res, { ok: true, ...navState });
817
935
  return;
818
936
  }
819
937
  case '/goForward': {
820
- await (await getPage(dlog))
821
- .goForward({ waitUntil: 'domcontentloaded', timeout: 10000 })
822
- .catch(() => { });
823
- jsonResponse(res, { ok: true });
938
+ const p = await getPage(dlog);
939
+ await p.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => { });
940
+ const navState = await getNavState(p);
941
+ jsonResponse(res, { ok: true, ...navState });
824
942
  return;
825
943
  }
826
944
  case '/reload': {
827
- await (await getPage(dlog)).reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
828
- jsonResponse(res, { ok: true });
945
+ const p = await getPage(dlog);
946
+ await p.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
947
+ const navState = await getNavState(p);
948
+ jsonResponse(res, { ok: true, ...navState });
829
949
  return;
830
950
  }
831
951
  case '/clearCookies': {
@@ -859,10 +979,6 @@ async function handleRequest(req, res, dlog) {
859
979
  jsonResponse(res, { ok: true });
860
980
  return;
861
981
  }
862
- case '/runningApp': {
863
- jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
864
- return;
865
- }
866
982
  case '/eraseText': {
867
983
  const count = body['count'] ?? 50;
868
984
  const p = await getPage(dlog);
@@ -872,6 +988,53 @@ async function handleRequest(req, res, dlog) {
872
988
  jsonResponse(res, { ok: true });
873
989
  return;
874
990
  }
991
+ case '/setViewport': {
992
+ if (_cdpMode) {
993
+ jsonResponse(res, { error: 'setViewport not supported in CDP mode' }, 400);
994
+ return;
995
+ }
996
+ if (!_browser) {
997
+ jsonResponse(res, { error: 'No browser running' }, 400);
998
+ return;
999
+ }
1000
+ const vpWidth = body['width'] ?? DEFAULT_VIEWPORT.width;
1001
+ const vpHeight = body['height'] ?? DEFAULT_VIEWPORT.height;
1002
+ const vpUserAgent = body['userAgent'];
1003
+ const vpIsMobile = body['isMobile'] ?? false;
1004
+ const vpScaleFactor = body['deviceScaleFactor'] ?? (vpIsMobile ? 2 : 1);
1005
+ const vpColorScheme = body['colorScheme'];
1006
+ const savedUrl = _page ? _page.url() : '';
1007
+ if (_page)
1008
+ await _page.close().catch(() => { });
1009
+ if (_context)
1010
+ await _context.close().catch(() => { });
1011
+ _context = await _browser.newContext({
1012
+ viewport: { width: vpWidth, height: vpHeight },
1013
+ userAgent: vpUserAgent,
1014
+ deviceScaleFactor: vpScaleFactor,
1015
+ isMobile: vpIsMobile,
1016
+ hasTouch: vpIsMobile,
1017
+ colorScheme: vpColorScheme,
1018
+ });
1019
+ _page = await _context.newPage();
1020
+ attachConsoleListeners(_page);
1021
+ _pageTargetId = await resolvePageTargetId();
1022
+ if (savedUrl && savedUrl !== 'about:blank') {
1023
+ await gotoWithRecovery(savedUrl, dlog);
1024
+ }
1025
+ jsonResponse(res, { ok: true, cdpTargetId: _pageTargetId });
1026
+ return;
1027
+ }
1028
+ case '/setColorScheme': {
1029
+ const scheme = body['colorScheme'];
1030
+ if (scheme !== 'dark' && scheme !== 'light') {
1031
+ jsonResponse(res, { error: 'colorScheme must be "dark" or "light"' }, 400);
1032
+ return;
1033
+ }
1034
+ await (await getPage(dlog)).emulateMedia({ colorScheme: scheme });
1035
+ jsonResponse(res, { ok: true });
1036
+ return;
1037
+ }
875
1038
  case '/shutdown': {
876
1039
  jsonResponse(res, { ok: true });
877
1040
  // Graceful shutdown after response is sent
@@ -608,7 +608,12 @@ async function stopWebDriver(port) {
608
608
  */
609
609
  async function uninstallDriver(deviceId, platform) {
610
610
  (0, verbose_js_1.log)(`uninstallDriver: removing ${platform} driver from ${deviceId}`);
611
- if (platform === 'ios') {
611
+ if (platform === 'web') {
612
+ // Web has no persistent driver to uninstall — the browser is managed by the daemon.
613
+ // Stopping the web server is handled by the daemon cleanup.
614
+ return;
615
+ }
616
+ else if (platform === 'ios') {
612
617
  await spawnAndWait('xcrun', [
613
618
  'simctl',
614
619
  'uninstall',
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ const scroll_until_visible_js_1 = require("./commands/scroll-until-visible.js");
42
42
  const set_location_js_1 = require("./commands/set-location.js");
43
43
  const set_orientation_js_1 = require("./commands/set-orientation.js");
44
44
  const start_device_js_1 = require("./commands/start-device.js");
45
+ const stop_device_js_1 = require("./commands/stop-device.js");
45
46
  const delete_device_js_1 = require("./commands/delete-device.js");
46
47
  const logs_js_1 = require("./commands/logs.js");
47
48
  const device_picker_js_1 = require("./device-picker.js");
@@ -51,6 +52,7 @@ const fs_1 = __importDefault(require("fs"));
51
52
  const path_1 = __importDefault(require("path"));
52
53
  const COMMAND_HELP = {
53
54
  'start-device': start_device_js_1.HELP,
55
+ 'stop-device': stop_device_js_1.HELP,
54
56
  'delete-device': delete_device_js_1.HELP,
55
57
  'list-devices': list_devices_js_1.HELP,
56
58
  'foreground-app': foreground_app_js_1.HELP,
@@ -187,6 +189,7 @@ async function main() {
187
189
  const NO_DEVICE_COMMANDS = new Set([
188
190
  'list-devices',
189
191
  'start-device',
192
+ 'stop-device',
190
193
  'delete-device',
191
194
  'cheat-sheet',
192
195
  'install-plugin',
@@ -244,6 +247,12 @@ async function main() {
244
247
  case 'list-devices':
245
248
  exitCode = await (0, list_devices_js_1.listDevices)(opts);
246
249
  break;
250
+ case 'stop-device':
251
+ exitCode = await (0, stop_device_js_1.stopDevice)(rest[0], opts, {
252
+ platform: argv['platform'],
253
+ all: argv['all'],
254
+ });
255
+ break;
247
256
  case 'delete-device':
248
257
  exitCode = await (0, delete_device_js_1.deleteDevice)(rest[0], opts, {
249
258
  platform: argv['platform'],
package/dist/runner.js CHANGED
@@ -60,6 +60,17 @@ async function detectFirstDevice() {
60
60
  /* ignore */
61
61
  }
62
62
  }
63
+ // Web: check for running daemon web sessions
64
+ for (const session of (0, client_js_1.listDaemonSessions)()) {
65
+ if (!(session === 'web' || session.startsWith('web:')))
66
+ continue;
67
+ const status = await (0, client_js_1.daemonStatus)(session);
68
+ if (status.running) {
69
+ (0, verbose_js_1.log)(`detectFirstDevice: found running web session "${session}"`);
70
+ _cachedDeviceId = session;
71
+ return session;
72
+ }
73
+ }
63
74
  _cachedDeviceId = null;
64
75
  return undefined;
65
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.7.0",
3
+ "version": "0.8.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.7.0
3
+ version: 0.8.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.7.0
6
+ version: 0.8.0
7
7
  requires:
8
8
  bins: [conductor]