Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The project is in early development and considered experimental. Pull requests a
## Features
- Platforms: iOS (simulator + physical device core automation) and Android (emulator + device).
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
- Inspection commands: `snapshot` (accessibility tree).
- Inspection commands: `snapshot` (accessibility tree), `appstate`, `apps`, `devices`.
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).

Expand Down Expand Up @@ -148,6 +148,7 @@ Sessions:
- `--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` ensures the target is ready without launching an app.
Expand Down Expand Up @@ -217,8 +218,10 @@ Settings helpers:
Note: iOS supports these only on simulators. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.

App state:
- `appstate` shows the foreground app/activity (Android). On iOS it uses the current session app when available, otherwise it resolves via XCTest snapshot.
- `apps --metadata` returns app list with minimal metadata.
- `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` supports Android, iOS simulators, and iOS devices.
- `apps` includes default/system apps by default (use `--user-installed` to filter).

## Debug

Expand All @@ -227,6 +230,7 @@ App state:
- 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 `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock` and retry.

Boot diagnostics:
- Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs.
Expand All @@ -242,7 +246,8 @@ Boot diagnostics:

## iOS notes
- Core runner commands (`snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`) support iOS simulators and iOS devices.
- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `apps`, `settings`.
- `apps` is supported on both iOS simulators and iOS devices.
- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `settings`.
- iOS deep link open (`open <url>`) is simulator-only.
- 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`.

Expand Down Expand Up @@ -270,11 +275,11 @@ 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_BOOT_TIMEOUT_MS=<ms>` to adjust iOS simulator boot timeout (default: `120000`, minimum: `5000`).
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS=<ms>` to increase daemon request timeout for slow first-run iOS device setup (for example `180000`).
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS=<ms>` to override daemon request timeout (default `90000`). Increase for slow physical-device setup (for example `120000`).
- `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_RUNNER_DERIVED_PATH=<path>` optional override for iOS runner derived data root. By default, agent-device separates caches by target kind (`.../derived/simulator` and `.../derived/device`). If you set this override, use separate paths per kind to avoid simulator/device artifact collisions.
- `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. 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,20 @@ final class RunnerTests: XCTestCase {
}

private func executeOnMain(command: Command) throws -> Response {
let bundleId = command.appBundleId ?? currentBundleId ?? "com.apple.Preferences"
if currentBundleId != bundleId {
let normalizedBundleId = command.appBundleId?
.trimmingCharacters(in: .whitespacesAndNewlines)
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
if let bundleId = requestedBundleId, currentBundleId != bundleId {
let target = XCUIApplication(bundleIdentifier: bundleId)
NSLog("AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d", bundleId, target.state.rawValue)
// activate avoids terminating and relaunching the target app
target.activate()
currentApp = target
currentBundleId = bundleId
} else if requestedBundleId == nil {
// Do not reuse stale bundle targets when the caller does not explicitly request one.
currentApp = nil
currentBundleId = nil
}
let activeApp = currentApp ?? app
_ = activeApp.waitForExistence(timeout: 5)
Expand Down
17 changes: 11 additions & 6 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ iOS settings helpers are simulator-only.

```bash
agent-device appstate
agent-device apps --metadata --platform ios
agent-device apps --metadata --platform android
```

- Android: `appstate` reports live foreground package/activity.
- iOS: `appstate` is session-scoped and reports the app tracked by the active session on the target device.
- For iOS `appstate`, ensure a matching session exists (for example `open --session <name> --platform ios --device "<name>" <app>`).

### Interactions (use @refs from snapshot)

```bash
Expand Down Expand Up @@ -167,9 +169,11 @@ agent-device trace stop ./trace.log # Stop and move trace log

```bash
agent-device devices
agent-device apps --platform ios
agent-device apps --platform android # default: launchable only
agent-device apps --platform android --all
agent-device apps --platform ios # iOS simulator + iOS device, includes default/system apps
agent-device apps --platform ios --all # explicit include-all (same as default)
agent-device apps --platform ios --user-installed
agent-device apps --platform android # includes default/system apps
agent-device apps --platform android --all # explicit include-all (same as default)
agent-device apps --platform android --user-installed
```

Expand All @@ -192,7 +196,8 @@ agent-device apps --platform android --user-installed
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
- iOS deep-link opens are simulator-only.
- iOS physical-device runner requires Xcode signing/provisioning; optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
- For long first-run physical-device setup/build, increase daemon timeout: `AGENT_DEVICE_DAEMON_TIMEOUT_MS=180000` (or higher).
- Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
- For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.
- Use `fill` when you want clear-then-type semantics.
- Use `type` when you want to append/enter text without clearing.
- On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
Expand Down
9 changes: 7 additions & 2 deletions skills/agent-device/references/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ Use Automatic Signing in Xcode, or provide optional overrides:
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY`
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`

If first-run setup/build takes long, increase:
If setup/build takes long, increase:

- `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `180000`)
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (default `45000`, for example `120000`)

If daemon startup fails with stale metadata hints, clean stale files and retry:

- `~/.agent-device/daemon.json`
- `~/.agent-device/daemon.lock`

## Simulator troubleshooting

Expand Down
1 change: 1 addition & 0 deletions skills/agent-device/references/session-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Sessions isolate device context. A device can only be held by one session at a t
- Close sessions when done.
- Use separate sessions for parallel work.
- In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
- On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
Expand Down
155 changes: 155 additions & 0 deletions src/__tests__/cli-close.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runCli } from '../cli.ts';
import { AppError } from '../utils/errors.ts';
import type { DaemonResponse } from '../daemon-client.ts';

class ExitSignal extends Error {
public readonly code: number;

constructor(code: number) {
super(`EXIT_${code}`);
this.code = code;
}
}

type RunResult = {
code: number | null;
stdout: string;
stderr: string;
daemonCalls: number;
};

async function runCliCapture(argv: string[]): Promise<RunResult> {
let daemonCalls = 0;
let stdout = '';
let stderr = '';
let code: number | null = null;

const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);

(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
}) as typeof process.exit;
(process.stdout as any).write = ((chunk: unknown) => {
stdout += String(chunk);
return true;
}) as typeof process.stdout.write;
(process.stderr as any).write = ((chunk: unknown) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;

const sendToDaemon = async (): Promise<DaemonResponse> => {
daemonCalls += 1;
throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
infoPath: '/tmp/daemon.json',
hint: 'stale daemon info',
});
};

try {
await runCli(argv, { sendToDaemon });
} catch (error) {
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}

return { code, stdout, stderr, daemonCalls };
}

async function runCliCaptureWithErrorDetails(
argv: string[],
details: Record<string, unknown>,
message = 'Failed to start daemon',
): Promise<RunResult> {
let daemonCalls = 0;
let stdout = '';
let stderr = '';
let code: number | null = null;

const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);

(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
}) as typeof process.exit;
(process.stdout as any).write = ((chunk: unknown) => {
stdout += String(chunk);
return true;
}) as typeof process.stdout.write;
(process.stderr as any).write = ((chunk: unknown) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;

const sendToDaemon = async (): Promise<DaemonResponse> => {
daemonCalls += 1;
throw new AppError('COMMAND_FAILED', message, details);
};

try {
await runCli(argv, { sendToDaemon });
} catch (error) {
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}

return { code, stdout, stderr, daemonCalls };
}

test('close treats daemon startup failure as no-op', async () => {
const result = await runCliCapture(['close']);
assert.equal(result.code, null);
assert.equal(result.daemonCalls, 1);
assert.equal(result.stdout, '');
assert.equal(result.stderr, '');
});

test('close --json treats daemon startup failure as no-op success', async () => {
const result = await runCliCapture(['close', '--json']);
assert.equal(result.code, null);
assert.equal(result.daemonCalls, 1);
const payload = JSON.parse(result.stdout);
assert.equal(payload.success, true);
assert.equal(payload.data.closed, 'session');
assert.equal(payload.data.source, 'no-daemon');
assert.equal(result.stderr, '');
});

test('close treats lock-only daemon startup failure as no-op', async () => {
const result = await runCliCaptureWithErrorDetails(['close'], {
lockPath: '/tmp/daemon.lock',
hint: 'stale daemon lock',
});
assert.equal(result.code, null);
assert.equal(result.daemonCalls, 1);
assert.equal(result.stdout, '');
assert.equal(result.stderr, '');
});

test('close treats structured daemon startup failure as no-op without relying on message text', async () => {
const result = await runCliCaptureWithErrorDetails(
['close'],
{
kind: 'daemon_startup_failed',
lockPath: '/tmp/daemon.lock',
},
'daemon bootstrap failed',
);
assert.equal(result.code, null);
assert.equal(result.daemonCalls, 1);
assert.equal(result.stdout, '');
assert.equal(result.stderr, '');
});
48 changes: 32 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
const bundleId = app.bundleId ?? app.package;
const name = app.name ?? app.label;
if (name && bundleId) return `${name} (${bundleId})`;
if (bundleId && typeof app.launchable === 'boolean') {
return `${bundleId} (launchable=${app.launchable})`;
}
if (bundleId) return String(bundleId);
return JSON.stringify(app);
}
Expand All @@ -199,7 +196,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
const pkg = (data as any)?.package;
const activity = (data as any)?.activity;
if (platform === 'ios') {
process.stdout.write(`Foreground app: ${appName ?? appBundleId}\n`);
process.stdout.write(`Foreground app: ${appName ?? appBundleId ?? 'unknown'}\n`);
if (appBundleId) process.stdout.write(`Bundle: ${appBundleId}\n`);
if (source) process.stdout.write(`Source: ${source}\n`);
if (logTailStopper) logTailStopper();
Expand All @@ -220,6 +217,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
throw new AppError(response.error.code as any, response.error.message, response.error.details);
} catch (err) {
const appErr = asAppError(err);
if (command === 'close' && isDaemonStartupFailure(appErr)) {
if (flags.json) {
printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } });
}
if (logTailStopper) logTailStopper();
return;
}
if (flags.json) {
printJson({
success: false,
Expand All @@ -229,9 +233,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
printHumanError(appErr);
if (flags.verbose) {
try {
const fs = await import('node:fs');
const os = await import('node:os');
const path = await import('node:path');
const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
if (fs.existsSync(logPath)) {
const content = fs.readFileSync(logPath, 'utf8');
Expand All @@ -251,6 +252,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
}
}

function isDaemonStartupFailure(error: AppError): boolean {
if (error.code !== 'COMMAND_FAILED') return false;
if (error.details?.kind === 'daemon_startup_failed') return true;
if (!error.message.toLowerCase().includes('failed to start daemon')) return false;
return typeof error.details?.infoPath === 'string' || typeof error.details?.lockPath === 'string';
}

const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
if (isDirectRun) {
runCli(process.argv.slice(2)).catch((err) => {
Expand All @@ -268,15 +276,23 @@ function startDaemonLogTail(): (() => void) | null {
const interval = setInterval(() => {
if (stopped) return;
if (!fs.existsSync(logPath)) return;
const stats = fs.statSync(logPath);
if (stats.size <= offset) return;
const fd = fs.openSync(logPath, 'r');
const buffer = Buffer.alloc(stats.size - offset);
fs.readSync(fd, buffer, 0, buffer.length, offset);
fs.closeSync(fd);
offset = stats.size;
if (buffer.length > 0) {
process.stdout.write(buffer.toString('utf8'));
try {
const stats = fs.statSync(logPath);
if (stats.size < offset) offset = 0;
if (stats.size <= offset) return;
const fd = fs.openSync(logPath, 'r');
try {
const buffer = Buffer.alloc(stats.size - offset);
fs.readSync(fd, buffer, 0, buffer.length, offset);
offset = stats.size;
if (buffer.length > 0) {
process.stdout.write(buffer.toString('utf8'));
}
} finally {
fs.closeSync(fd);
}
} catch {
// Best-effort tailing should not crash CLI flow.
}
}, 200);
return () => {
Expand Down
Loading