@houwert/conductor 0.27.2 → 0.29.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.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  <img src="assets/banner.png" alt="Conductor" width="800" />
4
4
 
5
- # 🎼 Conductor
5
+ # Conductor
6
6
 
7
- **Give Claude hands. Let it drive your app.**
7
+ **Mobile and web UI automation for AI agents.**
8
8
 
9
9
  [![CI](https://github.com/DouweBos/conductor/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DouweBos/conductor/actions/workflows/ci.yml)
10
10
  [![npm version](https://img.shields.io/npm/v/@houwert/conductor)](https://www.npmjs.com/package/@houwert/conductor)
@@ -14,13 +14,9 @@
14
14
 
15
15
  ---
16
16
 
17
- Conductor is a token-efficient CLI for mobile UI interactions, built for AI agents. It's a TypeScript reimplementation and partial fork of [Maestro](https://maestro.mobile.dev) that bundles its own native drivers no external CLI, no setup friction, no nonsense.
17
+ Conductor is a token-efficient CLI for driving and inspecting running apps, built for AI agents. It is a TypeScript reimplementation and partial fork of [Maestro](https://maestro.mobile.dev) that ships its own native drivers, so there is no external CLI to install and no JVM to configure.
18
18
 
19
- It started as an experiment: what if Claude could tap through your app while it's writing the code? Turns out that's extremely useful. Now it's a proper tool.
20
-
21
- ## ✨ What it does
22
-
23
- Conductor gives Claude Code the ability to interact with iOS simulators and Android emulators directly from a coding session. It can navigate UI, inspect the live hierarchy, take screenshots, run flows, and manage multiple devices in parallel across concurrent agents.
19
+ It gives a coding agent the ability to operate an app while writing the code for it: navigate the UI, read the live view hierarchy, take screenshots, run flows, and drive several devices in parallel across concurrent agents.
24
20
 
25
21
  ```bash
26
22
  conductor launch-app com.example.myapp
@@ -30,47 +26,85 @@ conductor assert-visible "Dashboard"
30
26
  conductor take-screenshot --output /tmp/screen.png
31
27
  ```
32
28
 
33
- One agent writes the feature. Another taps through the app. They talk. It works. 🤝
29
+ Targets iOS and tvOS simulators, physical iOS/tvOS devices, Android emulators and devices, Amazon Fire TV, and web via Playwright.
34
30
 
35
- ## 🚀 Quick start
31
+ ## Quick start
36
32
 
37
33
  ```bash
38
34
  npm install -g @houwert/conductor
39
35
  ```
40
36
 
41
- That's it. Conductor is a pure CLI. To teach an AI agent how to use it, set up the bundled skills in your repo:
37
+ Conductor is a pure CLI. To teach an AI agent how to use it, install the bundled skills into your repository:
42
38
 
43
39
  ```bash
44
- conductor init # interactive: pick scope + skills, writes them into .claude/skills/
40
+ conductor init # interactive: pick scope + skills, writes to .claude/skills/
45
41
  conductor init --yes # non-interactive: install all skills into ./.claude/skills/
46
42
  conductor init --global # install into ~/.claude/skills/ for all repos
47
- conductor init --force # re-sync skills you've already installed
43
+ conductor init --force # re-sync skills you have already installed
48
44
  ```
49
45
 
50
- `init` is the one manual setup step — run it once per repo. In a terminal it walks you through which skills and where; piped or headless (CI/agent) it installs everything non-interactively. It drops a set of capability-scoped Claude Code skills — `conductor-device-interact`, `conductor-inspect`, `conductor-create-flow`, `conductor-metro-debugger`, `conductor-profiler`, and `conductor-device-setup` — that document every command and the act → observe → act workflow.
46
+ `init` is the one manual setup step — run it once per repository. In a terminal it prompts for which skills and where; piped or headless (CI, agent) it installs everything non-interactively. It writes a set of capability-scoped Claude Code skills — `conductor-device-interact`, `conductor-inspect`, `conductor-create-flow`, `conductor-native`, `conductor-metro-debugger`, `conductor-profiler` and `conductor-device-setup` — documenting every command and the act → observe → act workflow.
51
47
 
52
- When you upgrade conductor, re-run `conductor init --force` to re-sync the installed skills (it stamps the installed version, so `init` tells you when they're out of date, and prunes any skills no longer shipped). Or wire it in however you like (a custom `CLAUDE.md`, a slash command — it's up to you). Run `conductor --help` for the full command reference, or `conductor <command> --help` for per-command flags.
48
+ After upgrading conductor, re-run `conductor init --force` to re-sync the installed skills. `init` stamps the version it installed, so it can tell you when they are stale and prune skills no longer shipped. Integrating another way (a custom `CLAUDE.md`, a slash command) works equally well.
53
49
 
54
- ### 📱 What the CLI can do
50
+ Run `conductor --help` for the full command reference, or `conductor <command> --help` for per-command flags.
51
+
52
+ ### What the CLI can do
55
53
 
56
54
  | Capability | Commands |
57
55
  |---|---|
58
- | App lifecycle | `launch-app`, `stop-app`, `clear-state`, `uninstall-app`, `install-app`, `foreground-app`, `copy-app` |
59
- | Interaction | `tap-on`, `input-text`, `scroll`, `scroll-until-visible`, `swipe`, `press-key`, `erase-text`, `hide-keyboard` |
60
- | Inspection | `inspect`, `focused`, `take-screenshot`, `capture-ui`, `list-apps` |
61
- | Assertions | `assert-visible`, `assert-not-visible` |
56
+ | App lifecycle | `launch-app`, `stop-app`, `clear-state`, `install-app`, `uninstall-app`, `foreground-app`, `copy-app`, `download-app` |
57
+ | Interaction | `tap-on`, `input-text`, `scroll`, `scroll-until-visible`, `swipe`, `gesture`, `pinch`, `press-key`, `erase-text`, `hide-keyboard` |
58
+ | Inspection | `inspect`, `focused`, `capture-ui`, `take-screenshot`, `list-apps` |
59
+ | Assertions | `assert-visible`, `assert-not-visible`, `assert-true`, `assert-screenshot` |
62
60
  | Navigation | `open-link`, `back` |
63
- | Flows | `run-flow`, `run-flow-inline`, `run-parallel` |
64
- | Devices | `start-device`, `list-devices`, `set-location`, `set-orientation` |
61
+ | Flows | `run-flow`, `run-flow-inline`, `run-parallel`, `run-sequence`, `flow` |
62
+ | Devices | `start-device`, `stop-device`, `list-devices`, `device-pool`, `set-location`, `set-orientation`, `set-permissions` |
63
+ | Debugging | `logs`, `crashes`, `network`, `memory`, `profile`, `metro`, `record-video`, `stream-server` |
64
+ | In-process (iOS/tvOS) | `native-inspect`, `native-find`, `native-set`, `native-eval`, `native-heap`, and more |
65
+ | Test cases | `cases` |
65
66
  | Web setup | `install-web [browser]` (installs a Playwright browser; `--check` prints status) |
66
- | Discovery | `list-options [command]` / `<command> --options` (valid values for enumerated params) |
67
+ | Discovery | `list-options [command]` / `<command> --options`, `workspace` |
68
+
69
+ ## Conductor Studio
70
+
71
+ **[Conductor Studio](apps/studio)** is a desktop app (Electron + React) built on this CLI. It does three jobs: writing and managing Maestro tests, writing them with an agent, and tracking them as test cases.
72
+
73
+ It provides a flow editor with autocomplete and linting, a live device stream with element picking and a record mode that turns your interactions into flow steps, an agentic test writer that verifies described behaviour on a device and files a visual report, and local test case management. Light and dark, signed and notarized, auto-updating.
74
+
75
+ Studio bundles its own copy of the conductor CLI, so it needs nothing installed globally. The version it uses can be pinned from Settings.
76
+
77
+ **Download:** [Releases](https://github.com/DouweBos/conductor/releases) — macOS (Apple silicon). Studio releases are tagged `studio-v*`; the CLI's are tagged `cli-v*`.
78
+
79
+ See the [Studio README](apps/studio/README.md) for the full feature tour, architecture and release process.
80
+
81
+ ```bash
82
+ pnpm dev:studio # run it from source
83
+ ```
84
+
85
+ ## Repository structure
86
+
87
+ ```
88
+ conductor/
89
+ ├── packages/
90
+ │ ├── cli/ # TypeScript CLI (@houwert/conductor)
91
+ │ ├── android-driver/ # Kotlin/Gradle instrumentation driver
92
+ │ ├── ios-driver/ # Swift/Xcode XCTest driver
93
+ │ ├── ios-inproc/ # Library injected into the app for a second inspection plane
94
+ │ ├── ios-hid/ # Host binary injecting HID below the XCTest layer
95
+ │ ├── ios-capture/ # Host binary capturing the Simulator framebuffer
96
+ │ └── studio-ui/ # Design system for Conductor Studio
97
+ ├── apps/
98
+ │ └── studio/ # Conductor Studio — the desktop app
99
+ └── Makefile
100
+ ```
67
101
 
68
- ## 🔨 Building locally
102
+ ## Building locally
69
103
 
70
104
  ### Prerequisites
71
105
 
72
- - Node.js + pnpm v9
73
- - **iOS:** Xcode with command-line tools
106
+ - Node.js and pnpm 10
107
+ - **iOS/tvOS:** Xcode with command-line tools
74
108
  - **Android:** Android SDK with `adb` on `PATH`
75
109
 
76
110
  ### Full build
@@ -79,7 +113,7 @@ When you upgrade conductor, re-run `conductor init --force` to re-sync the insta
79
113
  make build
80
114
  ```
81
115
 
82
- Builds the iOS driver (xcodebuild), the Android driver (Gradle), packages both into the CLI, and compiles TypeScript. Then link it globally:
116
+ Builds the iOS and tvOS XCTest drivers, the in-process library and capture binary (xcodebuild), and the Android driver (Gradle); packages them all into the CLI and compiles TypeScript. Then link it globally:
83
117
 
84
118
  ```bash
85
119
  cd packages/cli && pnpm link --global
@@ -97,33 +131,23 @@ pnpm install && pnpm build
97
131
  ### Individual targets
98
132
 
99
133
  ```bash
100
- make build-cli # CLI TypeScript only
101
- make build-ios-driver # iOS XCTest driver
134
+ make build-cli # CLI TypeScript only
135
+ make build-ios-driver # iOS XCTest driver
102
136
  make build-android-driver # Android instrumentation driver
103
- make package-cli # Bundle drivers into CLI package
104
- ```
105
-
106
- ## 🗂️ Repository structure
107
-
108
- ```
109
- conductor/
110
- ├── packages/
111
- │ ├── cli/ # TypeScript CLI (@houwert/conductor)
112
- │ ├── android-driver/ # Kotlin/Gradle instrumentation driver
113
- │ └── ios-driver/ # Swift/Xcode XCTest driver
114
- └── Makefile
137
+ make package-cli # Bundle drivers into CLI package
115
138
  ```
116
139
 
117
- ## 🛠️ Development
140
+ ## Development
118
141
 
119
142
  ```bash
120
- pnpm dev # TypeScript watch mode
121
- pnpm lint # ESLint + Prettier check
143
+ pnpm dev # TypeScript watch mode
144
+ pnpm lint # ESLint + Prettier check
122
145
  pnpm lint:fix # Auto-fix formatting
123
- pnpm test # Run test suite
146
+ pnpm test # Run test suite
124
147
  ```
125
148
 
126
- ## 📋 Requirements
149
+ ## Requirements
127
150
 
128
- - **iOS:** Xcode with a booted simulator
151
+ - **iOS/tvOS:** Xcode with a booted simulator, or a paired physical device
129
152
  - **Android:** `adb` on `PATH` with a running emulator or connected device
153
+ - **Web:** a Playwright browser (`conductor install-web`)
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.adb = adb;
4
+ exports.adbShell = adbShell;
5
+ exports.parseResumedPackage = parseResumedPackage;
6
+ exports.resolveAndroidForegroundApp = resolveAndroidForegroundApp;
7
+ exports.shellBracketed = shellBracketed;
8
+ exports.buildClockAnchor = buildClockAnchor;
9
+ exports.parseProcUptimeMs = parseProcUptimeMs;
10
+ exports.toDeviceRealtimeMs = toDeviceRealtimeMs;
11
+ /**
12
+ * Small adb helpers for read-only device queries.
13
+ *
14
+ * Deliberately spawns adb directly rather than going through `runner.ts` so the
15
+ * profiling commands can query a device without booting the gRPC driver daemon —
16
+ * `dumpsys` needs neither, and starting the daemon would perturb what we measure.
17
+ */
18
+ const child_process_1 = require("child_process");
19
+ const sdk_js_1 = require("./sdk.js");
20
+ function adb(deviceId, args) {
21
+ return new Promise((resolve) => {
22
+ const proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, ...args], {
23
+ stdio: ['ignore', 'pipe', 'pipe'],
24
+ env: (0, sdk_js_1.androidSpawnEnv)(),
25
+ });
26
+ let stdout = '';
27
+ let stderr = '';
28
+ proc.stdout.on('data', (c) => (stdout += c.toString()));
29
+ proc.stderr.on('data', (c) => (stderr += c.toString()));
30
+ proc.on('close', (code) => resolve({ success: code === 0, stdout, stderr }));
31
+ proc.on('error', (err) => resolve({ success: false, stdout: '', stderr: err.message }));
32
+ });
33
+ }
34
+ function adbShell(deviceId, args) {
35
+ return adb(deviceId, ['shell', ...args]);
36
+ }
37
+ /**
38
+ * Package name of the resumed activity, or undefined. Android spells this
39
+ * differently across API levels, hence the alternation.
40
+ */
41
+ function parseResumedPackage(dumpsysActivities) {
42
+ const m = dumpsysActivities.match(/(?:m|top)?ResumedActivity[=:].*?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)+)\//);
43
+ return m ? m[1] : undefined;
44
+ }
45
+ async function resolveAndroidForegroundApp(deviceId) {
46
+ const dump = await adbShell(deviceId, ['dumpsys', 'activity', 'activities']);
47
+ return dump.success ? parseResumedPackage(dump.stdout) : undefined;
48
+ }
49
+ /**
50
+ * Run `command` on the device bracketed by two CLOCK_REALTIME reads, in one
51
+ * adb invocation. The caller supplies the monotonic reading by parsing it out
52
+ * of `stdout` (for gfxinfo, its own `Uptime:` header line).
53
+ */
54
+ async function shellBracketed(deviceId, command) {
55
+ const hostStart = Date.now();
56
+ // One argument, not argv: adb joins everything after `shell` with spaces and
57
+ // the device's own shell parses it, so the semicolons survive.
58
+ const res = await adbShell(deviceId, [`date +%s%N; ${command}; date +%s%N`]);
59
+ const hostElapsedMs = Date.now() - hostStart;
60
+ if (!res.success)
61
+ return undefined;
62
+ const stamps = [...res.stdout.matchAll(/^\s*(\d{16,})\s*$/gm)].map((m) => Number(m[1]) / 1e6);
63
+ if (stamps.length < 2)
64
+ return undefined;
65
+ const realtimeStartMs = stamps[0];
66
+ const realtimeEndMs = stamps[stamps.length - 1];
67
+ // Strip the two stamp lines so callers parse only the command's own output.
68
+ const stdout = res.stdout
69
+ .split(/\r?\n/)
70
+ .filter((l) => !/^\s*\d{16,}\s*$/.test(l))
71
+ .join('\n');
72
+ return { stdout, realtimeStartMs, realtimeEndMs, hostElapsedMs };
73
+ }
74
+ function buildClockAnchor(bracket, deviceMonotonicMs) {
75
+ const spread = bracket.realtimeEndMs - bracket.realtimeStartMs;
76
+ return {
77
+ deviceMonotonicMs,
78
+ deviceRealtimeMs: (bracket.realtimeStartMs + bracket.realtimeEndMs) / 2,
79
+ anchorErrorMs: Math.round((spread / 2) * 100) / 100,
80
+ hostEpochMs: Date.now(),
81
+ // The device cannot have spent more wall time on this than the host did
82
+ // waiting for it; if it claims to, the realtime clock was stepped.
83
+ clockStepped: spread > bracket.hostElapsedMs + 50 ? true : undefined,
84
+ };
85
+ }
86
+ /** Device CLOCK_MONOTONIC ms from /proc/uptime. Cheap (~1ms) but 10ms-grained. */
87
+ function parseProcUptimeMs(out) {
88
+ const m = out.match(/^\s*(\d+\.\d+)\s+\d+\.\d+\s*$/m);
89
+ return m ? Number(m[1]) * 1000 : undefined;
90
+ }
91
+ /** Convert a framestats vsync timestamp (ns, monotonic) to the app's Date.now() domain. */
92
+ function toDeviceRealtimeMs(anchor, monotonicNs) {
93
+ return monotonicNs / 1e6 - anchor.deviceMonotonicMs + anchor.deviceRealtimeMs;
94
+ }
@@ -47,11 +47,11 @@ function resolveSession(sessionName) {
47
47
  };
48
48
  }
49
49
  async function debugStatus(opts, sessionName, debugOpts) {
50
- const port = debugOpts.port ?? 8081;
51
50
  try {
52
- const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
53
51
  const { deviceId, platformPromise } = resolveSession(sessionName);
54
52
  const platform = await platformPromise;
53
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
54
+ const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
55
55
  const client = new metro_cdp_js_1.MetroCdpClient();
56
56
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
57
57
  await client.enableDomain('Runtime');
@@ -120,10 +120,10 @@ async function debugEvaluate(expr, opts, sessionName, debugOpts) {
120
120
  return 1;
121
121
  }
122
122
  }
123
- const port = debugOpts.port ?? 8081;
124
123
  const { deviceId, platformPromise } = resolveSession(sessionName);
125
124
  try {
126
125
  const platform = await platformPromise;
126
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
127
127
  const client = new metro_cdp_js_1.MetroCdpClient();
128
128
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
129
129
  const value = await client.evaluate(expr);
@@ -140,10 +140,10 @@ async function debugEvaluate(expr, opts, sessionName, debugOpts) {
140
140
  }
141
141
  }
142
142
  async function debugComponentTree(opts, sessionName, debugOpts) {
143
- const port = debugOpts.port ?? 8081;
144
143
  const { deviceId, platformPromise } = resolveSession(sessionName);
145
144
  try {
146
145
  const platform = await platformPromise;
146
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
147
147
  const client = new metro_cdp_js_1.MetroCdpClient();
148
148
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
149
149
  const awaitCallback = await client.installCallbackBinding();
@@ -214,10 +214,10 @@ async function debugInspectElement(at, opts, sessionName, debugOpts) {
214
214
  }
215
215
  const x = Number(m[1]);
216
216
  const y = Number(m[2]);
217
- const port = debugOpts.port ?? 8081;
218
217
  const { deviceId, platformPromise } = resolveSession(sessionName);
219
218
  try {
220
219
  const platform = await platformPromise;
220
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
221
221
  const client = new metro_cdp_js_1.MetroCdpClient();
222
222
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
223
223
  const awaitCallback = await client.installCallbackBinding();
@@ -253,10 +253,10 @@ async function debugLogRegistry(opts, sessionName) {
253
253
  return (0, logs_js_1.logs)(opts, sessionName, { source: 'metro', list: true });
254
254
  }
255
255
  async function debugReload(opts, sessionName, debugOpts) {
256
- const port = debugOpts.port ?? 8081;
257
256
  const { deviceId, platformPromise } = resolveSession(sessionName);
258
257
  try {
259
258
  const platform = await platformPromise;
259
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
260
260
  await (0, metro_cdp_js_1.cdpCall)('Page.reload', undefined, {
261
261
  port,
262
262
  deviceId,
@@ -7,6 +7,10 @@ exports._testDeviceOverride = exports.HELP = void 0;
7
7
  exports.devicePool = devicePool;
8
8
  exports.HELP = ` device-pool --list List all devices and pool status
9
9
  device-pool --acquire Claim a free device (prints device ID)
10
+ --device <id> Claim this specific device instead of any free one
11
+ --owner <pid> Hold the claim for this process (default: this CLI's
12
+ own PID, which exits immediately — pass a long-lived
13
+ PID to keep the device reserved)
10
14
  device-pool --release <id> Release a device back to the pool`;
11
15
  /**
12
16
  * device-pool: Manage a pool of available devices for concurrent multi-agent use.
@@ -115,7 +119,11 @@ function pruneStaleAcquisitions(state) {
115
119
  }
116
120
  }
117
121
  // ── Commands ──────────────────────────────────────────────────────────────────
118
- async function devicePool(action, releaseId, opts = {}) {
122
+ async function devicePool(action, releaseId, opts = {},
123
+ /** Claim this device specifically, rather than any free one. */
124
+ wantedDevice,
125
+ /** Process that owns the claim; it is released when that process goes away. */
126
+ ownerPid) {
119
127
  if (action === 'list') {
120
128
  const allDevices = await discoverAllDevices();
121
129
  const pool = await withLock(() => {
@@ -152,6 +160,9 @@ async function devicePool(action, releaseId, opts = {}) {
152
160
  (0, output_js_1.printError)('No devices available', opts);
153
161
  return 1;
154
162
  }
163
+ // A claim lives as long as its owner process. This CLI exits immediately, so
164
+ // a caller that wants to keep a device reserved passes its own long-lived PID.
165
+ const owner = String(ownerPid ?? process.pid);
155
166
  const result = await withLock(() => {
156
167
  const state = readPool();
157
168
  pruneStaleAcquisitions(state);
@@ -161,17 +172,25 @@ async function devicePool(action, releaseId, opts = {}) {
161
172
  state.devices.push({ deviceId: id });
162
173
  }
163
174
  }
164
- // Find a free device
165
- const free = state.devices.find((e) => allDevices.includes(e.deviceId) && !e.acquiredBy);
175
+ // Asking for a device by name is idempotent: re-claiming one you already
176
+ // hold succeeds, because an owner running two things against one device
177
+ // isn't a conflict. Asking for *any* free device is the opposite request
178
+ // — "give me one nobody is on" — so a device you already hold doesn't
179
+ // count as free, or two parallel runs would land on the same screen.
180
+ const free = wantedDevice
181
+ ? state.devices.find((e) => e.deviceId === wantedDevice && (!e.acquiredBy || e.acquiredBy === owner))
182
+ : state.devices.find((e) => allDevices.includes(e.deviceId) && !e.acquiredBy);
166
183
  if (!free)
167
184
  return null;
168
- free.acquiredBy = String(process.pid);
185
+ free.acquiredBy = owner;
169
186
  free.acquiredAt = Date.now();
170
187
  writePool(state);
171
188
  return free.deviceId;
172
189
  });
173
190
  if (!result) {
174
- (0, output_js_1.printError)('No free devices available in pool', opts);
191
+ (0, output_js_1.printError)(wantedDevice
192
+ ? `Device ${wantedDevice} is already in use by another agent`
193
+ : 'No free devices available in pool', opts);
175
194
  return 1;
176
195
  }
177
196
  if (opts.json) {
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
+ exports.queryFocused = queryFocused;
5
+ exports.focusKey = focusKey;
4
6
  exports.focused = focused;
5
7
  exports.HELP = ` focused [--poll [interval_ms]] Print metadata of the currently focused element
6
8
  --poll continuously watches for focus changes (default 500ms)`;
@@ -216,6 +218,31 @@ async function queryFocused(driver) {
216
218
  }
217
219
  throw new Error('Unknown driver type');
218
220
  }
221
+ /**
222
+ * Stable identity for a focused element, for detecting *that focus moved*.
223
+ *
224
+ * Deliberately excludes the visible label: TV rows repeat titles, so two
225
+ * neighbouring cards can print identically. Bounds plus whichever id the
226
+ * platform exposes distinguishes them.
227
+ */
228
+ function focusKey(data) {
229
+ if (!data)
230
+ return '(none)';
231
+ const bounds = data.bounds;
232
+ const boundsKey = bounds
233
+ ? Object.entries(bounds)
234
+ .sort(([a], [b]) => a.localeCompare(b))
235
+ .map(([k, v]) => `${k}=${v}`)
236
+ .join(',')
237
+ : '';
238
+ const id = data.identifier ||
239
+ data.resourceId ||
240
+ data.ref ||
241
+ data.contentDesc ||
242
+ '';
243
+ const type = data.elementTypeName || data.className || data.role || '';
244
+ return `${id}|${type}|${boundsKey}`;
245
+ }
219
246
  async function focused(opts = {}, sessionName = 'default', { poll = false, interval = 500 } = {}) {
220
247
  try {
221
248
  const driver = await (0, runner_js_1.getDriver)(sessionName);