Generalize the inline CI "Layering Guard" grep into a structured
import-direction lint (scripts/layering/check.ts) over the resolved
import graph, per plans/perfect-shape.md §5.5.
The full target DAG (kernel ◄ platforms ◄ core ◄ commands ◄ {cli,
client, daemon/server}; client ◄ daemon/client) is only partly realized
— the client/remote/metro extraction, the daemon/server split, and the
utils dissolution are still pending Phase-5 moves, so the tree still
holds legitimate back-edges (platforms→core, commands→cli, utils→*).
Enforcing the whole DAG today would need a mass import rewrite that
Phase 5 defers. The lint therefore enforces the three invariants the
completed moves (kernel/, daemon/client/) already guarantee and that are
green today:
R1 kernel-sink — nothing under src/kernel/ imports another zone,
except the one type-only kernel→contracts re-export.
R2 commands-floor — nothing below the command surface (kernel,
platforms, core, daemon) imports src/commands/.
Generalizes the former guard (daemon + platforms).
R3 platforms-seam — platforms/ is statically imported only at the
core interactor seam (src/core/interactors/) and by
the daemon server; elsewhere use a dynamic import()
or a type-only import, preserving CLI cold-start.
Dynamic import('../platforms/*') and `import type` stay allowed.
Fixes the three pre-existing R3 violations by converting static
platforms value imports to dynamic imports (all in already-async call
sites, behavior-preserving and cold-start-improving):
- src/client/client.ts debug.symbols → lazy symbolicateCrashArtifact
- src/cli/commands/web.ts setup/doctor → lazy agent-browser-tool
- src/core/dispatch-interactions.ts runner-sequence → lazy (matches the
file's own dynamic-import pattern)
Wire the check into the Layering Guard CI job and add a check:layering
package.json script (also folded into check:tooling). scripts/layering/**
is excluded from fallow (untested CI script, like scripts/perf/**).
Replaces the manual "run with --debug, hand-count the runner phases" check with
an automated, committed assertion so the Phase 3 step (c) runner relocation (and
future runner refactors) can prove byte-identical runner request behavior.
- src/daemon/runner-request-count.ts: pure, unit-testable counter. Parses the
daemon --debug diagnostics ndjson and counts the iOS-runner round-trip phases,
plus baseline parse/compare logic. Owns RUNNER_ROUND_TRIP_PHASES as the single
source of truth, now imported by request-router.ts (was a local const) so the
in-process cost graft and the external counter never drift.
- src/daemon/__tests__/runner-request-count.test.ts: 13 unit tests over synthetic
ndjson fixtures (tolerant parse, counting, baseline parse/compare). Run in the
normal unit suite; no hardware.
- scripts/runner-request-count/: assertion harness (run.ts) + committed baseline
(expected-counts.json). Drives the existing smoke-ios replay scenario with
--debug in an isolated --state-dir, counts runner round-trips from daemon.log,
and asserts against the baseline. --update regenerates the baseline. Infra
hiccups are inconclusive (don't fail); only a real count drift fails.
- .github/workflows/ios.yml: new "Assert iOS runner request count" step in the
smoke-ios job, reusing the booted simulator.
- package.json: `validate:runner-count` script. .fallowrc.json: harness entry.
The baseline ships unarmed (established=false); the harness records observed
counts (printed + uploaded as a test/artifacts artifact) without failing, so the
maintainer arms it once from a real CI run.
* perf: reuse Apple runner cache across version bumps
* perf: remove unused Apple runner symbols
* perf: keep Swift runner unit tests out of runtime builds
* perf: skip Apple runner asset catalog in runtime builds
* perf: use concrete simulator for xcuitest script builds
* perf: show cold Apple runner startup progress
* perf: prewarm Apple runner cache during simulator boot
* refactor: dedupe Apple runner option plumbing
Add a Lint & Format job (oxlint --deny-warnings + new format:check script) and
a warn-only guard that flags imports of src/commands/* from src/daemon and
src/platforms; the guard flips to a hard failure once shared contracts move
out of the commands layer.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
* feat: add Linux desktop automation support via AT-SPI2 (Phase 1+2)
Add Linux as a first-class platform using AT-SPI2 accessibility framework
via node-gtk for accessibility tree snapshots. This mirrors the macOS
desktop automation approach using accessibility snapshots.
New files:
- src/platforms/linux/atspi-bridge.ts: Core AT-SPI2 bridge using node-gtk
with lazy loading, recursive tree traversal (max 1500 nodes, depth 12)
- src/platforms/linux/role-map.ts: AT-SPI2 role normalization (~100 roles
mapped to existing snapshot type conventions)
- src/platforms/linux/snapshot.ts: Snapshot entry point with surface,
scope, depth, and interactive-only filtering support
- src/platforms/linux/devices.ts: Local device discovery for Linux
- src/platforms/linux/node-gtk.d.ts: Type declarations for node-gtk
Integration:
- Extended Platform type with 'linux', backend union with 'linux-atspi'
- Wired snapshot into dispatch.ts and snapshot-capture.ts
- Added Linux device discovery to dispatch-resolve.ts
- Added stub interactor (input actions deferred to Phase 3)
- Added 'linux' to CLI --platform flag
- node-gtk added as optional dependency (only installs on Linux)
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* refactor: code review cleanup for Linux platform support
- Extract SnapshotBackend type alias to replace repeated string union
across 5 files (snapshot.ts, snapshot-capture.ts, session-replay-heal.ts,
interaction.test.ts)
- Remove duplicate scope/interactive/depth filtering from
linux/snapshot.ts — let the existing buildSnapshotState pipeline handle
it, same as Android
- Extract isDesktopBackend() helper in snapshot-capture.ts to consolidate
the "skip mobile semantics" pattern for macos-helper and linux-atspi
- Collapse 17 repetitive throw statements in Linux interactor stubs
into a linuxStub() factory function
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* feat: Linux input synthesis, screenshots, and app lifecycle (Phase 3+4)
Add xdotool/ydotool input actions (tap, swipe, scroll, type, fill,
right/middle click, long press, double click), screenshot capture via
grim/scrot, and app lifecycle management (open, close, back, home).
Wire Linux interactors with real implementations and fix device
discovery order so Linux doesn't displace Android in auto-selection.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* refactor: consolidate Linux env detection, simplify input actions
- Extract linux-env.ts with cached display server + input tool detection
so every action avoids repeated `which` lookups
- Add moveTo/clickButton/sendKey helpers to eliminate repeated
mousemove boilerplate across 5 mouse actions
- Make scrollLinux respect amount/pixels options instead of hardcoded
scroll count
- Have backLinux/homeLinux reuse sendKey instead of duplicating tool
detection
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* feat: add Linux CI smoke test with Xvfb and AT-SPI2
Add GitHub Actions workflow that boots a virtual X11 display (Xvfb),
installs AT-SPI2 accessibility tooling and xdotool, opens
gnome-calculator, takes screenshots, and captures an accessibility
snapshot. Screenshots are uploaded as artifacts for visual verification.
Also adds 'linux' to replay script metadata platforms and a
test:replay:linux script to package.json.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: remove pre-session screenshot from Linux replay test
The replay runner requires an active session before any commands can
run. Move the screenshot after the open command that creates the
session.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: Linux CI — add missing node-gtk build deps and AT-SPI2 env
- Add gobject-introspection, libcairo2-dev, build-essential for
node-gtk native compilation
- Split AT-SPI2 registry start into its own step so it picks up
DBUS_SESSION_BUS_ADDRESS from GITHUB_ENV
- Set GTK_A11Y=atspi, GTK_MODULES=gail:atk-bridge, NO_AT_BRIDGE=0
to ensure GTK apps expose their accessibility tree on headless CI
- Set GSETTINGS_BACKEND=memory to avoid dconf failures
- Add node-gtk verification step to catch build failures early
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: explicitly rebuild node-gtk native module in Linux CI
pnpm install silently skips failed optional dependency builds and
the pnpm cache may not include the native binary. Force a rebuild
after install to ensure the node-gtk .node binding is compiled
against the system GI/cairo headers.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: use node-pre-gyp directly to build node-gtk from source
pnpm rebuild doesn't trigger node-pre-gyp properly for optional deps.
Run node-pre-gyp install --fallback-to-build --update-binary directly
inside the node-gtk package directory to force compilation when no
prebuilt binary exists for the current Node ABI (v127 / Node 22).
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* refactor: replace node-gtk with Python subprocess for AT-SPI2
node-gtk is a native C++ addon that requires compilation against
specific Node ABI versions and GObject Introspection headers. This
proved unreliable on CI (no prebuilt binaries for Node 22 ABI v127,
silent optional dep build failures, pnpm cache staleness).
Replace it with a Python helper script (atspi-dump.py) that uses
PyGObject — the reference GObject Introspection consumer. python3-gi
is trivially installable on any Linux distro with no compilation step.
The Node bridge spawns `python3 atspi-dump.py` and parses JSON output.
- Remove node-gtk from optionalDependencies
- Remove node-gtk.d.ts type stub
- Add atspi-dump.py (~200 lines) doing the same tree traversal
- Rewrite atspi-bridge.ts to use subprocess instead of in-process GI
- Simplify CI workflow: no more native build deps or rebuild steps
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* chore: drop pre-installed packages from Linux CI apt-get
python3-gi, gir1.2-atspi-2.0, at-spi2-core, and dbus-x11 are already
present on Ubuntu GitHub Actions runners.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* feat: surface support for Linux, unit tests, stronger CI assertions
- Allow --surface desktop and --surface frontmost-app on Linux
(previously only macOS could use --surface)
- Add unit tests for atspi-bridge (9 tests: JSON parsing, role
normalization, null coercion, error handling, arg forwarding)
- Add unit tests for role-map (3 tests: common roles, case
normalization, PascalCase fallback)
- Improve .py script path resolution (walk upward instead of
hardcoded relative paths)
- CI replay test now asserts snapshot contains calculator UI
nodes via is-exists
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* docs: add cross-platform snapshot traversal contract
Document the shared schema, traversal rules, surface semantics, and
normalized role types that all snapshot backends (Swift, Python,
Android) must conform to. This serves as the single source of truth
when adding or modifying platform backends.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: update lockfile after removing node-gtk optional dependency
pnpm-lock.yaml still referenced node-gtk after it was removed from
package.json, causing pnpm install --frozen-lockfile to fail in CI.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: address review findings in Linux platform code
- atspi-dump.py: use ctx dict for traversal limits instead of globals,
fix rect filter (width/height <= 0 should use `or`), add surface validation
- input-actions.ts: make sendKey scancodes required to prevent silent
no-op on ydotool, fix ydotool longPress/swipe to use click --down/--up
- app-lifecycle.ts: use pkill -x (exact match) instead of pkill -f
- linux-env.ts: emit diagnostic warning when falling back to xdotool on Wayland
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix(ci): explicitly install all Linux a11y dependencies
Ubuntu runners may not have at-spi2-core, python3-gi, gir1.2-atspi-2.0,
or dbus-x11 pre-installed. Install them explicitly instead of assuming
they exist. Also make the verify step's tree dump non-fatal since no
apps are running at that point.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: quote multi-word role value in Linux smoke test selector
The selector parser tokenizes on whitespace, so `role=push button`
was split into two tokens causing a parse failure. Use single quotes
inside the selector: `role='push button'`.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: use valid selector keys in Linux smoke test
appName is not a valid selector key. The supported keys are: id, role,
text, label, value, visible, hidden, editable, selected, enabled,
hittable. Simplified to use label and role only.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* chore: cleanup pass — menubar warning, fix contract doc example
- snapshot.ts: emit diagnostic warning when menubar surface is
requested on Linux (falls back to desktop silently otherwise)
- SNAPSHOT_CONTRACT.md: fix unmapped role example to use a role
that isn't actually mapped (was "color chooser" which maps to Dialog)
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* chore: add Python bytecache to gitignore
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* feat: harden Linux platform — capability matrix, CI, error handling
P0: Add explicit Linux capability matrix with 3-way platform routing
(Apple/Linux/Android) in isCommandSupportedOnDevice. Linux now correctly
blocks unsupported commands (clipboard, rotate, scrollIntoView, etc.)
at capability level rather than throwing at runtime. Includes tests.
P0: Expand Linux CI to run typecheck + unit tests before smoke tests.
Add AT-SPI2 registry health probe with fail-fast on missing registry.
P1: Harden atspi-dump.py — arg parsing now produces JSON errors on bad
int values, and a top-level catch wraps unexpected exceptions in JSON.
P1: Add 10s per-action timeout to xdotool/ydotool input commands to
prevent indefinite hangs.
P1: Tighten smoke test selectors to calculator-specific signals
(digit labels) instead of generic role='push button'.
P2: Document Linux surface mapping, supported commands, and known
limitations in SNAPSHOT_CONTRACT.md.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: apply depth/interactive filtering to Linux snapshots
Linux snapshots were bypassing snapshotInteractiveOnly and snapshotDepth
filtering that macOS-helper gets via shapeDesktopSurfaceSnapshot. Route
Linux through the same function so snapshot -i and --depth flags work.
Renamed shapeMacOsSurfaceSnapshot → shapeDesktopSurfaceSnapshot since
it's now shared between macOS and Linux desktop backends.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* fix: address review findings — error reporting, Wayland, timeout
- app-lifecycle.ts: emit diagnostic on fire-and-forget app launch
failure instead of silently swallowing errors
- linux-env.ts: make xdotool on Wayland a hard error instead of
a broken fallback (xdotool doesn't work on Wayland)
- atspi-bridge.ts: increase Python subprocess timeout from 15s to
30s for safety on slow/loaded systems with large a11y trees
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* feat: appName/windowTitle selectors, clipboard, input-action tests
Selectors:
- Add appname and windowtitle as selector keys for desktop platforms.
Both macOS and Linux snapshots already populate these fields — now
they're usable in selector expressions (e.g., "label=OK appname=Calc").
Keys are case-insensitive.
Clipboard:
- Implement readLinuxClipboard/writeLinuxClipboard using xclip/xsel
(X11) or wl-copy/wl-paste (Wayland) with descriptive TOOL_MISSING
errors. Enable clipboard in Linux capability matrix. 7 unit tests.
Input action tests:
- Add 18 unit tests covering xdotool and ydotool code paths: press,
right/middle click, double click, sendKey, type, scroll, swipe,
focus, fill. Tests mock runCmd and verify correct tool + args.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
* chore: cache tool detection for screenshot/clipboard, extract get_app_info helper
Avoid repeated `which` calls on every screenshot/clipboard operation by
caching the resolved tool on first use, matching the input-action pattern.
Extract duplicated app_name/pid retrieval in atspi-dump.py into get_app_info.
https://claude.ai/code/session_01H9hrmueNF5pcBM8JeX81mT
---------
Co-authored-by: Claude <noreply@anthropic.com>
Remove all test-only DI parameters from handleSessionCommands (signature
drops from ~30 params to 5: req, sessionName, logPath, sessionStore,
invoke) and from the remaining sub-handlers (session-inventory,
session-observability, session-replay, session-close, session-state).
Migrate all 89 remaining node:test unit test files to vitest. Tests that
passed DI overrides now use vi.mock instead. Update vitest.config.ts to
include *.test.ts alongside *.vitest.ts, and remove the dual-runner
node --test from package.json scripts.
Add a permanent CI lint guard that fails if optional typeof DI seams
reappear in production code.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: remove JS test wrappers, run .ad replay suites directly on CI
The platform JS test files (ios.test.ts, android.test.ts, macos.test.ts)
were thin wrappers that shelled out to `agent-device test` and asserted
on JSON counts. Since the CLI already exits non-zero on failure, the
wrappers added no value. CI now invokes the replay suites directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add local replay entrypoints and restore physical device test
- Add pnpm scripts for each replay suite (test:replay:ios,
test:replay:ios-device, test:replay:android, test:replay:macos)
so local runs still exercise platform replays
- Restore physical-device iOS replay step in CI workflow
(conditional on IOS_UDID variable)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ci: add manual iOS runner prebuild cache for iOS workflow
* ci: simplify manual iOS prebuild workflow
* ci: pin cache actions in iOS workflows
* ci: fix iOS workflow env paths for parser compatibility
* ci: increase daemon timeout for iOS boot preflight
* ios: harden simulator state timeout and reuse preflight session
* ci: remove redundant iOS boot preflight step
* ios: retry transient simctl launch failures on simulator
* ci: pin iOS integration test target to resolved 26.2 simulator
* ci: harden iOS prebuild cache keys and remove derived-data copy path
* ci: simplify iOS workflow caching and drop redundant prebuild workflow
- Remove standalone ios-runner-prebuild.yml (redundant — ios.yml builds on cache miss)
- Switch from actions/cache/restore to actions/cache (auto-saves on miss)
- Drop restore-keys and -stable suffix (no longer needed without prebuild workflow)
- Remove UDID resolution step (test falls back to --platform ios)
- Use point-free shouldRetry for isTransientSimulatorLaunchFailure
Co-authored-by: Cursor <cursoragent@cursor.com>
* ios: increase simctl launch retry budget for CI simulators
SpringBoard can take several seconds after boot to accept app launches.
Increase from 3 attempts / 500ms–2s to 5 attempts / 1s–5s to give CI
simulators enough time.
Co-authored-by: Cursor <cursoragent@cursor.com>
* ios: use deadline-based retry for simulator app launch
The fixed-attempt retry (5 attempts, ~12s window) is too short for CI
simulators where SpringBoard needs 30-60s after boot to accept app
launches — especially when the build step is cached and provides zero
warm-up time.
Switch to a deadline-based approach matching ensureBootedSimulator:
- Default 30s timeout (configurable via AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS)
- CI sets 60s to handle cold-boot scenarios
- maxAttempts set high (30) so the deadline is the real limit
Co-authored-by: Cursor <cursoragent@cursor.com>
* ci(ios): keep tests platform-based and stabilize simulator selection
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Complete issue #39 phase diagnostics telemetry and boot command
* Address review findings for boot diagnostics and command gating
* Use agent-device boot preflight in iOS CI workflow
* Run iOS boot preflight via source CLI in CI