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/README.md +12 -3
- package/dist/src/274.js +1 -1
- package/dist/src/bin.js +25 -22
- package/dist/src/daemon.js +15 -11
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +8 -1
- package/src/cli.ts +7 -0
- package/src/core/__tests__/capabilities.test.ts +2 -0
- package/src/core/capabilities.ts +2 -0
- package/src/daemon/__tests__/selectors.test.ts +133 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +64 -0
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
- package/src/daemon/handlers/__tests__/session.test.ts +122 -0
- package/src/daemon/handlers/find.ts +23 -3
- package/src/daemon/handlers/interaction.ts +5 -3
- package/src/daemon/handlers/session.ts +187 -15
- package/src/daemon/selectors.ts +138 -20
- package/src/daemon/snapshot-processing.ts +5 -1
- package/src/platforms/__tests__/boot-diagnostics.test.ts +37 -8
- package/src/platforms/android/__tests__/index.test.ts +17 -0
- package/src/platforms/android/devices.ts +47 -14
- package/src/platforms/android/index.ts +101 -14
- package/src/platforms/boot-diagnostics.ts +78 -17
- package/src/platforms/ios/index.ts +76 -9
- package/src/platforms/ios/runner-client.ts +19 -1
- package/src/utils/__tests__/exec.test.ts +16 -0
- package/src/utils/__tests__/finders.test.ts +34 -0
- package/src/utils/__tests__/retry.test.ts +17 -0
- package/src/utils/args.ts +2 -0
- package/src/utils/exec.ts +39 -0
- package/src/utils/finders.ts +27 -9
- package/src/utils/retry.ts +72 -2
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
|
+
}
|
package/src/utils/finders.ts
CHANGED
|
@@ -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
|
|
17
|
-
let
|
|
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 (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
48
|
+
return { matches, score: bestScore };
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {
|
package/src/utils/retry.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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
|
}
|