| name | agent-emulator-debugging |
| description | Efficient app-driving workflow for the real Flutter app on emulator or Linux/macOS desktop via VM service extensions, including common blocker recovery. |
Agent App Driving
Drive the real app to inspect runtime state and unblock UI/action dead-ends, via the ext.nothingness.* VM-service extensions wrapped by scripts/drive.py. Works against an Android emulator/device (through adb) or a Flutter desktop build (-d linux/-d macos, no ADB) — the extension surface is identical on both. This file is the operational entry point; for the per-extension reference and architecture see docs/agent-driven-debugging.md.
D=.claude/skills/agent-emulator-debugging/scripts/drive.py is used as shorthand below.
Step 0: drive.py preflight
Don't assume which targets exist — detect them. Which platforms you can/can't see depends on the host (WSL is a feature of this env, not a law), so start every session with:
$D preflight
It probes, with no hardcoded verdicts, and prints JSON (stdout) plus a one-line human summary (stderr):
- host — OS/platform and whether this is WSL (
/proc/version).
- flutter_devices —
flutter devices --machine; which targets exist (linux / android / chrome).
- adb — an adb on PATH or
$ANDROID_HOME/platform-tools/adb, and adb devices output.
- live_run — is
/tmp/flutter_run.log (or $DRIVE_RUN_LOG) present/fresh, does it carry a VM URI, and does the VM actually answer (isolate + extension count)?
- recommendation — the
DRIVE_TARGET that would be used and the exact next command.
For desktop sessions, preflight also reports the concrete sidecar paths it will use in drive_paths:
run_log — the flutter run stdout log the harness will scan.
fifo — the stdin fifo used for reload / restart.
ws_cache — the VM-service websocket cache file (under /tmp by default; override with $DRIVE_WS_CACHE if needed).
desktop_home — the isolated HOME path the recommended Linux/macOS launch command uses to avoid Hive lock collisions with another desktop instance.
Follow its recommendation.next. The three shapes:
ready → a live VM answers. Set the reported DRIVE_TARGET and go ($D inspect).
needs-launch → a target exists but nothing is running yet. It prints the launch line (Linux fifo+flutter run, or the x86_64 Android line). See Appendix: target variants for the full launch recipes.
needs-relaunch → a VM answers, but the session is not automatable yet (for example dev/main_debug.dart was not used, or bootstrap failed before AgentService registered). Follow the printed relaunch line; on desktop it uses isolated HOME/log/fifo paths so you don't collide with another Nothingness instance.
no-target → nothing reachable; it tells you how to start one.
The agent-driving entrypoint is always dev/main_debug.dart (flutter run -t dev/main_debug.dart): it installs the harness onto the lib/debug_hooks.dart seam, then runs the real lib/main.dart, so the extensions register. Plain flutter run (lib/main.dart) exposes no extensions; never use dev/main_test.dart (that's for integration_test/ with fake transport).
First time on a host: uv sync once (creates .venv, installs websockets). drive.py also carries a PEP 723 block + uv run --script shebang, so ./drive.py … works before uv sync.
Command reference
export DRIVE_TARGET=linux
$D preflight
$D contract
$D inspect
$D tree 40
$D overflows
$D shoot before_x
$D screen void
$D variant dark
$D mode own
$D nav /home/user/Music
$D up
$D settings open
$D permit
$D play /home/user/Music/foo.mp3
$D pause | $D resume | $D next | $D prev | $D seek 1:23
$D pref void_hint_shown=false:bool
$D clearpref void_hint_shown
$D emulate phone
$D emulate 360x800
$D window 390 844
$D reload
$D restart
$D reset
$D call ext.nothingness.simulateInterruption phase=begin kind=pause
$D call ext.nothingness.simulateNoisy
$D logcat 500
$D replay smoke.txt
$D probe hero-song
$D frames --clear
$D timeline --clear
$D profile --seconds 2
$D breakpoint --line 692 --watch _track.path --trigger prev
Prefer a direct drive.py subcommand or extension over gesture synthesis when the harness can target the behavior semantically (for example seek/next/prev/settings/nav). Use adb swipes or taps only when there is no direct control surface.
For any extension not yet wrapped, $D call ext.nothingness.<method> k=v k=v … calls it with arbitrary params (the fully-qualified name is required). The exact registered set + count is whatever $D contract reports against the running build — don't quote a fixed number. Per-extension params/returns live in docs/agent-driven-debugging.md.
The five runtime-inspection lenses (probe/frames/timeline/profile/breakpoint) are detailed — with their caveats — in docs/agent-driven-debugging.md. probe/frames wrap ext.nothingness.*; timeline/profile/breakpoint are raw VM-service RPCs.
Which lens when
| Symptom | Reach for |
|---|
| Stuck navigating / can't find a control | tree (hierarchy + keys), tapByKey, nav/up/screen |
| Wrong pixels / label rendered wrong | probe <key> (live text + resolved TextStyle), shoot (PNG) |
| Slow / janky / a "hang" | timeline (what ran on the UI isolate), profile (CPU hot spot), frames (visible-rebuild jank — Android/real device) |
| Need a live variable at a point in code | breakpoint --line N --watch expr (run ALONE) |
| Layout overflow / errors | overflows, emulate <preset> to repro a portrait phone |
| Audio focus / route behavior | call ext.nothingness.simulateInterruption / simulateNoisy, then inspect |
Hazards
- Cadence ≤5/s with jitter for mutating RPCs (
setSetting/setPreference cost ~140 ms each on emulator). Above ~7/s the response queue backs up and looks like a wedged isolate (it just hasn't drained). Hammering the same path (20+ ops <100 ms) belongs in a widget test (test/p6_adversarial_test.dart), not the VM service. Recovery if a session hangs: rm -f "${DRIVE_WS_CACHE:-/tmp/drive_vm_ws.txt}" && $D restart.
- Never kill the live
flutter run. No adb shell am force-stop, pm clear, or pm revoke against com.saplin.nothingness — they trigger Lost connection to device + a 60-90 s rebuild. $D reset wraps the same hazard, so it refuses (exit 1) when a live run is detected; pass --force only if you accept the rebuild. To reset isolate state without wiping app data, use $D restart (hot restart). For audio-focus/transport exercises use the cheap side-channel: $D call ext.nothingness.simulateInterruption …, $D pause/$D resume.
- Velocity-gated gestures need widget tests.
adb shell input swipe injects fixed-cadence synthetic events whose computed velocity is far below a real flick, so hero swipes / PageView flings / dismissibles silently no-op. Don't chase them via adb/VM service — regress with tester.fling(finder, Offset(dx, 0), velocity) (see test/screens/void_screen_test.dart, B-027). ADB swipes are fine for coarse distance-only pan/scroll.
breakpoint pauses the UI isolate → run it ALONE. While paused, every ext.nothingness.* extension (and any other driver) is frozen — never run it concurrently with another drive.py call. It always removeBreakpoint+resumes in a finally; if orphaned, recover with rm -f "${DRIVE_WS_CACHE:-/tmp/drive_vm_ws.txt}" && $D restart. --trigger next no-ops at the last queue index — use --trigger prev from the end.
frames is empty on the headless Linux build. addTimingsCallback fires only for on-screen compositor frames; background-only activity (a skip storm with no visible change) leaves count=0. It populates on real UI churn (navigation, hero rebuilds) and is the primary jank lens on Android. On Linux, prove UI-isolate blocking with timeline+breakpoint.
Common blockers (fast recovery)
- "could not find Dart VM service URI" — the app isn't in debug mode (release build /
flutter run exited), or the configured run log / websocket cache points at a dead session. Run $D preflight to see what's actually reachable; it prints the active drive_paths and will refresh a stale cache automatically when the run log has a newer VM URI.
- VM responds but
extension_count=0 / needs-relaunch — the process reached a Dart VM, but AgentService never registered. Check the live_run.bootstrap_error / lock_conflict fields in preflight; on desktop this commonly means another Nothingness instance already holds the Hive box lock. Relaunch with the printed isolated HOME/log/fifo command.
- Install fails
INSTALL_FAILED_UPDATE_INCOMPATIBLE (Android) — uninstall the package, then re-run flutter run.
- Queue empty after reinstall —
queueLen=0; queue real files via $D play <path> or the Appendix adb push snippet.
- Shared-storage track won't load /
isNotFound (Android API 30+) — scoped storage blocks raw-path access to /storage/emulated/0/.... The app tries a native loadFile(path) first and, when that fails (strict scoped storage, e.g. the API 37 emulator), falls back to resolving the _data path to a MediaStore content:// URI (see lib/services/android_audio_source.dart), so the file must be MediaStore-indexed. adb push into /storage/emulated/0/Music/ auto-triggers indexing on recent images — verify with adb shell content query --uri content://media/external/audio/media --where "_data='<path>'" (a Row: with an _id means it's playable). For a quick one-off that sidesteps MediaStore entirely, push into the app-private dir (adb push <host> /data/user/0/com.saplin.nothingness/files/<name>) — it loads via the direct-file fallback.
Permissions Required overlay (Android) — run the Appendix pm grant loop, or $D permit.
- Can't tap a control via
tapByKey — the production widget has no ValueKey<String>. Drive via drive.py (settings/nav/pref) or adb shell uiautomator dump + tap by bounds.
reload reports "no changes detected" — hot reload skips initState / static / top-level changes; use $D restart or a full rebuild.
reload reports "Reloaded 0 libraries" after a real edit (WSL) — the resident compiler's file watcher (inotify) doesn't always fire on WSL2, even after touch. So a code change won't take via $D reload/restart. Kill and relaunch flutter run to pick it up — there's no in-process workaround. (Hot reload remains reliable on native Linux/macOS.)
Minimal drive loop
- Read baseline (
$D inspect).
- Apply precondition (queue/permission/screen).
- Trigger one action.
- Read state immediately.
- Assert only required fields (index, isPlaying, title, position).
Appendix: target variants
Moved out of the happy path — drive.py preflight tells you which of these applies.
Target selection (how DRIVE_TARGET resolves)
drive.py picks its target from, in order: (1) DRIVE_TARGET={android|linux|macos}; (2) sniffing /tmp/flutter_run.log for Launching … on Linux|macOS|Android; (3) default android. On linux/macos, ADB is bypassed, shoot rasterizes via ext.nothingness.screenshot, logcat tails the run log, and reset refuses. Which of these targets is actually reachable is a preflight output, not an assumption — drive.py never hard-asserts "emulator unavailable" or "must use linux".
Linux / macOS desktop (no ADB)
export DRIVE_SESSION_TAG=nothingness_linux_debug
export DRIVE_TARGET=linux
export DRIVE_RUN_LOG=/tmp/flutter_run_${DRIVE_SESSION_TAG}.log
export DRIVE_FLUTTER_FIFO=/tmp/flutter_input_${DRIVE_SESSION_TAG}
export DRIVE_DESKTOP_HOME=/tmp/nothingness_${DRIVE_SESSION_TAG}
mkdir -p "$DRIVE_DESKTOP_HOME/.config" "$DRIVE_DESKTOP_HOME/.local/share"
[ -p "$DRIVE_FLUTTER_FIFO" ] || { rm -f "$DRIVE_FLUTTER_FIFO"; mkfifo "$DRIVE_FLUTTER_FIFO"; }
nohup sleep infinity > "$DRIVE_FLUTTER_FIFO" 2>/dev/null &
HOME="$DRIVE_DESKTOP_HOME" \
XDG_CONFIG_HOME="$DRIVE_DESKTOP_HOME/.config" \
XDG_DATA_HOME="$DRIVE_DESKTOP_HOME/.local/share" \
nohup flutter run -d linux --debug -t dev/main_debug.dart \
< "$DRIVE_FLUTTER_FIFO" > "$DRIVE_RUN_LOG" 2>&1 &
$D inspect
If you intentionally want the historical shared paths, leave the env vars unset and use /tmp/flutter_run.log + /tmp/flutter_input as before.
Library permissions are a no-op on desktop; stage audio by passing any readable absolute host path to $D play. The only feature gap vs Android is MediaStore-backed library scans (Android-only); desktop is folder-based.
Android (emulator or device), x86_64
On an x86_64 emulator you must build x86_64. This project's android/app/build.gradle.kts defaults target-platform/abiFilters to the device ABI (arm64 on release for size); a default flutter run cross-compiles flutter_soloud for arm64, which fails on the snap Flutter toolchain (host glibc headers leak into the NDK aarch64 sysroot — B-045). Set the ABI explicitly:
CI_EMULATOR_ABI=x86_64 flutter run -d emulator-5554 --debug -t dev/main_debug.dart
Then prepare the device once (idempotent — fine to re-run after a reinstall):
for P in RECORD_AUDIO READ_MEDIA_AUDIO READ_EXTERNAL_STORAGE POST_NOTIFICATIONS; do
adb -s emulator-5554 shell pm grant com.saplin.nothingness "android.permission.$P" || true
done
adb -s emulator-5554 push .tmp/test_tone.wav \
/data/user/0/com.saplin.nothingness/files/test_tone.wav
$D play /data/user/0/com.saplin.nothingness/files/test_tone.wav
To exercise the real shared-storage path (content-URI resolution, the actual user scenario — see blocker #4), push into /storage/emulated/0/Music/ and confirm MediaStore indexed it before playing:
adb -s emulator-5554 push .tmp/test_tone.wav /storage/emulated/0/Music/test_tone.wav
adb -s emulator-5554 shell content query --uri content://media/external/audio/media \
--where "_data='/storage/emulated/0/Music/test_tone.wav'"
$D play /storage/emulated/0/Music/test_tone.wav
Android alongside a live Linux session (parallel regression): drive.py resolves the VM URI from the run log, defaulting to /tmp/flutter_run.log. If a Linux session owns that path, point Android at its own log so it doesn't read the stale Linux URI:
CI_EMULATOR_ABI=x86_64 flutter run -d emulator-5554 --debug -t dev/main_debug.dart < /dev/null > /tmp/flutter_run_android.log 2>&1 &
export DRIVE_TARGET=android DRIVE_RUN_LOG=/tmp/flutter_run_android.log
WSL2 + Windows-hosted emulator (only if preflight shows WSL + no working local adb)
This is conditional, not the default story: only when preflight reports host.is_wsl=true and adb can't reach an emulator locally. Bridge to the Windows adb server:
"/mnt/c/Users/<windows-user>/AppData/Local/Android/Sdk/platform-tools/adb.exe" -a start-server
HOST_IP=$(ip route | awk '/default/ {print $3; exit}')
ADB_SERVER_SOCKET=tcp:${HOST_IP}:5037 CI_EMULATOR_ABI=x86_64 flutter run -d emulator-5554 --debug -t dev/main_debug.dart
If adb devices shows nothing from WSL2, run the wsl2-adb-setup skill first. If the emulator is flaky on this host (a recurring x86 WSL2 issue), prefer the Linux desktop path above — every drive.py command except reset and Android perm grants works identically without ADB.
Reference: docs/agent-driven-debugging.md (extension reference + architecture), docs/regression-testing-playbook.md (QA process).