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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Minimal operating guide for AI coding agents in this repo.
## Routing
- Keep `src/daemon.ts` as a thin router.
- Put command logic in handler modules:
- session/apps/appstate/open/close/replay: `src/daemon/handlers/session.ts`
- session/apps/appstate/open/close/replay/logs: `src/daemon/handlers/session.ts`
- click/fill/get/is: `src/daemon/handlers/interaction.ts`
- snapshot/wait/alert/settings: `src/daemon/handlers/snapshot.ts`
- find: `src/daemon/handlers/find.ts`
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The project is in early development and considered experimental. Pull requests a
- 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), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
- App logs: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs doctor` checks readiness; `logs mark` writes timeline markers.
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).

Expand Down Expand Up @@ -142,6 +143,7 @@ agent-device scrollintoview @e42
- `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is`
- `alert`, `wait`, `screenshot`
- `trace start`, `trace stop`
- `logs path`, `logs start`, `logs stop`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
- `settings wifi|airplane|location on|off`
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `appstate`, `apps`, `devices`, `session list`
Expand Down Expand Up @@ -296,6 +298,12 @@ App state:

## Debug

- **App logs (token-efficient):** With an active session, run `logs path` to get path + state metadata (e.g. `~/.agent-device/sessions/<session>/app.log`). Run `logs start` to stream app output to that file; use `logs stop` to stop. 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.
- `logs start` appends to `app.log` and rotates to `app.log.1` when the file exceeds 5 MB.
- Android log streaming automatically rebinds to the app PID after process restarts.
- 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.
Expand Down
13 changes: 13 additions & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ iOS settings helpers are simulator-only.
Use `match`/`nonmatch` as the canonical command values.
Think of them as validate/invalidate outcomes when describing intent.

### Logs (token-efficient debugging)

Use the detailed logs workflow reference:
`skills/agent-device/references/logs.md`

Recommended minimum:

```bash
agent-device logs doctor
agent-device logs start
agent-device logs path
```

### App state

```bash
Expand Down
51 changes: 51 additions & 0 deletions skills/agent-device/references/logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Logs (Token-Efficient Debugging)

App output is written to a session-scoped file so agents can grep it instead of loading full logs into context.

## Quick Flow

```bash
agent-device open MyApp --platform ios
agent-device logs start # Start streaming app logs to session file
agent-device logs path # Print path, e.g. ~/.agent-device/sessions/default/app.log
agent-device logs doctor # Check tool/runtime readiness for current session/device
agent-device logs mark "before tap" # Insert a timeline marker into app.log
# ... run flows; on failure, grep the path (see below)
agent-device logs stop # Stop streaming (optional; close also stops)
```

## Command Notes

- `logs path`: returns log file path and metadata (`active`, `state`, `backend`, size, timestamps).
- `logs start`: starts streaming; requires an active app session (`open` first). Supported on iOS simulator, iOS device, and Android.
- `logs stop`: stops streaming. Session `close` also stops logging.
- `logs doctor`: reports backend/tool checks and readiness notes for troubleshooting.
- `logs mark`: writes a timestamped marker line to the session log.

## Behavior and Limits

- `logs start` appends to `app.log` and rotates to `app.log.1` when `app.log` exceeds 5 MB.
- Android log streaming automatically rebinds to the app PID after process restarts.
- 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:
- `AGENT_DEVICE_APP_LOG_MAX_BYTES`
- `AGENT_DEVICE_APP_LOG_MAX_FILES`
- Optional write-time redaction patterns:
- `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` (comma-separated regex)

## Grep Patterns

After getting the path from `logs path`, run `grep` (or `grep -E`) so only matching lines enter context.

```bash
# Get path first, then grep it; -n adds line numbers
grep -n "Error\|Exception\|Fatal" <path>
grep -n -E "Error|Exception|Fatal|crash" <path>

# Bounded context: last N lines only
tail -50 <path>
```

- Use `-n` for line numbers.
- Use `-E` for extended regex so `|` in the pattern does not need escaping.
- Prefer targeted patterns (e.g. `Error`, `Exception`, or app-specific tags) over reading the full file.
32 changes: 32 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,38 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
if (logTailStopper) logTailStopper();
return;
}
if (command === 'logs') {
const data = response.data as Record<string, unknown> | undefined;
const pathOut = typeof data?.path === 'string' ? data.path : '';
if (pathOut) {
process.stdout.write(`${pathOut}\n`);
const active = typeof data?.active === 'boolean' ? data.active : undefined;
const state = typeof data?.state === 'string' ? data.state : undefined;
const backend = typeof data?.backend === 'string' ? data.backend : undefined;
const sizeBytes = typeof data?.sizeBytes === 'number' ? data.sizeBytes : undefined;
if (!flags.json && (active !== undefined || state || backend || sizeBytes !== undefined)) {
const meta = [
active !== undefined ? `active=${active}` : '',
state ? `state=${state}` : '',
backend ? `backend=${backend}` : '',
sizeBytes !== undefined ? `sizeBytes=${sizeBytes}` : '',
].filter(Boolean).join(' ');
if (meta) process.stderr.write(`${meta}\n`);
}
if (data?.hint && !flags.json) {
process.stderr.write(`${data.hint}\n`);
}
if (Array.isArray(data?.notes) && !flags.json) {
for (const note of data.notes) {
if (typeof note === 'string' && note.length > 0) {
process.stderr.write(`${note}\n`);
}
}
}
}
if (logTailStopper) logTailStopper();
return;
}
if (command === 'click' || command === 'press') {
const ref = (response.data as any)?.ref ?? '';
const x = (response.data as any)?.x;
Expand Down
1 change: 1 addition & 0 deletions src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
'get',
'home',
'longpress',
'logs',
'open',
'press',
'record',
Expand Down
1 change: 1 addition & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
get: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
is: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
logs: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
longpress: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
Expand Down
2 changes: 2 additions & 0 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts';
import { handleFindCommands } from './daemon/handlers/find.ts';
import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
import { cleanupStaleAppLogProcesses } from './daemon/app-log.ts';
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
import { clearRequestCanceled, isRequestCanceled, markRequestCanceled } from './daemon/request-cancel.ts';
Expand All @@ -30,6 +31,7 @@ const infoPath = path.join(baseDir, 'daemon.json');
const lockPath = path.join(baseDir, 'daemon.lock');
const logPath = path.join(baseDir, 'daemon.log');
const sessionsDir = path.join(baseDir, 'sessions');
cleanupStaleAppLogProcesses(sessionsDir);
const sessionStore = new SessionStore(sessionsDir);
const version = readVersion();
const token = crypto.randomBytes(24).toString('hex');
Expand Down
123 changes: 123 additions & 0 deletions src/daemon/__tests__/app-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
APP_LOG_PID_FILENAME,
appendAppLogMarker,
assertAndroidPackageArgSafe,
buildIosDeviceLogStreamArgs,
buildIosLogPredicate,
cleanupStaleAppLogProcesses,
getAppLogPathMetadata,
runAppLogDoctor,
rotateAppLogIfNeeded,
stopAppLog,
} from '../app-log.ts';

test('buildIosLogPredicate includes bundle-aware filters', () => {
const predicate = buildIosLogPredicate('com.example.app');
assert.match(predicate, /subsystem == "com\.example\.app"/);
assert.match(predicate, /processImagePath ENDSWITH\[c\] "\/com\.example\.app"/);
assert.match(predicate, /senderImagePath ENDSWITH\[c\] "\/com\.example\.app"/);
assert.match(predicate, /eventMessage CONTAINS\[c\] "com\.example\.app"/);
});

test('assertAndroidPackageArgSafe rejects unsafe values', () => {
assert.doesNotThrow(() => assertAndroidPackageArgSafe('com.example.app'));
assert.throws(() => assertAndroidPackageArgSafe('com.example.app;rm -rf /'), /Invalid Android package/);
});

test('rotateAppLogIfNeeded rotates and truncates oldest by configured max files', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-rotate-'));
const outPath = path.join(root, 'app.log');
fs.writeFileSync(outPath, 'a'.repeat(20));
fs.writeFileSync(`${outPath}.1`, 'old1');
fs.writeFileSync(`${outPath}.2`, 'old2');

rotateAppLogIfNeeded(outPath, { maxBytes: 10, maxRotatedFiles: 2 });

assert.equal(fs.existsSync(outPath), false);
assert.equal(fs.readFileSync(`${outPath}.1`, 'utf8').length, 20);
assert.equal(fs.readFileSync(`${outPath}.2`, 'utf8'), 'old1');
});

test('stopAppLog delegates stop and waits for completion', async () => {
let stopped = false;
let resolved = false;
const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
setTimeout(() => {
resolved = true;
resolve({ stdout: '', stderr: '', exitCode: 0 });
}, 5);
});
await stopAppLog({
backend: 'android',
getState: () => 'active',
startedAt: Date.now(),
stop: async () => {
stopped = true;
},
wait,
});
assert.equal(stopped, true);
assert.equal(resolved, true);
});

test('cleanupStaleAppLogProcesses removes pid files even when pid is stale', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clean-'));
const sessionDir = path.join(root, 'default');
fs.mkdirSync(sessionDir, { recursive: true });
const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME);
fs.writeFileSync(pidPath, '999999\n');

cleanupStaleAppLogProcesses(root);

assert.equal(fs.existsSync(pidPath), false);
});

test('buildIosDeviceLogStreamArgs builds expected devicectl command args', () => {
assert.deepEqual(buildIosDeviceLogStreamArgs('00008150-0000AAAA'), [
'devicectl',
'device',
'log',
'stream',
'--device',
'00008150-0000AAAA',
]);
});

test('cleanupStaleAppLogProcesses removes legacy plain pid files safely', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clean-legacy-'));
const sessionDir = path.join(root, 'default');
fs.mkdirSync(sessionDir, { recursive: true });
const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME);
fs.writeFileSync(pidPath, '1\n');

cleanupStaleAppLogProcesses(root);

assert.equal(fs.existsSync(pidPath), false);
});

test('appendAppLogMarker writes marker lines and metadata reflects file', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-mark-'));
const outPath = path.join(root, 'app.log');
appendAppLogMarker(outPath, 'checkpoint');
const content = fs.readFileSync(outPath, 'utf8');
assert.match(content, /checkpoint/);
const metadata = getAppLogPathMetadata(outPath);
assert.equal(metadata.exists, true);
assert.ok(metadata.sizeBytes > 0);
});

test('runAppLogDoctor returns note when app bundle is missing', async () => {
const result = await runAppLogDoctor({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
});
assert.equal(Array.isArray(result.notes), true);
assert.ok(result.notes.some((note) => note.includes('Run open <app> first')));
});
Loading
Loading