Three measured dev-loop/CI cuts, no signal loss:
- typecheck now runs tsgo (already trusted for declaration emit by the
tsdown build): 21.7s -> 5.3s locally, and check:tooling drops to ~18s
total. tsc stays available as typecheck:tsc; verified tsgo fails on
type errors and respects noUnusedLocals.
- remove the Unit Tests CI job: Coverage runs the same unit +
provider-integration suites under coverage thresholds, so the job
reran ~64s of tests every PR for no extra signal.
- Size workflow: skip docs-only paths (same paths-ignore as CI) and
cache the base commit's dist keyed on base SHA, since dist is fully
determined by that commit. Startup medians are still measured fresh
on the same runner so the base/PR startup comparison stays
same-machine; the cache is saved immediately after the base
measurement so the PR build never poisons it.
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
Co-authored-by: Claude <noreply@anthropic.com>
* docs: retire plans/perfect-shape.md — roadmap complete
The perfect-shape roadmap (two-registry thesis: CommandDescriptor +
PlatformPlugin, typed-result spine, folder DAG + layering lint, agent-cost,
and the Apple apple+appleOs platform model with a non-breaking leaf wire) is
substantively complete and merged. Per its own §5 retirement note, the durable
decisions now live in ADR-0008 (command descriptor) and ADR-0009 (Apple/AppleOS),
and current-state terms in CONTEXT.md; this removes the last plan file.
- Delete plans/perfect-shape.md (plans/ is now empty and gone).
- CONTEXT.md: add "Architecture (perfect-shape refactor, completed 2026-07)"
end-state summary plus a "Deferred / next-minor" note (Phase 2c client-types
narrowing, b.3 recording/providers facets, strict DAG back-edge inversion,
legacy alias drops) so nothing is lost.
- Repoint every remaining perfect-shape.md/§ reference (ADRs 0003/0008/0009,
ci.yml, scripts/layering/check.ts, and the platform-plugin/apple comments)
to ADR-0008/0009 or CONTEXT.md. No dangling references remain.
Docs/comment-only; tsc, oxlint, oxfmt, and the layering DAG check all pass.
* docs: repoint dangling perfect-shape section refs before retiring the roadmap
Removing plans/perfect-shape.md left three comments citing bare section numbers
with no surviving target. The rationales are already inlined, so drop the numbers
(and point the do-not-flatten note at the durable ADR):
- src/platforms/apple/plugin.ts: `(§7)` -> "do-not-flatten; see docs/adr/0009".
- src/core/interactors/register-builtins.ts: "the §5.1 ... sketch" -> "an ... sketch".
- scripts/layering/check.ts: drop `(§5.5 ...)`, keep the inline "re-export barrels only".
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>