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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +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.
- App logs: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs clear` truncates session app logs; `logs clear --restart` resets and restarts stream in one step; `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 @@ -145,7 +145,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)
- `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)
- `settings wifi|airplane|location on|off`
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `appstate`, `apps`, `devices`, `session list`
Expand Down Expand Up @@ -300,9 +300,11 @@ 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.
- **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. `~/.agent-device/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.
- 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.
Expand Down
6 changes: 4 additions & 2 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,18 @@ 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`
`skills/agent-device/references/logs-and-debug.md`

Recommended minimum:

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

Logging is off by default for normal flows. Turn it on only for debugging windows.

### App state

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# 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.
Logging is off by default in normal flows. Enable it on demand for debugging windows. 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 clear --restart # Preferred: stop stream, clear logs, and start streaming again
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
Expand All @@ -19,6 +19,8 @@ agent-device logs stop # Stop streaming (optional; close also stop
- `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 clear`: truncates `app.log` and removes rotated `app.log.N` files. Requires logging to be stopped first.
- `logs clear --restart`: convenience reset for repro loops (stop stream, clear files, restart stream).
- `logs doctor`: reports backend/tool checks and readiness notes for troubleshooting.
- `logs mark`: writes a timestamped marker line to the session log.

Expand Down Expand Up @@ -49,3 +51,35 @@ 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.

## Crash Triage Fast Path

Always start from the session app log, then branch by platform.

```bash
agent-device logs path
grep -n -E "SIGABRT|SIGSEGV|EXC_|fatal|exception|terminated|killed|jetsam|memorystatus|FATAL EXCEPTION|Abort message" <path>
nl -ba <path> | sed -n '<start>,<end>p'
```

### iOS

```bash
# If log shows ReportCrash / SIGABRT / EXC_*, inspect simulator DiagnosticReports:
ls -lt ~/Library/Logs/DiagnosticReports | grep -E "<AppName>|<BundleId>" | head
```

- `SIGABRT`: app/runtime abort; inspect `.ips` triggered thread and top frames.
- `SIGKILL` + jetsam/memorystatus markers: memory-pressure kill.
- `EXC_BAD_ACCESS`/`SIGSEGV`: native memory access issue.

### Android

```bash
# Capture fatal crash lines around app process death:
adb -s <serial> logcat -d | grep -n -E "FATAL EXCEPTION|Process: <package>|Abort message|signal [0-9]+ \\(SIG"
```

- `FATAL EXCEPTION` with Java stack: uncaught Java/Kotlin exception.
- `signal 6 (SIGABRT)` or `signal 11 (SIGSEGV)` with tombstone refs: native crash path (NDK/JNI/runtime).
- `Low memory killer` / `Killing <pid>` entries: OS memory-pressure/process reclaim.
84 changes: 84 additions & 0 deletions src/__tests__/cli-logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runCli } from '../cli.ts';
import type { DaemonRequest, 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;
calls: Omit<DaemonRequest, 'token'>[];
};

async function runCliCapture(
argv: string[],
responder: (req: Omit<DaemonRequest, 'token'>) => Promise<DaemonResponse>,
): Promise<RunResult> {
let stdout = '';
let stderr = '';
let code: number | null = null;
const calls: Array<Omit<DaemonRequest, 'token'>> = [];

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 (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
calls.push(req);
return await responder(req);
};

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, calls };
}

test('logs clear prints action metadata and forwards --restart flag', async () => {
const result = await runCliCapture(['logs', 'clear', '--restart'], async () => ({
ok: true,
data: {
path: '/tmp/app.log',
cleared: true,
restarted: true,
removedRotatedFiles: 2,
},
}));

assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.flags?.restart, true);
assert.match(result.stdout, /\/tmp\/app\.log/);
assert.match(result.stderr, /cleared=true/);
assert.match(result.stderr, /restarted=true/);
assert.match(result.stderr, /removedRotatedFiles=2/);
});
18 changes: 18 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
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;
const started = data?.started === true;
const stopped = data?.stopped === true;
const marked = data?.marked === true;
const cleared = data?.cleared === true;
const restarted = data?.restarted === true;
const removedRotatedFiles =
typeof data?.removedRotatedFiles === 'number' ? data.removedRotatedFiles : undefined;
if (!flags.json && (active !== undefined || state || backend || sizeBytes !== undefined)) {
const meta = [
active !== undefined ? `active=${active}` : '',
Expand All @@ -258,6 +265,17 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
].filter(Boolean).join(' ');
if (meta) process.stderr.write(`${meta}\n`);
}
if (!flags.json && (started || stopped || marked || cleared || restarted || removedRotatedFiles !== undefined)) {
const actionMeta = [
started ? 'started=true' : '',
stopped ? 'stopped=true' : '',
marked ? 'marked=true' : '',
cleared ? 'cleared=true' : '',
restarted ? 'restarted=true' : '',
removedRotatedFiles !== undefined ? `removedRotatedFiles=${removedRotatedFiles}` : '',
].filter(Boolean).join(' ');
if (actionMeta) process.stderr.write(`${actionMeta}\n`);
}
if (data?.hint && !flags.json) {
process.stderr.write(`${data.hint}\n`);
}
Expand Down
18 changes: 18 additions & 0 deletions src/daemon/__tests__/app-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
assertAndroidPackageArgSafe,
buildIosDeviceLogStreamArgs,
buildIosLogPredicate,
clearAppLogFiles,
cleanupStaleAppLogProcesses,
getAppLogPathMetadata,
runAppLogDoctor,
Expand Down Expand Up @@ -111,6 +112,23 @@ test('appendAppLogMarker writes marker lines and metadata reflects file', () =>
assert.ok(metadata.sizeBytes > 0);
});

test('clearAppLogFiles truncates current log and removes rotated log files', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clear-'));
const outPath = path.join(root, 'app.log');
fs.writeFileSync(outPath, 'line1\nline2\n');
fs.writeFileSync(`${outPath}.1`, 'older');
fs.writeFileSync(`${outPath}.2`, 'oldest');

const result = clearAppLogFiles(outPath);

assert.equal(result.path, outPath);
assert.equal(result.cleared, true);
assert.equal(result.removedRotatedFiles, 2);
assert.equal(fs.readFileSync(outPath, 'utf8'), '');
assert.equal(fs.existsSync(`${outPath}.1`), false);
assert.equal(fs.existsSync(`${outPath}.2`), false);
});

test('runAppLogDoctor returns note when app bundle is missing', async () => {
const result = await runAppLogDoctor({
platform: 'android',
Expand Down
24 changes: 24 additions & 0 deletions src/daemon/app-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,4 +496,28 @@ export function appendAppLogMarker(outPath: string, marker: string): void {
fs.appendFileSync(outPath, line, 'utf8');
}

export function clearAppLogFiles(outPath: string): { path: string; cleared: boolean; removedRotatedFiles: number } {
const dir = path.dirname(outPath);
const base = path.basename(outPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (fs.existsSync(outPath)) {
fs.truncateSync(outPath, 0);
} else {
fs.writeFileSync(outPath, '', 'utf8');
}
let removedRotatedFiles = 0;
for (const entry of fs.readdirSync(dir)) {
if (!entry.startsWith(`${base}.`)) continue;
const suffix = entry.slice(base.length + 1);
if (!/^\d+$/.test(suffix)) continue;
try {
fs.unlinkSync(path.join(dir, entry));
removedRotatedFiles += 1;
} catch {
// best-effort cleanup
}
}
return { path: outPath, cleared: true, removedRotatedFiles };
}

export const APP_LOG_PID_FILENAME = APP_LOG_PID_FILE;
Loading
Loading