// Action verbs for `agents computer` — the interaction surface over the
// computer-helper daemon's RPC methods (click, type, key, drag, scroll,
// describe, ax-action, focus, ...). These mirror `agents browser`'s verb
// layout: flat verbs under the noun, bundle-id targeting, --json on reads.
//
// The daemon already implements every method; this file is the thin, typed
// CLI skin over it plus a shared target resolver so callers stay in bundle-id
// space and never hand-manage pids.
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { openComputerClient, describeTransport, } from '../lib/computer/computer-rpc.js';
import { emitComputerAction, resolveTargetPidDecision } from '../lib/computer/actions.js';
export { emitComputerAction, pickTarget, resolveTargetPidDecision } from '../lib/computer/actions.js';
// Pure target picker — exercised by unit tests. Precedence: explicit --pid,
// then --bundle, then the frontmost active allow-listed app (the same default
// `screenshot` uses). Kept side-effect-free so the resolution rules are
// testable without a live daemon.
// Parse an "x,y" coordinate pair. Pure + tested.
export function parseXY(s, flag) {
    const parts = s.split(',').map((v) => v.trim());
    if (parts.length !== 2) {
        throw new Error(`${flag} must be "x,y" (got: ${s})`);
    }
    const x = Number(parts[0]);
    const y = Number(parts[1]);
    if (!Number.isFinite(x) || !Number.isFinite(y)) {
        throw new Error(`${flag} must be two numbers "x,y" (got: ${s})`);
    }
    return { x, y };
}
// Build the element-or-coords target spec shared by click/type/scroll/etc.
// Pure + tested: returns the params fragment or an error string.
export function buildElementOrCoords(opts) {
    if (opts.id)
        return { ok: true, params: { element_id: opts.id } };
    if (opts.x != null && opts.y != null)
        return { ok: true, params: { x: opts.x, y: opts.y } };
    return { ok: false, error: 'pass --id <@eN> (from `describe`) or --x <n> --y <n>' };
}
// Build the focus_window params for `raise`. Pure + tested: window_id and
// title are both optional refinements over the app-level activate.
export function buildRaiseParams(opts) {
    const params = {};
    if (opts.windowId != null)
        params.window_id = opts.windowId;
    if (opts.title)
        params.title = opts.title;
    return params;
}
// Inter-character typing delay for type-text. Default 4ms matches the daemon's
// historical fixed rate; lossy keyboard relays (Parallels/VM guests) drop chars
// at that rate, so callers can raise it. Clamp to [1, 250]ms CLI-side (the
// daemon clamps too — defense in depth). Returns undefined when unset so the
// daemon applies its own default. Pure + tested.
export const CHAR_DELAY_MIN_MS = 1;
export const CHAR_DELAY_MAX_MS = 250;
export function clampCharDelay(ms) {
    if (ms === undefined || !Number.isFinite(ms))
        return undefined;
    return Math.min(CHAR_DELAY_MAX_MS, Math.max(CHAR_DELAY_MIN_MS, Math.trunc(ms)));
}
// Build the wait RPC params. Pure + tested. Three modes, mirroring the
// daemon's Wait.run: --duration (unconditional sleep), --id + --until
// (cached-element poll), or --role/--label/--identifier (live locator poll).
export function buildWaitParams(opts) {
    if (opts.duration != null) {
        return { ok: true, params: { duration_ms: opts.duration } };
    }
    const params = {};
    if (opts.until)
        params.until = opts.until;
    if (opts.timeout != null)
        params.timeout_ms = opts.timeout;
    if (opts.id) {
        return { ok: true, params: { ...params, element_id: opts.id } };
    }
    const locator = {};
    if (opts.role)
        locator.role = opts.role;
    if (opts.label)
        locator.label = opts.label;
    if (opts.identifier)
        locator.identifier = opts.identifier;
    if (Object.keys(locator).length === 0) {
        return { ok: false, error: 'pass --duration <ms>, --id <@eN>, or a locator (--role/--label/--identifier)' };
    }
    return { ok: true, params: { ...params, locator } };
}
// postToPid keyboard delivery is dropped by key-window-gated apps (Parallels
// VMs and friends) — when the daemon reports the target was not frontmost,
// the keystrokes may have landed nowhere. Surface that loudly on stderr.
function warnIfNotFrontmost(res) {
    if (res.frontmost === false) {
        console.error('warning: target was not the frontmost app — keystrokes may have been dropped. Run `agents computer raise` first.');
    }
}
function reportMissingHelper() {
    console.error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
    process.exit(1);
}
// Open a client, run fn, always close. Fails fast if no helper is present.
export async function withClient(fn) {
    if (describeTransport().kind === 'none')
        reportMissingHelper();
    const client = openComputerClient();
    try {
        return await fn(client);
    }
    finally {
        await client.close();
    }
}
// Unwrap an RPC response: print + exit on error, else return result.
export function unwrap(r) {
    if (r.error) {
        console.error(`error: ${r.error.code}: ${r.error.message}`);
        process.exit(1);
    }
    return r.result ?? {};
}
// Resolve the target pid via list_apps + pickTarget, printing a precise error
// and exiting when no target matches.
async function resolveTargetPid(client, opts, gate) {
    const resolved = await resolveTargetPidDecision(client, opts, gate);
    if (!resolved.ok) {
        console.error(resolved.error);
        process.exit(1);
    }
    return resolved.pid;
}
// Focus-safety policy for input verbs. Element mode (--id) drives an app through
// Accessibility actions (AXPress / set AXValue) that never activate the app or move
// the cursor, so the user keeps working while an agent acts. The two paths that DO
// take over the screen are (a) --raise (brings the app to the front + steals keyboard
// focus) and (b) coordinate mode (--x/--y warps the physical cursor and needs the app
// frontmost). Both are surfaced as notes, and --raise is ignored in element mode so a
// reflexive flag cannot hijack the user's session.
// Pure, unit-tested: the focus/cursor costs an action will impose, as human notes.
export function focusStealNotes(opts) {
    const notes = [];
    const elementMode = opts.id != null;
    if (opts.raise) {
        notes.push(elementMode
            ? 'note: --raise ignored in element mode (--id) — element actions do not need the app frontmost, so your focus is left alone.'
            : 'note: --raise brings the target app to the front and takes keyboard focus from you. Element mode (`describe` then --id) drives apps without stealing focus.');
    }
    if (!elementMode && (opts.x != null || opts.y != null)) {
        notes.push('note: coordinate mode moves your real cursor and needs the app frontmost. Prefer element mode (`describe` then --id) to act without moving your pointer.');
    }
    return notes;
}
// Pure, unit-tested: whether --raise is actually honored. Element mode suppresses it
// so an element-targeted action never steals the user's foreground.
export function shouldRaise(opts) {
    return Boolean(opts.raise) && opts.id == null;
}
// Apply the focus-safety policy for an input verb: print the cost notes, and raise
// only when raising is actually warranted (non-element mode with --raise).
async function applyFocusPolicy(client, pid, opts) {
    for (const note of focusStealNotes(opts))
        console.error(note);
    if (shouldRaise(opts))
        unwrap(await client.call('focus_window', { pid }));
}
// Electron/webview steering. macOS accepts an AX action (AXPress / set-AXValue)
// on an Electron/Chromium window, but it does NOT run the web app's real DOM
// handlers — React ignores it — so a reported `clicked`/`typed` on a webview can
// be a silent no-op. Detect Electron targets and steer the caller to CDP
// (`agents browser --electron`), which drives the webview for real. We warn, not
// block: the caller may still want the raw action (e.g. to focus + coordinate).
const electronCache = new Map();
// Resolve a bundle id to its .app path via Spotlight. Best-effort; null on miss.
function appPathForBundle(bundleId) {
    try {
        const out = execFileSync('mdfind', [`kMDItemCFBundleIdentifier == '${bundleId}'`], {
            encoding: 'utf-8',
            timeout: 3000,
        });
        return out.split('\n').map((s) => s.trim()).find((s) => s.endsWith('.app')) ?? null;
    }
    catch {
        return null;
    }
}
// Pure + unit-tested: does the .app at this path bundle the Electron framework?
export function appPathIsElectron(appPath, exists = fs.existsSync) {
    if (!appPath)
        return false;
    return exists(path.join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework'));
}
// Is the app for this bundle id an Electron/webview app? macOS-only; memoized so
// the mdfind lookup runs at most once per bundle id per process.
function isElectronApp(bundleId) {
    if (!bundleId || process.platform !== 'darwin')
        return false;
    const cached = electronCache.get(bundleId);
    if (cached !== undefined)
        return cached;
    const result = appPathIsElectron(appPathForBundle(bundleId));
    electronCache.set(bundleId, result);
    return result;
}
// Pure + unit-tested: the CDP-steer note printed for a webview target.
export function electronWebviewTip(appLabel) {
    return `note: ${appLabel} is an Electron/web UI — an AX click/type may not reach the webview `
        + `(a reported success can be a no-op). To drive it reliably, relaunch it with `
        + '`--remote-debugging-port=9222` and use `agents browser --electron` (CDP).';
}
// Print the CDP steer when the target is a known Electron app. Keyed off --bundle
// (the recommended way to target); a frontmost-resolved target without --bundle is
// left alone to avoid a second RPC on the hot path. Skipped for remote --device.
function warnIfElectronWebview(opts) {
    if (opts.device)
        return;
    if (opts.bundle && isElectronApp(opts.bundle))
        console.error(electronWebviewTip(opts.bundle));
}
function emit(result, json, human) {
    if (json) {
        console.log(JSON.stringify(result, null, 2));
    }
    else {
        console.log(human());
    }
}
// Record one `computer.action` per verb invocation, one call site per command
// below. Session/agent/machine identity is stamped for free by emitEvent's
// provenance floor (events.ts resolveProvenance) — this only carries the
// action-specific facts: which verb, against which target pid/bundle/device.
// NOTE: the field is `targetPid`, never `pid` — `pid` is a reserved envelope
// key (the emitting process's OWN pid, events.ts RESERVED_META_KEYS) that
// sanitizePayload() silently strips from the payload before it can collide.
// emitComputerAction lives in lib/computer/actions.ts (re-exported above).
// Add the shared --pid/--bundle/--device target options to a verb. `--device`
// routes the verb at a remote Windows device: the `computer` preAction hook
// hydrates COMPUTER_HELPER_TCP from the tunnel `start --device` recorded, so
// withClient's openComputerClient() transparently selects the TCP transport.
function addTargetOpts(cmd) {
    return cmd
        .option('--bundle <id>', 'Bundle id of the target app (default: frontmost allow-listed app)')
        .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10))
        .option('--device <name>', 'Drive a remote Windows device (requires `agents computer start --device <name>` first)');
}
// Add the shared --id/--x/--y element-or-coords options to a verb.
function addElementOrCoordOpts(cmd) {
    return cmd
        .option('--id <@eN>', 'Element id from `describe` (focus-safe: no foreground steal, no cursor move)')
        .option('--x <n>', 'X coordinate (global, points; moves your real cursor, needs app frontmost)', (v) => parseInt(v, 10))
        .option('--y <n>', 'Y coordinate (global, points; moves your real cursor, needs app frontmost)', (v) => parseInt(v, 10));
}
export function registerActionCommands(program) {
    // apps — list_apps
    addTargetOpts(program
        .command('apps')
        .description('List apps the daemon may drive (allow-listed + running)')
        .option('--json', 'Emit JSON')).action(async (opts) => {
        await withClient(async (client) => {
            const res = unwrap(await client.call('list_apps'));
            const list = res.apps || [];
            emitComputerAction('apps', undefined, opts);
            emit(res, Boolean(opts.json), () => list.length === 0
                ? '(no allow-listed apps running)'
                : list
                    .map((a) => `${a.active ? '*' : ' '} ${String(a.pid).padStart(6)}  ${a.bundle_id}  ${a.name}`)
                    .join('\n'));
        });
    });
    // describe — AX tree
    addTargetOpts(program
        .command('describe')
        .description('Dump the accessibility tree (element ids feed click/type --id)')
        .option('--depth <n>', 'Max tree depth', (v) => parseInt(v, 10))
        .option('--json', 'Emit compact JSON (default: pretty)')).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'describe' });
            const params = { pid };
            if (opts.depth != null)
                params.max_depth = opts.depth;
            const res = unwrap(await client.call('describe', params));
            emitComputerAction('describe', pid, opts, { depth: opts.depth });
            // The tree is inherently structured — always JSON, pretty unless --json.
            console.log(JSON.stringify(opts.json ? res : res.tree ?? res, null, 2));
        });
    });
    // click
    addElementOrCoordOpts(addTargetOpts(program
        .command('click')
        .description('Click an element (--id) or screen coordinate (--x --y)')
        .option('--count <n>', 'Click count (2 = double-click)', (v) => parseInt(v, 10))
        .option('--background', 'Focus-safe postToPid delivery (plain AppKit only; skips HID tap)')
        .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
        .option('--json', 'Emit JSON'))).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'click' });
            warnIfElectronWebview(opts);
            const spec = buildElementOrCoords(opts);
            if (!spec.ok) {
                console.error(spec.error);
                process.exit(1);
            }
            await applyFocusPolicy(client, pid, opts);
            const params = { pid, ...spec.params };
            if (opts.count != null)
                params.count = opts.count;
            if (opts.background)
                params.background = true;
            const res = unwrap(await client.call('click', params));
            emitComputerAction('click', pid, opts, { id: opts.id, count: opts.count });
            emit(res, Boolean(opts.json), () => `clicked (${res.action ?? 'ok'})`);
        });
    });
    // right-click
    addElementOrCoordOpts(addTargetOpts(program
        .command('right-click')
        .description('Right-click (context menu) an element or coordinate')
        .option('--json', 'Emit JSON'))).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'right-click' });
            warnIfElectronWebview(opts);
            const spec = buildElementOrCoords(opts);
            if (!spec.ok) {
                console.error(spec.error);
                process.exit(1);
            }
            await applyFocusPolicy(client, pid, opts);
            const res = unwrap(await client.call('right_click', { pid, ...spec.params }));
            emitComputerAction('right-click', pid, opts, { id: opts.id });
            emit(res, Boolean(opts.json), () => `right-clicked (${res.method ?? 'ok'})`);
        });
    });
    // type — set value on a field (--id) or paste at coords, optional commit
    addElementOrCoordOpts(addTargetOpts(program
        .command('type')
        .description('Set a field value (--id) or paste at a coordinate (--x --y)')
        .requiredOption('--text <s>', 'Text to enter')
        .option('--commit', 'Commit after typing (AXConfirm / Return) so the value reaches the model')
        .option('--allow-secure-field', 'Permit typing into a password field')
        .option('--json', 'Emit JSON'))).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'type' });
            warnIfElectronWebview(opts);
            const spec = buildElementOrCoords(opts);
            if (!spec.ok) {
                console.error(spec.error);
                process.exit(1);
            }
            await applyFocusPolicy(client, pid, opts);
            const params = { pid, ...spec.params, text: opts.text };
            if (opts.commit)
                params.commit = true;
            if (opts.allowSecureField)
                params.allow_secure_field = true;
            const res = unwrap(await client.call('type', params));
            // textLength, never the text itself — users type passwords/secrets into fields.
            emitComputerAction('type', pid, opts, { id: opts.id, textLength: opts.text.length, committed: Boolean(res.committed) });
            emit(res, Boolean(opts.json), () => `typed ${opts.text.length} char(s)${res.committed ? ' (committed)' : ''}`);
        });
    });
    // type-text — stream an arbitrary unicode string into the focused field
    addTargetOpts(program
        .command('type-text')
        .description('Type an arbitrary unicode string into the focused field (focus first via click/focus)')
        .requiredOption('--text <s>', 'Text to type')
        .option('--commit', 'Press Return after typing')
        .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
        .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
        .option('--char-delay <ms>', 'Inter-character delay in ms (default 4; raise for lossy keyboard relays like VM guests, e.g. 25). Clamped to [1, 250].', (v) => parseInt(v, 10))
        .option('--json', 'Emit JSON')).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'type-text' });
            warnIfElectronWebview(opts);
            await applyFocusPolicy(client, pid, opts);
            const params = { pid, text: opts.text };
            if (opts.commit)
                params.commit = true;
            if (opts.requireFrontmost)
                params.require_frontmost = true;
            const charDelay = clampCharDelay(opts.charDelay);
            if (charDelay !== undefined)
                params.char_delay_ms = charDelay;
            const res = unwrap(await client.call('type_text', params));
            warnIfNotFrontmost(res);
            emitComputerAction('type-text', pid, opts, { textLength: opts.text.length, committed: Boolean(opts.commit) });
            emit(res, Boolean(opts.json), () => `typed ${res.chars ?? opts.text.length} char(s)`);
        });
    });
    // key — single chord
    addTargetOpts(program
        .command('key')
        .description('Send a key chord, e.g. "cmd+shift+s", "enter", "esc"')
        .requiredOption('--keys <chord>', 'Key chord')
        .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
        .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
        .option('--json', 'Emit JSON')).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'key' });
            warnIfElectronWebview(opts);
            await applyFocusPolicy(client, pid, opts);
            const params = { pid, keys: opts.keys };
            if (opts.requireFrontmost)
                params.require_frontmost = true;
            const res = unwrap(await client.call('key', params));
            warnIfNotFrontmost(res);
            emitComputerAction('key', pid, opts, { keys: opts.keys });
            emit(res, Boolean(opts.json), () => `sent ${opts.keys}`);
        });
    });
    // drag — from one point to another
    addTargetOpts(program
        .command('drag')
        .description('Drag from one coordinate to another')
        .requiredOption('--from <x,y>', 'Start coordinate "x,y"')
        .requiredOption('--to <x,y>', 'End coordinate "x,y"')
        .option('--button <left|right>', 'Mouse button', 'left')
        .option('--background', 'Focus-safe postToPid delivery (plain AppKit only)')
        .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
        .option('--json', 'Emit JSON')).action(async (opts) => {
        let from;
        let to;
        try {
            from = parseXY(opts.from, '--from');
            to = parseXY(opts.to, '--to');
        }
        catch (err) {
            console.error(err.message);
            process.exit(1);
        }
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'drag' });
            await applyFocusPolicy(client, pid, opts);
            const params = {
                pid,
                from: [from.x, from.y],
                to: [to.x, to.y],
                button: opts.button,
            };
            if (opts.background)
                params.background = true;
            const res = unwrap(await client.call('drag', params));
            emitComputerAction('drag', pid, opts, { from: opts.from, to: opts.to });
            emit(res, Boolean(opts.json), () => `dragged ${opts.from} -> ${opts.to} (${res.method ?? 'ok'})`);
        });
    });
    // scroll — by delta at an element or coordinate
    addElementOrCoordOpts(addTargetOpts(program
        .command('scroll')
        .description('Scroll by a pixel delta at an element or coordinate')
        .option('--dy <n>', 'Vertical delta (negative = down)', (v) => parseInt(v, 10))
        .option('--dx <n>', 'Horizontal delta', (v) => parseInt(v, 10))
        .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
        .option('--json', 'Emit JSON'))).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'scroll' });
            await applyFocusPolicy(client, pid, opts);
            const params = { pid };
            if (opts.id)
                params.element_id = opts.id;
            if (opts.x != null)
                params.x = opts.x;
            if (opts.y != null)
                params.y = opts.y;
            if (opts.dy != null)
                params.dy = opts.dy;
            if (opts.dx != null)
                params.dx = opts.dx;
            const res = unwrap(await client.call('scroll', params));
            emitComputerAction('scroll', pid, opts, { id: opts.id, dx: opts.dx, dy: opts.dy });
            emit(res, Boolean(opts.json), () => `scrolled (${res.method ?? 'ok'})`);
        });
    });
    // ax-action — perform any advertised AX action on an element
    addTargetOpts(program
        .command('ax-action')
        .description('Perform an arbitrary AX action (AXConfirm, AXCancel, AXRaise, ...) on an element')
        .requiredOption('--id <@eN>', 'Element id from `describe`')
        .requiredOption('--action <name>', 'AX action name')
        .option('--json', 'Emit JSON')).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'ax-action' });
            const res = unwrap(await client.call('ax_action', { pid, element_id: opts.id, action: opts.action }));
            emitComputerAction('ax-action', pid, opts, { id: opts.id, action: opts.action });
            emit(res, Boolean(opts.json), () => `performed ${opts.action}`);
        });
    });
    // focus — set keyboard focus to an element
    addTargetOpts(program
        .command('focus')
        .description('Set keyboard focus to an element (so type-text/key land there)')
        .requiredOption('--id <@eN>', 'Element id from `describe`')
        .option('--json', 'Emit JSON')).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'focus' });
            const res = unwrap(await client.call('set_focus', { pid, element_id: opts.id }));
            emitComputerAction('focus', pid, opts, { id: opts.id });
            emit(res, Boolean(opts.json), () => `focused ${opts.id}`);
        });
    });
    // raise — bring an app (or one of its windows) to the front. The window
    // forms (--window-id/--title) also switch macOS Spaces, which is the only
    // way to reach a fullscreen-Space window (VM, fullscreen editor) for
    // capture and HID-tap input.
    addTargetOpts(program
        .command('raise')
        .description('Bring an app (or a specific window) to the front — switches Spaces for fullscreen windows')
        .option('--window-id <n>', 'Raise a specific window by id (from `screenshot --list`)', (v) => parseInt(v, 10))
        .option('--title <s>', 'Raise the window whose title contains this string')
        .option('--json', 'Emit JSON')).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'raise' });
            const res = unwrap(await client.call('focus_window', { pid, ...buildRaiseParams(opts) }));
            emitComputerAction('raise', pid, opts, { windowId: opts.windowId, title: opts.title });
            emit(res, Boolean(opts.json), () => {
                const scope = res.raised_window ? `window ${res.title ?? res.window_id ?? ''}`.trim() : 'app';
                return `raised ${scope} (${res.focus_elapsed_ms ?? 0}ms)`;
            });
        });
    });
    // wait — settle the UI before the next action
    addTargetOpts(program
        .command('wait')
        .description('Wait for a duration (--duration) or for an element (--id / --role/--label) to satisfy --until')
        .option('--duration <ms>', 'Unconditional sleep in ms (50-30000)', (v) => parseInt(v, 10))
        .option('--id <@eN>', 'Element id from `describe` to poll')
        .option('--until <cond>', 'Condition: exists | enabled | disappears (default: exists)')
        .option('--role <s>', 'Locator: AX role (e.g. AXButton)')
        .option('--label <s>', 'Locator: element label')
        .option('--identifier <s>', 'Locator: AX identifier')
        .option('--timeout <ms>', 'Poll timeout in ms (default 5000)', (v) => parseInt(v, 10))
        .option('--json', 'Emit JSON')).action(async (opts) => {
        const spec = buildWaitParams(opts);
        if (!spec.ok) {
            console.error(spec.error);
            process.exit(1);
        }
        await withClient(async (client) => {
            const params = { ...spec.params };
            // duration-only waits don't need a target pid
            if (params.duration_ms == null)
                params.pid = await resolveTargetPid(client, opts, { verb: 'wait' });
            const res = unwrap(await client.call('wait', params));
            emitComputerAction('wait', params.pid, opts, {
                until: opts.until, durationMs: opts.duration, satisfied: Boolean(res.satisfied),
            });
            emit(res, Boolean(opts.json), () => res.satisfied ? `satisfied (${res.waited_ms}ms)` : `timed out (${res.waited_ms}ms)`);
        });
    });
    // get-text — read text without OCR
    addTargetOpts(program
        .command('get-text')
        .description('Extract visible text from the app (or a subtree via --id)')
        .option('--id <@eN>', 'Element id from `describe` to scope the extraction')
        .option('--max-chars <n>', 'Cap the extracted text length', (v) => parseInt(v, 10))
        .option('--json', 'Emit JSON')).action(async (opts) => {
        await withClient(async (client) => {
            const pid = await resolveTargetPid(client, opts, { verb: 'get-text' });
            const params = { pid };
            if (opts.id)
                params.element_id = opts.id;
            if (opts.maxChars != null)
                params.max_chars = opts.maxChars;
            const res = unwrap(await client.call('get_text', params));
            emitComputerAction('get-text', pid, opts, { id: opts.id });
            emit(res, Boolean(opts.json), () => String(res.text ?? ''));
        });
    });
    // launch — start an app (no target resolution: it isn't running yet)
    program
        .command('launch')
        .description('Launch an app by bundle id, path, or name')
        .option('--bundle <id>', 'Bundle id (e.g. com.apple.TextEdit)')
        .option('--path <p>', 'Path to the .app bundle')
        .option('--name <s>', 'App name (resolved via /Applications and LaunchServices)')
        .option('--device <name>', 'Drive a remote Windows device (requires `agents computer start --device <name>` first)')
        .option('--json', 'Emit JSON')
        .action(async (opts) => {
        if (!opts.bundle && !opts.path && !opts.name) {
            console.error('pass one of --bundle, --path, --name');
            process.exit(1);
        }
        await withClient(async (client) => {
            const params = {};
            if (opts.bundle)
                params.bundle_id = opts.bundle;
            if (opts.path)
                params.path = opts.path;
            if (opts.name)
                params.name = opts.name;
            const res = unwrap(await client.call('launch_app', params));
            emitComputerAction('launch', res.pid, opts, { path: opts.path, name: opts.name });
            emit(res, Boolean(opts.json), () => `launched ${res.name} (pid ${res.pid})`);
        });
    });
}
