You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

agent-device

Package Overview
Dependencies
Maintainers
1
Versions
71
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

agent-device - npm Package Compare versions

Comparing version
0.10.0
to
0.10.1
+11
dist/src/daemon/android-system-dialog.d.ts
import { openAndroidApp, snapshotAndroid, getAndroidAppState } from '../platforms/android/index.ts';
import { runCmd } from '../utils/exec.ts';
import type { SessionState } from './types.ts';
export type AndroidBlockingDialogRecoveryResult = 'absent' | 'recovered' | 'failed';
export declare function recoverAndroidBlockingSystemDialog(params: {
session: SessionState;
snapshotAndroidUi?: typeof snapshotAndroid;
reopenAndroidApp?: typeof openAndroidApp;
readAndroidAppState?: typeof getAndroidAppState;
execCommand?: typeof runCmd;
}): Promise<AndroidBlockingDialogRecoveryResult>;
import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts';
import { type SnapshotNode } from '../../utils/snapshot.ts';
import type { DaemonCommandContext } from '../context.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
import { SessionStore } from '../session-store.ts';
import { getAndroidScreenSize } from '../../platforms/android/index.ts';
type ContextFromFlags = (flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string) => DaemonCommandContext;
type CaptureSnapshotForSession = (session: SessionState, flags: CommandFlags | undefined, sessionStore: SessionStore, contextFromFlags: ContextFromFlags, options: {
interactiveOnly: boolean;
}, dispatch?: typeof dispatchCommand) => Promise<{
nodes: SnapshotNode[];
truncated?: boolean;
createdAt: number;
backend?: 'xctest' | 'android';
}>;
type ResolveRefTarget = ((params: {
session: SessionState;
refInput: string;
fallbackLabel: string;
requireRect: boolean;
invalidRefMessage: string;
notFoundMessage: string;
}) => {
ok: true;
target: {
ref: string;
node: SnapshotNode;
snapshotNodes: SnapshotNode[];
};
} | {
ok: false;
response: DaemonResponse;
}) | undefined;
type RefSnapshotFlagGuardResponse = (command: 'press' | 'fill' | 'get' | 'scrollintoview', flags: CommandFlags | undefined) => DaemonResponse | null;
export declare function handleTouchInteractionCommands(params: {
req: DaemonRequest;
sessionName: string;
sessionStore: SessionStore;
contextFromFlags: ContextFromFlags;
dispatch?: typeof dispatchCommand;
readAndroidScreenSize?: typeof getAndroidScreenSize;
captureSnapshotForSession: CaptureSnapshotForSession;
resolveRefTarget: NonNullable<ResolveRefTarget>;
refSnapshotFlagGuardResponse: RefSnapshotFlagGuardResponse;
}): Promise<DaemonResponse | null>;
export {};
import type { DaemonResponse, SessionState } from '../types.ts';
import type { RecordTraceDeps } from './record-trace-recording.ts';
type AndroidDevice = SessionState['device'];
type AndroidRecording = Extract<NonNullable<SessionState['recording']>, {
platform: 'android';
}>;
type AndroidRecordingBase = Pick<AndroidRecording, 'outPath' | 'clientOutPath' | 'telemetryPath' | 'startedAt' | 'showTouches' | 'gestureEvents'>;
export declare function startAndroidRecording(params: {
deps: RecordTraceDeps;
device: AndroidDevice;
recordingBase: AndroidRecordingBase;
}): Promise<DaemonResponse | AndroidRecording>;
export declare function stopAndroidRecording(params: {
deps: RecordTraceDeps;
device: AndroidDevice;
recording: AndroidRecording;
}): Promise<DaemonResponse | null>;
export {};
import { SessionStore } from '../session-store.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
import type { RecordTraceDeps, RecordingBase } from './record-trace-recording.ts';
export declare function normalizeAppBundleId(session: SessionState): string | undefined;
export declare function warmIosSimulatorRunner(params: {
req: DaemonRequest;
activeSession: SessionState;
device: SessionState['device'];
logPath?: string;
deps: RecordTraceDeps;
}): Promise<void>;
export declare function startIosDeviceRecording(params: {
req: DaemonRequest;
activeSession: SessionState;
sessionStore: SessionStore;
device: SessionState['device'];
logPath?: string;
deps: RecordTraceDeps;
fpsFlag: number | undefined;
recordingBase: RecordingBase;
appBundleId: string;
}): Promise<DaemonResponse | NonNullable<SessionState['recording']>>;
export declare function startMacOsRecording(params: {
req: DaemonRequest;
activeSession: SessionState;
device: SessionState['device'];
logPath?: string;
deps: RecordTraceDeps;
fpsFlag: number | undefined;
recordingBase: RecordingBase;
appBundleId: string;
}): Promise<DaemonResponse | NonNullable<SessionState['recording']>>;
export declare function stopIosDeviceRecording(params: {
req: DaemonRequest;
activeSession: SessionState;
device: SessionState['device'];
logPath?: string;
deps: RecordTraceDeps;
recording: Extract<NonNullable<SessionState['recording']>, {
platform: 'ios-device-runner';
}>;
}): Promise<DaemonResponse | null>;
export declare function stopMacOsRecording(params: {
req: DaemonRequest;
activeSession: SessionState;
device: SessionState['device'];
logPath?: string;
deps: RecordTraceDeps;
recording: Extract<NonNullable<SessionState['recording']>, {
platform: 'macos-runner';
}>;
}): Promise<DaemonResponse | null>;
import { SessionStore } from '../session-store.ts';
import type { DaemonRequest, DaemonResponse, RecordingGestureEvent } from '../types.ts';
import { runCmd, runCmdBackground } from '../../utils/exec.ts';
import { isPlayableVideo, waitForStableFile } from '../../utils/video.ts';
import { writeRecordingTelemetry } from '../recording-telemetry.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import { overlayRecordingTouches, trimRecordingStart } from '../../recording/overlay.ts';
export type RecordTraceDeps = {
runCmd: typeof runCmd;
runCmdBackground: typeof runCmdBackground;
runIosRunnerCommand: typeof runIosRunnerCommand;
waitForStableFile: typeof waitForStableFile;
isPlayableVideo: typeof isPlayableVideo;
writeRecordingTelemetry: typeof writeRecordingTelemetry;
trimRecordingStart: typeof trimRecordingStart;
overlayRecordingTouches: typeof overlayRecordingTouches;
};
export type RecordingBase = {
outPath: string;
clientOutPath?: string;
startedAt: number;
showTouches: boolean;
gestureEvents: RecordingGestureEvent[];
};
export declare function buildRecordTraceDeps(overrides?: Partial<RecordTraceDeps>): RecordTraceDeps;
export declare function handleRecordCommand(params: {
req: DaemonRequest;
sessionName: string;
sessionStore: SessionStore;
logPath?: string;
deps?: Partial<RecordTraceDeps>;
}): Promise<DaemonResponse>;
export declare function formatRecordTraceError(error: unknown): string;
export declare function formatRecordTraceExecFailure(result: {
stdout: string;
stderr: string;
exitCode: number;
}, command: string): string;
import type { SessionState } from './types.ts';
export declare function recordTouchVisualizationEvent(session: SessionState, command: string, positionals: string[], result: Record<string, unknown> | void, fallback?: Record<string, unknown>, startedAtMs?: number, finishedAtMs?: number): void;
export declare function augmentScrollVisualizationResult(session: SessionState, command: string, positionals: string[], result: Record<string, unknown> | void): Record<string, unknown> | void;
import type { RecordingGestureEvent } from './types.ts';
type RecordingTelemetryState = {
outPath: string;
gestureEvents: RecordingGestureEvent[];
telemetryPath?: string;
};
export declare function deriveRecordingTelemetryPath(videoPath: string): string;
export declare function trimRecordingTelemetryEvents(events: RecordingGestureEvent[], trimStartMs: number): RecordingGestureEvent[];
export declare function normalizeRecordingTelemetryEvents(events: RecordingGestureEvent[]): RecordingGestureEvent[];
export declare function writeRecordingTelemetry(params: {
videoPath: string;
events: RecordingGestureEvent[];
trimStartMs?: number;
}): string;
export declare function persistRecordingTelemetry(params: {
recording: RecordingTelemetryState;
trimStartMs?: number;
writeTelemetry?: typeof writeRecordingTelemetry;
}): string;
export {};
type GestureTimingSource = {
recordingStartedAt: number;
gestureClockOriginAtMs?: number;
gestureClockOriginUptimeMs?: number;
runnerStartedAtUptimeMs?: number;
gestureStartUptimeMs?: number;
gestureEndUptimeMs?: number;
fallbackStartedAtMs: number;
fallbackFinishedAtMs: number;
};
type GestureDurationSource = {
gestureStartUptimeMs?: number;
gestureEndUptimeMs?: number;
reportedDurationMs?: number;
fallbackStartedAtMs: number;
fallbackFinishedAtMs: number;
};
type TapVisualizationOffsetSource = GestureTimingSource & {
gestureDurationMs: number;
};
export declare function resolveGestureOffsetMs(source: GestureTimingSource): number;
export declare function resolveGestureDurationMs(source: GestureDurationSource): number;
export declare function resolveTapVisualizationOffsetMs(source: TapVisualizationOffsetSource): number;
export {};
import type { SnapshotNode, SnapshotState } from '../utils/snapshot.ts';
export type TouchReferenceFrame = {
referenceWidth: number;
referenceHeight: number;
};
export declare function getSnapshotReferenceFrame(snapshot: SnapshotState | undefined): TouchReferenceFrame | undefined;
export declare function inferTouchReferenceFrame(nodes: Array<Pick<SnapshotNode, 'type' | 'rect'>>): TouchReferenceFrame | undefined;
export declare function getRecordingOverlaySupportWarning(hostPlatform?: NodeJS.Platform): string | undefined;
export declare function trimRecordingStart(params: {
videoPath: string;
trimStartMs: number;
}): Promise<void>;
export declare function overlayRecordingTouches(params: {
videoPath: string;
telemetryPath: string;
targetLabel?: string;
}): Promise<void>;
export declare function waitForStableFile(filePath: string, options?: {
pollMs?: number;
attempts?: number;
}): Promise<void>;
export declare function isPlayableVideo(filePath: string): Promise<boolean>;
export declare function waitForPlayableVideo(filePath: string, options?: {
pollMs?: number;
attempts?: number;
}): Promise<void>;
import AppKit
import AVFoundation
import Foundation
import QuartzCore
let touchDotColor = NSColor(calibratedRed: 0.20, green: 0.63, blue: 0.98, alpha: 0.48).cgColor
let touchDotBorderColor = NSColor(calibratedRed: 0.94, green: 0.98, blue: 1.0, alpha: 0.68).cgColor
let minimumTapVisibility: CFTimeInterval = 0.45
let minimumSwipeVisibility: CFTimeInterval = 0.5
let minimumPinchVisibility: CFTimeInterval = 0.5
let swipeVisibilityTail: CFTimeInterval = 0.16
let trailOpacityKeyTimes: [NSNumber] = [0.0, 0.08, 0.62, 1.0]
struct GestureEnvelope: Decodable {
let events: [GestureEvent]
}
struct GestureEvent: Decodable {
let kind: String
let tMs: Double
let x: Double
let y: Double
let x2: Double?
let y2: Double?
let referenceWidth: Double?
let referenceHeight: Double?
let durationMs: Double?
let scale: Double?
let contentDirection: String?
let edge: String?
}
enum OverlayError: Error, CustomStringConvertible {
case invalidArgs(String)
case missingVideoTrack
case exportFailed(String)
var description: String {
switch self {
case .invalidArgs(let message):
return message
case .missingVideoTrack:
return "Input video does not contain a video track."
case .exportFailed(let message):
return message
}
}
}
do {
try run()
} catch {
fputs("recording-overlay: \(error)\n", stderr)
exit(1)
}
func run() throws {
let arguments = Array(CommandLine.arguments.dropFirst())
let parsedArgs = try parseArguments(arguments)
let inputURL = URL(fileURLWithPath: parsedArgs.inputPath)
let outputURL = URL(fileURLWithPath: parsedArgs.outputPath)
let eventsURL = URL(fileURLWithPath: parsedArgs.eventsPath)
if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
}
let payload = try Data(contentsOf: eventsURL)
let envelope = try JSONDecoder().decode(GestureEnvelope.self, from: payload)
if envelope.events.isEmpty {
try FileManager.default.copyItem(at: inputURL, to: outputURL)
return
}
let asset = AVURLAsset(url: inputURL)
guard let sourceVideoTrack = asset.tracks(withMediaType: .video).first else {
throw OverlayError.missingVideoTrack
}
let composition = AVMutableComposition()
guard let compositionVideoTrack = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
throw OverlayError.exportFailed("Failed to create composition video track.")
}
let fullRange = CMTimeRange(start: .zero, duration: asset.duration)
try compositionVideoTrack.insertTimeRange(fullRange, of: sourceVideoTrack, at: .zero)
if let sourceAudioTrack = asset.tracks(withMediaType: .audio).first,
let compositionAudioTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
) {
try? compositionAudioTrack.insertTimeRange(fullRange, of: sourceAudioTrack, at: .zero)
}
let renderSize = resolvedRenderSize(for: sourceVideoTrack)
let videoComposition = AVMutableVideoComposition()
videoComposition.renderSize = renderSize
videoComposition.frameDuration = resolvedFrameDuration(for: sourceVideoTrack)
let instruction = AVMutableVideoCompositionInstruction()
instruction.timeRange = fullRange
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
layerInstruction.setTransform(sourceVideoTrack.preferredTransform, at: .zero)
instruction.layerInstructions = [layerInstruction]
videoComposition.instructions = [instruction]
let parentLayer = CALayer()
parentLayer.frame = CGRect(origin: .zero, size: renderSize)
parentLayer.masksToBounds = true
let videoLayer = CALayer()
videoLayer.frame = parentLayer.frame
parentLayer.addSublayer(videoLayer)
let overlayLayer = CALayer()
overlayLayer.frame = parentLayer.frame
parentLayer.addSublayer(overlayLayer)
for event in envelope.events {
switch event.kind {
case "tap":
addTapLayer(event: event, renderSize: renderSize, to: overlayLayer)
case "longpress":
addLongPressLayer(event: event, renderSize: renderSize, to: overlayLayer)
case "swipe":
addSwipeLayers(event: event, renderSize: renderSize, to: overlayLayer)
case "scroll":
addScrollLayers(event: event, renderSize: renderSize, to: overlayLayer)
case "back-swipe":
addBackSwipeLayers(event: event, renderSize: renderSize, to: overlayLayer)
case "pinch":
addPinchLayers(event: event, renderSize: renderSize, to: overlayLayer)
default:
continue
}
}
videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(
postProcessingAsVideoLayer: videoLayer,
in: parentLayer
)
guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
throw OverlayError.exportFailed("Failed to create export session.")
}
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
exporter.videoComposition = videoComposition
exporter.shouldOptimizeForNetworkUse = true
let semaphore = DispatchSemaphore(value: 0)
exporter.exportAsynchronously {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 120) == .timedOut {
exporter.cancelExport()
throw OverlayError.exportFailed("Touch overlay export timed out.")
}
if exporter.status != .completed {
throw OverlayError.exportFailed(exporter.error?.localizedDescription ?? "Touch overlay export failed.")
}
}
func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, eventsPath: String) {
var inputPath: String?
var outputPath: String?
var eventsPath: String?
var index = 0
while index < arguments.count {
let argument = arguments[index]
let nextIndex = index + 1
switch argument {
case "--input":
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--input requires a value") }
inputPath = arguments[nextIndex]
index += 2
case "--output":
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--output requires a value") }
outputPath = arguments[nextIndex]
index += 2
case "--events":
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--events requires a value") }
eventsPath = arguments[nextIndex]
index += 2
default:
throw OverlayError.invalidArgs("Unknown argument: \(argument)")
}
}
guard let inputPath, let outputPath, let eventsPath else {
throw OverlayError.invalidArgs("Usage: recording-overlay.swift --input <video> --output <video> --events <json>")
}
return (inputPath, outputPath, eventsPath)
}
func resolvedRenderSize(for track: AVAssetTrack) -> CGSize {
let transformed = track.naturalSize.applying(track.preferredTransform)
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
}
func resolvedFrameDuration(for track: AVAssetTrack) -> CMTime {
let minFrameDuration = track.minFrameDuration
if minFrameDuration.isValid && !minFrameDuration.isIndefinite && minFrameDuration.seconds > 0 {
return minFrameDuration
}
let nominalFrameRate = track.nominalFrameRate
if nominalFrameRate > 0 {
let timescale = Int32(max(1, round(nominalFrameRate)))
return CMTime(value: 1, timescale: timescale)
}
return CMTime(value: 1, timescale: 60)
}
func overlayPoint(event: GestureEvent, x: Double, y: Double, renderSize: CGSize) -> CGPoint {
let scaleX = scaledAxis(renderSize: renderSize.width, referenceSize: event.referenceWidth)
let scaleY = scaledAxis(renderSize: renderSize.height, referenceSize: event.referenceHeight)
let scaledX = x * scaleX
let scaledY = y * scaleY
let flippedY = max(0, Double(renderSize.height) - scaledY)
return CGPoint(x: scaledX, y: flippedY)
}
func scaledAxis(renderSize: CGFloat, referenceSize: Double?) -> Double {
guard let referenceSize, referenceSize > 0 else { return 1.0 }
return Double(renderSize) / referenceSize
}
func addTapLayer(event: GestureEvent, renderSize: CGSize, to overlayLayer: CALayer) {
let layer = makeTouchDotLayer(
center: overlayPoint(event: event, x: event.x, y: event.y, renderSize: renderSize),
renderSize: renderSize
)
overlayLayer.addSublayer(layer)
let opacity = CAKeyframeAnimation(keyPath: "opacity")
opacity.values = [0.0, 0.98, 0.98, 0.0]
opacity.keyTimes = [0.0, 0.08, 0.8, 1.0]
let scale = CAKeyframeAnimation(keyPath: "transform.scale")
scale.values = [0.84, 1.0, 1.0]
scale.keyTimes = [0.0, 0.22, 1.0]
let group = makeAnimationGroup(
animations: [opacity, scale],
duration: minimumTapVisibility,
beginTime: AVCoreAnimationBeginTimeAtZero + (event.tMs / 1000.0)
)
layer.add(group, forKey: "tap")
}
func addLongPressLayer(event: GestureEvent, renderSize: CGSize, to overlayLayer: CALayer) {
let duration = max(0.75, (event.durationMs ?? 800) / 1000.0)
let layer = makeTouchDotLayer(
center: overlayPoint(event: event, x: event.x, y: event.y, renderSize: renderSize),
renderSize: renderSize
)
overlayLayer.addSublayer(layer)
let opacity = CAKeyframeAnimation(keyPath: "opacity")
opacity.values = [0.0, 0.98, 0.98, 0.0]
opacity.keyTimes = [0.0, 0.08, 0.92, 1.0]
let scale = CAKeyframeAnimation(keyPath: "transform.scale")
scale.values = [0.84, 1.0, 1.0]
scale.keyTimes = [0.0, 0.15, 1.0]
let group = makeAnimationGroup(
animations: [opacity, scale],
duration: duration,
beginTime: AVCoreAnimationBeginTimeAtZero + (event.tMs / 1000.0)
)
layer.add(group, forKey: "longpress")
}
func addSwipeLayers(event: GestureEvent, renderSize: CGSize, to overlayLayer: CALayer) {
addTrailLayers(event: event, renderSize: renderSize, to: overlayLayer, style: .swipe)
}
func addScrollLayers(event: GestureEvent, renderSize: CGSize, to overlayLayer: CALayer) {
addTrailLayers(event: event, renderSize: renderSize, to: overlayLayer, style: .scroll)
}
func addBackSwipeLayers(event: GestureEvent, renderSize: CGSize, to overlayLayer: CALayer) {
addTrailLayers(event: event, renderSize: renderSize, to: overlayLayer, style: .backSwipe)
}
enum TrailStyle: Equatable {
case swipe
case scroll
case backSwipe
}
extension TrailStyle {
var tail: CFTimeInterval {
switch self {
case .swipe:
return swipeVisibilityTail
case .scroll:
return 0.08
case .backSwipe:
return 0.12
}
}
var lineWidth: CGFloat {
switch self {
case .swipe:
return 4
case .scroll:
return 5
case .backSwipe:
return 6
}
}
var color: CGColor {
switch self {
case .swipe:
return touchDotColor
case .scroll:
return NSColor(calibratedRed: 0.16, green: 0.74, blue: 0.88, alpha: 0.34).cgColor
case .backSwipe:
return NSColor(calibratedRed: 0.24, green: 0.69, blue: 1.0, alpha: 0.55).cgColor
}
}
var borderColor: CGColor {
switch self {
case .swipe:
return touchDotBorderColor
case .scroll:
return NSColor(calibratedRed: 0.92, green: 1.0, blue: 1.0, alpha: 0.48).cgColor
case .backSwipe:
return NSColor(calibratedRed: 0.94, green: 0.98, blue: 1.0, alpha: 0.8).cgColor
}
}
var trailOpacityValues: [NSNumber] {
switch self {
case .swipe:
return [0.0, 0.9, 0.35, 0.0]
case .scroll:
return [0.0, 0.5, 0.18, 0.0]
case .backSwipe:
return [0.0, 1.0, 0.45, 0.0]
}
}
var dotOpacityValues: [NSNumber] {
switch self {
case .swipe:
return [0.0, 1.0, 0.92, 0.0]
case .scroll:
return [0.0, 0.72, 0.4, 0.0]
case .backSwipe:
return [0.0, 1.0, 0.9, 0.0]
}
}
}
func makeAnimationGroup(
animations: [CAAnimation],
duration: CFTimeInterval,
beginTime: CFTimeInterval
) -> CAAnimationGroup {
let group = CAAnimationGroup()
group.animations = animations
group.duration = duration
group.beginTime = beginTime
group.fillMode = .both
group.isRemovedOnCompletion = false
return group
}
func addTrailLayers(
event: GestureEvent,
renderSize: CGSize,
to overlayLayer: CALayer,
style: TrailStyle
) {
guard let x2 = event.x2, let y2 = event.y2 else { return }
let startPoint = overlayPoint(event: event, x: event.x, y: event.y, renderSize: renderSize)
let endPoint = overlayPoint(event: event, x: x2, y: y2, renderSize: renderSize)
let duration = max(0.1, (event.durationMs ?? 250) / 1000.0)
let visibleDuration = max(minimumSwipeVisibility, duration + style.tail)
let beginTime = AVCoreAnimationBeginTimeAtZero + (event.tMs / 1000.0)
let pathLayer = CAShapeLayer()
pathLayer.frame = overlayLayer.bounds
pathLayer.strokeEnd = 1.0
pathLayer.path = {
let path = CGMutablePath()
path.move(to: startPoint)
path.addLine(to: endPoint)
return path
}()
pathLayer.strokeColor = style.color
pathLayer.lineWidth = style.lineWidth
pathLayer.lineCap = .round
pathLayer.fillColor = nil
pathLayer.opacity = 0
overlayLayer.addSublayer(pathLayer)
let stroke = CABasicAnimation(keyPath: "strokeEnd")
stroke.fromValue = 0.0
stroke.toValue = 1.0
let strokeOpacity = CAKeyframeAnimation(keyPath: "opacity")
strokeOpacity.values = style.trailOpacityValues
strokeOpacity.keyTimes = trailOpacityKeyTimes
let strokeGroup = makeAnimationGroup(
animations: [stroke, strokeOpacity],
duration: visibleDuration,
beginTime: beginTime
)
strokeGroup.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
pathLayer.add(strokeGroup, forKey: "stroke")
let dotLayer = makeTouchDotLayer(center: startPoint, renderSize: renderSize)
dotLayer.backgroundColor = style.color
dotLayer.borderColor = style.borderColor
dotLayer.position = endPoint
overlayLayer.addSublayer(dotLayer)
let position = CABasicAnimation(keyPath: "position")
position.fromValue = NSValue(point: startPoint)
position.toValue = NSValue(point: endPoint)
position.duration = duration
let opacity = CAKeyframeAnimation(keyPath: "opacity")
opacity.values = style.dotOpacityValues
opacity.keyTimes = trailOpacityKeyTimes
let group = makeAnimationGroup(
animations: [position, opacity],
duration: visibleDuration,
beginTime: beginTime
)
group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
dotLayer.add(group, forKey: "swipe-dot")
if style == .backSwipe {
addBackSwipeEdgeHint(
event: event,
renderSize: renderSize,
beginTime: beginTime,
visibleDuration: visibleDuration,
to: overlayLayer
)
}
}
func addPinchDot(
overlayLayer: CALayer,
start: CGPoint,
end: CGPoint,
renderSize: CGSize,
beginTime: CFTimeInterval,
duration: CFTimeInterval
) {
let dotLayer = makeTouchDotLayer(center: start, renderSize: renderSize)
overlayLayer.addSublayer(dotLayer)
let position = CABasicAnimation(keyPath: "position")
position.fromValue = NSValue(point: start)
position.toValue = NSValue(point: end)
position.duration = duration
let opacity = CAKeyframeAnimation(keyPath: "opacity")
opacity.values = [0.0, 1.0, 1.0, 0.0]
opacity.keyTimes = [0.0, 0.1, 0.82, 1.0]
let group = makeAnimationGroup(
animations: [position, opacity],
duration: duration,
beginTime: beginTime
)
dotLayer.add(group, forKey: "pinch")
}
func addPinchLayers(event: GestureEvent, renderSize: CGSize, to overlayLayer: CALayer) {
let duration = max(minimumPinchVisibility, (event.durationMs ?? 280) / 1000.0)
let beginTime = AVCoreAnimationBeginTimeAtZero + (event.tMs / 1000.0)
let startOffset: CGFloat = 28
let scale = max(0.2, min(event.scale ?? 1.0, 3.0))
let endOffset = scale >= 1.0 ? startOffset * CGFloat(min(scale, 2.0)) : startOffset * CGFloat(max(scale, 0.5))
let startLeft = overlayPoint(event: event, x: event.x - Double(startOffset), y: event.y, renderSize: renderSize)
let startRight = overlayPoint(event: event, x: event.x + Double(startOffset), y: event.y, renderSize: renderSize)
let endLeft = overlayPoint(event: event, x: event.x - Double(endOffset), y: event.y, renderSize: renderSize)
let endRight = overlayPoint(event: event, x: event.x + Double(endOffset), y: event.y, renderSize: renderSize)
addPinchDot(
overlayLayer: overlayLayer,
start: startLeft,
end: endLeft,
renderSize: renderSize,
beginTime: beginTime,
duration: duration
)
addPinchDot(
overlayLayer: overlayLayer,
start: startRight,
end: endRight,
renderSize: renderSize,
beginTime: beginTime,
duration: duration
)
}
func makeTouchDotLayer(center: CGPoint, renderSize: CGSize) -> CALayer {
let dotRadius = resolvedTouchDotRadius(renderSize: renderSize)
let layer = CALayer()
layer.bounds = CGRect(x: 0, y: 0, width: dotRadius * 2, height: dotRadius * 2)
layer.position = center
layer.cornerRadius = dotRadius
layer.backgroundColor = touchDotColor
layer.borderWidth = 2
layer.borderColor = touchDotBorderColor
layer.shadowColor = NSColor(calibratedRed: 0.08, green: 0.20, blue: 0.36, alpha: 1.0).cgColor
layer.shadowOpacity = 0.18
layer.shadowRadius = 4
layer.opacity = 0
return layer
}
func resolvedTouchDotRadius(renderSize: CGSize) -> CGFloat {
let minDimension = min(renderSize.width, renderSize.height)
return max(18, min(40, minDimension * 0.035))
}
func addBackSwipeEdgeHint(
event: GestureEvent,
renderSize: CGSize,
beginTime: CFTimeInterval,
visibleDuration: CFTimeInterval,
to overlayLayer: CALayer
) {
let edge = (event.edge ?? "left").lowercased()
let hintLayer = CALayer()
let width: CGFloat = 10
let height: CGFloat = min(renderSize.height * 0.3, 320)
let y = (renderSize.height - height) / 2
let x: CGFloat = edge == "right" ? renderSize.width - width : 0
hintLayer.frame = CGRect(x: x, y: y, width: width, height: height)
hintLayer.backgroundColor = NSColor(calibratedRed: 0.24, green: 0.69, blue: 1.0, alpha: 0.22).cgColor
hintLayer.cornerRadius = width / 2
hintLayer.opacity = 0
overlayLayer.addSublayer(hintLayer)
let opacity = CAKeyframeAnimation(keyPath: "opacity")
opacity.values = [0.0, 0.9, 0.0]
opacity.keyTimes = [0.0, 0.2, 1.0]
let group = makeAnimationGroup(
animations: [opacity],
duration: visibleDuration,
beginTime: beginTime
)
hintLayer.add(group, forKey: "back-swipe-edge")
}
import AVFoundation
import Foundation
enum TrimError: Error, CustomStringConvertible {
case invalidArgs(String)
case invalidTrimRange
case missingVideoTrack
case exportFailed(String)
var description: String {
switch self {
case .invalidArgs(let message):
return message
case .invalidTrimRange:
return "Trim start must be before the end of the recording."
case .missingVideoTrack:
return "Input video does not contain a video track."
case .exportFailed(let message):
return message
}
}
}
do {
try run()
} catch {
fputs("recording-trim: \(error)\n", stderr)
exit(1)
}
func run() throws {
let arguments = Array(CommandLine.arguments.dropFirst())
let parsedArgs = try parseArguments(arguments)
let inputURL = URL(fileURLWithPath: parsedArgs.inputPath)
let outputURL = URL(fileURLWithPath: parsedArgs.outputPath)
if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
}
let asset = AVURLAsset(url: inputURL)
guard let sourceVideoTrack = asset.tracks(withMediaType: .video).first else {
throw TrimError.missingVideoTrack
}
let trimStart = CMTime(seconds: parsedArgs.trimStartMs / 1000.0, preferredTimescale: 600)
guard CMTimeCompare(trimStart, asset.duration) < 0 else {
throw TrimError.invalidTrimRange
}
let trimmedDuration = CMTimeSubtract(asset.duration, trimStart)
guard CMTimeCompare(trimmedDuration, .zero) > 0 else {
throw TrimError.invalidTrimRange
}
let composition = AVMutableComposition()
let trimmedRange = CMTimeRange(start: trimStart, duration: trimmedDuration)
guard let compositionVideoTrack = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
throw TrimError.exportFailed("Failed to create composition video track.")
}
try compositionVideoTrack.insertTimeRange(trimmedRange, of: sourceVideoTrack, at: .zero)
compositionVideoTrack.preferredTransform = sourceVideoTrack.preferredTransform
if let sourceAudioTrack = asset.tracks(withMediaType: .audio).first,
let compositionAudioTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
) {
try? compositionAudioTrack.insertTimeRange(trimmedRange, of: sourceAudioTrack, at: .zero)
}
let presetName = AVAssetExportSession.exportPresets(compatibleWith: composition)
.contains(AVAssetExportPresetPassthrough)
? AVAssetExportPresetPassthrough
: AVAssetExportPresetHighestQuality
guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
throw TrimError.exportFailed("Failed to create export session.")
}
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
exporter.shouldOptimizeForNetworkUse = true
let semaphore = DispatchSemaphore(value: 0)
exporter.exportAsynchronously {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 120) == .timedOut {
exporter.cancelExport()
throw TrimError.exportFailed("Trim export timed out.")
}
if exporter.status != .completed {
throw TrimError.exportFailed(exporter.error?.localizedDescription ?? "Trim export failed.")
}
}
func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, trimStartMs: Double) {
var inputPath: String?
var outputPath: String?
var trimStartMs: Double?
var index = 0
while index < arguments.count {
let argument = arguments[index]
let nextIndex = index + 1
switch argument {
case "--input":
guard nextIndex < arguments.count else { throw TrimError.invalidArgs("--input requires a value") }
inputPath = arguments[nextIndex]
index += 2
case "--output":
guard nextIndex < arguments.count else { throw TrimError.invalidArgs("--output requires a value") }
outputPath = arguments[nextIndex]
index += 2
case "--trim-start-ms":
guard nextIndex < arguments.count else {
throw TrimError.invalidArgs("--trim-start-ms requires a value")
}
guard let parsed = Double(arguments[nextIndex]), parsed >= 0 else {
throw TrimError.invalidArgs("--trim-start-ms must be a non-negative number")
}
trimStartMs = parsed
index += 2
default:
throw TrimError.invalidArgs("Unknown argument: \(argument)")
}
}
guard let inputPath, let outputPath, let trimStartMs else {
throw TrimError.invalidArgs(
"Usage: recording-trim.swift --input <video> --output <video> --trim-start-ms <ms>"
)
}
return (inputPath, outputPath, trimStartMs)
}
+9
-9

@@ -1,2 +0,2 @@

let e,t,s,r,o,a,i;import{PNG as n}from"pngjs";import{formatSnapshotLine as l,SETTINGS_USAGE_OVERRIDE as p,styleText as u,parseBatchStepsJson as c,buildSnapshotDisplayLines as d}from"./274.js";import{createRequestId as m,node_path as g,normalizeError as f,resolveUserPath as h,readVersion as y,getDiagnosticsMeta as w,emitDiagnostic as v,promises as b,asAppError as D,expandUserHomePath as k,pathToFileURL as S,AppError as A,node_fs as $,node_os as I,withDiagnosticsScope as L,flushDiagnosticsToSessionFile as x,resolveDaemonPaths as N}from"./331.js";import{serializeOpenResult as O,sendToDaemon as E,serializeSessionListEntry as R,serializeDeployResult as P,serializeSnapshotResult as _,serializeCloseResult as F,serializeEnsureSimulatorResult as C,serializeInstallFromSourceResult as j,serializeDevice as T,createAgentDeviceClient as V}from"./224.js";let M=["snapshotInteractiveOnly","snapshotCompact","snapshotDepth","snapshotScope","snapshotRaw"],G=["snapshotDepth","snapshotScope","snapshotRaw"],U=[{key:"config",names:["--config"],type:"string",usageLabel:"--config <path>",usageDescription:"Load CLI defaults from a specific config file"},{key:"remoteConfig",names:["--remote-config"],type:"string",usageLabel:"--remote-config <path>",usageDescription:"Load remote host + Metro workflow settings from a specific profile file"},{key:"stateDir",names:["--state-dir"],type:"string",usageLabel:"--state-dir <path>",usageDescription:"Daemon state directory (defaults to ~/.agent-device)"},{key:"daemonBaseUrl",names:["--daemon-base-url"],type:"string",usageLabel:"--daemon-base-url <url>",usageDescription:"Explicit remote HTTP daemon base URL (skip local daemon discovery/startup)"},{key:"daemonAuthToken",names:["--daemon-auth-token"],type:"string",usageLabel:"--daemon-auth-token <token>",usageDescription:"Remote HTTP daemon auth token (sent as request token and bearer header)"},{key:"daemonTransport",names:["--daemon-transport"],type:"enum",enumValues:["auto","socket","http"],usageLabel:"--daemon-transport auto|socket|http",usageDescription:"Daemon client transport preference"},{key:"daemonServerMode",names:["--daemon-server-mode"],type:"enum",enumValues:["socket","http","dual"],usageLabel:"--daemon-server-mode socket|http|dual",usageDescription:"Daemon server mode used when spawning daemon"},{key:"tenant",names:["--tenant"],type:"string",usageLabel:"--tenant <id>",usageDescription:"Tenant scope identifier for isolated daemon sessions"},{key:"sessionIsolation",names:["--session-isolation"],type:"enum",enumValues:["none","tenant"],usageLabel:"--session-isolation none|tenant",usageDescription:"Session isolation strategy (tenant prefixes session namespace)"},{key:"runId",names:["--run-id"],type:"string",usageLabel:"--run-id <id>",usageDescription:"Run identifier used for tenant lease admission checks"},{key:"leaseId",names:["--lease-id"],type:"string",usageLabel:"--lease-id <id>",usageDescription:"Lease identifier bound to tenant/run admission scope"},{key:"sessionLock",names:["--session-lock"],type:"enum",enumValues:["reject","strip"],usageLabel:"--session-lock reject|strip",usageDescription:"Lock bound-session device routing for this CLI invocation and nested batch steps"},{key:"sessionLocked",names:["--session-locked"],type:"boolean",usageLabel:"--session-locked",usageDescription:"Deprecated alias for --session-lock reject"},{key:"sessionLockConflicts",names:["--session-lock-conflicts"],type:"enum",enumValues:["reject","strip"],usageLabel:"--session-lock-conflicts reject|strip",usageDescription:"Deprecated alias for --session-lock"},{key:"platform",names:["--platform"],type:"enum",enumValues:["ios","macos","android","apple"],usageLabel:"--platform ios|macos|android|apple",usageDescription:"Platform to target (`apple` aliases the Apple automation backend)"},{key:"target",names:["--target"],type:"enum",enumValues:["mobile","tv","desktop"],usageLabel:"--target mobile|tv|desktop",usageDescription:"Device target class to match"},{key:"device",names:["--device"],type:"string",usageLabel:"--device <name>",usageDescription:"Device name to target"},{key:"udid",names:["--udid"],type:"string",usageLabel:"--udid <udid>",usageDescription:"iOS device UDID"},{key:"serial",names:["--serial"],type:"string",usageLabel:"--serial <serial>",usageDescription:"Android device serial"},{key:"headless",names:["--headless"],type:"boolean",usageLabel:"--headless",usageDescription:"Boot: launch Android emulator without a GUI window"},{key:"runtime",names:["--runtime"],type:"string",usageLabel:"--runtime <id>",usageDescription:"ensure-simulator: CoreSimulator runtime identifier (e.g. com.apple.CoreSimulator.SimRuntime.iOS-18-0)"},{key:"metroHost",names:["--metro-host"],type:"string",usageLabel:"--metro-host <host>",usageDescription:"Session-scoped Metro/debug host hint"},{key:"metroPort",names:["--metro-port"],type:"int",min:1,max:65535,usageLabel:"--metro-port <port>",usageDescription:"Session-scoped Metro/debug port hint"},{key:"metroProjectRoot",names:["--project-root"],type:"string",usageLabel:"--project-root <path>",usageDescription:"metro prepare: React Native project root (default: cwd)"},{key:"metroKind",names:["--kind"],type:"enum",enumValues:["auto","react-native","expo"],usageLabel:"--kind auto|react-native|expo",usageDescription:"metro prepare: detect or force the Metro launcher kind"},{key:"metroPublicBaseUrl",names:["--public-base-url"],type:"string",usageLabel:"--public-base-url <url>",usageDescription:"metro prepare: public base URL used to build bundle hints"},{key:"metroProxyBaseUrl",names:["--proxy-base-url"],type:"string",usageLabel:"--proxy-base-url <url>",usageDescription:"metro prepare: optional remote host bridge base URL for Metro access"},{key:"metroBearerToken",names:["--bearer-token"],type:"string",usageLabel:"--bearer-token <token>",usageDescription:"metro prepare: host bridge bearer token (prefer AGENT_DEVICE_PROXY_TOKEN or AGENT_DEVICE_METRO_BEARER_TOKEN)"},{key:"metroPreparePort",names:["--port"],type:"int",min:1,max:65535,usageLabel:"--port <port>",usageDescription:"metro prepare: local Metro port (default: 8081)"},{key:"metroListenHost",names:["--listen-host"],type:"string",usageLabel:"--listen-host <host>",usageDescription:"metro prepare: host Metro listens on (default: 0.0.0.0)"},{key:"metroStatusHost",names:["--status-host"],type:"string",usageLabel:"--status-host <host>",usageDescription:"metro prepare: host used for local /status polling (default: 127.0.0.1)"},{key:"metroStartupTimeoutMs",names:["--startup-timeout-ms"],type:"int",min:1,usageLabel:"--startup-timeout-ms <ms>",usageDescription:"metro prepare: timeout while waiting for Metro to become ready"},{key:"metroProbeTimeoutMs",names:["--probe-timeout-ms"],type:"int",min:1,usageLabel:"--probe-timeout-ms <ms>",usageDescription:"metro prepare: timeout for /status and proxy bridge calls"},{key:"metroRuntimeFile",names:["--runtime-file"],type:"string",usageLabel:"--runtime-file <path>",usageDescription:"metro prepare: optional file path to persist the JSON result"},{key:"metroNoReuseExisting",names:["--no-reuse-existing"],type:"boolean",usageLabel:"--no-reuse-existing",usageDescription:"metro prepare: always start a fresh Metro process"},{key:"metroNoInstallDeps",names:["--no-install-deps"],type:"boolean",usageLabel:"--no-install-deps",usageDescription:"metro prepare: skip package-manager install when node_modules is missing"},{key:"bundleUrl",names:["--bundle-url"],type:"string",usageLabel:"--bundle-url <url>",usageDescription:"Session-scoped bundle URL hint"},{key:"launchUrl",names:["--launch-url"],type:"string",usageLabel:"--launch-url <url>",usageDescription:"Session-scoped deep link / launch URL hint"},{key:"boot",names:["--boot"],type:"boolean",usageLabel:"--boot",usageDescription:"ensure-simulator: boot the simulator after ensuring it exists"},{key:"reuseExisting",names:["--reuse-existing"],type:"boolean",usageLabel:"--reuse-existing",usageDescription:"ensure-simulator: reuse an existing simulator (default: true)"},{key:"iosSimulatorDeviceSet",names:["--ios-simulator-device-set"],type:"string",usageLabel:"--ios-simulator-device-set <path>",usageDescription:"Scope iOS simulator discovery/commands to this simulator device set"},{key:"androidDeviceAllowlist",names:["--android-device-allowlist"],type:"string",usageLabel:"--android-device-allowlist <serials>",usageDescription:"Comma/space separated Android serial allowlist for discovery/selection"},{key:"activity",names:["--activity"],type:"string",usageLabel:"--activity <component>",usageDescription:"Android app launch activity (package/Activity); not for URL opens"},{key:"header",names:["--header"],type:"string",multiple:!0,usageLabel:"--header <name:value>",usageDescription:"install-from-source: repeatable HTTP header for URL downloads"},{key:"session",names:["--session"],type:"string",usageLabel:"--session <name>",usageDescription:"Named session"},{key:"count",names:["--count"],type:"int",min:1,max:200,usageLabel:"--count <n>",usageDescription:"Repeat count for press/swipe series"},{key:"fps",names:["--fps"],type:"int",min:1,max:120,usageLabel:"--fps <n>",usageDescription:"Record: target frames per second (iOS physical device runner)"},{key:"intervalMs",names:["--interval-ms"],type:"int",min:0,max:1e4,usageLabel:"--interval-ms <ms>",usageDescription:"Delay between press iterations"},{key:"holdMs",names:["--hold-ms"],type:"int",min:0,max:1e4,usageLabel:"--hold-ms <ms>",usageDescription:"Press hold duration for each iteration"},{key:"jitterPx",names:["--jitter-px"],type:"int",min:0,max:100,usageLabel:"--jitter-px <n>",usageDescription:"Deterministic coordinate jitter radius for press"},{key:"doubleTap",names:["--double-tap"],type:"boolean",usageLabel:"--double-tap",usageDescription:"Use double-tap gesture per press iteration"},{key:"clickButton",names:["--button"],type:"enum",enumValues:["primary","secondary","middle"],usageLabel:"--button primary|secondary|middle",usageDescription:"Click: choose mouse button (middle reserved for future macOS support)"},{key:"pauseMs",names:["--pause-ms"],type:"int",min:0,max:1e4,usageLabel:"--pause-ms <ms>",usageDescription:"Delay between swipe iterations"},{key:"pattern",names:["--pattern"],type:"enum",enumValues:["one-way","ping-pong"],usageLabel:"--pattern one-way|ping-pong",usageDescription:"Swipe repeat pattern"},{key:"verbose",names:["--debug","--verbose","-v"],type:"boolean",usageLabel:"--debug, --verbose, -v",usageDescription:"Enable debug diagnostics and stream daemon/runner logs"},{key:"json",names:["--json"],type:"boolean",usageLabel:"--json",usageDescription:"JSON output"},{key:"help",names:["--help","-h"],type:"boolean",usageLabel:"--help, -h",usageDescription:"Print help and exit"},{key:"version",names:["--version","-V"],type:"boolean",usageLabel:"--version, -V",usageDescription:"Print version and exit"},{key:"saveScript",names:["--save-script"],type:"booleanOrString",usageLabel:"--save-script [path]",usageDescription:"Save session script (.ad) on close; optional custom output path"},{key:"shutdown",names:["--shutdown"],type:"boolean",usageLabel:"--shutdown",usageDescription:"close: shutdown associated iOS simulator after ending session"},{key:"relaunch",names:["--relaunch"],type:"boolean",usageLabel:"--relaunch",usageDescription:"open: terminate app process before launching it"},{key:"restart",names:["--restart"],type:"boolean",usageLabel:"--restart",usageDescription:"logs clear: stop active stream, clear logs, then start streaming again"},{key:"retainPaths",names:["--retain-paths"],type:"boolean",usageLabel:"--retain-paths",usageDescription:"install-from-source: keep materialized artifact paths after install"},{key:"retentionMs",names:["--retention-ms"],type:"int",min:1,usageLabel:"--retention-ms <ms>",usageDescription:"install-from-source: retention TTL for materialized artifact paths"},{key:"noRecord",names:["--no-record"],type:"boolean",usageLabel:"--no-record",usageDescription:"Do not record this action"},{key:"replayUpdate",names:["--update","-u"],type:"boolean",usageLabel:"--update, -u",usageDescription:"Replay: update selectors and rewrite replay file in place"},{key:"steps",names:["--steps"],type:"string",usageLabel:"--steps <json>",usageDescription:"Batch: JSON array of steps"},{key:"stepsFile",names:["--steps-file"],type:"string",usageLabel:"--steps-file <path>",usageDescription:"Batch: read steps JSON from file"},{key:"batchOnError",names:["--on-error"],type:"enum",enumValues:["stop"],usageLabel:"--on-error stop",usageDescription:"Batch: stop when a step fails"},{key:"batchMaxSteps",names:["--max-steps"],type:"int",min:1,max:1e3,usageLabel:"--max-steps <n>",usageDescription:"Batch: maximum number of allowed steps"},{key:"appsFilter",names:["--user-installed"],type:"enum",setValue:"user-installed",usageLabel:"--user-installed",usageDescription:"Apps: list user-installed apps"},{key:"appsFilter",names:["--all"],type:"enum",setValue:"all",usageLabel:"--all",usageDescription:"Apps: list all apps (include system/default apps)"},{key:"snapshotInteractiveOnly",names:["-i"],type:"boolean",usageLabel:"-i",usageDescription:"Snapshot: interactive elements only"},{key:"snapshotCompact",names:["-c"],type:"boolean",usageLabel:"-c",usageDescription:"Snapshot: compact output (drop empty structure)"},{key:"snapshotDepth",names:["--depth","-d"],type:"int",min:0,usageLabel:"--depth, -d <depth>",usageDescription:"Snapshot: limit snapshot depth"},{key:"snapshotScope",names:["--scope","-s"],type:"string",usageLabel:"--scope, -s <scope>",usageDescription:"Snapshot: scope snapshot to label/identifier"},{key:"snapshotRaw",names:["--raw"],type:"boolean",usageLabel:"--raw",usageDescription:"Snapshot: raw node output"},{key:"out",names:["--out"],type:"string",usageLabel:"--out <path>",usageDescription:"Output path"},{key:"baseline",names:["--baseline","-b"],type:"string",usageLabel:"--baseline, -b <path>",usageDescription:"Diff screenshot: path to baseline image file"},{key:"threshold",names:["--threshold"],type:"string",usageLabel:"--threshold <0-1>",usageDescription:"Diff screenshot: color distance threshold (default 0.1)"}],B=new Set(["json","config","remoteConfig","stateDir","daemonBaseUrl","daemonAuthToken","daemonTransport","daemonServerMode","tenant","sessionIsolation","runId","leaseId","sessionLock","sessionLocked","sessionLockConflicts","help","version","verbose","platform","target","device","udid","serial","iosSimulatorDeviceSet","androidDeviceAllowlist","session","noRecord"]),H={boot:{helpDescription:"Ensure target device/simulator is booted and ready",summary:"Boot target device/simulator",positionalArgs:[],allowedFlags:["headless"]},open:{helpDescription:"Boot device/simulator; optionally launch app or deep link URL",summary:"Open an app, deep link or URL, save replays",positionalArgs:["appOrUrl?","url?"],allowedFlags:["activity","saveScript","relaunch"]},close:{helpDescription:"Close app or just end session",summary:"Close app or end session",positionalArgs:["app?"],allowedFlags:["saveScript","shutdown"]},reinstall:{helpDescription:"Uninstall + install app from binary path",summary:"Reinstall app from binary path",positionalArgs:["app","path"],allowedFlags:[]},install:{helpDescription:"Install app from binary path without uninstalling first",summary:"Install app from binary path",positionalArgs:["app","path"],allowedFlags:[]},"install-from-source":{helpDescription:"Install app from a URL source through the normal daemon artifact flow",summary:"Install app from a URL source",positionalArgs:["url"],allowedFlags:["header","retainPaths","retentionMs"]},push:{helpDescription:"Simulate push notification payload delivery",summary:"Deliver push payload",positionalArgs:["bundleOrPackage","payloadOrJson"],allowedFlags:[]},snapshot:{helpDescription:"Capture accessibility tree",positionalArgs:[],allowedFlags:[...M]},diff:{usageOverride:"diff snapshot | diff screenshot --baseline <path> [--out <diff.png>] [--threshold <0-1>]",helpDescription:"Diff accessibility snapshot or compare screenshots pixel-by-pixel",summary:"Diff snapshot or screenshot",positionalArgs:["kind"],allowedFlags:[...M,"baseline","threshold","out"]},"ensure-simulator":{helpDescription:"Ensure an iOS simulator exists in a device set (create if missing)",summary:"Ensure iOS simulator exists",positionalArgs:[],allowedFlags:["runtime","boot","reuseExisting"],skipCapabilityCheck:!0},devices:{helpDescription:"List available devices",positionalArgs:[],allowedFlags:[],skipCapabilityCheck:!0},apps:{helpDescription:"List installed apps (includes default/system apps by default)",summary:"List installed apps",positionalArgs:[],allowedFlags:["appsFilter"],defaults:{appsFilter:"all"}},appstate:{helpDescription:"Show foreground app/activity",positionalArgs:[],allowedFlags:[],skipCapabilityCheck:!0},metro:{usageOverride:"metro prepare --public-base-url <url> [--project-root <path>] [--port <port>] [--kind auto|react-native|expo]",listUsageOverride:"metro prepare --public-base-url <url>",helpDescription:"Prepare a local Metro runtime and optionally bridge it through a remote host",summary:"Prepare local Metro runtime",positionalArgs:["prepare"],allowedFlags:["metroProjectRoot","metroKind","metroPublicBaseUrl","metroProxyBaseUrl","metroBearerToken","metroPreparePort","metroListenHost","metroStatusHost","metroStartupTimeoutMs","metroProbeTimeoutMs","metroRuntimeFile","metroNoReuseExisting","metroNoInstallDeps"],skipCapabilityCheck:!0},clipboard:{usageOverride:"clipboard read | clipboard write <text>",listUsageOverride:"clipboard read | clipboard write <text>",helpDescription:"Read or write device clipboard text",positionalArgs:["read|write","text?"],allowsExtraPositionals:!0,allowedFlags:[]},keyboard:{usageOverride:"keyboard [status|get|dismiss]",helpDescription:"Inspect Android keyboard visibility/type or dismiss it",summary:"Inspect or dismiss Android keyboard",positionalArgs:["action?"],allowedFlags:[]},perf:{helpDescription:"Show session performance metrics (startup timing)",summary:"Show startup metrics",positionalArgs:[],allowedFlags:[]},back:{helpDescription:"Navigate back (where supported)",summary:"Go back",positionalArgs:[],allowedFlags:[]},home:{helpDescription:"Go to home screen (where supported)",summary:"Go home",positionalArgs:[],allowedFlags:[]},"app-switcher":{helpDescription:"Open app switcher (where supported)",summary:"Open app switcher",positionalArgs:[],allowedFlags:[]},wait:{usageOverride:"wait <ms>|text <text>|@ref|<selector> [timeoutMs]",helpDescription:"Wait for duration, text, ref, or selector to appear",summary:"Wait for time, text, ref, or selector",positionalArgs:["durationOrSelector","timeoutMs?"],allowsExtraPositionals:!0,allowedFlags:[...G]},alert:{usageOverride:"alert [get|accept|dismiss|wait] [timeout]",helpDescription:"Inspect or handle alert (iOS simulator)",summary:"Inspect or handle iOS alert",positionalArgs:["action?","timeout?"],allowedFlags:[]},click:{usageOverride:"click <x y|@ref|selector>",helpDescription:"Tap/click by coordinates, snapshot ref, or selector",summary:"Tap by coordinates, ref, or selector",positionalArgs:["target"],allowsExtraPositionals:!0,allowedFlags:["count","intervalMs","holdMs","jitterPx","doubleTap","clickButton",...G]},get:{usageOverride:"get text|attrs <@ref|selector>",helpDescription:"Return element text/attributes by ref or selector",summary:"Get text or attrs by ref or selector",positionalArgs:["subcommand","target"],allowedFlags:[...G]},replay:{helpDescription:"Replay a recorded session",positionalArgs:["path"],allowedFlags:["replayUpdate"],skipCapabilityCheck:!0},batch:{usageOverride:"batch [--steps <json> | --steps-file <path>]",listUsageOverride:"batch --steps <json> | --steps-file <path>",helpDescription:"Execute multiple commands in one daemon request",summary:"Run multiple commands",positionalArgs:[],allowedFlags:["steps","stepsFile","batchOnError","batchMaxSteps","out"],skipCapabilityCheck:!0},press:{usageOverride:"press <x y|@ref|selector>",helpDescription:"Tap/press by coordinates, snapshot ref, or selector (supports repeated series)",summary:"Press by coordinates, ref, or selector",positionalArgs:["targetOrX","y?"],allowsExtraPositionals:!0,allowedFlags:["count","intervalMs","holdMs","jitterPx","doubleTap",...G]},longpress:{helpDescription:"Long press by coordinates (iOS and Android)",summary:"Long press by coordinates",positionalArgs:["x","y","durationMs?"],allowedFlags:[]},swipe:{helpDescription:"Swipe coordinates with optional repeat pattern",summary:"Swipe coordinates",positionalArgs:["x1","y1","x2","y2","durationMs?"],allowedFlags:["count","pauseMs","pattern"]},focus:{helpDescription:"Focus input at coordinates",positionalArgs:["x","y"],allowedFlags:[]},type:{helpDescription:"Type text in focused field",positionalArgs:["text"],allowsExtraPositionals:!0,allowedFlags:[]},fill:{usageOverride:"fill <x> <y> <text> | fill <@ref|selector> <text>",helpDescription:"Tap then type",positionalArgs:["targetOrX","yOrText","text?"],allowsExtraPositionals:!0,allowedFlags:[...G]},scroll:{helpDescription:"Scroll in direction (0-1 amount)",summary:"Scroll in a direction",positionalArgs:["direction","amount?"],allowedFlags:[]},scrollintoview:{usageOverride:"scrollintoview <text|@ref>",helpDescription:"Scroll until text appears or a snapshot ref is brought into view",summary:"Scroll until text or ref is visible",positionalArgs:["target"],allowsExtraPositionals:!0,allowedFlags:[]},pinch:{helpDescription:"Pinch/zoom gesture (iOS simulator)",positionalArgs:["scale","x?","y?"],allowedFlags:[]},screenshot:{helpDescription:"Capture screenshot",positionalArgs:["path?"],allowedFlags:["out"]},"trigger-app-event":{usageOverride:"trigger-app-event <event> [payloadJson]",helpDescription:"Trigger app-defined event hook via deep link template",summary:"Trigger app event hook",positionalArgs:["event","payloadJson?"],allowedFlags:[]},record:{usageOverride:"record start [path] [--fps <n>] | record stop",listUsageOverride:"record start [path] | record stop",helpDescription:"Start/stop screen recording",summary:"Start or stop screen recording",positionalArgs:["start|stop","path?"],allowedFlags:["fps"]},trace:{usageOverride:"trace start [path] | trace stop [path]",listUsageOverride:"trace start [path] | trace stop",helpDescription:"Start/stop trace log capture",summary:"Start or stop trace capture",positionalArgs:["start|stop","path?"],allowedFlags:[],skipCapabilityCheck:!0},logs:{usageOverride:"logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]",helpDescription:"Session app log info, start/stop streaming, diagnostics, and markers",summary:"Manage session app logs",positionalArgs:["path|start|stop|clear|doctor|mark","message?"],allowsExtraPositionals:!0,allowedFlags:["restart"]},network:{usageOverride:"network dump [limit] [summary|headers|body|all] | network log [limit] [summary|headers|body|all]",helpDescription:"Dump recent HTTP(s) traffic parsed from the session app log",summary:"Show recent HTTP traffic",positionalArgs:["dump|log","limit?","include?"],allowedFlags:[]},find:{usageOverride:"find <locator|text> <action> [value]",helpDescription:"Find by text/label/value/role/id and run action",summary:"Find an element and act",positionalArgs:["query","action","value?"],allowsExtraPositionals:!0,allowedFlags:["snapshotDepth","snapshotRaw"]},is:{helpDescription:"Assert UI state (visible|hidden|exists|editable|selected|text)",summary:"Assert UI state",positionalArgs:["predicate","selector","value?"],allowsExtraPositionals:!0,allowedFlags:[...G]},settings:{usageOverride:p,listUsageOverride:"settings [area] [options]",helpDescription:"Toggle OS settings, appearance, and app permissions (macOS supports only settings appearance; permission actions use the active session app)",summary:"Change OS settings and app permissions",positionalArgs:["setting","state","target?","mode?"],allowedFlags:[]},session:{usageOverride:"session list",helpDescription:"List active sessions",positionalArgs:["list?"],allowedFlags:[],skipCapabilityCheck:!0}},q=new Map,J=new Map;for(let e of U){for(let t of e.names)q.set(t,e);let t=J.get(e.key);t?t.push(e):J.set(e.key,[e])}function K(e){if(e)return H[e]}function W(){return Object.keys(H)}function z(e){let t=e.endsWith("?"),s=t?e.slice(0,-1):e;return t?`[${s}]`:`<${s}>`}let X=(e=`agent-device <command> [args] [--json]
let e,t,s,r,o,a,i;import{PNG as n}from"pngjs";import{formatSnapshotLine as l,SETTINGS_USAGE_OVERRIDE as p,styleText as u,parseBatchStepsJson as c,buildSnapshotDisplayLines as d}from"./274.js";import{createRequestId as m,node_path as g,normalizeError as f,resolveUserPath as h,readVersion as y,getDiagnosticsMeta as w,emitDiagnostic as b,promises as v,asAppError as D,expandUserHomePath as k,pathToFileURL as S,AppError as A,node_fs as $,node_os as I,withDiagnosticsScope as L,flushDiagnosticsToSessionFile as x,resolveDaemonPaths as N}from"./331.js";import{serializeOpenResult as O,sendToDaemon as E,serializeSessionListEntry as R,serializeDeployResult as P,serializeSnapshotResult as _,serializeCloseResult as F,serializeEnsureSimulatorResult as C,serializeInstallFromSourceResult as T,serializeDevice as j,createAgentDeviceClient as V}from"./224.js";let M=["snapshotInteractiveOnly","snapshotCompact","snapshotDepth","snapshotScope","snapshotRaw"],G=["snapshotDepth","snapshotScope","snapshotRaw"],U=[{key:"config",names:["--config"],type:"string",usageLabel:"--config <path>",usageDescription:"Load CLI defaults from a specific config file"},{key:"remoteConfig",names:["--remote-config"],type:"string",usageLabel:"--remote-config <path>",usageDescription:"Load remote host + Metro workflow settings from a specific profile file"},{key:"stateDir",names:["--state-dir"],type:"string",usageLabel:"--state-dir <path>",usageDescription:"Daemon state directory (defaults to ~/.agent-device)"},{key:"daemonBaseUrl",names:["--daemon-base-url"],type:"string",usageLabel:"--daemon-base-url <url>",usageDescription:"Explicit remote HTTP daemon base URL (skip local daemon discovery/startup)"},{key:"daemonAuthToken",names:["--daemon-auth-token"],type:"string",usageLabel:"--daemon-auth-token <token>",usageDescription:"Remote HTTP daemon auth token (sent as request token and bearer header)"},{key:"daemonTransport",names:["--daemon-transport"],type:"enum",enumValues:["auto","socket","http"],usageLabel:"--daemon-transport auto|socket|http",usageDescription:"Daemon client transport preference"},{key:"daemonServerMode",names:["--daemon-server-mode"],type:"enum",enumValues:["socket","http","dual"],usageLabel:"--daemon-server-mode socket|http|dual",usageDescription:"Daemon server mode used when spawning daemon"},{key:"tenant",names:["--tenant"],type:"string",usageLabel:"--tenant <id>",usageDescription:"Tenant scope identifier for isolated daemon sessions"},{key:"sessionIsolation",names:["--session-isolation"],type:"enum",enumValues:["none","tenant"],usageLabel:"--session-isolation none|tenant",usageDescription:"Session isolation strategy (tenant prefixes session namespace)"},{key:"runId",names:["--run-id"],type:"string",usageLabel:"--run-id <id>",usageDescription:"Run identifier used for tenant lease admission checks"},{key:"leaseId",names:["--lease-id"],type:"string",usageLabel:"--lease-id <id>",usageDescription:"Lease identifier bound to tenant/run admission scope"},{key:"sessionLock",names:["--session-lock"],type:"enum",enumValues:["reject","strip"],usageLabel:"--session-lock reject|strip",usageDescription:"Lock bound-session device routing for this CLI invocation and nested batch steps"},{key:"sessionLocked",names:["--session-locked"],type:"boolean",usageLabel:"--session-locked",usageDescription:"Deprecated alias for --session-lock reject"},{key:"sessionLockConflicts",names:["--session-lock-conflicts"],type:"enum",enumValues:["reject","strip"],usageLabel:"--session-lock-conflicts reject|strip",usageDescription:"Deprecated alias for --session-lock"},{key:"platform",names:["--platform"],type:"enum",enumValues:["ios","macos","android","apple"],usageLabel:"--platform ios|macos|android|apple",usageDescription:"Platform to target (`apple` aliases the Apple automation backend)"},{key:"target",names:["--target"],type:"enum",enumValues:["mobile","tv","desktop"],usageLabel:"--target mobile|tv|desktop",usageDescription:"Device target class to match"},{key:"device",names:["--device"],type:"string",usageLabel:"--device <name>",usageDescription:"Device name to target"},{key:"udid",names:["--udid"],type:"string",usageLabel:"--udid <udid>",usageDescription:"iOS device UDID"},{key:"serial",names:["--serial"],type:"string",usageLabel:"--serial <serial>",usageDescription:"Android device serial"},{key:"headless",names:["--headless"],type:"boolean",usageLabel:"--headless",usageDescription:"Boot: launch Android emulator without a GUI window"},{key:"runtime",names:["--runtime"],type:"string",usageLabel:"--runtime <id>",usageDescription:"ensure-simulator: CoreSimulator runtime identifier (e.g. com.apple.CoreSimulator.SimRuntime.iOS-18-0)"},{key:"metroHost",names:["--metro-host"],type:"string",usageLabel:"--metro-host <host>",usageDescription:"Session-scoped Metro/debug host hint"},{key:"metroPort",names:["--metro-port"],type:"int",min:1,max:65535,usageLabel:"--metro-port <port>",usageDescription:"Session-scoped Metro/debug port hint"},{key:"metroProjectRoot",names:["--project-root"],type:"string",usageLabel:"--project-root <path>",usageDescription:"metro prepare: React Native project root (default: cwd)"},{key:"metroKind",names:["--kind"],type:"enum",enumValues:["auto","react-native","expo"],usageLabel:"--kind auto|react-native|expo",usageDescription:"metro prepare: detect or force the Metro launcher kind"},{key:"metroPublicBaseUrl",names:["--public-base-url"],type:"string",usageLabel:"--public-base-url <url>",usageDescription:"metro prepare: public base URL used to build bundle hints"},{key:"metroProxyBaseUrl",names:["--proxy-base-url"],type:"string",usageLabel:"--proxy-base-url <url>",usageDescription:"metro prepare: optional remote host bridge base URL for Metro access"},{key:"metroBearerToken",names:["--bearer-token"],type:"string",usageLabel:"--bearer-token <token>",usageDescription:"metro prepare: host bridge bearer token (prefer AGENT_DEVICE_PROXY_TOKEN or AGENT_DEVICE_METRO_BEARER_TOKEN)"},{key:"metroPreparePort",names:["--port"],type:"int",min:1,max:65535,usageLabel:"--port <port>",usageDescription:"metro prepare: local Metro port (default: 8081)"},{key:"metroListenHost",names:["--listen-host"],type:"string",usageLabel:"--listen-host <host>",usageDescription:"metro prepare: host Metro listens on (default: 0.0.0.0)"},{key:"metroStatusHost",names:["--status-host"],type:"string",usageLabel:"--status-host <host>",usageDescription:"metro prepare: host used for local /status polling (default: 127.0.0.1)"},{key:"metroStartupTimeoutMs",names:["--startup-timeout-ms"],type:"int",min:1,usageLabel:"--startup-timeout-ms <ms>",usageDescription:"metro prepare: timeout while waiting for Metro to become ready"},{key:"metroProbeTimeoutMs",names:["--probe-timeout-ms"],type:"int",min:1,usageLabel:"--probe-timeout-ms <ms>",usageDescription:"metro prepare: timeout for /status and proxy bridge calls"},{key:"metroRuntimeFile",names:["--runtime-file"],type:"string",usageLabel:"--runtime-file <path>",usageDescription:"metro prepare: optional file path to persist the JSON result"},{key:"metroNoReuseExisting",names:["--no-reuse-existing"],type:"boolean",usageLabel:"--no-reuse-existing",usageDescription:"metro prepare: always start a fresh Metro process"},{key:"metroNoInstallDeps",names:["--no-install-deps"],type:"boolean",usageLabel:"--no-install-deps",usageDescription:"metro prepare: skip package-manager install when node_modules is missing"},{key:"bundleUrl",names:["--bundle-url"],type:"string",usageLabel:"--bundle-url <url>",usageDescription:"Session-scoped bundle URL hint"},{key:"launchUrl",names:["--launch-url"],type:"string",usageLabel:"--launch-url <url>",usageDescription:"Session-scoped deep link / launch URL hint"},{key:"boot",names:["--boot"],type:"boolean",usageLabel:"--boot",usageDescription:"ensure-simulator: boot the simulator after ensuring it exists"},{key:"reuseExisting",names:["--reuse-existing"],type:"boolean",usageLabel:"--reuse-existing",usageDescription:"ensure-simulator: reuse an existing simulator (default: true)"},{key:"iosSimulatorDeviceSet",names:["--ios-simulator-device-set"],type:"string",usageLabel:"--ios-simulator-device-set <path>",usageDescription:"Scope iOS simulator discovery/commands to this simulator device set"},{key:"androidDeviceAllowlist",names:["--android-device-allowlist"],type:"string",usageLabel:"--android-device-allowlist <serials>",usageDescription:"Comma/space separated Android serial allowlist for discovery/selection"},{key:"activity",names:["--activity"],type:"string",usageLabel:"--activity <component>",usageDescription:"Android app launch activity (package/Activity); not for URL opens"},{key:"header",names:["--header"],type:"string",multiple:!0,usageLabel:"--header <name:value>",usageDescription:"install-from-source: repeatable HTTP header for URL downloads"},{key:"session",names:["--session"],type:"string",usageLabel:"--session <name>",usageDescription:"Named session"},{key:"count",names:["--count"],type:"int",min:1,max:200,usageLabel:"--count <n>",usageDescription:"Repeat count for press/swipe series"},{key:"fps",names:["--fps"],type:"int",min:1,max:120,usageLabel:"--fps <n>",usageDescription:"Record: target frames per second (iOS physical device runner)"},{key:"hideTouches",names:["--hide-touches"],type:"boolean",usageLabel:"--hide-touches",usageDescription:"Record: disable touch overlays in the final video"},{key:"intervalMs",names:["--interval-ms"],type:"int",min:0,max:1e4,usageLabel:"--interval-ms <ms>",usageDescription:"Delay between press iterations"},{key:"holdMs",names:["--hold-ms"],type:"int",min:0,max:1e4,usageLabel:"--hold-ms <ms>",usageDescription:"Press hold duration for each iteration"},{key:"jitterPx",names:["--jitter-px"],type:"int",min:0,max:100,usageLabel:"--jitter-px <n>",usageDescription:"Deterministic coordinate jitter radius for press"},{key:"doubleTap",names:["--double-tap"],type:"boolean",usageLabel:"--double-tap",usageDescription:"Use double-tap gesture per press iteration"},{key:"clickButton",names:["--button"],type:"enum",enumValues:["primary","secondary","middle"],usageLabel:"--button primary|secondary|middle",usageDescription:"Click: choose mouse button (middle reserved for future macOS support)"},{key:"pauseMs",names:["--pause-ms"],type:"int",min:0,max:1e4,usageLabel:"--pause-ms <ms>",usageDescription:"Delay between swipe iterations"},{key:"pattern",names:["--pattern"],type:"enum",enumValues:["one-way","ping-pong"],usageLabel:"--pattern one-way|ping-pong",usageDescription:"Swipe repeat pattern"},{key:"verbose",names:["--debug","--verbose","-v"],type:"boolean",usageLabel:"--debug, --verbose, -v",usageDescription:"Enable debug diagnostics and stream daemon/runner logs"},{key:"json",names:["--json"],type:"boolean",usageLabel:"--json",usageDescription:"JSON output"},{key:"help",names:["--help","-h"],type:"boolean",usageLabel:"--help, -h",usageDescription:"Print help and exit"},{key:"version",names:["--version","-V"],type:"boolean",usageLabel:"--version, -V",usageDescription:"Print version and exit"},{key:"saveScript",names:["--save-script"],type:"booleanOrString",usageLabel:"--save-script [path]",usageDescription:"Save session script (.ad) on close; optional custom output path"},{key:"shutdown",names:["--shutdown"],type:"boolean",usageLabel:"--shutdown",usageDescription:"close: shutdown associated iOS simulator after ending session"},{key:"relaunch",names:["--relaunch"],type:"boolean",usageLabel:"--relaunch",usageDescription:"open: terminate app process before launching it"},{key:"restart",names:["--restart"],type:"boolean",usageLabel:"--restart",usageDescription:"logs clear: stop active stream, clear logs, then start streaming again"},{key:"retainPaths",names:["--retain-paths"],type:"boolean",usageLabel:"--retain-paths",usageDescription:"install-from-source: keep materialized artifact paths after install"},{key:"retentionMs",names:["--retention-ms"],type:"int",min:1,usageLabel:"--retention-ms <ms>",usageDescription:"install-from-source: retention TTL for materialized artifact paths"},{key:"noRecord",names:["--no-record"],type:"boolean",usageLabel:"--no-record",usageDescription:"Do not record this action"},{key:"replayUpdate",names:["--update","-u"],type:"boolean",usageLabel:"--update, -u",usageDescription:"Replay: update selectors and rewrite replay file in place"},{key:"steps",names:["--steps"],type:"string",usageLabel:"--steps <json>",usageDescription:"Batch: JSON array of steps"},{key:"stepsFile",names:["--steps-file"],type:"string",usageLabel:"--steps-file <path>",usageDescription:"Batch: read steps JSON from file"},{key:"batchOnError",names:["--on-error"],type:"enum",enumValues:["stop"],usageLabel:"--on-error stop",usageDescription:"Batch: stop when a step fails"},{key:"batchMaxSteps",names:["--max-steps"],type:"int",min:1,max:1e3,usageLabel:"--max-steps <n>",usageDescription:"Batch: maximum number of allowed steps"},{key:"appsFilter",names:["--user-installed"],type:"enum",setValue:"user-installed",usageLabel:"--user-installed",usageDescription:"Apps: list user-installed apps"},{key:"appsFilter",names:["--all"],type:"enum",setValue:"all",usageLabel:"--all",usageDescription:"Apps: list all apps (include system/default apps)"},{key:"snapshotInteractiveOnly",names:["-i"],type:"boolean",usageLabel:"-i",usageDescription:"Snapshot: interactive elements only"},{key:"snapshotCompact",names:["-c"],type:"boolean",usageLabel:"-c",usageDescription:"Snapshot: compact output (drop empty structure)"},{key:"snapshotDepth",names:["--depth","-d"],type:"int",min:0,usageLabel:"--depth, -d <depth>",usageDescription:"Snapshot: limit snapshot depth"},{key:"snapshotScope",names:["--scope","-s"],type:"string",usageLabel:"--scope, -s <scope>",usageDescription:"Snapshot: scope snapshot to label/identifier"},{key:"snapshotRaw",names:["--raw"],type:"boolean",usageLabel:"--raw",usageDescription:"Snapshot: raw node output"},{key:"out",names:["--out"],type:"string",usageLabel:"--out <path>",usageDescription:"Output path"},{key:"baseline",names:["--baseline","-b"],type:"string",usageLabel:"--baseline, -b <path>",usageDescription:"Diff screenshot: path to baseline image file"},{key:"threshold",names:["--threshold"],type:"string",usageLabel:"--threshold <0-1>",usageDescription:"Diff screenshot: color distance threshold (default 0.1)"}],B=new Set(["json","config","remoteConfig","stateDir","daemonBaseUrl","daemonAuthToken","daemonTransport","daemonServerMode","tenant","sessionIsolation","runId","leaseId","sessionLock","sessionLocked","sessionLockConflicts","help","version","verbose","platform","target","device","udid","serial","iosSimulatorDeviceSet","androidDeviceAllowlist","session","noRecord"]),H={boot:{helpDescription:"Ensure target device/simulator is booted and ready",summary:"Boot target device/simulator",positionalArgs:[],allowedFlags:["headless"]},open:{helpDescription:"Boot device/simulator; optionally launch app or deep link URL",summary:"Open an app, deep link or URL, save replays",positionalArgs:["appOrUrl?","url?"],allowedFlags:["activity","saveScript","relaunch"]},close:{helpDescription:"Close app or just end session",summary:"Close app or end session",positionalArgs:["app?"],allowedFlags:["saveScript","shutdown"]},reinstall:{helpDescription:"Uninstall + install app from binary path",summary:"Reinstall app from binary path",positionalArgs:["app","path"],allowedFlags:[]},install:{helpDescription:"Install app from binary path without uninstalling first",summary:"Install app from binary path",positionalArgs:["app","path"],allowedFlags:[]},"install-from-source":{helpDescription:"Install app from a URL source through the normal daemon artifact flow",summary:"Install app from a URL source",positionalArgs:["url"],allowedFlags:["header","retainPaths","retentionMs"]},push:{helpDescription:"Simulate push notification payload delivery",summary:"Deliver push payload",positionalArgs:["bundleOrPackage","payloadOrJson"],allowedFlags:[]},snapshot:{helpDescription:"Capture accessibility tree",positionalArgs:[],allowedFlags:[...M]},diff:{usageOverride:"diff snapshot | diff screenshot --baseline <path> [--out <diff.png>] [--threshold <0-1>]",helpDescription:"Diff accessibility snapshot or compare screenshots pixel-by-pixel",summary:"Diff snapshot or screenshot",positionalArgs:["kind"],allowedFlags:[...M,"baseline","threshold","out"]},"ensure-simulator":{helpDescription:"Ensure an iOS simulator exists in a device set (create if missing)",summary:"Ensure iOS simulator exists",positionalArgs:[],allowedFlags:["runtime","boot","reuseExisting"],skipCapabilityCheck:!0},devices:{helpDescription:"List available devices",positionalArgs:[],allowedFlags:[],skipCapabilityCheck:!0},apps:{helpDescription:"List installed apps (includes default/system apps by default)",summary:"List installed apps",positionalArgs:[],allowedFlags:["appsFilter"],defaults:{appsFilter:"all"}},appstate:{helpDescription:"Show foreground app/activity",positionalArgs:[],allowedFlags:[],skipCapabilityCheck:!0},metro:{usageOverride:"metro prepare --public-base-url <url> [--project-root <path>] [--port <port>] [--kind auto|react-native|expo]",listUsageOverride:"metro prepare --public-base-url <url>",helpDescription:"Prepare a local Metro runtime and optionally bridge it through a remote host",summary:"Prepare local Metro runtime",positionalArgs:["prepare"],allowedFlags:["metroProjectRoot","metroKind","metroPublicBaseUrl","metroProxyBaseUrl","metroBearerToken","metroPreparePort","metroListenHost","metroStatusHost","metroStartupTimeoutMs","metroProbeTimeoutMs","metroRuntimeFile","metroNoReuseExisting","metroNoInstallDeps"],skipCapabilityCheck:!0},clipboard:{usageOverride:"clipboard read | clipboard write <text>",listUsageOverride:"clipboard read | clipboard write <text>",helpDescription:"Read or write device clipboard text",positionalArgs:["read|write","text?"],allowsExtraPositionals:!0,allowedFlags:[]},keyboard:{usageOverride:"keyboard [status|get|dismiss]",helpDescription:"Inspect Android keyboard visibility/type or dismiss it",summary:"Inspect or dismiss Android keyboard",positionalArgs:["action?"],allowedFlags:[]},perf:{helpDescription:"Show session performance metrics (startup timing)",summary:"Show startup metrics",positionalArgs:[],allowedFlags:[]},back:{helpDescription:"Navigate back (where supported)",summary:"Go back",positionalArgs:[],allowedFlags:[]},home:{helpDescription:"Go to home screen (where supported)",summary:"Go home",positionalArgs:[],allowedFlags:[]},"app-switcher":{helpDescription:"Open app switcher (where supported)",summary:"Open app switcher",positionalArgs:[],allowedFlags:[]},wait:{usageOverride:"wait <ms>|text <text>|@ref|<selector> [timeoutMs]",helpDescription:"Wait for duration, text, ref, or selector to appear",summary:"Wait for time, text, ref, or selector",positionalArgs:["durationOrSelector","timeoutMs?"],allowsExtraPositionals:!0,allowedFlags:[...G]},alert:{usageOverride:"alert [get|accept|dismiss|wait] [timeout]",helpDescription:"Inspect or handle alert (iOS simulator)",summary:"Inspect or handle iOS alert",positionalArgs:["action?","timeout?"],allowedFlags:[]},click:{usageOverride:"click <x y|@ref|selector>",helpDescription:"Tap/click by coordinates, snapshot ref, or selector",summary:"Tap by coordinates, ref, or selector",positionalArgs:["target"],allowsExtraPositionals:!0,allowedFlags:["count","intervalMs","holdMs","jitterPx","doubleTap","clickButton",...G]},get:{usageOverride:"get text|attrs <@ref|selector>",helpDescription:"Return element text/attributes by ref or selector",summary:"Get text or attrs by ref or selector",positionalArgs:["subcommand","target"],allowedFlags:[...G]},replay:{helpDescription:"Replay a recorded session",positionalArgs:["path"],allowedFlags:["replayUpdate"],skipCapabilityCheck:!0},batch:{usageOverride:"batch [--steps <json> | --steps-file <path>]",listUsageOverride:"batch --steps <json> | --steps-file <path>",helpDescription:"Execute multiple commands in one daemon request",summary:"Run multiple commands",positionalArgs:[],allowedFlags:["steps","stepsFile","batchOnError","batchMaxSteps","out"],skipCapabilityCheck:!0},press:{usageOverride:"press <x y|@ref|selector>",helpDescription:"Tap/press by coordinates, snapshot ref, or selector (supports repeated series)",summary:"Press by coordinates, ref, or selector",positionalArgs:["targetOrX","y?"],allowsExtraPositionals:!0,allowedFlags:["count","intervalMs","holdMs","jitterPx","doubleTap",...G]},longpress:{helpDescription:"Long press by coordinates (iOS and Android)",summary:"Long press by coordinates",positionalArgs:["x","y","durationMs?"],allowedFlags:[]},swipe:{helpDescription:"Swipe coordinates with optional repeat pattern",summary:"Swipe coordinates",positionalArgs:["x1","y1","x2","y2","durationMs?"],allowedFlags:["count","pauseMs","pattern"]},focus:{helpDescription:"Focus input at coordinates",positionalArgs:["x","y"],allowedFlags:[]},type:{helpDescription:"Type text in focused field",positionalArgs:["text"],allowsExtraPositionals:!0,allowedFlags:[]},fill:{usageOverride:"fill <x> <y> <text> | fill <@ref|selector> <text>",helpDescription:"Tap then type",positionalArgs:["targetOrX","yOrText","text?"],allowsExtraPositionals:!0,allowedFlags:[...G]},scroll:{helpDescription:"Scroll in direction (0-1 amount)",summary:"Scroll in a direction",positionalArgs:["direction","amount?"],allowedFlags:[]},scrollintoview:{usageOverride:"scrollintoview <text|@ref>",helpDescription:"Scroll until text appears or a snapshot ref is brought into view",summary:"Scroll until text or ref is visible",positionalArgs:["target"],allowsExtraPositionals:!0,allowedFlags:[]},pinch:{helpDescription:"Pinch/zoom gesture (iOS simulator)",positionalArgs:["scale","x?","y?"],allowedFlags:[]},screenshot:{helpDescription:"Capture screenshot",positionalArgs:["path?"],allowedFlags:["out"]},"trigger-app-event":{usageOverride:"trigger-app-event <event> [payloadJson]",helpDescription:"Trigger app-defined event hook via deep link template",summary:"Trigger app event hook",positionalArgs:["event","payloadJson?"],allowedFlags:[]},record:{usageOverride:"record start [path] [--fps <n>] [--hide-touches] | record stop",listUsageOverride:"record start [path] | record stop",helpDescription:"Start/stop screen recording",summary:"Start or stop screen recording",positionalArgs:["start|stop","path?"],allowedFlags:["fps","hideTouches"]},trace:{usageOverride:"trace start [path] | trace stop [path]",listUsageOverride:"trace start [path] | trace stop",helpDescription:"Start/stop trace log capture",summary:"Start or stop trace capture",positionalArgs:["start|stop","path?"],allowedFlags:[],skipCapabilityCheck:!0},logs:{usageOverride:"logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]",helpDescription:"Session app log info, start/stop streaming, diagnostics, and markers",summary:"Manage session app logs",positionalArgs:["path|start|stop|clear|doctor|mark","message?"],allowsExtraPositionals:!0,allowedFlags:["restart"]},network:{usageOverride:"network dump [limit] [summary|headers|body|all] | network log [limit] [summary|headers|body|all]",helpDescription:"Dump recent HTTP(s) traffic parsed from the session app log",summary:"Show recent HTTP traffic",positionalArgs:["dump|log","limit?","include?"],allowedFlags:[]},find:{usageOverride:"find <locator|text> <action> [value]",helpDescription:"Find by text/label/value/role/id and run action",summary:"Find an element and act",positionalArgs:["query","action","value?"],allowsExtraPositionals:!0,allowedFlags:["snapshotDepth","snapshotRaw"]},is:{helpDescription:"Assert UI state (visible|hidden|exists|editable|selected|text)",summary:"Assert UI state",positionalArgs:["predicate","selector","value?"],allowsExtraPositionals:!0,allowedFlags:[...G]},settings:{usageOverride:p,listUsageOverride:"settings [area] [options]",helpDescription:"Toggle OS settings, appearance, and app permissions (macOS supports only settings appearance; permission actions use the active session app)",summary:"Change OS settings and app permissions",positionalArgs:["setting","state","target?","mode?"],allowedFlags:[]},session:{usageOverride:"session list",helpDescription:"List active sessions",positionalArgs:["list?"],allowedFlags:[],skipCapabilityCheck:!0}},q=new Map,J=new Map;for(let e of U){for(let t of e.names)q.set(t,e);let t=J.get(e.key);t?t.push(e):J.set(e.key,[e])}function K(e){if(e)return H[e]}function W(){return Object.keys(H)}function z(e){let t=e.endsWith("?"),s=t?e.slice(0,-1):e;return t?`[${s}]`:`<${s}>`}let X=(e=`agent-device <command> [args] [--json]

@@ -32,10 +32,10 @@ CLI to control iOS and Android devices for AI agents.

${c.join("\n")}
`}function ev(e){return"number"==typeof e&&Number.isFinite(e)?e:0}function eb(){let e=process.env.FORCE_COLOR;return"string"==typeof e?"0"!==e:"string"!=typeof process.env.NO_COLOR&&!!process.stdout.isTTY}let eD=255*Math.sqrt(3);async function ek(e,t,s={}){await eS(e,"Baseline image not found"),await eS(t,"Current screenshot not found");let r=s.outputPath,[o,a]=await Promise.all([b.readFile(e),b.readFile(t)]),i=eA(o,"baseline"),l=eA(a,"current"),p=s.threshold??.1;if(i.width!==l.width||i.height!==l.height){let e=i.width*i.height;return await e$(s.outputPath),{match:!1,mismatchPercentage:100,totalPixels:e,differentPixels:e,dimensionMismatch:{expected:{width:i.width,height:i.height},actual:{width:l.width,height:l.height}}}}let u=i.width*i.height,c=p*eD,d=new n({width:i.width,height:i.height}),m=0;for(let e=0;e<i.data.length;e+=4){if(Math.sqrt((i.data[e]-l.data[e])**2+(i.data[e+1]-l.data[e+1])**2+(i.data[e+2]-l.data[e+2])**2)>c){m+=1,d.data[e]=255,d.data[e+1]=0,d.data[e+2]=0,d.data[e+3]=255;continue}let t=Math.round(.3*Math.round((i.data[e]+i.data[e+1]+i.data[e+2])/3));d.data[e]=t,d.data[e+1]=t,d.data[e+2]=t,d.data[e+3]=255}m>0&&r?(await b.mkdir(g.dirname(r),{recursive:!0}),await b.writeFile(r,n.sync.write(d))):await e$(s.outputPath);let f=u>0?Math.round(m/u*1e4)/100:0;return{...m>0&&r?{diffPath:r}:{},totalPixels:u,differentPixels:m,mismatchPercentage:f,match:0===m}}async function eS(e,t){try{await b.access(e)}catch{throw new A("INVALID_ARGS",`${t}: ${e}`)}}function eA(e,t){try{return n.sync.read(e)}catch(e){throw new A("COMMAND_FAILED",`Failed to decode ${t} screenshot as PNG`,{label:t,reason:e instanceof Error?e.message:String(e)})}}async function e$(e){if(e)try{await b.unlink(e)}catch(e){var t;if(!("object"==typeof(t=e)&&null!==t&&"code"in t&&"ENOENT"===t.code))throw e}}async function eI(e,t){if(!e.remoteConfig)return;let s=e.platform;if("ios"!==s&&"android"!==s)throw new A("INVALID_ARGS",'open --remote-config requires platform "ios" or "android" in the remote config file or CLI flags.');if(!e.metroPublicBaseUrl)throw new A("INVALID_ARGS",'open --remote-config requires "metroPublicBaseUrl" in the remote config file.');let r=await t.metro.prepare({projectRoot:e.metroProjectRoot,kind:e.metroKind,publicBaseUrl:e.metroPublicBaseUrl,proxyBaseUrl:e.metroProxyBaseUrl,bearerToken:e.metroBearerToken,port:e.metroPreparePort,listenHost:e.metroListenHost,statusHost:e.metroStatusHost,startupTimeoutMs:e.metroStartupTimeoutMs,probeTimeoutMs:e.metroProbeTimeoutMs,reuseExisting:!e.metroNoReuseExisting&&void 0,installDependenciesIfNeeded:!e.metroNoInstallDeps&&void 0,runtimeFilePath:e.metroRuntimeFile});return"ios"===s?r.iosRuntime:r.androidRuntime}async function eL(e){let t=ex[e.command];return!!t&&await t(e)}let ex={session:async({positionals:e,flags:t,client:s})=>{if("list"!==(e[0]??"list"))throw new A("INVALID_ARGS","session only supports list");let r={sessions:(await s.sessions.list()).map(R)};return t.json?eh({success:!0,data:r}):process.stdout.write(`${JSON.stringify(r,null,2)}
`),!0},devices:async({flags:e,client:t})=>{let s=await t.devices.list(eE(e)),r={devices:s.map(T)};return e.json?eh({success:!0,data:r}):process.stdout.write(`${s.map(eR).join("\n")}
`}function eb(e){return"number"==typeof e&&Number.isFinite(e)?e:0}function ev(){let e=process.env.FORCE_COLOR;return"string"==typeof e?"0"!==e:"string"!=typeof process.env.NO_COLOR&&!!process.stdout.isTTY}let eD=255*Math.sqrt(3);async function ek(e,t,s={}){await eS(e,"Baseline image not found"),await eS(t,"Current screenshot not found");let r=s.outputPath,[o,a]=await Promise.all([v.readFile(e),v.readFile(t)]),i=eA(o,"baseline"),l=eA(a,"current"),p=s.threshold??.1;if(i.width!==l.width||i.height!==l.height){let e=i.width*i.height;return await e$(s.outputPath),{match:!1,mismatchPercentage:100,totalPixels:e,differentPixels:e,dimensionMismatch:{expected:{width:i.width,height:i.height},actual:{width:l.width,height:l.height}}}}let u=i.width*i.height,c=p*eD,d=new n({width:i.width,height:i.height}),m=0;for(let e=0;e<i.data.length;e+=4){if(Math.sqrt((i.data[e]-l.data[e])**2+(i.data[e+1]-l.data[e+1])**2+(i.data[e+2]-l.data[e+2])**2)>c){m+=1,d.data[e]=255,d.data[e+1]=0,d.data[e+2]=0,d.data[e+3]=255;continue}let t=Math.round(.3*Math.round((i.data[e]+i.data[e+1]+i.data[e+2])/3));d.data[e]=t,d.data[e+1]=t,d.data[e+2]=t,d.data[e+3]=255}m>0&&r?(await v.mkdir(g.dirname(r),{recursive:!0}),await v.writeFile(r,n.sync.write(d))):await e$(s.outputPath);let f=u>0?Math.round(m/u*1e4)/100:0;return{...m>0&&r?{diffPath:r}:{},totalPixels:u,differentPixels:m,mismatchPercentage:f,match:0===m}}async function eS(e,t){try{await v.access(e)}catch{throw new A("INVALID_ARGS",`${t}: ${e}`)}}function eA(e,t){try{return n.sync.read(e)}catch(e){throw new A("COMMAND_FAILED",`Failed to decode ${t} screenshot as PNG`,{label:t,reason:e instanceof Error?e.message:String(e)})}}async function e$(e){if(e)try{await v.unlink(e)}catch(e){var t;if(!("object"==typeof(t=e)&&null!==t&&"code"in t&&"ENOENT"===t.code))throw e}}async function eI(e,t){if(!e.remoteConfig)return;let s=e.platform;if("ios"!==s&&"android"!==s)throw new A("INVALID_ARGS",'open --remote-config requires platform "ios" or "android" in the remote config file or CLI flags.');if(!e.metroPublicBaseUrl)throw new A("INVALID_ARGS",'open --remote-config requires "metroPublicBaseUrl" in the remote config file.');let r=await t.metro.prepare({projectRoot:e.metroProjectRoot,kind:e.metroKind,publicBaseUrl:e.metroPublicBaseUrl,proxyBaseUrl:e.metroProxyBaseUrl,bearerToken:e.metroBearerToken,port:e.metroPreparePort,listenHost:e.metroListenHost,statusHost:e.metroStatusHost,startupTimeoutMs:e.metroStartupTimeoutMs,probeTimeoutMs:e.metroProbeTimeoutMs,reuseExisting:!e.metroNoReuseExisting&&void 0,installDependenciesIfNeeded:!e.metroNoInstallDeps&&void 0,runtimeFilePath:e.metroRuntimeFile});return"ios"===s?r.iosRuntime:r.androidRuntime}async function eL(e){let t=ex[e.command];return!!t&&await t(e)}let ex={session:async({positionals:e,flags:t,client:s})=>{if("list"!==(e[0]??"list"))throw new A("INVALID_ARGS","session only supports list");let r={sessions:(await s.sessions.list()).map(R)};return t.json?eh({success:!0,data:r}):process.stdout.write(`${JSON.stringify(r,null,2)}
`),!0},devices:async({flags:e,client:t})=>{let s=await t.devices.list(eE(e)),r={devices:s.map(j)};return e.json?eh({success:!0,data:r}):process.stdout.write(`${s.map(eR).join("\n")}
`),!0},"ensure-simulator":async({flags:e,client:t})=>{if(!e.device)throw new A("INVALID_ARGS","ensure-simulator requires --device <name>");let s=await t.simulators.ensure({device:e.device,runtime:e.runtime,boot:e.boot,reuseExisting:e.reuseExisting,iosSimulatorDeviceSet:e.iosSimulatorDeviceSet}),r=C(s);if(e.json)eh({success:!0,data:r});else{let e=s.created?"Created":"Reused",t=s.booted?" (booted)":"";process.stdout.write(`${e}: ${s.device} ${s.udid}${t}
`),s.runtime&&process.stdout.write(`Runtime: ${s.runtime}
`)}return!0},metro:async({positionals:e,flags:t,client:s})=>{var r;if("prepare"!==(e[0]??"").toLowerCase())throw new A("INVALID_ARGS","metro only supports prepare");if(!t.metroPublicBaseUrl)throw new A("INVALID_ARGS","metro prepare requires --public-base-url <url>.");return r=await s.metro.prepare({projectRoot:t.metroProjectRoot,kind:t.metroKind,port:t.metroPreparePort,listenHost:t.metroListenHost,statusHost:t.metroStatusHost,publicBaseUrl:t.metroPublicBaseUrl,proxyBaseUrl:t.metroProxyBaseUrl,bearerToken:t.metroBearerToken,startupTimeoutMs:t.metroStartupTimeoutMs,probeTimeoutMs:t.metroProbeTimeoutMs,reuseExisting:!t.metroNoReuseExisting&&void 0,installDependenciesIfNeeded:!t.metroNoInstallDeps&&void 0,runtimeFilePath:t.metroRuntimeFile}),t.json?eh({success:!0,data:r}):process.stdout.write(`${JSON.stringify(r,null,2)}
`),!0},install:async({positionals:e,flags:t,client:s})=>{let r=await eN("install",e,t,s);return t.json&&eh({success:!0,data:P(r)}),!0},reinstall:async({positionals:e,flags:t,client:s})=>{let r=await eN("reinstall",e,t,s);return t.json&&eh({success:!0,data:P(r)}),!0},"install-from-source":async({positionals:e,flags:t,client:s})=>{let r=await eO(e,t,s);return t.json&&eh({success:!0,data:j(r)}),!0},open:async({positionals:e,flags:t,client:s})=>{if(!e[0])return!1;let r=await eI(t,s),o=await s.apps.open({app:e[0],url:e[1],activity:t.activity,relaunch:t.relaunch,saveScript:t.saveScript,noRecord:t.noRecord,runtime:r,...eE(t)});return t.json&&eh({success:!0,data:O(o)}),!0},close:async({positionals:e,flags:t,client:s})=>{let r=e[0]?await s.apps.close({app:e[0],shutdown:t.shutdown}):await s.sessions.close({shutdown:t.shutdown});return t.json&&eh({success:!0,data:F(r)}),!0},snapshot:async({flags:e,client:t})=>{let s=_(await t.capture.snapshot({...eE(e),interactiveOnly:e.snapshotInteractiveOnly,compact:e.snapshotCompact,depth:e.snapshotDepth,scope:e.snapshotScope,raw:e.snapshotRaw}));return e.json?eh({success:!0,data:s}):process.stdout.write(ew(s,{raw:e.snapshotRaw,flatten:e.snapshotInteractiveOnly})),!0},screenshot:async({positionals:e,flags:t,client:s})=>{let r=await s.capture.screenshot({path:e[0]??t.out}),o={path:r.path};return t.json?eh({success:!0,data:o}):process.stdout.write(`${r.path}
`),!0},diff:async({positionals:e,flags:t,client:s})=>{let r;if("screenshot"!==e[0])return!1;let o=t.baseline;if(!o||"string"!=typeof o)throw new A("INVALID_ARGS","diff screenshot requires --baseline <path>");let a=h(o),i="string"==typeof t.out?h(t.out):void 0,n=.1;if(null!=t.threshold&&""!==t.threshold&&(Number.isNaN(n=Number(t.threshold))||n<0||n>1))throw new A("INVALID_ARGS","--threshold must be a number between 0 and 1");let l=$.mkdtempSync(g.join(I.tmpdir(),"agent-device-diff-current-")),p=g.join(l,`current-${Date.now()}.png`),c=(await s.capture.screenshot({path:p})).path;try{r=await ek(a,c,{threshold:n,outputPath:i})}finally{try{$.unlinkSync(c)}catch{}try{$.rmSync(l,{recursive:!0,force:!0})}catch{}}return t.json?eh({success:!0,data:r}):process.stdout.write(function(e){var t,s;let r=eb(),o=!0===e.match,a=ev(e.differentPixels),i=ev(e.totalPixels),n=ev(e.mismatchPercentage),l=e.diffPath,p=e.dimensionMismatch,c=[];if(o){let e=r?u("green","✓"):"✓";c.push(`${e} Screenshots match.`)}else if(p){let e=r?u("red","✗"):"✗",t=p.expected,s=p.actual;c.push(`${e} Screenshots have different dimensions: expected ${t?.width}x${t?.height}, got ${s?.width}x${s?.height}`)}else{let e=r?u("red","✗"):"✗",t=0===n&&a>0?"<0.01":String(n);c.push(`${e} ${t}% pixels differ`)}if(l&&!o){let e,s,o=(t=l,e=process.cwd(),""!==(s=g.relative(e,t))&&(s.startsWith("..")||g.isAbsolute(s))?t:""===s?".":`.${g.sep}${s}`),a=r?u("dim","Diff image:"):"Diff image:",i=r?u("green",o):o;c.push(` ${a} ${i}`)}if(!o&&!p){let e=r?(s=String(a),u("red",s)):String(a);c.push(` ${e} different / ${i} total pixels`)}return`${c.join("\n")}
`}(r)),!0}};async function eN(e,t,s,r){let o=t[0],a=t[1];if(!o||!a)throw new A("INVALID_ARGS",`${e} requires: ${e} <app> <path-to-app-binary>`);let i={app:o,appPath:a,...eE(s)};return"install"===e?await r.apps.install(i):await r.apps.reinstall(i)}async function eO(e,t,s){let r=e[0]?.trim();if(!r)throw new A("INVALID_ARGS","install-from-source requires: install-from-source <url>");if(e.length>1)throw new A("INVALID_ARGS","install-from-source accepts exactly one positional argument: <url>");return await s.apps.installFromSource({...eE(t),retainPaths:t.retainPaths,retentionMs:t.retentionMs,source:{kind:"url",url:r,headers:function(e){if(!e||0===e.length)return;let t={};for(let s of e){let e=s.indexOf(":");if(e<=0)throw new A("INVALID_ARGS",`Invalid --header value "${s}". Expected "name:value".`);let r=s.slice(0,e).trim(),o=s.slice(e+1).trim();if(!r)throw new A("INVALID_ARGS",`Invalid --header value "${s}". Header name cannot be empty.`);t[r]=o}return t}(t.header)}})}function eE(e){return{platform:e.platform,target:e.target,device:e.device,udid:e.udid,serial:e.serial,iosSimulatorDeviceSet:e.iosSimulatorDeviceSet,androidDeviceAllowlist:e.androidDeviceAllowlist}}function eR(e){let t=e.kind?` ${e.kind}`:"",s=e.target?` target=${e.target}`:"",r="boolean"==typeof e.booted?` booted=${e.booted}`:"";return`${e.name} (${e.platform}${t}${s})${r}`}function eP(e,t={}){let s=e_(t),r={...e};return s.defaultPlatform&&void 0===r.platform&&(r.platform=s.defaultPlatform),r}function e_(e){var t,s,r,o;let a,i=e.env??process.env,n=e.inheritedPlatform??e.configuredPlatform??function(e){if(void 0===e)return;let t=e.trim().toLowerCase();if(t){if("ios"===t||"android"===t||"apple"===t)return t;throw new A("INVALID_ARGS",`Invalid AGENT_DEVICE_PLATFORM: ${e}. Use ios, android, or apple.`)}}(i.AGENT_DEVICE_PLATFORM),l="string"==typeof(t=e.configuredSession??i.AGENT_DEVICE_SESSION)&&t.trim().length>0;return{defaultPlatform:n,lockPolicy:(s=e.policyOverrides,r=i,o=l,(a=s?.sessionLock??s?.sessionLockConflicts??eF(r.AGENT_DEVICE_SESSION_LOCK)??eF(r.AGENT_DEVICE_SESSION_LOCK_CONFLICTS))||(s?.sessionLocked===!0||function(e){if(!e)return!1;switch(e.trim().toLowerCase()){case"1":case"true":case"yes":case"on":return!0;default:return!1}}(r.AGENT_DEVICE_SESSION_LOCKED)||o?"reject":void 0))}}function eF(e){if(void 0===e)return;let t=e.trim().toLowerCase();if(t){if("reject"===t||"strip"===t)return t;throw new A("INVALID_ARGS",`Invalid session lock mode: ${e}. Use reject or strip.`)}}function eC(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&(e[s]=r);return e}let ej=["stateDir","daemonBaseUrl","daemonAuthToken","daemonTransport","daemonServerMode","tenant","sessionIsolation","runId","leaseId","platform","target","device","udid","serial","iosSimulatorDeviceSet","androidDeviceAllowlist","session","metroProjectRoot","metroKind","metroPublicBaseUrl","metroProxyBaseUrl","metroBearerToken","metroPreparePort","metroListenHost","metroStatusHost","metroStartupTimeoutMs","metroProbeTimeoutMs","metroRuntimeFile","metroNoReuseExisting","metroNoInstallDeps"],eT=new Set(["stateDir","iosSimulatorDeviceSet","metroProjectRoot","metroRuntimeFile"]),eV=["remoteConfig","session","platform","daemonBaseUrl","daemonAuthToken","daemonTransport","metroProjectRoot","metroKind","metroPublicBaseUrl","metroProxyBaseUrl","metroBearerToken","metroPreparePort","metroListenHost","metroStatusHost","metroStartupTimeoutMs","metroProbeTimeoutMs","metroRuntimeFile","metroNoReuseExisting","metroNoInstallDeps"],eM={sendToDaemon:E};async function eG(e,t=eM){let s=m(),r=e.includes("--debug")||e.includes("--verbose")||e.includes("-v"),o=e.includes("--json"),a=function(e){for(let t=0;t<e.length;t+=1){let s=e[t];if(s.startsWith("--session=")){let e=s.slice(10).trim();return e.length>0?e:null}if("--session"===s){let s=e[t+1]?.trim();if(s&&!s.startsWith("-"))return s;break}}return null}(e)??process.env.AGENT_DEVICE_SESSION??"default";await L({session:a,requestId:s,command:e[0],debug:r},async()=>{var a,i,n,l,p,d,m,b,S,I,L;let O;try{let t,s,r,o,u,c,f,y,w;a={cwd:process.cwd(),env:process.env},t=function(e){let t={json:!1,help:!1,version:!1},s=null,r=[],o=[],a=!0;for(let i=0;i<e.length;i+=1){let n=e[i];if(a&&"--"===n){a=!1;continue}if(!a){s?r.push(n):s=eg(n);continue}let l=n.startsWith("--"),p=n.startsWith("-")&&n.length>1;if(!l&&!p){s?r.push(n):s=eg(n);continue}let[u,c]=l?ec(n):[n,void 0],d=q.get(u);if(!d){if(function(e,t,s){var r;if(r=s,!/^-\d+(\.\d+)?$/.test(r)||!e)return!1;let o=K(e);return!o||!!o.allowsExtraPositionals||0!==o.positionalArgs.length&&(t.length<o.positionalArgs.length||o.positionalArgs.some(e=>e.includes("?")))}(s,r,n)){s?r.push(n):s=n;continue}throw new A("INVALID_ARGS",`Unknown flag: ${u}`)}let m=function(e,t,s,r){if(void 0!==e.setValue){if(void 0!==s)throw new A("INVALID_ARGS",`Flag ${t} does not take a value.`);return{value:e.setValue,consumeNext:!1}}if("boolean"===e.type){if(void 0!==s)throw new A("INVALID_ARGS",`Flag ${t} does not take a value.`);return{value:!0,consumeNext:!1}}if("booleanOrString"===e.type){if(void 0!==s){if(0===s.trim().length)throw new A("INVALID_ARGS",`Flag ${t} requires a non-empty value when provided.`);return{value:s,consumeNext:!1}}return void 0===r||em(r)||!function(e){let t=e.trim();return!(!t||/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(t))&&!!(t.startsWith("./")||t.startsWith("../")||t.startsWith("~/")||t.startsWith("/")||t.includes("/")||t.includes("\\"))}(r)?{value:!0,consumeNext:!1}:{value:r,consumeNext:!0}}let o=s??r;if(void 0===o||void 0===s&&em(o))throw new A("INVALID_ARGS",`Flag ${t} requires a value.`);if("string"===e.type)return{value:o,consumeNext:void 0===s};if("enum"===e.type){if(!e.enumValues?.includes(o))throw new A("INVALID_ARGS",`Invalid ${ed(t)}: ${o}`);return{value:o,consumeNext:void 0===s}}let a=Number(o);if(!Number.isFinite(a)||"number"==typeof e.min&&a<e.min||"number"==typeof e.max&&a>e.max)throw new A("INVALID_ARGS",`Invalid ${ed(t)}: ${o}`);return{value:Math.floor(a),consumeNext:void 0===s}}(d,u,c,e[i+1]);m.consumeNext&&(i+=1);let g=t[d.key];if(d.multiple){let e=Array.isArray(g)?[...g,m.value]:void 0===g?[m.value]:[g,m.value];t[d.key]=e}else t[d.key]=m.value;o.push({key:d.key,token:u})}return{command:s,positionals:r,flags:t,warnings:[],providedFlags:o}}(e),s=a?.env??process.env,r=a?.cwd??process.cwd(),o=function(e){if(!e.cliFlags.remoteConfig)return{};let t=function(e,t){let s={};for(let r of t){let t=en(r);if(!t)continue;let o=t.env.names.map(t=>({name:t,value:e[t]})).find(e=>"string"==typeof e.value&&e.value.trim().length>0);o&&(s[r]=ep(t,o.value,`environment variable ${o.name}`,o.name))}return s}(e.env,eV);return function(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&(e[s]=r)}(t,function(e){let t,s,r=e.env??process.env,o=h(e.configPath,{cwd:e.cwd,env:r});if(!$.existsSync(o))throw new A("INVALID_ARGS",`Remote config file not found: ${o}`);try{t=$.readFileSync(o,"utf8")}catch(e){throw new A("INVALID_ARGS",`Failed to read remote config file: ${o}`,{cause:e instanceof Error?e.message:String(e)})}try{s=JSON.parse(t)}catch(e){throw new A("INVALID_ARGS",`Invalid JSON in remote config file: ${o}`,{cause:e instanceof Error?e.message:String(e)})}if(!s||"object"!=typeof s||Array.isArray(s))throw new A("INVALID_ARGS",`Remote config file must contain a JSON object: ${o}`);let a={},i=s,n=g.dirname(o);for(let[e,t]of Object.entries(i)){if(!ej.includes(e))throw new A("INVALID_ARGS",`Unsupported remote config key "${e}" in remote config file ${o}.`);let s=en(e);if(!s)throw new A("INVALID_ARGS",`Unknown remote config key "${e}" in remote config file ${o}.`);let i=ep(s,t,`remote config file ${o}`,e);a[e]="string"==typeof i&&eT.has(e)?h(i,{cwd:n,env:r}):i}return a}({configPath:e.cliFlags.remoteConfig,cwd:e.cwd,env:e.env})),t.remoteConfig=e.cliFlags.remoteConfig,t}({cliFlags:t.flags,cwd:r,env:s}),f=function(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&(e[s]=r);return e}((u=(i={command:t.command,cwd:r,cliFlags:t.flags,env:s}).env??process.env,c=eC({},function(e){let t={};for(let s of e)eC(t,function(e,t){let s,r;if(!$.existsSync(e)){if(t)throw new A("INVALID_ARGS",`Config file not found: ${e}`);return{}}try{s=$.readFileSync(e,"utf8")}catch(t){throw new A("INVALID_ARGS",`Failed to read config file: ${e}`,{cause:t instanceof Error?t.message:String(t)})}try{r=JSON.parse(s)}catch(t){throw new A("INVALID_ARGS",`Invalid JSON in config file: ${e}`,{cause:t instanceof Error?t.message:String(t)})}if(!r||"object"!=typeof r||Array.isArray(r))throw new A("INVALID_ARGS",`Config file must contain a JSON object: ${e}`);return function(e,t){let s={};for(let[r,o]of Object.entries(e)){let e=en(r);if(!e)throw new A("INVALID_ARGS",`Unknown config key "${r}" in ${t}.`);if(!e.config.enabled)throw new A("INVALID_ARGS",`Unsupported config key "${r}" in ${t}.`);s[r]=ep(e,o,t,r)}return s}(r,`config file ${e}`)}(s.path,s.required));return t}((n=i.cwd,l=i.cliFlags.config,p=u,(w=l??p.AGENT_DEVICE_CONFIG)?[{path:(d=w,m=n,b=p,h(d,{cwd:m,env:b})),required:!0}]:[{path:(S=p,g.join(k("~",{env:S}),".agent-device","config.json")),required:!1},{path:g.resolve(n,"agent-device.json"),required:!1}]))),eC(c,function(e,t){let s={};for(let r of ea.filter(e=>e.config.enabled&&e.supportsCommand(t))){let t=r.env.names.map(t=>({name:t,value:e[t]})).find(e=>"string"==typeof e.value&&e.value.trim().length>0);t&&(s[r.key]=ep(r,t.value,`environment variable ${t.name}`,t.name))}return s}(u,i.command))),o),y=function(e,t){let s=t?.strictFlags??function(e){if(!e)return!1;let t=e.trim().toLowerCase();return"1"===t||"true"===t||"yes"===t||"on"===t}(process.env.AGENT_DEVICE_STRICT_FLAGS),r=[...e.warnings],o=ef({json:!1,help:!1,version:!1},t?.defaultFlags??{});ef(o,e.flags);let a=K(e.command),i=e.providedFlags.filter(t=>!el(t.key,e.command));if(i.length>0){var n,l;let t=i.map(e=>e.token),a=(n=e.command,l=t,n?1===l.length?`Flag ${l[0]} is not supported for command ${n}.`:`Flags ${l.join(", ")} are not supported for command ${n}.`:1===l.length?`Flag ${l[0]} requires a command that supports it.`:`Flags ${l.join(", ")} require a command that supports them.`);if(s)throw new A("INVALID_ARGS",a);for(let e of(r.push(`${a} Enable AGENT_DEVICE_STRICT_FLAGS=1 to fail fast.`),i))delete o[e.key]}for(let t of Object.keys(o))void 0!==o[t]&&(el(t,e.command)||delete o[t]);if(a?.defaults)for(let[e,t]of Object.entries(a.defaults))void 0===o[e]&&(o[e]=t);if("batch"===e.command&&1!=+!!o.steps+ +!!o.stepsFile)throw new A("INVALID_ARGS","batch requires exactly one step source: --steps or --steps-file.");return{command:e.command,positionals:e.positionals,flags:o,warnings:r}}(t,{strictFlags:a?.strictFlags,defaultFlags:f}),"open"===t.command&&t.flags.remoteConfig&&function(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&void 0===e[s]&&(e[s]=r)}(y.flags,function(e){let t={};for(let s of eV){let r=e[s];void 0!==r&&(t[s]=r)}return t}(f)),O=y}catch(t){v({level:"error",phase:"cli_parse_failed",data:{error:t instanceof Error?t.message:String(t)}});let e=f(t,{diagnosticId:w().diagnosticId,logPath:x({force:!0})??void 0});o?eh({success:!1,error:e}):ey(e,{showDetails:r}),process.exit(1);return}for(let e of O.warnings)process.stderr.write(`Warning: ${e}
`),!0},install:async({positionals:e,flags:t,client:s})=>{let r=await eN("install",e,t,s);return t.json&&eh({success:!0,data:P(r)}),!0},reinstall:async({positionals:e,flags:t,client:s})=>{let r=await eN("reinstall",e,t,s);return t.json&&eh({success:!0,data:P(r)}),!0},"install-from-source":async({positionals:e,flags:t,client:s})=>{let r=await eO(e,t,s);return t.json&&eh({success:!0,data:T(r)}),!0},open:async({positionals:e,flags:t,client:s})=>{if(!e[0])return!1;let r=await eI(t,s),o=await s.apps.open({app:e[0],url:e[1],activity:t.activity,relaunch:t.relaunch,saveScript:t.saveScript,noRecord:t.noRecord,runtime:r,...eE(t)});return t.json&&eh({success:!0,data:O(o)}),!0},close:async({positionals:e,flags:t,client:s})=>{let r=e[0]?await s.apps.close({app:e[0],shutdown:t.shutdown}):await s.sessions.close({shutdown:t.shutdown});return t.json&&eh({success:!0,data:F(r)}),!0},snapshot:async({flags:e,client:t})=>{let s=_(await t.capture.snapshot({...eE(e),interactiveOnly:e.snapshotInteractiveOnly,compact:e.snapshotCompact,depth:e.snapshotDepth,scope:e.snapshotScope,raw:e.snapshotRaw}));return e.json?eh({success:!0,data:s}):process.stdout.write(ew(s,{raw:e.snapshotRaw,flatten:e.snapshotInteractiveOnly})),!0},screenshot:async({positionals:e,flags:t,client:s})=>{let r=await s.capture.screenshot({path:e[0]??t.out}),o={path:r.path};return t.json?eh({success:!0,data:o}):process.stdout.write(`${r.path}
`),!0},diff:async({positionals:e,flags:t,client:s})=>{let r;if("screenshot"!==e[0])return!1;let o=t.baseline;if(!o||"string"!=typeof o)throw new A("INVALID_ARGS","diff screenshot requires --baseline <path>");let a=h(o),i="string"==typeof t.out?h(t.out):void 0,n=.1;if(null!=t.threshold&&""!==t.threshold&&(Number.isNaN(n=Number(t.threshold))||n<0||n>1))throw new A("INVALID_ARGS","--threshold must be a number between 0 and 1");let l=$.mkdtempSync(g.join(I.tmpdir(),"agent-device-diff-current-")),p=g.join(l,`current-${Date.now()}.png`),c=(await s.capture.screenshot({path:p})).path;try{r=await ek(a,c,{threshold:n,outputPath:i})}finally{try{$.unlinkSync(c)}catch{}try{$.rmSync(l,{recursive:!0,force:!0})}catch{}}return t.json?eh({success:!0,data:r}):process.stdout.write(function(e){var t,s;let r=ev(),o=!0===e.match,a=eb(e.differentPixels),i=eb(e.totalPixels),n=eb(e.mismatchPercentage),l=e.diffPath,p=e.dimensionMismatch,c=[];if(o){let e=r?u("green","✓"):"✓";c.push(`${e} Screenshots match.`)}else if(p){let e=r?u("red","✗"):"✗",t=p.expected,s=p.actual;c.push(`${e} Screenshots have different dimensions: expected ${t?.width}x${t?.height}, got ${s?.width}x${s?.height}`)}else{let e=r?u("red","✗"):"✗",t=0===n&&a>0?"<0.01":String(n);c.push(`${e} ${t}% pixels differ`)}if(l&&!o){let e,s,o=(t=l,e=process.cwd(),""!==(s=g.relative(e,t))&&(s.startsWith("..")||g.isAbsolute(s))?t:""===s?".":`.${g.sep}${s}`),a=r?u("dim","Diff image:"):"Diff image:",i=r?u("green",o):o;c.push(` ${a} ${i}`)}if(!o&&!p){let e=r?(s=String(a),u("red",s)):String(a);c.push(` ${e} different / ${i} total pixels`)}return`${c.join("\n")}
`}(r)),!0}};async function eN(e,t,s,r){let o=t[0],a=t[1];if(!o||!a)throw new A("INVALID_ARGS",`${e} requires: ${e} <app> <path-to-app-binary>`);let i={app:o,appPath:a,...eE(s)};return"install"===e?await r.apps.install(i):await r.apps.reinstall(i)}async function eO(e,t,s){let r=e[0]?.trim();if(!r)throw new A("INVALID_ARGS","install-from-source requires: install-from-source <url>");if(e.length>1)throw new A("INVALID_ARGS","install-from-source accepts exactly one positional argument: <url>");return await s.apps.installFromSource({...eE(t),retainPaths:t.retainPaths,retentionMs:t.retentionMs,source:{kind:"url",url:r,headers:function(e){if(!e||0===e.length)return;let t={};for(let s of e){let e=s.indexOf(":");if(e<=0)throw new A("INVALID_ARGS",`Invalid --header value "${s}". Expected "name:value".`);let r=s.slice(0,e).trim(),o=s.slice(e+1).trim();if(!r)throw new A("INVALID_ARGS",`Invalid --header value "${s}". Header name cannot be empty.`);t[r]=o}return t}(t.header)}})}function eE(e){return{platform:e.platform,target:e.target,device:e.device,udid:e.udid,serial:e.serial,iosSimulatorDeviceSet:e.iosSimulatorDeviceSet,androidDeviceAllowlist:e.androidDeviceAllowlist}}function eR(e){let t=e.kind?` ${e.kind}`:"",s=e.target?` target=${e.target}`:"",r="boolean"==typeof e.booted?` booted=${e.booted}`:"";return`${e.name} (${e.platform}${t}${s})${r}`}function eP(e,t={}){let s=e_(t),r={...e};return s.defaultPlatform&&void 0===r.platform&&(r.platform=s.defaultPlatform),r}function e_(e){var t,s,r,o;let a,i=e.env??process.env,n=e.inheritedPlatform??e.configuredPlatform??function(e){if(void 0===e)return;let t=e.trim().toLowerCase();if(t){if("ios"===t||"android"===t||"apple"===t)return t;throw new A("INVALID_ARGS",`Invalid AGENT_DEVICE_PLATFORM: ${e}. Use ios, android, or apple.`)}}(i.AGENT_DEVICE_PLATFORM),l="string"==typeof(t=e.configuredSession??i.AGENT_DEVICE_SESSION)&&t.trim().length>0;return{defaultPlatform:n,lockPolicy:(s=e.policyOverrides,r=i,o=l,(a=s?.sessionLock??s?.sessionLockConflicts??eF(r.AGENT_DEVICE_SESSION_LOCK)??eF(r.AGENT_DEVICE_SESSION_LOCK_CONFLICTS))||(s?.sessionLocked===!0||function(e){if(!e)return!1;switch(e.trim().toLowerCase()){case"1":case"true":case"yes":case"on":return!0;default:return!1}}(r.AGENT_DEVICE_SESSION_LOCKED)||o?"reject":void 0))}}function eF(e){if(void 0===e)return;let t=e.trim().toLowerCase();if(t){if("reject"===t||"strip"===t)return t;throw new A("INVALID_ARGS",`Invalid session lock mode: ${e}. Use reject or strip.`)}}function eC(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&(e[s]=r);return e}let eT=["stateDir","daemonBaseUrl","daemonAuthToken","daemonTransport","daemonServerMode","tenant","sessionIsolation","runId","leaseId","platform","target","device","udid","serial","iosSimulatorDeviceSet","androidDeviceAllowlist","session","metroProjectRoot","metroKind","metroPublicBaseUrl","metroProxyBaseUrl","metroBearerToken","metroPreparePort","metroListenHost","metroStatusHost","metroStartupTimeoutMs","metroProbeTimeoutMs","metroRuntimeFile","metroNoReuseExisting","metroNoInstallDeps"],ej=new Set(["stateDir","iosSimulatorDeviceSet","metroProjectRoot","metroRuntimeFile"]),eV=["remoteConfig","session","platform","daemonBaseUrl","daemonAuthToken","daemonTransport","metroProjectRoot","metroKind","metroPublicBaseUrl","metroProxyBaseUrl","metroBearerToken","metroPreparePort","metroListenHost","metroStatusHost","metroStartupTimeoutMs","metroProbeTimeoutMs","metroRuntimeFile","metroNoReuseExisting","metroNoInstallDeps"],eM={sendToDaemon:E};async function eG(e,t=eM){let s=m(),r=e.includes("--debug")||e.includes("--verbose")||e.includes("-v"),o=e.includes("--json"),a=function(e){for(let t=0;t<e.length;t+=1){let s=e[t];if(s.startsWith("--session=")){let e=s.slice(10).trim();return e.length>0?e:null}if("--session"===s){let s=e[t+1]?.trim();if(s&&!s.startsWith("-"))return s;break}}return null}(e)??process.env.AGENT_DEVICE_SESSION??"default";await L({session:a,requestId:s,command:e[0],debug:r},async()=>{var a,i,n,l,p,d,m,v,S,I,L;let O;try{let t,s,r,o,u,c,f,y,w;a={cwd:process.cwd(),env:process.env},t=function(e){let t={json:!1,help:!1,version:!1},s=null,r=[],o=[],a=!0;for(let i=0;i<e.length;i+=1){let n=e[i];if(a&&"--"===n){a=!1;continue}if(!a){s?r.push(n):s=eg(n);continue}let l=n.startsWith("--"),p=n.startsWith("-")&&n.length>1;if(!l&&!p){s?r.push(n):s=eg(n);continue}let[u,c]=l?ec(n):[n,void 0],d=q.get(u);if(!d){if(function(e,t,s){var r;if(r=s,!/^-\d+(\.\d+)?$/.test(r)||!e)return!1;let o=K(e);return!o||!!o.allowsExtraPositionals||0!==o.positionalArgs.length&&(t.length<o.positionalArgs.length||o.positionalArgs.some(e=>e.includes("?")))}(s,r,n)){s?r.push(n):s=n;continue}throw new A("INVALID_ARGS",`Unknown flag: ${u}`)}let m=function(e,t,s,r){if(void 0!==e.setValue){if(void 0!==s)throw new A("INVALID_ARGS",`Flag ${t} does not take a value.`);return{value:e.setValue,consumeNext:!1}}if("boolean"===e.type){if(void 0!==s)throw new A("INVALID_ARGS",`Flag ${t} does not take a value.`);return{value:!0,consumeNext:!1}}if("booleanOrString"===e.type){if(void 0!==s){if(0===s.trim().length)throw new A("INVALID_ARGS",`Flag ${t} requires a non-empty value when provided.`);return{value:s,consumeNext:!1}}return void 0===r||em(r)||!function(e){let t=e.trim();return!(!t||/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(t))&&!!(t.startsWith("./")||t.startsWith("../")||t.startsWith("~/")||t.startsWith("/")||t.includes("/")||t.includes("\\"))}(r)?{value:!0,consumeNext:!1}:{value:r,consumeNext:!0}}let o=s??r;if(void 0===o||void 0===s&&em(o))throw new A("INVALID_ARGS",`Flag ${t} requires a value.`);if("string"===e.type)return{value:o,consumeNext:void 0===s};if("enum"===e.type){if(!e.enumValues?.includes(o))throw new A("INVALID_ARGS",`Invalid ${ed(t)}: ${o}`);return{value:o,consumeNext:void 0===s}}let a=Number(o);if(!Number.isFinite(a)||"number"==typeof e.min&&a<e.min||"number"==typeof e.max&&a>e.max)throw new A("INVALID_ARGS",`Invalid ${ed(t)}: ${o}`);return{value:Math.floor(a),consumeNext:void 0===s}}(d,u,c,e[i+1]);m.consumeNext&&(i+=1);let g=t[d.key];if(d.multiple){let e=Array.isArray(g)?[...g,m.value]:void 0===g?[m.value]:[g,m.value];t[d.key]=e}else t[d.key]=m.value;o.push({key:d.key,token:u})}return{command:s,positionals:r,flags:t,warnings:[],providedFlags:o}}(e),s=a?.env??process.env,r=a?.cwd??process.cwd(),o=function(e){if(!e.cliFlags.remoteConfig)return{};let t=function(e,t){let s={};for(let r of t){let t=en(r);if(!t)continue;let o=t.env.names.map(t=>({name:t,value:e[t]})).find(e=>"string"==typeof e.value&&e.value.trim().length>0);o&&(s[r]=ep(t,o.value,`environment variable ${o.name}`,o.name))}return s}(e.env,eV);return function(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&(e[s]=r)}(t,function(e){let t,s,r=e.env??process.env,o=h(e.configPath,{cwd:e.cwd,env:r});if(!$.existsSync(o))throw new A("INVALID_ARGS",`Remote config file not found: ${o}`);try{t=$.readFileSync(o,"utf8")}catch(e){throw new A("INVALID_ARGS",`Failed to read remote config file: ${o}`,{cause:e instanceof Error?e.message:String(e)})}try{s=JSON.parse(t)}catch(e){throw new A("INVALID_ARGS",`Invalid JSON in remote config file: ${o}`,{cause:e instanceof Error?e.message:String(e)})}if(!s||"object"!=typeof s||Array.isArray(s))throw new A("INVALID_ARGS",`Remote config file must contain a JSON object: ${o}`);let a={},i=s,n=g.dirname(o);for(let[e,t]of Object.entries(i)){if(!eT.includes(e))throw new A("INVALID_ARGS",`Unsupported remote config key "${e}" in remote config file ${o}.`);let s=en(e);if(!s)throw new A("INVALID_ARGS",`Unknown remote config key "${e}" in remote config file ${o}.`);let i=ep(s,t,`remote config file ${o}`,e);a[e]="string"==typeof i&&ej.has(e)?h(i,{cwd:n,env:r}):i}return a}({configPath:e.cliFlags.remoteConfig,cwd:e.cwd,env:e.env})),t.remoteConfig=e.cliFlags.remoteConfig,t}({cliFlags:t.flags,cwd:r,env:s}),f=function(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&(e[s]=r);return e}((u=(i={command:t.command,cwd:r,cliFlags:t.flags,env:s}).env??process.env,c=eC({},function(e){let t={};for(let s of e)eC(t,function(e,t){let s,r;if(!$.existsSync(e)){if(t)throw new A("INVALID_ARGS",`Config file not found: ${e}`);return{}}try{s=$.readFileSync(e,"utf8")}catch(t){throw new A("INVALID_ARGS",`Failed to read config file: ${e}`,{cause:t instanceof Error?t.message:String(t)})}try{r=JSON.parse(s)}catch(t){throw new A("INVALID_ARGS",`Invalid JSON in config file: ${e}`,{cause:t instanceof Error?t.message:String(t)})}if(!r||"object"!=typeof r||Array.isArray(r))throw new A("INVALID_ARGS",`Config file must contain a JSON object: ${e}`);return function(e,t){let s={};for(let[r,o]of Object.entries(e)){let e=en(r);if(!e)throw new A("INVALID_ARGS",`Unknown config key "${r}" in ${t}.`);if(!e.config.enabled)throw new A("INVALID_ARGS",`Unsupported config key "${r}" in ${t}.`);s[r]=ep(e,o,t,r)}return s}(r,`config file ${e}`)}(s.path,s.required));return t}((n=i.cwd,l=i.cliFlags.config,p=u,(w=l??p.AGENT_DEVICE_CONFIG)?[{path:(d=w,m=n,v=p,h(d,{cwd:m,env:v})),required:!0}]:[{path:(S=p,g.join(k("~",{env:S}),".agent-device","config.json")),required:!1},{path:g.resolve(n,"agent-device.json"),required:!1}]))),eC(c,function(e,t){let s={};for(let r of ea.filter(e=>e.config.enabled&&e.supportsCommand(t))){let t=r.env.names.map(t=>({name:t,value:e[t]})).find(e=>"string"==typeof e.value&&e.value.trim().length>0);t&&(s[r.key]=ep(r,t.value,`environment variable ${t.name}`,t.name))}return s}(u,i.command))),o),y=function(e,t){let s=t?.strictFlags??function(e){if(!e)return!1;let t=e.trim().toLowerCase();return"1"===t||"true"===t||"yes"===t||"on"===t}(process.env.AGENT_DEVICE_STRICT_FLAGS),r=[...e.warnings],o=ef({json:!1,help:!1,version:!1},t?.defaultFlags??{});ef(o,e.flags);let a=K(e.command),i=e.providedFlags.filter(t=>!el(t.key,e.command));if(i.length>0){var n,l;let t=i.map(e=>e.token),a=(n=e.command,l=t,n?1===l.length?`Flag ${l[0]} is not supported for command ${n}.`:`Flags ${l.join(", ")} are not supported for command ${n}.`:1===l.length?`Flag ${l[0]} requires a command that supports it.`:`Flags ${l.join(", ")} require a command that supports them.`);if(s)throw new A("INVALID_ARGS",a);for(let e of(r.push(`${a} Enable AGENT_DEVICE_STRICT_FLAGS=1 to fail fast.`),i))delete o[e.key]}for(let t of Object.keys(o))void 0!==o[t]&&(el(t,e.command)||delete o[t]);if(a?.defaults)for(let[e,t]of Object.entries(a.defaults))void 0===o[e]&&(o[e]=t);if("batch"===e.command&&1!=+!!o.steps+ +!!o.stepsFile)throw new A("INVALID_ARGS","batch requires exactly one step source: --steps or --steps-file.");return{command:e.command,positionals:e.positionals,flags:o,warnings:r}}(t,{strictFlags:a?.strictFlags,defaultFlags:f}),"open"===t.command&&t.flags.remoteConfig&&function(e,t){for(let[s,r]of Object.entries(t))void 0!==r&&void 0===e[s]&&(e[s]=r)}(y.flags,function(e){let t={};for(let s of eV){let r=e[s];void 0!==r&&(t[s]=r)}return t}(f)),O=y}catch(t){b({level:"error",phase:"cli_parse_failed",data:{error:t instanceof Error?t.message:String(t)}});let e=f(t,{diagnosticId:w().diagnosticId,logPath:x({force:!0})??void 0});o?eh({success:!1,error:e}):ey(e,{showDetails:r}),process.exit(1);return}for(let e of O.warnings)process.stderr.write(`Warning: ${e}
`);O.flags.version&&(process.stdout.write(`${y()}

@@ -53,4 +53,4 @@ `),process.exit(0));let E="help"===O.command,R=O.flags.help;if(E||R){E&&O.positionals.length>1&&(ey(new A("INVALID_ARGS","help accepts at most one command.")),process.exit(1));let e=E?O.positionals[0]:O.command;e||(process.stdout.write(`${X}

`),process.exit(1)}O.command||(process.stdout.write(`${X}
`),process.exit(1));let{command:P,positionals:_}=O,F=e_({policyOverrides:O.flags,configuredPlatform:O.flags.platform,configuredSession:O.flags.session}),C=F.lockPolicy?{...O.flags}:eP(O.flags,{policyOverrides:O.flags,configuredPlatform:O.flags.platform,configuredSession:O.flags.session}),j=function(e){let{json:t,config:s,remoteConfig:r,help:o,version:a,sessionLock:i,sessionLocked:n,sessionLockConflicts:l,...p}=e;return p}(C),T=N(C.stateDir),M=C.session??"default",G=C.daemonBaseUrl,U=!C.verbose||C.json||G?null:function(e){try{let t=0,s=!1,r=setInterval(()=>{if(!s&&$.existsSync(e))try{let s=$.statSync(e);if(s.size<t&&(t=0),s.size<=t)return;let r=$.openSync(e,"r");try{let e=Buffer.alloc(s.size-t);$.readSync(r,e,0,e.length,t),t=s.size,e.length>0&&process.stdout.write(e.toString("utf8"))}finally{$.closeSync(r)}}catch{}},200);return()=>{s=!0,clearInterval(r)}}catch{return null}}(T.logPath),H=V({session:M,requestId:s,stateDir:C.stateDir,daemonBaseUrl:C.daemonBaseUrl,daemonAuthToken:C.daemonAuthToken,daemonTransport:C.daemonTransport,daemonServerMode:C.daemonServerMode,tenant:C.tenant,sessionIsolation:C.sessionIsolation,runId:C.runId,leaseId:C.leaseId,lockPolicy:F.lockPolicy,lockPlatform:F.defaultPlatform,cwd:process.cwd(),debug:!!C.verbose},{transport:t.sendToDaemon}),W=async e=>await t.sendToDaemon({session:M,command:e.command,positionals:e.positionals,flags:e.flags,meta:{requestId:s,debug:!!C.verbose,cwd:process.cwd(),tenantId:C.tenant,runId:C.runId,leaseId:C.leaseId,sessionIsolation:C.sessionIsolation,lockPolicy:F.lockPolicy,lockPlatform:F.defaultPlatform}});try{if("batch"===P){let e,t,s;if(_.length>0)throw new A("INVALID_ARGS","batch does not accept positional arguments.");let r=(function(e){let t="";if(e.steps)t=e.steps;else if(e.stepsFile)try{t=$.readFileSync(e.stepsFile,"utf8")}catch(s){let t=s instanceof Error?s.message:String(s);throw new A("INVALID_ARGS",`Failed to read --steps-file ${e.stepsFile}: ${t}`)}return c(t)})(C).map((e,t)=>({...e,flags:F.lockPolicy&&void 0===C.platform?{...e.flags??{}}:eP(e.flags??{},{policyOverrides:C,configuredPlatform:C.platform,configuredSession:C.session,inheritedPlatform:C.platform})})),o={...j,batchSteps:r};delete o.steps,delete o.stepsFile;let a=await W({command:"batch",positionals:_,flags:o});if(!a.ok)throw new A(a.error.code,a.error.message,{...a.error.details??{},hint:a.error.hint,diagnosticId:a.error.diagnosticId,logPath:a.error.logPath});C.json?eh({success:!0,data:a.data??{}}):(I=a.data??{},e="number"==typeof I.total?I.total:0,t="number"==typeof I.executed?I.executed:0,s="number"==typeof I.totalDurationMs?I.totalDurationMs:void 0,process.stdout.write(`Batch completed: ${t}/${e} steps${void 0!==s?` in ${s}ms`:""}
`)),U&&U();return}if("runtime"===P)throw new A("INVALID_ARGS","runtime command was removed. Use open --remote-config <path> --relaunch for remote Metro launches, or metro prepare --remote-config <path> for inspection.");if(await eL({command:P,positionals:_,flags:C,client:H})){U&&U();return}let e=await W({command:P,positionals:_,flags:j});if(e.ok){if(C.json){eh({success:!0,data:e.data??{}}),U&&U();return}if("snapshot"===P){process.stdout.write(ew(e.data??{},{raw:C.snapshotRaw,flatten:C.snapshotInteractiveOnly})),U&&U();return}if("diff"===P&&"snapshot"===_[0]){process.stdout.write(function(e){var t,s,r,o;let a=!0===e.baselineInitialized,i=e.summary??{},n=ev(i.additions),l=ev(i.removals),p=ev(i.unchanged),c=eb();if(a)return`Baseline initialized (${p} lines).
`),process.exit(1));let{command:P,positionals:_}=O,F=e_({policyOverrides:O.flags,configuredPlatform:O.flags.platform,configuredSession:O.flags.session}),C=F.lockPolicy?{...O.flags}:eP(O.flags,{policyOverrides:O.flags,configuredPlatform:O.flags.platform,configuredSession:O.flags.session}),T=function(e){let{json:t,config:s,remoteConfig:r,help:o,version:a,sessionLock:i,sessionLocked:n,sessionLockConflicts:l,...p}=e;return p}(C),j=N(C.stateDir),M=C.session??"default",G=C.daemonBaseUrl,U=!C.verbose||C.json||G?null:function(e){try{let t=0,s=!1,r=setInterval(()=>{if(!s&&$.existsSync(e))try{let s=$.statSync(e);if(s.size<t&&(t=0),s.size<=t)return;let r=$.openSync(e,"r");try{let e=Buffer.alloc(s.size-t);$.readSync(r,e,0,e.length,t),t=s.size,e.length>0&&process.stdout.write(e.toString("utf8"))}finally{$.closeSync(r)}}catch{}},200);return()=>{s=!0,clearInterval(r)}}catch{return null}}(j.logPath),H=V({session:M,requestId:s,stateDir:C.stateDir,daemonBaseUrl:C.daemonBaseUrl,daemonAuthToken:C.daemonAuthToken,daemonTransport:C.daemonTransport,daemonServerMode:C.daemonServerMode,tenant:C.tenant,sessionIsolation:C.sessionIsolation,runId:C.runId,leaseId:C.leaseId,lockPolicy:F.lockPolicy,lockPlatform:F.defaultPlatform,cwd:process.cwd(),debug:!!C.verbose},{transport:t.sendToDaemon}),W=async e=>await t.sendToDaemon({session:M,command:e.command,positionals:e.positionals,flags:e.flags,meta:{requestId:s,debug:!!C.verbose,cwd:process.cwd(),tenantId:C.tenant,runId:C.runId,leaseId:C.leaseId,sessionIsolation:C.sessionIsolation,lockPolicy:F.lockPolicy,lockPlatform:F.defaultPlatform}});try{if("batch"===P){let e,t,s;if(_.length>0)throw new A("INVALID_ARGS","batch does not accept positional arguments.");let r=(function(e){let t="";if(e.steps)t=e.steps;else if(e.stepsFile)try{t=$.readFileSync(e.stepsFile,"utf8")}catch(s){let t=s instanceof Error?s.message:String(s);throw new A("INVALID_ARGS",`Failed to read --steps-file ${e.stepsFile}: ${t}`)}return c(t)})(C).map((e,t)=>({...e,flags:F.lockPolicy&&void 0===C.platform?{...e.flags??{}}:eP(e.flags??{},{policyOverrides:C,configuredPlatform:C.platform,configuredSession:C.session,inheritedPlatform:C.platform})})),o={...T,batchSteps:r};delete o.steps,delete o.stepsFile;let a=await W({command:"batch",positionals:_,flags:o});if(!a.ok)throw new A(a.error.code,a.error.message,{...a.error.details??{},hint:a.error.hint,diagnosticId:a.error.diagnosticId,logPath:a.error.logPath});C.json?eh({success:!0,data:a.data??{}}):(I=a.data??{},e="number"==typeof I.total?I.total:0,t="number"==typeof I.executed?I.executed:0,s="number"==typeof I.totalDurationMs?I.totalDurationMs:void 0,process.stdout.write(`Batch completed: ${t}/${e} steps${void 0!==s?` in ${s}ms`:""}
`)),U&&U();return}if("runtime"===P)throw new A("INVALID_ARGS","runtime command was removed. Use open --remote-config <path> --relaunch for remote Metro launches, or metro prepare --remote-config <path> for inspection.");if(await eL({command:P,positionals:_,flags:C,client:H})){U&&U();return}let e=await W({command:P,positionals:_,flags:T});if(e.ok){if(C.json){eh({success:!0,data:e.data??{}}),U&&U();return}if("snapshot"===P){process.stdout.write(ew(e.data??{},{raw:C.snapshotRaw,flatten:C.snapshotInteractiveOnly})),U&&U();return}if("diff"===P&&"snapshot"===_[0]){process.stdout.write(function(e){var t,s,r,o;let a=!0===e.baselineInitialized,i=e.summary??{},n=eb(i.additions),l=eb(i.removals),p=eb(i.unchanged),c=ev();if(a)return`Baseline initialized (${p} lines).
`;let d=(function(e,t){if(0===e.length)return e;let s=e.map((e,t)=>({index:t,kind:e.kind})).filter(e=>"added"===e.kind||"removed"===e.kind).map(e=>e.index);if(0===s.length)return e;let r=Array(e.length).fill(!1);for(let t of s){let s=Math.max(0,t-1),o=Math.min(e.length-1,t+1);for(let e=s;e<=o;e+=1)r[e]=!0}return e.filter((e,t)=>r[t])})(Array.isArray(e.lines)?e.lines:[],1).map(e=>{var t,s,r,o;let a="string"==typeof e.text?e.text:"";if("added"===e.kind){let e=a.startsWith(" ")?`+${a}`:`+ ${a}`;return c?(t=e,s="green",u(s,t)):e}if("removed"===e.kind){let e=a.startsWith(" ")?`-${a}`:`- ${a}`;return c?(r=e,u("red",r)):e}return c?(o=a,u("dim",o)):a}),m=d.length>0?`${d.join("\n")}

@@ -92,5 +92,5 @@ `:"";if(!c)return`${m}${n} additions, ${l} removals, ${p} unchanged

`),U&&U();return}}if("perf"===P){process.stdout.write(`${JSON.stringify(t,null,2)}
`),U&&U();return}}U&&U();return}throw new A(e.error.code,e.error.message,{...e.error.details??{},hint:e.error.hint,diagnosticId:e.error.diagnosticId,logPath:e.error.logPath})}catch(s){let e=D(s),t=f(e,{diagnosticId:w().diagnosticId,logPath:x({force:!0})??void 0});if("close"===P&&"COMMAND_FAILED"===(L=e).code&&(L.details?.kind==="daemon_startup_failed"||L.message.toLowerCase().includes("failed to start daemon")&&("string"==typeof L.details?.infoPath||"string"==typeof L.details?.lockPath))){C.json&&eh({success:!0,data:{closed:"session",source:"no-daemon"}}),U&&U();return}if(C.json)eh({success:!1,error:t});else if(ey(t,{showDetails:C.verbose}),C.verbose)try{let e=T.logPath;if($.existsSync(e)){let t=$.readFileSync(e,"utf8").split("\n"),s=t.slice(Math.max(0,t.length-200)).join("\n");s.trim().length>0&&process.stderr.write(`
`),U&&U();return}}U&&U();return}throw new A(e.error.code,e.error.message,{...e.error.details??{},hint:e.error.hint,diagnosticId:e.error.diagnosticId,logPath:e.error.logPath})}catch(s){let e=D(s),t=f(e,{diagnosticId:w().diagnosticId,logPath:x({force:!0})??void 0});if("close"===P&&"COMMAND_FAILED"===(L=e).code&&(L.details?.kind==="daemon_startup_failed"||L.message.toLowerCase().includes("failed to start daemon")&&("string"==typeof L.details?.infoPath||"string"==typeof L.details?.lockPath))){C.json&&eh({success:!0,data:{closed:"session",source:"no-daemon"}}),U&&U();return}if(C.json)eh({success:!1,error:t});else if(ey(t,{showDetails:C.verbose}),C.verbose)try{let e=j.logPath;if($.existsSync(e)){let t=$.readFileSync(e,"utf8").split("\n"),s=t.slice(Math.max(0,t.length-200)).join("\n");s.trim().length>0&&process.stderr.write(`
[daemon log]
${s}
`)}}catch{}U&&U(),process.exit(1)}})}S(process.argv[1]??"").href===import.meta.url&&eG(process.argv.slice(2)).catch(e=>{ey(f(D(e)),{showDetails:!0}),process.exit(1)}),eG(process.argv.slice(2));

@@ -1,7 +0,16 @@

import { dispatchCommand } from '../../core/dispatch.ts';
import type { DaemonResponse } from '../types.ts';
import type { InteractionHandlerParams } from './interaction-common.ts';
export { unsupportedRefSnapshotFlags } from './interaction-flags.ts';
export declare function handleInteractionCommands(params: Omit<InteractionHandlerParams, 'dispatch'> & {
import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts';
import type { DaemonCommandContext } from '../context.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { SessionStore } from '../session-store.ts';
import { getAndroidScreenSize } from '../../platforms/android/index.ts';
type ContextFromFlags = (flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string) => DaemonCommandContext;
export declare function handleInteractionCommands(params: {
req: DaemonRequest;
sessionName: string;
sessionStore: SessionStore;
contextFromFlags: ContextFromFlags;
dispatch?: typeof dispatchCommand;
readAndroidScreenSize?: typeof getAndroidScreenSize;
}): Promise<DaemonResponse | null>;
export declare function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[];
export {};

@@ -1,5 +0,4 @@

import { runCmd, runCmdBackground } from '../../utils/exec.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { SessionStore } from '../session-store.ts';
import { type RecordTraceDeps } from './record-trace-recording.ts';
export declare function handleRecordTraceCommands(params: {

@@ -10,7 +9,3 @@ req: DaemonRequest;

logPath?: string;
deps?: {
runCmd: typeof runCmd;
runCmdBackground: typeof runCmdBackground;
runIosRunnerCommand: typeof runIosRunnerCommand;
};
deps?: Partial<RecordTraceDeps>;
}): Promise<DaemonResponse | null>;

@@ -5,2 +5,4 @@ import { dispatchCommand } from '../core/dispatch.ts';

import type { LeaseRegistry } from './lease-registry.ts';
import { snapshotAndroid, openAndroidApp, getAndroidAppState } from '../platforms/android/index.ts';
import { runCmd } from '../utils/exec.ts';
export type RequestRouterDeps = {

@@ -17,3 +19,7 @@ logPath: string;

dispatchCommand?: typeof dispatchCommand;
snapshotAndroidUi?: typeof snapshotAndroid;
reopenAndroidApp?: typeof openAndroidApp;
readAndroidAppState?: typeof getAndroidAppState;
execCommand?: typeof runCmd;
};
export declare function createRequestHandler(deps: RequestRouterDeps): (req: DaemonRequest) => Promise<DaemonResponse>;

@@ -14,2 +14,3 @@ import type { SessionAction } from './types.ts';

} | undefined): void;
export declare function appendRecordActionScriptArgs(parts: string[], action: SessionAction): void;
export declare function parseReplaySeriesFlags(command: string, args: string[]): {

@@ -16,0 +17,0 @@ positionals: string[];

@@ -66,2 +66,48 @@ import type { MaterializeInstallSource } from '../platforms/install-source.ts';

};
type RecordingTelemetryBase = {
tMs: number;
x: number;
y: number;
referenceWidth?: number;
referenceHeight?: number;
};
type RecordingTelemetryTravel = RecordingTelemetryBase & {
x2: number;
y2: number;
durationMs: number;
};
export type RecordingGestureEvent = (RecordingTelemetryBase & {
kind: 'tap' | 'longpress';
durationMs?: number;
}) | (RecordingTelemetryTravel & {
kind: 'swipe';
}) | (RecordingTelemetryTravel & {
kind: 'scroll';
contentDirection: 'up' | 'down' | 'left' | 'right';
amount?: number;
}) | (RecordingTelemetryTravel & {
kind: 'back-swipe';
edge: 'left' | 'right';
}) | (RecordingTelemetryBase & {
kind: 'pinch';
scale: number;
durationMs: number;
});
type SessionRecordingBase = {
outPath: string;
clientOutPath?: string;
telemetryPath?: string;
overlayWarning?: string;
startedAt: number;
showTouches: boolean;
gestureEvents: RecordingGestureEvent[];
touchReferenceFrame?: {
referenceWidth: number;
referenceHeight: number;
};
gestureClockOriginAtMs?: number;
gestureClockOriginUptimeMs?: number;
runnerSessionId?: string;
invalidatedReason?: string;
};
export type SessionState = {

@@ -81,15 +127,20 @@ name: string;

actions: SessionAction[];
recording?: {
platform: 'ios' | 'android';
outPath: string;
clientOutPath?: string;
remotePath?: string;
recording?: (SessionRecordingBase & {
platform: 'ios';
child: ReturnType<typeof import('node:child_process').spawn>;
wait: Promise<ExecResult>;
} | {
platform: 'ios-device-runner' | 'macos-runner';
outPath: string;
clientOutPath?: string;
remotePath?: string;
};
}) | (SessionRecordingBase & {
platform: 'android';
remotePath: string;
remotePid: string;
}) | (SessionRecordingBase & {
platform: 'ios-device-runner';
remotePath: string;
runnerStartedAtUptimeMs?: number;
targetAppReadyUptimeMs?: number;
}) | (SessionRecordingBase & {
platform: 'macos-runner';
remotePath?: string;
});
/** Session-scoped app log stream; logs written to outPath for agent to grep */

@@ -122,1 +173,2 @@ appLog?: {

};
export {};
export { ensureAdb } from './adb.ts';
export { resolveAndroidApp, listAndroidApps, inferAndroidAppName, getAndroidAppState, openAndroidApp, isAmStartError, parseAndroidLaunchComponent, openAndroidDevice, closeAndroidApp, installAndroidInstallablePath, installAndroidInstallablePathAndResolvePackageName, installAndroidApp, reinstallAndroidApp, } from './app-lifecycle.ts';
export { pressAndroid, swipeAndroid, backAndroid, homeAndroid, appSwitcherAndroid, longPressAndroid, typeAndroid, focusAndroid, fillAndroid, scrollAndroid, scrollIntoViewAndroid, } from './input-actions.ts';
export { pressAndroid, swipeAndroid, backAndroid, homeAndroid, appSwitcherAndroid, longPressAndroid, typeAndroid, focusAndroid, fillAndroid, scrollAndroid, scrollIntoViewAndroid, getAndroidScreenSize, } from './input-actions.ts';
export { type AndroidKeyboardState, getAndroidKeyboardState, dismissAndroidKeyboard, readAndroidClipboardText, writeAndroidClipboardText, } from './device-input-state.ts';

@@ -5,0 +5,0 @@ export { setAndroidSetting } from './settings.ts';

@@ -13,1 +13,5 @@ import type { DeviceInfo } from '../../utils/device.ts';

export declare function scrollIntoViewAndroid(device: DeviceInfo, text: string): Promise<void>;
export declare function getAndroidScreenSize(device: DeviceInfo): Promise<{
width: number;
height: number;
}>;
import type { DeviceInfo } from '../../utils/device.ts';
import type { ClickButton } from '../../core/click-button.ts';
export type RunnerCommand = {
command: 'tap' | 'mouseClick' | 'tapSeries' | 'longPress' | 'drag' | 'dragSeries' | 'type' | 'swipe' | 'findText' | 'snapshot' | 'screenshot' | 'back' | 'home' | 'appSwitcher' | 'alert' | 'pinch' | 'recordStart' | 'recordStop' | 'shutdown';
command: 'tap' | 'mouseClick' | 'tapSeries' | 'longPress' | 'drag' | 'dragSeries' | 'type' | 'swipe' | 'findText' | 'snapshot' | 'screenshot' | 'back' | 'home' | 'appSwitcher' | 'alert' | 'pinch' | 'recordStart' | 'recordStop' | 'uptime' | 'shutdown';
appBundleId?: string;

@@ -38,2 +38,2 @@ text?: string;

export { resolveRunnerDestination, resolveRunnerBuildDestination, resolveRunnerMaxConcurrentDestinationsFlag, resolveRunnerSigningBuildSettings, resolveRunnerBundleBuildSettings, assertSafeDerivedCleanup, IOS_RUNNER_CONTAINER_BUNDLE_IDS, } from './runner-xctestrun.ts';
export { stopIosRunnerSession, abortAllIosRunnerSessions, stopAllIosRunnerSessions, } from './runner-session.ts';
export { getRunnerSessionSnapshot, stopIosRunnerSession, abortAllIosRunnerSessions, stopAllIosRunnerSessions, } from './runner-session.ts';

@@ -5,2 +5,3 @@ import { type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';

export type RunnerSession = {
sessionId: string;
device: DeviceInfo;

@@ -20,2 +21,6 @@ deviceId: string;

}): Promise<RunnerSession>;
export declare function getRunnerSessionSnapshot(deviceId: string): {
sessionId: string;
alive: boolean;
} | null;
export declare function stopRunnerSession(session: RunnerSession): Promise<void>;

@@ -22,0 +27,0 @@ export declare function stopIosRunnerSession(deviceId: string): Promise<void>;

@@ -7,3 +7,3 @@ import { type DeviceInfo } from '../../utils/device.ts';

findProjectRoot: () => string;
findXctestrun: (root: string) => string | null;
findXctestrun: (root: string, device?: DeviceInfo) => string | null;
xctestrunReferencesProjectRoot: (xctestrunPath: string, projectRoot: string) => boolean;

@@ -26,2 +26,4 @@ resolveExistingXctestrunProductPaths: (xctestrunPath: string) => string[] | null;

export declare function shouldDeleteRunnerDerivedRootEntry(entryName: string): boolean;
export declare function findXctestrun(root: string, device?: DeviceInfo): string | null;
export declare function scoreXctestrunCandidate(candidatePath: string, device: DeviceInfo): number;
export declare function xctestrunReferencesProjectRoot(xctestrunPath: string, projectRoot: string): boolean;

@@ -28,0 +30,0 @@ export declare function prepareXctestrunWithEnv(xctestrunPath: string, envVars: Record<string, string>, suffix: string): Promise<{

@@ -57,2 +57,3 @@ export type CliFlags = {

fps?: number;
hideTouches?: boolean;
intervalMs?: number;

@@ -59,0 +60,0 @@ holdMs?: number;

@@ -18,10 +18,10 @@ import type { DeviceInfo } from './device.ts';

close(app: string): Promise<void>;
tap(x: number, y: number): Promise<void>;
doubleTap(x: number, y: number): Promise<void>;
swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
longPress(x: number, y: number, durationMs?: number): Promise<void>;
focus(x: number, y: number): Promise<void>;
tap(x: number, y: number): Promise<Record<string, unknown> | void>;
doubleTap(x: number, y: number): Promise<Record<string, unknown> | void>;
swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<Record<string, unknown> | void>;
longPress(x: number, y: number, durationMs?: number): Promise<Record<string, unknown> | void>;
focus(x: number, y: number): Promise<Record<string, unknown> | void>;
type(text: string): Promise<void>;
fill(x: number, y: number, text: string): Promise<void>;
scroll(direction: string, amount?: number): Promise<void>;
fill(x: number, y: number, text: string): Promise<Record<string, unknown> | void>;
scroll(direction: string, amount?: number): Promise<Record<string, unknown> | void>;
scrollIntoView(text: string): Promise<{

@@ -28,0 +28,0 @@ attempts?: number;

@@ -32,2 +32,3 @@ //

static let springboardBundleId = "com.apple.springboard"
static let defaultRecordingFps: Int32 = 15
var listener: NWListener?

@@ -34,0 +35,0 @@ var doneExpectation: XCTestExpectation?

@@ -6,2 +6,12 @@ import XCTest

private func currentUptimeMs() -> Double {
ProcessInfo.processInfo.systemUptime * 1000
}
private func measureGesture(_ action: () -> Void) -> (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double) {
let gestureStartUptimeMs = currentUptimeMs()
action()
return (gestureStartUptimeMs, currentUptimeMs())
}
func execute(command: Command) throws -> Response {

@@ -179,3 +189,3 @@ if Thread.isMainThread {

let resolvedOutPath = resolveRecordingOutPath(requestedOutPath)
let fpsLabel = command.fps.map(String.init) ?? "max"
let fpsLabel = command.fps.map(String.init) ?? String(RunnerTests.defaultRecordingFps)
NSLog(

@@ -209,7 +219,23 @@ "AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@",

}
case .uptime:
return Response(
ok: true,
data: DataPayload(currentUptimeMs: currentUptimeMs())
)
case .tap:
if let text = command.text {
if let element = findElement(app: activeApp, text: text) {
element.tap()
return Response(ok: true, data: DataPayload(message: "tapped"))
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
element.tap()
}
}
return Response(
ok: true,
data: DataPayload(
message: "tapped",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs
)
)
}

@@ -219,4 +245,20 @@ return Response(ok: false, error: ErrorPayload(message: "element not found"))

if let x = command.x, let y = command.y {
tapAt(app: activeApp, x: x, y: y)
return Response(ok: true, data: DataPayload(message: "tapped"))
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
tapAt(app: activeApp, x: x, y: y)
}
}
return Response(
ok: true,
data: DataPayload(
message: "tapped",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: touchFrame.x,
y: touchFrame.y,
referenceWidth: touchFrame.referenceWidth,
referenceHeight: touchFrame.referenceHeight
)
)
}

@@ -228,5 +270,27 @@ return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))

}
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
do {
try mouseClickAt(app: activeApp, x: x, y: y, button: command.button ?? "primary")
return Response(ok: true, data: DataPayload(message: "clicked"))
var clickError: Error?
let timing = measureGesture {
do {
try mouseClickAt(app: activeApp, x: x, y: y, button: command.button ?? "primary")
} catch {
clickError = error
}
}
if let clickError {
throw clickError
}
return Response(
ok: true,
data: DataPayload(
message: "clicked",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: touchFrame.x,
y: touchFrame.y,
referenceWidth: touchFrame.referenceWidth,
referenceHeight: touchFrame.referenceHeight
)
)
} catch {

@@ -242,12 +306,43 @@ return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))

let doubleTap = command.doubleTap ?? false
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
if doubleTap {
runSeries(count: count, pauseMs: intervalMs) { _ in
doubleTapAt(app: activeApp, x: x, y: y)
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
runSeries(count: count, pauseMs: intervalMs) { _ in
doubleTapAt(app: activeApp, x: x, y: y)
}
}
}
return Response(ok: true, data: DataPayload(message: "tap series"))
return Response(
ok: true,
data: DataPayload(
message: "tap series",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: touchFrame.x,
y: touchFrame.y,
referenceWidth: touchFrame.referenceWidth,
referenceHeight: touchFrame.referenceHeight
)
)
}
runSeries(count: count, pauseMs: intervalMs) { _ in
tapAt(app: activeApp, x: x, y: y)
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
runSeries(count: count, pauseMs: intervalMs) { _ in
tapAt(app: activeApp, x: x, y: y)
}
}
}
return Response(ok: true, data: DataPayload(message: "tap series"))
return Response(
ok: true,
data: DataPayload(
message: "tap series",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: touchFrame.x,
y: touchFrame.y,
referenceWidth: touchFrame.referenceWidth,
referenceHeight: touchFrame.referenceHeight
)
)
case .longPress:

@@ -258,4 +353,20 @@ guard let x = command.x, let y = command.y else {

let duration = (command.durationMs ?? 800) / 1000.0
longPressAt(app: activeApp, x: x, y: y, duration: duration)
return Response(ok: true, data: DataPayload(message: "long pressed"))
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
longPressAt(app: activeApp, x: x, y: y, duration: duration)
}
}
return Response(
ok: true,
data: DataPayload(
message: "long pressed",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: touchFrame.x,
y: touchFrame.y,
referenceWidth: touchFrame.referenceWidth,
referenceHeight: touchFrame.referenceHeight
)
)
case .drag:

@@ -266,6 +377,22 @@ guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {

let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
let dragFrame = resolvedDragVisualizationFrame(app: activeApp, x: x, y: y, x2: x2, y2: y2)
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
}
}
return Response(ok: true, data: DataPayload(message: "dragged"))
return Response(
ok: true,
data: DataPayload(
message: "dragged",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: dragFrame.x,
y: dragFrame.y,
x2: dragFrame.x2,
y2: dragFrame.y2,
referenceWidth: dragFrame.referenceWidth,
referenceHeight: dragFrame.referenceHeight
)
)
case .dragSeries:

@@ -282,13 +409,22 @@ guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {

let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
runSeries(count: count, pauseMs: pauseMs) { idx in
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
if reverse {
dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
} else {
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
runSeries(count: count, pauseMs: pauseMs) { idx in
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
if reverse {
dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
} else {
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
}
}
}
}
return Response(ok: true, data: DataPayload(message: "drag series"))
return Response(
ok: true,
data: DataPayload(
message: "drag series",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs
)
)
case .type:

@@ -316,6 +452,18 @@ guard let text = command.text else {

}
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
swipe(app: activeApp, direction: direction)
let referenceFrame = resolvedGestureReferenceFrame(app: activeApp)
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
swipe(app: activeApp, direction: direction)
}
}
return Response(ok: true, data: DataPayload(message: "swiped"))
return Response(
ok: true,
data: DataPayload(
message: "swiped",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
referenceWidth: referenceFrame.referenceWidth,
referenceHeight: referenceFrame.referenceHeight
)
)
case .findText:

@@ -371,26 +519,11 @@ guard let text = command.text else {

}
#if os(macOS)
return Response(ok: false, error: ErrorPayload(message: "back button is not available on macOS"))
#else
performBackGesture(app: activeApp)
return Response(ok: true, data: DataPayload(message: "back"))
#endif
case .home:
#if os(macOS)
return Response(ok: false, error: ErrorPayload(message: "home is not supported on macOS"))
#else
pressHomeButton()
return Response(ok: true, data: DataPayload(message: "home"))
#endif
case .appSwitcher:
#if os(macOS)
return Response(ok: false, error: ErrorPayload(message: "appSwitcher is not supported on macOS"))
#else
performAppSwitcherGesture(app: activeApp)
return Response(ok: true, data: DataPayload(message: "appSwitcher"))
#endif
case .alert:
#if os(macOS)
return Response(ok: false, error: ErrorPayload(message: "alert is not supported on macOS"))
#else
let action = (command.action ?? "get").lowercased()

@@ -413,15 +546,19 @@ let alert = activeApp.alerts.firstMatch

return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
#endif
case .pinch:
#if os(macOS)
return Response(ok: false, error: ErrorPayload(message: "pinch is not supported on macOS"))
#else
guard let scale = command.scale, scale > 0 else {
return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
}
pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
return Response(ok: true, data: DataPayload(message: "pinched"))
#endif
let timing = measureGesture {
pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
}
return Response(
ok: true,
data: DataPayload(
message: "pinched",
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs
)
)
}
}
}
import XCTest
extension RunnerTests {
struct TouchVisualizationFrame {
let x: Double
let y: Double
let referenceWidth: Double
let referenceHeight: Double
}
struct DragVisualizationFrame {
let x: Double
let y: Double
let x2: Double
let y2: Double
let referenceWidth: Double
let referenceHeight: Double
}
struct GestureReferenceFrame {
let referenceWidth: Double
let referenceHeight: Double
}
// MARK: - Navigation Gestures

@@ -212,2 +233,54 @@

func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
let appFrame = app.frame
let referenceFrame = resolvedTouchReferenceFrame(app: app, appFrame: appFrame)
let originX = appFrame.isEmpty ? referenceFrame.minX : appFrame.minX
let originY = appFrame.isEmpty ? referenceFrame.minY : appFrame.minY
return TouchVisualizationFrame(
x: originX + x,
y: originY + y,
referenceWidth: referenceFrame.width,
referenceHeight: referenceFrame.height
)
}
func resolvedDragVisualizationFrame(
app: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double
) -> DragVisualizationFrame {
let start = resolvedTouchVisualizationFrame(app: app, x: x, y: y)
let end = resolvedTouchVisualizationFrame(app: app, x: x2, y: y2)
return DragVisualizationFrame(
x: start.x,
y: start.y,
x2: end.x,
y2: end.y,
referenceWidth: start.referenceWidth,
referenceHeight: start.referenceHeight
)
}
private func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
let window = app.windows.firstMatch
let windowFrame = window.frame
if window.exists && !windowFrame.isEmpty {
return windowFrame
}
if !appFrame.isEmpty {
return appFrame
}
return CGRect(x: 0, y: 0, width: 0, height: 0)
}
func resolvedGestureReferenceFrame(app: XCUIApplication) -> GestureReferenceFrame {
let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
return GestureReferenceFrame(
referenceWidth: frame.width,
referenceHeight: frame.height
)
}
func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {

@@ -214,0 +287,0 @@ let total = max(count, 1)

@@ -22,2 +22,3 @@ // MARK: - Wire Models

case recordStop
case uptime
case shutdown

@@ -79,2 +80,11 @@ }

let truncated: Bool?
let gestureStartUptimeMs: Double?
let gestureEndUptimeMs: Double?
let x: Double?
let y: Double?
let x2: Double?
let y2: Double?
let referenceWidth: Double?
let referenceHeight: Double?
let currentUptimeMs: Double?

@@ -86,3 +96,12 @@ init(

nodes: [SnapshotNode]? = nil,
truncated: Bool? = nil
truncated: Bool? = nil,
gestureStartUptimeMs: Double? = nil,
gestureEndUptimeMs: Double? = nil,
x: Double? = nil,
y: Double? = nil,
x2: Double? = nil,
y2: Double? = nil,
referenceWidth: Double? = nil,
referenceHeight: Double? = nil,
currentUptimeMs: Double? = nil
) {

@@ -94,2 +113,11 @@ self.message = message

self.truncated = truncated
self.gestureStartUptimeMs = gestureStartUptimeMs
self.gestureEndUptimeMs = gestureEndUptimeMs
self.x = x
self.y = y
self.x2 = x2
self.y2 = y2
self.referenceWidth = referenceWidth
self.referenceHeight = referenceHeight
self.currentUptimeMs = currentUptimeMs
}

@@ -96,0 +124,0 @@ }

@@ -10,9 +10,7 @@ import AVFoundation

private let fps: Int32?
private let uncappedFrameInterval: TimeInterval = 0.001
private var uncappedTimestampTimescale: Int32 {
Int32(max(1, Int((1.0 / uncappedFrameInterval).rounded())))
private var effectiveFps: Int32 {
max(1, fps ?? RunnerTests.defaultRecordingFps)
}
private var frameInterval: TimeInterval {
guard let fps else { return uncappedFrameInterval }
return 1.0 / Double(fps)
1.0 / Double(effectiveFps)
}

@@ -210,3 +208,3 @@ private let queue = DispatchQueue(label: "agent-device.runner.recorder")

let elapsed = max(0, nowUptime - (recordingStartUptime ?? nowUptime))
let timescale = fps ?? uncappedTimestampTimescale
let timescale = effectiveFps
var timestampValue = Int64((elapsed * Double(timescale)).rounded(.down))

@@ -213,0 +211,0 @@ if timestampValue <= lastTimestampValue {

{
"name": "agent-device",
"version": "0.10.0",
"version": "0.10.1",
"description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",

@@ -5,0 +5,0 @@ "license": "MIT",

+1
-607

@@ -62,2 +62,3 @@ <a href="https://www.callstack.com/open-source?utm_campaign=generic&utm_source=github&utm_medium=referral&utm_content=agent-device" align="center">

- [dogfood skill](skills/dogfood/SKILL.md)
- [agent-device skill on ClawHub](https://clawhub.ai/okwasniewski/agent-device)

@@ -70,609 +71,2 @@ ## Install

Or use it without installing:
```bash
npx agent-device open SampleApp
```
For the typed daemon client and `installFromSource` behavior, see [website/docs/docs/client-api.md](website/docs/docs/client-api.md).
The skill is also accessible on [ClawHub](https://clawhub.ai/okwasniewski/agent-device).
For structured exploratory QA workflows, use the dogfood skill at [skills/dogfood/SKILL.md](skills/dogfood/SKILL.md).
## Quick Start
Use refs for agent-driven exploration and normal automation flows.
Use `press` as the canonical tap command; `click` is an equivalent alias.
```bash
agent-device open Contacts --platform ios # creates session on iOS Simulator
agent-device snapshot
agent-device press @e5
agent-device diff snapshot # subsequent runs compare against previous baseline
agent-device fill @e6 "John"
agent-device fill @e7 "Doe"
agent-device press @e3
agent-device close
```
## Fast batching (JSON steps)
Use `batch` to execute multiple commands in a single daemon request.
CLI examples:
```bash
agent-device batch \
--session sim \
--platform ios \
--udid 00008150-001849640CF8401C \
--steps-file /tmp/batch-steps.json \
--json
```
Small inline payloads are also supported:
```bash
agent-device batch --steps '[{"command":"open","positionals":["settings"]},{"command":"wait","positionals":["100"]}]'
```
Batch payload format:
```json
[
{ "command": "open", "positionals": ["settings"], "flags": {} },
{ "command": "wait", "positionals": ["label=\"Privacy & Security\"", "3000"], "flags": {} },
{ "command": "click", "positionals": ["label=\"Privacy & Security\""], "flags": {} },
{ "command": "get", "positionals": ["text", "label=\"Tracking\""], "flags": {} }
]
```
Batch response includes:
- `total`, `executed`, `totalDurationMs`
- per-step `results[]` with `durationMs`
- failure context with failing `step` and `partialResults`
Agent usage guidelines:
- Keep each batch to one screen-local workflow.
- Add sync guards (`wait`, `is exists`) after mutating steps (`open`, `click`, `fill`, `swipe`).
- Treat refs/snapshot assumptions as stale after UI mutations.
- Prefer `--steps-file` over inline JSON for reliability.
- Keep batches moderate (about 5-20 steps) and stop on first error.
## CLI Usage
```bash
agent-device <command> [args] [--json]
```
## Configuration
Create an `agent-device.json` file to set persistent CLI defaults instead of repeating flags.
Config file lookup order:
- `~/.agent-device/config.json`
- `./agent-device.json`
- `AGENT_DEVICE_*` environment variables
- CLI flags
Later sources override earlier ones. Use `--config <path>` or `AGENT_DEVICE_CONFIG` to load one explicit config file instead of the default locations.
Example:
```json
{
"platform": "ios",
"device": "iPhone 16",
"session": "qa-ios",
"snapshotDepth": 3,
"daemonBaseUrl": "http://mac-host.example:4310/agent-device"
}
```
Notes:
- Config keys use the existing camelCase flag names, for example `stateDir`, `daemonAuthToken`, `iosSimulatorDeviceSet`, and `androidDeviceAllowlist`.
- Environment overrides use `AGENT_DEVICE_*` uppercase snake case names, for example `AGENT_DEVICE_SESSION`, `AGENT_DEVICE_DAEMON_BASE_URL`, and `AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET`.
- Bound-session routing defaults also work through config or env, for example `sessionLock` / `AGENT_DEVICE_SESSION_LOCK`.
- For options that have CLI aliases, config/env use the canonical value rather than the alias flag. Example: use `"appsFilter": "user-installed"` or `AGENT_DEVICE_APPS_FILTER=user-installed`, not `--user-installed`.
- Command-specific defaults are applied only when that command supports them, so a `snapshotDepth` default does not break `open` or `devices`.
Basic flow:
```bash
agent-device open SampleApp
agent-device snapshot
agent-device press @e7
agent-device fill @e8 "hello"
agent-device close SampleApp
```
Debug flow:
```bash
agent-device trace start
agent-device snapshot -s "Sample App"
agent-device find label "Wi-Fi" click
agent-device trace stop ./trace.log
```
Coordinates:
- All coordinate-based commands (`press`, `longpress`, `swipe`, `focus`, `fill`) use device coordinates with origin at top-left.
- X increases to the right, Y increases downward.
- `press` is the canonical tap command.
- `click` is an equivalent alias and accepts the same targets (`x y`, `@ref`, selector) and flags.
- `click --secondary` performs a secondary click on macOS, which is useful for opening context menus before a follow-up `snapshot -i`.
Gesture series examples:
```bash
agent-device press 300 500 --count 12 --interval-ms 45
agent-device press 300 500 --count 6 --hold-ms 120 --interval-ms 30 --jitter-px 2
agent-device press @e5 --count 5 --double-tap
agent-device swipe 540 1500 540 500 120 --count 8 --pause-ms 30 --pattern ping-pong
agent-device scrollintoview "Sign in"
agent-device scrollintoview @e42
```
## Command Index
- `boot`, `open`, `close`, `install`, `reinstall`, `home`, `back`, `app-switcher`
- `push`
- `batch`
- `snapshot`, `diff snapshot`, `find`, `get`
- `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is`
- `alert`, `wait`, `screenshot`
- `trigger-app-event <event> [payloadJson]`
- `trace start`, `trace stop`
- `logs path`, `logs start`, `logs stop`, `logs clear`, `logs clear --restart`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
- `clipboard read`, `clipboard write <text>` (macOS + iOS simulator + Android)
- `keyboard [status|get|dismiss]` (Android emulator/device)
- `network dump [limit] [summary|headers|body|all]`, `network log ...` (best-effort HTTP(s) parsing from session app log)
- `settings wifi|airplane|location on|off`
- `settings appearance light|dark|toggle`
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `settings touchid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `settings fingerprint match|nonmatch` (Android emulator/device where supported)
- `settings permission grant|deny|reset camera|microphone|photos|contacts|notifications [full|limited]`
- `appstate`, `apps`, `devices`, `session list`
- `perf` (alias: `metrics`)
Push notification simulation:
```bash
# iOS simulator: app bundle + payload file
agent-device push com.example.app ./payload.apns --platform ios --device "iPhone 16"
# iOS simulator: inline JSON payload
agent-device push com.example.app '{"aps":{"alert":"Welcome","badge":1}}' --platform ios
# Android: package + payload (action/extras map)
agent-device push com.example.app '{"action":"com.example.app.PUSH","extras":{"title":"Welcome","unread":3,"promo":true}}' --platform android
```
Payload notes:
- iOS uses `xcrun simctl push <device> <bundle> <payload>` and requires APNs-style JSON object (for example `{"aps":{"alert":"..."}}`).
- Android uses `adb shell am broadcast` with payload JSON shape:
`{"action":"<intent-action>","receiver":"<optional component>","extras":{"key":"value","flag":true,"count":3}}`.
- Android extras support string/boolean/number values.
- `push` works with session context (uses session device) or explicit device selectors.
App event triggers (app hook):
```bash
agent-device trigger-app-event screenshot_taken '{"source":"qa"}'
```
- `trigger-app-event` dispatches an app event via deep link and requires an app-side test/debug hook.
- `trigger-app-event` requires either an active session or explicit device selectors (`--platform`, `--device`, `--udid`, `--serial`).
- On macOS, use `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE` to override the desktop deep-link template.
- On iOS physical devices, custom-scheme deep links require active app context (open the app in-session first).
- Configure one of:
- `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`
- `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`
- `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`
- `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`
- Template placeholders: `{event}`, `{payload}`, `{platform}`.
- Example template: `myapp://agent-device/event?name={event}&payload={payload}`.
- `payloadJson` must be a JSON object.
- This is app-hook-based simulation, not an OS-global notification injector.
- Canonical trigger contract lives in [`website/docs/docs/commands.md`](website/docs/docs/commands.md) under **App event triggers**.
## iOS Snapshots
Notes:
- iOS snapshots use XCTest on simulators and physical devices.
- Scope snapshots with `-s "<label>"` or `-s @ref`.
- If XCTest returns 0 nodes (e.g., foreground app changed), agent-device fails explicitly.
- `diff snapshot` uses the same snapshot flags and compares the current capture with the previous session baseline, then updates baseline.
Diff snapshots:
- Run `diff snapshot` once to initialize baseline for the current session.
- Run `diff snapshot` again after UI changes to get unified-style output (`-` removed, `+` added, unchanged context).
- Use `--json` to get `{ mode, baselineInitialized, summary, lines }`.
Efficient snapshot usage:
- Default to `snapshot -i` for iterative agent loops.
- Add `-s "<label>"` (or `-s @ref`) for screen-local work to reduce payload size.
- Add `-d <depth>` when lower tree levels are not needed.
- Re-snapshot after UI mutations before reusing refs.
- Use `diff snapshot` for low-noise structural change verification between adjacent states.
- Reserve `--raw` for troubleshooting and parser/debug investigations.
Flags:
- `--version, -V` print version and exit
- `--platform ios|macos|android|apple` (`apple` aliases the Apple automation backend)
- `--target mobile|tv|desktop` select device class within platform (requires `--platform`; for example AndroidTV/tvOS/macOS)
- `--device <name>`
- `--udid <udid>` (iOS)
- `--serial <serial>` (Android)
- `--ios-simulator-device-set <path>` constrain iOS simulator discovery/commands to one simulator set (`xcrun simctl --set`)
- `--android-device-allowlist <serials>` constrain Android discovery/selection to comma/space-separated serials
- `--activity <component>` (Android app launch only; package/Activity or package/.Activity; not for URL opens)
- `--session <name>`
- `--state-dir <path>` daemon state directory override (default: `~/.agent-device`)
- `--daemon-base-url <url>` explicit remote HTTP daemon base URL; skips local daemon discovery/startup
- `--daemon-auth-token <token>` remote HTTP daemon auth token; sent in both the JSON-RPC request token and HTTP auth headers (`Authorization: Bearer` and `x-agent-device-token`)
- `--daemon-transport auto|socket|http` daemon client transport preference
- `--daemon-server-mode socket|http|dual` daemon server mode (`http` and `dual` expose JSON-RPC over HTTP at `/rpc`)
- `--tenant <id>` tenant identifier used with session isolation
- `--session-isolation none|tenant` explicit session isolation mode (`tenant` scopes session namespace as `<tenant>:<session>`)
- `--run-id <id>` run identifier used with tenant-scoped lease admission
- `--lease-id <id>` active lease identifier used with tenant-scoped lease admission
- `--count <n>` repeat count for `press`/`swipe`
- `--interval-ms <ms>` delay between `press` iterations
- `--hold-ms <ms>` hold duration per `press` iteration
- `--jitter-px <n>` deterministic coordinate jitter for `press`
- `--double-tap` use a double-tap gesture per `press`/`click` iteration (cannot be combined with `--hold-ms` or `--jitter-px`)
- `--pause-ms <ms>` delay between `swipe` iterations
- `--pattern one-way|ping-pong` repeat pattern for `swipe`
- `--debug` (alias: `--verbose`) for debug diagnostics + daemon/runner logs
- `--json` for structured output
- `--steps <json>` batch: JSON array of steps
- `--steps-file <path>` batch: read step JSON from file
- `--on-error stop` batch: stop when a step fails
- `--max-steps <n>` batch: max allowed steps per request
Isolation precedence:
- Discovery scope (`--ios-simulator-device-set`, `--android-device-allowlist`) is applied before selector matching (`--device`, `--udid`, `--serial`).
- If a selector points outside the scoped set/allowlist, command resolution fails with `DEVICE_NOT_FOUND` (no host-global fallback).
- When `--ios-simulator-device-set` is set (or its env equivalent), iOS discovery is simulator-set only (physical iOS devices are not enumerated).
TV targets:
- Use `--target tv` together with `--platform ios|android|apple`.
- TV target selection supports both simulator/emulator and connected physical devices (AppleTV + AndroidTV).
- AndroidTV app launch/app listing use TV launcher discovery (`LEANBACK_LAUNCHER`) and fallback component resolution when needed.
- tvOS uses the same runner-driven interaction/snapshot flow as iOS (`snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record`, and related selector flows).
- tvOS back/home/app-switcher use Siri Remote semantics in the runner (`menu`, `home`, double-home).
- tvOS follows iOS simulator-only command semantics for helpers like `pinch`, `settings`, and `push`.
Desktop targets:
- Use `--platform macos` for the host Mac, or `--platform apple --target desktop` when selecting through the Apple family alias.
- macOS uses the same runner-driven interaction/snapshot flow as iOS/tvOS for `open`, `appstate`, `snapshot`, `press`, `fill`, `scroll`, `back`, `screenshot`, `record`, and selector-based commands.
- macOS also supports `clipboard read|write`, `trigger-app-event`, and only `settings appearance light|dark|toggle`.
- Prefer selector or `@ref`-driven interactions on macOS. Window position is not stable, so raw x/y commands are more fragile than snapshot-derived refs.
- Mobile-only helpers remain unsupported on macOS: `boot`, `home`, `app-switcher`, `install`, `reinstall`, `install-from-source`, `push`, `logs`, and `network`.
Examples:
- `agent-device open YouTube --platform android --target tv`
- `agent-device apps --platform android --target tv`
- `agent-device open Settings --platform ios --target tv`
- `agent-device screenshot ./apple-tv.png --platform ios --target tv`
- `agent-device open TextEdit --platform macos`
- `agent-device snapshot -i --platform apple --target desktop`
Pinch:
- `pinch` is supported on iOS simulators (including tvOS simulator targets).
- On Android, `pinch` currently returns `UNSUPPORTED_OPERATION` in the adb backend.
Swipe timing:
- `swipe` accepts optional `durationMs` (default `250`, range `16..10000`).
- Android uses requested swipe duration directly.
- iOS clamps swipe duration to a safe range (`16..60ms`) to avoid longpress side effects.
- `scrollintoview` accepts either plain text or a snapshot ref (`@eN`); ref mode uses best-effort geometry-based scrolling without post-scroll verification. Run `snapshot` again before follow-up `@ref` commands.
## Skills
Install the automation skills listed in [SKILL.md](skills/agent-device/SKILL.md).
```bash
npx skills add https://github.com/callstackincubator/agent-device --skill agent-device
```
Sessions:
- `open` starts a session. Without args boots/activates the target device/simulator without launching an app.
- All interaction commands require an open session.
- If a session is already open, `open <app|url>` switches the active app or opens a deep link URL.
- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session.
- Use `--session <name>` to manage multiple sessions.
- Session scripts are written to `<state-dir>/sessions/<session>-<timestamp>.ad` when recording is enabled with `--save-script`.
- `--save-script` accepts an optional path: `--save-script ./workflows/my-flow.ad`.
- For ambiguous bare values, use an explicit form: `--save-script=workflow.ad` or a path-like value such as `./workflow.ad`.
- Deterministic replay is `.ad`-based; use `replay --update` (`-u`) to update selector drift and rewrite the replay file in place.
- On iOS, `appstate` is session-scoped and requires an active session on the target device.
Navigation helpers:
- `boot --platform ios|android|apple` ensures the target is ready without launching an app.
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
- `open [app|url] [url]` already boots/activates the selected target when needed.
- `install <app> <path>` installs app binary without uninstalling first (Android + iOS simulator/device).
- `install-from-source <url>` installs from a URL source through the normal daemon artifact flow; repeat `--header name:value` for authenticated downloads.
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator/device).
- `install`/`reinstall` accept package/bundle id style app names and support `~` in paths.
- `install-from-source` supports `--retain-paths` and `--retention-ms <ms>` when callers need retained materialized artifact paths after the install.
- When `AGENT_DEVICE_DAEMON_BASE_URL` targets a remote daemon, local `.apk`/`.aab`/`.ipa` files and `.app` bundles are uploaded automatically before `install`/`reinstall`.
- `open <app> --remote-config <path> --relaunch` is the canonical remote Metro-backed launch flow for sandbox agents. The remote profile supplies host + Metro settings, `open` prepares Metro locally when needed, derives platform runtime hints, and forwards them inline to the remote daemon before launch.
- `metro prepare --remote-config <path>` remains available for inspection and debugging. It prints JSON runtime hints to stdout, `--json` wraps them in the standard `{ success, data }` envelope, and `--runtime-file <path>` persists the same payload when callers need an artifact.
- Remote daemon screenshots and recordings are materialized back to the caller path instead of returning host-local daemon paths.
- To force a daemon-side path instead of uploading a local file, prefix it with `remote:`, for example `remote:/srv/builds/MyApp.app`.
- Supported binary formats for `install`/`reinstall`: Android `.apk` and `.aab`, iOS `.app` and `.ipa`.
- `.aab` requires `bundletool` in `PATH`, or `AGENT_DEVICE_BUNDLETOOL_JAR=<path-to-bundletool-all.jar>` (with `java` in `PATH`).
- For Android `.aab`, set `AGENT_DEVICE_ANDROID_BUNDLETOOL_MODE=<mode>` to override bundletool `--mode` (default: `universal`).
- `.ipa` install extracts `Payload/*.app`; when an IPA contains multiple app bundles, `<app>` is used as a bundle id/name hint to select the target bundle.
Deep links:
- `open <url>` supports deep links with `scheme://...`.
- `open <app> <url>` opens a deep link on iOS.
- Android opens deep links via `VIEW` intent.
- iOS simulator opens deep links via `simctl openurl`.
- iOS device opens deep links via `devicectl --payload-url`.
- On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs (`myapp://`) require an active app in the session.
- `--activity` cannot be combined with URL opens.
```bash
agent-device open "myapp://home" --platform android
agent-device open "https://example.com" --platform ios # open link in web browser
agent-device open MyApp "myapp://screen/to" --platform ios # open deep link to MyApp
```
Find (semantic):
- `find <text> <action> [value]` finds by any text (label/value/identifier) using a scoped snapshot.
- `find text|label|value|role|id <value> <action> [value]` for specific locators.
- Actions: `click` (default), `fill`, `type`, `focus`, `get text`, `get attrs`, `wait [timeout]`, `exists`.
Assertions:
- `is` predicates: `visible`, `hidden`, `exists`, `editable`, `selected`, `text`.
- `is text` uses exact equality.
Performance metrics:
- `perf` (or `metrics`) requires an active session and returns a JSON metrics blob.
- Current metric: `startup` sampled from the elapsed wall-clock time around each session `open` command dispatch (`open-command-roundtrip`), unit `ms`.
- Startup samples are session-scoped and include sample history from recent `open` actions.
- Platform support for current sampling: iOS simulator, iOS physical device, Android emulator/device.
- `fps`, `memory`, and `cpu` are reported as not yet implemented in this release.
- Quick usage:
```bash
agent-device open Settings --platform ios
agent-device perf --json
```
- How to read it:
- `metrics.startup.lastDurationMs`: most recent startup sample in milliseconds.
- `metrics.startup.samples[]`: recent startup history for this session.
- `sampling.startup.method`: currently `open-command-roundtrip`.
- Caveat: startup here is command-to-launch round-trip timing, not true app TTI/first-interactive telemetry.
Replay update:
- `replay <path>` runs deterministic replay from `.ad` scripts.
- `replay -u <path>` attempts selector updates on failures and atomically rewrites the same file.
- Refs are the default/core mechanism for interactive agent flows.
- Update targets: `click`, `fill`, `get`, `is`, `wait`.
- Selector matching is a replay-update internal: replay parses `.ad` lines into actions, tries them, snapshots on failure, resolves a better selector, then rewrites that failing line.
Update examples:
```sh
# Before (stale selector)
click "id=\"old_continue\" || label=\"Continue\""
# After replay -u (rewritten in place)
click "id=\"auth_continue\" || label=\"Continue\""
```
```sh
# Before (ref-based action from discovery)
snapshot -i -c -s "Continue"
click @e13 "Continue"
# After replay -u (upgraded to selector-based action)
snapshot -i -c -s "Continue"
click "id=\"auth_continue\" || label=\"Continue\""
```
Android fill reliability:
- `fill` clears the current value, then enters text.
- `type` enters text into the focused field without clearing.
- `fill` now verifies the entered value on Android.
- If value does not match, agent-device clears the field and retries once with slower typing.
- This reduces IME-related character swaps on long strings (e.g. emails and IDs).
- Some Android system images cannot inject non-ASCII text (for example Chinese or emoji) through shell input.
- If this occurs, install an ADB keyboard IME from a trusted source, verify checksum/signature, and enable it only for test sessions:
- Trusted sources: https://github.com/senzhk/ADBKeyBoard or https://f-droid.org/packages/com.android.adbkeyboard/
- `adb -s <serial> install <path-to-adbkeyboard.apk>`
- `adb -s <serial> shell ime enable com.android.adbkeyboard/.AdbIME`
- `adb -s <serial> shell ime set com.android.adbkeyboard/.AdbIME`
- `adb -s <serial> shell ime list -s` (verify current/default IME)
Settings helpers:
- `settings wifi on|off`
- `settings airplane on|off`
- `settings location on|off` (iOS uses per-app permission for the current session app)
- `settings appearance light|dark|toggle` (macOS appearance + iOS simulator appearance + Android night mode)
- `settings faceid|touchid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `settings fingerprint match|nonmatch` (Android emulator/device where supported)
On physical Android devices, fingerprint simulation depends on `cmd fingerprint` support.
- `settings permission grant|deny|reset <camera|microphone|photos|contacts|notifications> [full|limited]` (session app required)
Note: iOS supports these only on simulators. On macOS, only `settings appearance` is supported. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
- iOS permission targets map to `simctl privacy`: `camera`, `microphone`, `photos` (`full` => `photos`, `limited` => `photos-add`), `contacts`, `notifications`.
- Android permission targets: `camera`, `microphone`, `photos`, `contacts` use `pm grant|revoke` (`reset` maps to `pm revoke`); `notifications` uses `appops set POST_NOTIFICATION allow|deny|default`.
- `full|limited` mode is valid only for iOS `photos`; other targets reject mode.
App state:
- `appstate` shows the foreground app/activity (Android).
- On iOS, `appstate` returns the currently tracked session app (`source: session`) and requires an active session on the selected device.
- `apps` includes default/system apps by default (use `--user-installed` to filter).
Clipboard:
- `clipboard read` returns current clipboard text.
- `clipboard write <text>` sets clipboard text (`clipboard write ""` clears it).
- Supported on macOS, Android emulator/device, and iOS simulator.
- iOS physical devices currently return `UNSUPPORTED_OPERATION` for clipboard commands.
Keyboard:
- `keyboard status` (or `keyboard get`) reports Android keyboard visibility and best-effort input type classification (`text`, `number`, `email`, `phone`, `password`, `datetime`).
- `keyboard dismiss` issues Android back keyevent only when keyboard is visible, then verifies hidden state.
- Works with an active session device or explicit selectors (`--platform`, `--device`, `--udid`, `--serial`).
- Supported on Android emulator/device.
## Debug
- **App logs (token-efficient):** Logging is off by default in normal flows. Enable it on demand when debugging. With an active session, run `logs path` to get path + state metadata (e.g. `<state-dir>/sessions/<session>/app.log`). Run `logs start` to stream app output to that file; use `logs stop` to stop. Run `logs clear` to truncate `app.log` (and remove rotated `app.log.N` files) before a new repro window. Run `logs doctor` for tool/runtime checks and `logs mark "step"` to insert timeline markers. Grep the file when you need to inspect errors (e.g. `grep -n "Error\|Exception" <path>`) instead of pulling full logs into context. Supported on iOS simulator, iOS physical device, and Android.
- Use `logs clear --restart` when you want one command to stop an active stream, clear current logs, and immediately resume streaming.
- `logs start` appends to `app.log` and rotates to `app.log.1` when the file exceeds 5 MB.
- **Network dump (best-effort):** `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) lines from the same session app log file and returns method/url/status with optional headers/bodies. `network log ...` is an alias. Current limits: scans up to 4000 recent log lines, returns up to 200 entries, truncates payload/header fields at 2048 characters.
- Android log streaming automatically rebinds to the app PID after process restarts.
- Detailed playbook: `skills/agent-device/references/logs-and-debug.md`
- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime.
- Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits.
- Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list.
- `agent-device trace start`
- `agent-device trace stop ./trace.log`
- The trace log includes snapshot logs and XCTest runner logs for the session.
- Built-in retries cover transient runner connection failures and Android UI dumps.
- For snapshot issues (missing elements), compare with `--raw` flag for unaltered output and scope with `-s "<label>"`.
- If startup fails with stale metadata hints, remove stale `<state-dir>/daemon.json` / `<state-dir>/daemon.lock` and retry (state dir defaults to `~/.agent-device` unless overridden).
Boot diagnostics:
- Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs.
- Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.
- Use `agent-device boot --platform ios|android|apple` when starting a new session only if `open` cannot find/connect to an available target.
- Android emulator boot by AVD name (GUI): `agent-device boot --platform android --device Pixel_9_Pro_XL`.
- Android headless emulator boot: `agent-device boot --platform android --device Pixel_9_Pro_XL --headless`.
- `--debug` captures retry telemetry in diagnostics logs.
- Set `AGENT_DEVICE_RETRY_LOGS=1` to also print retry telemetry directly to stderr (ad-hoc troubleshooting).
Diagnostics files:
- Failed commands persist diagnostics in `~/.agent-device/logs/<session>/<date>/<timestamp>-<diagnosticId>.ndjson`.
- `--debug` persists diagnostics for successful commands too and streams live diagnostic events.
- JSON failures include `error.hint`, `error.diagnosticId`, and `error.logPath`.
## App resolution
- Bundle/package identifiers are accepted directly (e.g., `com.apple.Preferences`).
- Human-readable names are resolved when possible (e.g., `Settings`).
- Built-in aliases include `Settings` for both platforms.
## iOS notes
- Core runner commands: `snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `longpress`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`.
- Simulator-only commands: `alert`, `pinch`, `settings`.
- tvOS targets are selectable (`--platform ios --target tv` or `--platform apple --target tv`) and support runner-driven interaction/snapshot commands.
- `record` supports iOS simulators and physical iOS devices.
- iOS simulator recording uses native `simctl io ... recordVideo`.
- Physical iOS device recording is runner-based and built from repeated `XCUIScreen.main.screenshot()` frames (no native video stream/audio capture).
- Physical iOS device recording requires an active app session context (`open <app>` first) so capture targets your app instead of the runner host app.
- Physical iOS device capture is best-effort: dropped frames are expected and true 60 FPS is not guaranteed even with `--fps 60`.
- Physical iOS device recording defaults to uncapped (max available) FPS.
- Use `agent-device record start [path] --fps <n>` (1-120) to set an explicit FPS cap on physical iOS devices.
- iOS device runs require valid signing/provisioning (Automatic Signing recommended). Optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`.
- Free Apple Developer (Personal Team) accounts may need a unique runner bundle id; set `AGENT_DEVICE_IOS_BUNDLE_ID` to a reverse-DNS identifier unique to your team (for example `com.yourname.agentdevice.runner`).
## Testing
```bash
pnpm test
```
Useful local checks:
```bash
pnpm typecheck
pnpm test:unit
pnpm test:smoke
```
## Build
```bash
pnpm build
```
Environment selectors:
- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554`
- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=<udid>`
- `AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET=<path>` (or `IOS_SIMULATOR_DEVICE_SET=<path>`) to scope all iOS simulator discovery/commands to one simulator set.
- `AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST=<serials>` (or `ANDROID_DEVICE_ALLOWLIST=<serials>`) to scope Android discovery to allowlisted serials.
- `AGENT_DEVICE_SESSION=<name>` sets the default CLI session when `--session` is omitted.
- `AGENT_DEVICE_PLATFORM=ios|android|apple` sets the default CLI platform when `--platform` is omitted.
- When `AGENT_DEVICE_SESSION` is set, the CLI treats the run as session-bound by default and sends a shared daemon lock policy with the request.
- `--session-lock reject|strip` sets the lock policy for the current CLI invocation and nested batch steps.
- `AGENT_DEVICE_SESSION_LOCK=reject|strip` sets the default lock policy for bound-session automation runs. `strip` ignores `--target`, `--device`, `--udid`, `--serial`, `--ios-simulator-device-set`, and `--android-device-allowlist`, and restores the configured platform.
- The daemon is the source of truth for lock-policy enforcement across CLI requests, typed client calls, and direct RPC.
- Direct RPC callers can pass `meta.lockPolicy` and optional `meta.lockPlatform` on `agent_device.command` requests to use the same daemon-enforced session lock concept.
- `--session-locked`, `--session-lock-conflicts`, `AGENT_DEVICE_SESSION_LOCKED`, and `AGENT_DEVICE_SESSION_LOCK_CONFLICTS` remain supported as compatibility aliases.
- For `batch`, steps that omit `platform` continue to inherit the parent batch `--platform` even when session-bound defaults are configured.
- `AGENT_DEVICE_BUNDLETOOL_JAR=<path-to-bundletool-all.jar>` optional bundletool jar path used for Android `.aab` installs when `bundletool` is not in `PATH`.
- `AGENT_DEVICE_ANDROID_BUNDLETOOL_MODE=<mode>` optional bundletool `build-apks --mode` override for Android `.aab` installs (default: `universal`).
- CLI flags `--ios-simulator-device-set` / `--android-device-allowlist` override environment values.
- `AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS=<ms>` to adjust iOS simulator boot timeout (default: `120000`, minimum: `5000`).
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS=<ms>` to override daemon request timeout (default `90000`). Increase for slow physical-device setup (for example `120000`).
- `AGENT_DEVICE_STATE_DIR=<path>` override daemon state directory (metadata, logs, session artifacts).
- `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]` connect directly to a remote HTTP daemon and skip local daemon metadata/startup.
- Remote daemon installs upload local artifacts through `POST /upload`; use a `remote:` path prefix when you need the daemon to read an existing server-side artifact path as-is.
- `AGENT_DEVICE_DAEMON_AUTH_TOKEN=<token>` auth token for remote HTTP daemon mode; sent in both the JSON-RPC request token and HTTP auth headers (`Authorization: Bearer` and `x-agent-device-token`).
- `AGENT_DEVICE_PROXY_TOKEN=<token>` preferred bearer token for `metro prepare --proxy-base-url <url>` so the host-bridge secret does not need to be passed on the command line. `AGENT_DEVICE_METRO_BEARER_TOKEN` is also supported.
- `AGENT_DEVICE_DAEMON_SERVER_MODE=socket|http|dual` daemon server mode. `http` and `dual` expose JSON-RPC 2.0 at `POST /rpc` (`GET /health` available for liveness).
- `AGENT_DEVICE_DAEMON_TRANSPORT=auto|socket|http` client preference when connecting to daemon metadata.
- `AGENT_DEVICE_HTTP_AUTH_HOOK=<module-path>` optional HTTP auth hook module path for JSON-RPC server mode.
- `AGENT_DEVICE_HTTP_AUTH_EXPORT=<export-name>` optional export name from auth hook module (default: `default`).
- `AGENT_DEVICE_SOURCE_DOWNLOAD_TIMEOUT_MS=<ms>` timeout for `installFromSource` URL downloads (default: `120000`).
- `AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS=1` opt out of the default SSRF guard that blocks loopback/private-network artifact URLs for `installFromSource`.
- `AGENT_DEVICE_MAX_SIMULATOR_LEASES=<n>` optional max concurrent simulator leases for HTTP lease allocation (default: unlimited).
- `AGENT_DEVICE_LEASE_TTL_MS=<ms>` default lease TTL used by `agent_device.lease.allocate` and `agent_device.lease.heartbeat` (default: `60000`).
- `AGENT_DEVICE_LEASE_MIN_TTL_MS=<ms>` minimum accepted lease TTL (default: `5000`).
- `AGENT_DEVICE_LEASE_MAX_TTL_MS=<ms>` maximum accepted lease TTL (default: `600000`).
- `AGENT_DEVICE_IOS_TEAM_ID=<team-id>` optional Team ID override for iOS device runner signing.
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY=<identity>` optional signing identity override.
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE=<profile>` optional provisioning profile specifier for iOS device runner signing.
- `AGENT_DEVICE_IOS_BUNDLE_ID=<reverse-dns-id>` optional iOS runner app bundle id base. Tests derive from this as `<id>.uitests`.
- `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH=<path>` optional override for iOS runner derived data root. By default, simulator uses `~/.agent-device/ios-runner/derived` and physical device uses `~/.agent-device/ios-runner/derived/device`. If you set this override, use separate paths per kind to avoid simulator/device artifact collisions.
- `AGENT_DEVICE_IOS_CLEAN_DERIVED=1` rebuild iOS runner artifacts from scratch for runtime daemon-triggered builds (`pnpm ad ...`) on the selected path. `pnpm build:xcuitest` (alias of `pnpm build:xcuitest:ios`), `pnpm build:xcuitest:tvos`, and `pnpm build:all` already clear their default derived paths and do not require this variable. When `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, cleanup is blocked by default; set `AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1` only for trusted custom paths.
Test screenshots are written to:
- `test/screenshots/android-settings.png`
- `test/screenshots/ios-settings.png`
## Contributing

@@ -679,0 +73,0 @@

@@ -23,4 +23,12 @@ # Video Recording

`record` is iOS simulator-only.
`record` supports iOS simulators, physical iOS devices, and Android.
Recording outputs:
- a video artifact
- a gesture-telemetry sidecar JSON next to the video
Touch overlay support:
- macOS host: telemetry can be burned into the video as visible touch overlays
- non-macOS host: recording still succeeds, but the video stays raw and `record stop` returns an `overlayWarning`
## Android Emulator/Device

@@ -27,0 +35,0 @@

Sorry, the diff of this file is too big to display