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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The project is in early development and considered experimental. Pull requests a
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
- Clipboard commands: `clipboard read`, `clipboard write <text>`.
- Performance command: `perf` (alias: `metrics`) returns a metrics JSON blob for the active session; startup timing is currently sampled.
- 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.
- App logs and traffic inspection: `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; `network dump` parses recent HTTP(s) entries from session logs.
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).

Expand Down Expand Up @@ -150,6 +150,7 @@ agent-device scrollintoview @e42
- `trace start`, `trace stop`
- `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)
- `clipboard read`, `clipboard write <text>` (iOS simulator + Android)
- `network dump [limit] [summary|headers|body|all]`, `network log ...` (best-effort HTTP(s) parsing from session app log)
- `settings wifi|airplane|location on|off`
- `settings appearance light|dark|toggle`
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
Expand Down Expand Up @@ -367,6 +368,7 @@ Clipboard:
- **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.
- **Network dump (best-effort):** `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) lines from the same session app log file and returns method/url/status with optional headers/bodies. `network log ...` is an alias. Current limits: scans up to 4000 recent log lines, returns up to 200 entries, truncates payload/header fields at 2048 characters.
- 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.
Expand Down
5 changes: 4 additions & 1 deletion skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Use this skill as a router, not a full manual.

- No target context yet: `devices` -> pick target -> `open`.
- Normal UI task: `open` -> `snapshot -i` -> `press/fill` -> `diff snapshot -i` -> `close`
- Debug/crash: `open <app>` -> `logs clear --restart` -> reproduce -> `logs path` -> targeted `grep`
- Debug/crash: `open <app>` -> `logs clear --restart` -> reproduce -> `network dump` -> `logs path` -> targeted `grep`
- Replay drift: `replay -u <path>` -> verify updated selectors

## Canonical Flows
Expand All @@ -43,6 +43,7 @@ agent-device close
```bash
agent-device open MyApp --platform ios
agent-device logs clear --restart
agent-device network dump 25
agent-device logs path
```

Expand Down Expand Up @@ -89,6 +90,7 @@ agent-device appstate
agent-device clipboard read
agent-device clipboard write "token"
agent-device perf --json
agent-device network dump [limit] [summary|headers|body|all]
agent-device push <bundle|package> <payload.json|inline-json>
agent-device get text @e1
agent-device screenshot out.png
Expand Down Expand Up @@ -117,6 +119,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
- Clipboard helpers: `clipboard read` / `clipboard write <text>` are supported on Android and iOS simulators; iOS physical devices are not supported yet.
- `network dump` is best-effort and parses HTTP(s) entries from the session app log file.
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
- `push` simulates notification delivery:
- iOS simulator uses APNs-style payload JSON.
Expand Down
5 changes: 5 additions & 0 deletions skills/agent-device/references/logs-and-debug.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Logs (Token-Efficient Debugging)

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.
`network dump` parses recent HTTP(s) entries from this same session app log file.

## Data Handling

Expand All @@ -23,6 +24,7 @@ Logging is off by default in normal flows. Enable it on demand for debugging win
```bash
agent-device open MyApp --platform ios # or --platform android
agent-device logs clear --restart # Preferred: stop stream, clear logs, and start streaming again
agent-device network dump 25 # Parse latest HTTP(s) requests (method/url/status) from app.log
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 @@ -41,10 +43,13 @@ Precondition: `logs clear --restart` requires an active app session (`open <app>
- `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.
- `network dump [limit] [summary|headers|body|all]`: parses recent HTTP(s) lines from the session app log and returns request summaries.
- `network log ...`: alias for `network dump`.

## Behavior and Limits

- `logs start` appends to `app.log` and rotates to `app.log.1` when `app.log` exceeds 5 MB.
- `network dump` scans the last 4000 app-log lines, returns up to 200 entries, and truncates payload/header fields at 2048 characters.
- 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:
Expand Down
104 changes: 104 additions & 0 deletions src/__tests__/cli-network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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('network dump prints parsed entries and metadata', async () => {
const result = await runCliCapture(['network', 'dump', '10', 'all'], async () => ({
ok: true,
data: {
path: '/tmp/app.log',
include: 'all',
active: true,
state: 'active',
backend: 'android',
scannedLines: 120,
matchedLines: 2,
entries: [
{
timestamp: '2026-02-24T10:00:01Z',
method: 'POST',
url: 'https://api.example.com/v1/login',
status: 401,
headers: '{"x-id":"abc"}',
requestBody: '{"email":"u@example.com"}',
responseBody: '{"error":"denied"}',
},
],
notes: ['best-effort parser'],
},
}));

assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.deepEqual(result.calls[0]?.positionals, ['dump', '10', 'all']);
assert.match(result.stdout, /\/tmp\/app\.log/);
assert.match(result.stdout, /POST https:\/\/api\.example\.com\/v1\/login status=401/);
assert.match(result.stdout, /headers:/);
assert.match(result.stdout, /request:/);
assert.match(result.stdout, /response:/);
assert.match(result.stderr, /active=true/);
assert.match(result.stderr, /include=all/);
assert.match(result.stderr, /matchedLines=2/);
assert.match(result.stderr, /best-effort parser/);
});
52 changes: 52 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,58 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
return;
}
}
if (command === 'network') {
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 entries = Array.isArray(data?.entries) ? data.entries : [];
if (entries.length === 0) {
process.stdout.write('No recent HTTP(s) entries found.\n');
} else {
for (const entry of entries as Array<Record<string, unknown>>) {
const method = typeof entry.method === 'string' ? entry.method : 'HTTP';
const url = typeof entry.url === 'string' ? entry.url : '<unknown-url>';
const status = typeof entry.status === 'number' ? ` status=${entry.status}` : '';
const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : '';
process.stdout.write(`${timestamp}${method} ${url}${status}\n`);
if (typeof entry.headers === 'string') {
process.stdout.write(` headers: ${entry.headers}\n`);
}
if (typeof entry.requestBody === 'string') {
process.stdout.write(` request: ${entry.requestBody}\n`);
}
if (typeof entry.responseBody === 'string') {
process.stdout.write(` response: ${entry.responseBody}\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 scannedLines = typeof data?.scannedLines === 'number' ? data.scannedLines : undefined;
const matchedLines = typeof data?.matchedLines === 'number' ? data.matchedLines : undefined;
const include = typeof data?.include === 'string' ? data.include : undefined;
const meta = [
active !== undefined ? `active=${active}` : '',
state ? `state=${state}` : '',
backend ? `backend=${backend}` : '',
include ? `include=${include}` : '',
scannedLines !== undefined ? `scannedLines=${scannedLines}` : '',
matchedLines !== undefined ? `matchedLines=${matchedLines}` : '',
].filter(Boolean).join(' ');
if (meta) process.stderr.write(`${meta}\n`);
if (Array.isArray(data?.notes)) {
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/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
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 } },
network: { 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 } },
perf: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
Expand Down
45 changes: 45 additions & 0 deletions src/daemon/__tests__/network-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 { readRecentNetworkTraffic } from '../network-log.ts';

test('readRecentNetworkTraffic parses latest HTTP entries from session log', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-network-log-'));
const logPath = path.join(tempDir, 'app.log');
fs.writeFileSync(
logPath,
[
'2026-02-24T10:00:00Z GET https://api.example.com/v1/profile status=200',
'2026-02-24T10:00:02Z {"method":"POST","url":"https://api.example.com/v1/login","statusCode":401,"headers":{"x-id":"abc"},"requestBody":{"email":"u@example.com"},"responseBody":{"error":"denied"}}',
'non-network-line',
].join('\n'),
'utf8',
);

const dump = readRecentNetworkTraffic(logPath, {
maxEntries: 5,
include: 'all',
maxPayloadChars: 2048,
maxScanLines: 100,
});

assert.equal(dump.exists, true);
assert.equal(dump.entries.length, 2);
assert.equal(dump.entries[0]?.method, 'POST');
assert.equal(dump.entries[0]?.url, 'https://api.example.com/v1/login');
assert.equal(dump.entries[0]?.status, 401);
assert.equal(typeof dump.entries[0]?.headers, 'string');
assert.equal(typeof dump.entries[0]?.requestBody, 'string');
assert.equal(typeof dump.entries[0]?.responseBody, 'string');
assert.equal(dump.entries[1]?.method, 'GET');
assert.equal(dump.entries[1]?.status, 200);
});

test('readRecentNetworkTraffic returns empty result when log file is missing', () => {
const logPath = path.join(os.tmpdir(), 'agent-device-network-log-missing', 'app.log');
const dump = readRecentNetworkTraffic(logPath, { maxEntries: 10, include: 'summary' });
assert.equal(dump.exists, false);
assert.equal(dump.entries.length, 0);
});
Loading
Loading