agent-device 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils/args.ts CHANGED
@@ -173,8 +173,10 @@ export function usage(): string {
173
173
  CLI to control iOS and Android devices for AI agents.
174
174
 
175
175
  Commands:
176
+ boot Ensure target device/simulator is booted and ready
176
177
  open [app] Boot device/simulator; optionally launch app
177
178
  close [app] Close app or just end session
179
+ reinstall <app> <path> Uninstall + install app from binary path
178
180
  snapshot [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--backend ax|xctest]
179
181
  Capture accessibility tree
180
182
  -i Interactive elements only
package/src/utils/exec.ts CHANGED
@@ -14,6 +14,7 @@ export type ExecOptions = {
14
14
  allowFailure?: boolean;
15
15
  binaryStdout?: boolean;
16
16
  stdin?: string | Buffer;
17
+ timeoutMs?: number;
17
18
  };
18
19
 
19
20
  export type ExecStreamOptions = ExecOptions & {
@@ -41,6 +42,14 @@ export async function runCmd(
41
42
  let stdout = '';
42
43
  let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined;
43
44
  let stderr = '';
45
+ let didTimeout = false;
46
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
47
+ const timeoutHandle = timeoutMs
48
+ ? setTimeout(() => {
49
+ didTimeout = true;
50
+ child.kill('SIGKILL');
51
+ }, timeoutMs)
52
+ : null;
44
53
 
45
54
  if (!options.binaryStdout) child.stdout.setEncoding('utf8');
46
55
  child.stderr.setEncoding('utf8');
@@ -66,6 +75,7 @@ export async function runCmd(
66
75
  });
67
76
 
68
77
  child.on('error', (err) => {
78
+ if (timeoutHandle) clearTimeout(timeoutHandle);
69
79
  const code = (err as NodeJS.ErrnoException).code;
70
80
  if (code === 'ENOENT') {
71
81
  reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err));
@@ -75,7 +85,21 @@ export async function runCmd(
75
85
  });
76
86
 
77
87
  child.on('close', (code) => {
88
+ if (timeoutHandle) clearTimeout(timeoutHandle);
78
89
  const exitCode = code ?? 1;
90
+ if (didTimeout && timeoutMs) {
91
+ reject(
92
+ new AppError('COMMAND_FAILED', `${cmd} timed out after ${timeoutMs}ms`, {
93
+ cmd,
94
+ args,
95
+ stdout,
96
+ stderr,
97
+ exitCode,
98
+ timeoutMs,
99
+ }),
100
+ );
101
+ return;
102
+ }
79
103
  if (exitCode !== 0 && !options.allowFailure) {
80
104
  reject(
81
105
  new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, {
@@ -110,10 +134,18 @@ export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {
110
134
  stdio: ['pipe', 'pipe', 'pipe'],
111
135
  encoding: options.binaryStdout ? undefined : 'utf8',
112
136
  input: options.stdin,
137
+ timeout: normalizeTimeoutMs(options.timeoutMs),
113
138
  });
114
139
 
115
140
  if (result.error) {
116
141
  const code = (result.error as NodeJS.ErrnoException).code;
142
+ if (code === 'ETIMEDOUT') {
143
+ throw new AppError('COMMAND_FAILED', `${cmd} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`, {
144
+ cmd,
145
+ args,
146
+ timeoutMs: normalizeTimeoutMs(options.timeoutMs),
147
+ }, result.error);
148
+ }
117
149
  if (code === 'ENOENT') {
118
150
  throw new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, result.error);
119
151
  }
@@ -298,3 +330,10 @@ function resolveWhichArgs(cmd: string): { shell: string; args: string[] } {
298
330
  }
299
331
  return { shell: 'bash', args: ['-lc', `command -v ${cmd}`] };
300
332
  }
333
+
334
+ function normalizeTimeoutMs(value: number | undefined): number | undefined {
335
+ if (!Number.isFinite(value)) return undefined;
336
+ const timeout = Math.floor(value as number);
337
+ if (timeout <= 0) return undefined;
338
+ return timeout;
339
+ }
@@ -6,28 +6,46 @@ export type FindMatchOptions = {
6
6
  requireRect?: boolean;
7
7
  };
8
8
 
9
+ export type FindBestMatches = {
10
+ matches: SnapshotNode[];
11
+ score: number;
12
+ };
13
+
9
14
  export function findNodeByLocator(
10
15
  nodes: SnapshotNode[],
11
16
  locator: FindLocator,
12
17
  query: string,
13
18
  options: FindMatchOptions = {},
14
19
  ): SnapshotNode | null {
20
+ const best = findBestMatchesByLocator(nodes, locator, query, options);
21
+ return best.matches[0] ?? null;
22
+ }
23
+
24
+ export function findBestMatchesByLocator(
25
+ nodes: SnapshotNode[],
26
+ locator: FindLocator,
27
+ query: string,
28
+ options: FindMatchOptions = {},
29
+ ): FindBestMatches {
15
30
  const normalizedQuery = normalizeText(query);
16
- if (!normalizedQuery) return null;
17
- let best: { node: SnapshotNode; score: number } | null = null;
31
+ if (!normalizedQuery) return { matches: [], score: 0 };
32
+ let bestScore = 0;
33
+ const matches: SnapshotNode[] = [];
18
34
  for (const node of nodes) {
19
35
  if (options.requireRect && !node.rect) continue;
20
36
  const score = matchNode(node, locator, normalizedQuery);
21
37
  if (score <= 0) continue;
22
- if (!best || score > best.score) {
23
- best = { node, score };
24
- if (score >= 2) {
25
- // exact match, keep first exact match
26
- break;
27
- }
38
+ if (score > bestScore) {
39
+ bestScore = score;
40
+ matches.length = 0;
41
+ matches.push(node);
42
+ continue;
43
+ }
44
+ if (score === bestScore) {
45
+ matches.push(node);
28
46
  }
29
47
  }
30
- return best?.node ?? null;
48
+ return { matches, score: bestScore };
31
49
  }
32
50
 
33
51
  function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {
@@ -22,6 +22,33 @@ export type RetryAttemptContext = {
22
22
  deadline?: Deadline;
23
23
  };
24
24
 
25
+ export type TimeoutProfile = {
26
+ startupMs: number;
27
+ operationMs: number;
28
+ totalMs: number;
29
+ };
30
+
31
+ export type RetryTelemetryEvent = {
32
+ phase?: string;
33
+ event: 'attempt_failed' | 'retry_scheduled' | 'succeeded' | 'exhausted';
34
+ attempt: number;
35
+ maxAttempts: number;
36
+ delayMs?: number;
37
+ elapsedMs?: number;
38
+ remainingMs?: number;
39
+ reason?: string;
40
+ };
41
+
42
+ export function isEnvTruthy(value: string | undefined): boolean {
43
+ return ['1', 'true', 'yes', 'on'].includes((value ?? '').toLowerCase());
44
+ }
45
+
46
+ export const TIMEOUT_PROFILES: Record<string, TimeoutProfile> = {
47
+ ios_boot: { startupMs: 120_000, operationMs: 20_000, totalMs: 120_000 },
48
+ ios_runner_connect: { startupMs: 120_000, operationMs: 15_000, totalMs: 120_000 },
49
+ android_boot: { startupMs: 60_000, operationMs: 10_000, totalMs: 60_000 },
50
+ };
51
+
25
52
  const defaultOptions: Required<Pick<RetryOptions, 'attempts' | 'baseDelayMs' | 'maxDelayMs' | 'jitter'>> = {
26
53
  attempts: 3,
27
54
  baseDelayMs: 200,
@@ -58,7 +85,12 @@ export class Deadline {
58
85
  export async function retryWithPolicy<T>(
59
86
  fn: (context: RetryAttemptContext) => Promise<T>,
60
87
  policy: Partial<RetryPolicy> = {},
61
- options: { deadline?: Deadline } = {},
88
+ options: {
89
+ deadline?: Deadline;
90
+ phase?: string;
91
+ classifyReason?: (error: unknown) => string | undefined;
92
+ onEvent?: (event: RetryTelemetryEvent) => void;
93
+ } = {},
62
94
  ): Promise<T> {
63
95
  const merged: RetryPolicy = {
64
96
  maxAttempts: policy.maxAttempts ?? defaultOptions.attempts,
@@ -71,17 +103,55 @@ export async function retryWithPolicy<T>(
71
103
  for (let attempt = 1; attempt <= merged.maxAttempts; attempt += 1) {
72
104
  if (options.deadline?.isExpired() && attempt > 1) break;
73
105
  try {
74
- return await fn({ attempt, maxAttempts: merged.maxAttempts, deadline: options.deadline });
106
+ const result = await fn({ attempt, maxAttempts: merged.maxAttempts, deadline: options.deadline });
107
+ options.onEvent?.({
108
+ phase: options.phase,
109
+ event: 'succeeded',
110
+ attempt,
111
+ maxAttempts: merged.maxAttempts,
112
+ elapsedMs: options.deadline?.elapsedMs(),
113
+ remainingMs: options.deadline?.remainingMs(),
114
+ });
115
+ return result;
75
116
  } catch (err) {
76
117
  lastError = err;
118
+ const reason = options.classifyReason?.(err);
119
+ options.onEvent?.({
120
+ phase: options.phase,
121
+ event: 'attempt_failed',
122
+ attempt,
123
+ maxAttempts: merged.maxAttempts,
124
+ elapsedMs: options.deadline?.elapsedMs(),
125
+ remainingMs: options.deadline?.remainingMs(),
126
+ reason,
127
+ });
77
128
  if (attempt >= merged.maxAttempts) break;
78
129
  if (merged.shouldRetry && !merged.shouldRetry(err, attempt)) break;
79
130
  const delay = computeDelay(merged.baseDelayMs, merged.maxDelayMs, merged.jitter, attempt);
80
131
  const boundedDelay = options.deadline ? Math.min(delay, options.deadline.remainingMs()) : delay;
81
132
  if (boundedDelay <= 0) break;
133
+ options.onEvent?.({
134
+ phase: options.phase,
135
+ event: 'retry_scheduled',
136
+ attempt,
137
+ maxAttempts: merged.maxAttempts,
138
+ delayMs: boundedDelay,
139
+ elapsedMs: options.deadline?.elapsedMs(),
140
+ remainingMs: options.deadline?.remainingMs(),
141
+ reason,
142
+ });
82
143
  await sleep(boundedDelay);
83
144
  }
84
145
  }
146
+ options.onEvent?.({
147
+ phase: options.phase,
148
+ event: 'exhausted',
149
+ attempt: merged.maxAttempts,
150
+ maxAttempts: merged.maxAttempts,
151
+ elapsedMs: options.deadline?.elapsedMs(),
152
+ remainingMs: options.deadline?.remainingMs(),
153
+ reason: options.classifyReason?.(lastError),
154
+ });
85
155
  if (lastError) throw lastError;
86
156
  throw new AppError('COMMAND_FAILED', 'retry failed');
87
157
  }