Add an Agent Client Protocol (ACP) v1 agent mode so ACP clients such as
Zed can drive devices from the agent panel. agent-device has no LLM, so
the agent is deterministic: each prompt line is one agent-device command
in CLI syntax, executed through the same AgentDeviceClient path as MCP
tools.
- Hand-rolled protocol layer in src/acp/ mirroring src/mcp/ (zero new
runtime dependencies), reusing the MCP newline-delimited JSON-RPC
stdio transport, payload queue, and error formatting.
- Prompt lines are tokenized with the replay-script tokenizer and parsed
with the real CLI parser; target flags (--platform, --device, --udid,
--session, --state-dir) are sticky within an ACP session.
- Command executions stream as tool_call/tool_call_update notifications
with daemon progress forwarding; screenshots attach as inline images;
errors keep the code/hint/supportedOn contract.
- session/cancel is intercepted ahead of the serialized queue so it
takes effect between command lines of a running prompt; the command
already in flight runs to completion (documented v1 limitation).
- Natural-language prompts are refused with guidance instead of guessed
at; available commands are advertised per session after session/new.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: --settle returns the settled diff in the interaction response (#1101)
press/click/fill/longpress --settle executes the action, waits for the UI
to go quiet (wait stable's loop, shared via stable-capture.ts), and returns
the settled diff vs the pre-action tree in the same response — one round
trip instead of the interact -> observe pair.
- payload: changed lines only (bounded), summary counts, added-line refs,
refsGeneration; best-effort (settled:false + hint on never-quiet content,
never an action failure); --verify shares the settle captures
- ref issuance: the settled tree becomes the session snapshot; a
diff-carrying settle response clears snapshotRefsStale and the MCP layer
merge-only re-pins added-line refs at the settle generation
- grammar: --settle + --settle-quiet <ms> + --timeout <ms> (flag-sourced
descriptor budget with new envelope:'widen' semantics mirroring wait)
- ADR 0011: new settleObservation guarantee classified on every path with
contract scenarios per enforced/delegated cell
* test: give the two contention-flaky doctor scenarios explicit budgets
The doctor provider scenarios sit at ~5s of real daemon-harness work on a
loaded host and flake at vitest's 5s default during full-suite runs (the
known contention flake AGENTS.md documents). Same in-file precedent as the
Metro-probe scenario's 10s budget.
* fix: move SettleParams to contracts to satisfy the layering DAG
daemon/handlers/interaction-flags.ts imported the type across the
daemon -> commands boundary (R2 commands-floor). The tuning params are
part of the interaction contract like SettleObservation, so they live
in contracts/interaction.ts and both layers import from there.
* feat: keep settle diffs content-first — drop Key nodes, added lines win the cap
Bluesky dogfood: a fill that summons the iOS keyboard spent 49 of the 80
capped diff lines spelling out QWERTY keys, and a screen transition with
269 removals could starve out the added lines entirely. Key-type nodes
are now filtered from both diff sides (the [keyboard] container line
still signals presence), and under truncation added lines — the ones
carrying fresh refs — win slots over removals.
* docs: state the core loop in the top-level help starting point
Benchmarked with headless haiku/sonnet agents given only --help: both
models skipped the help-workflow pointer and started with plain
snapshot (38KB payloads they then had to re-read from files). One
core-loop line at the starting point is what teaches snapshot -i and
--settle to models that never read a second help page.
* fix: preserve settle digest refs for mcp
* fix: reduce settle fallow complexity
* fix: surface settle output in CLI text
* fix: complete settle handling for longpress
* refactor: localize daemon timeout envelopes
* refactor: deepen post-action observation
* refactor: centralize post-action observation planning
* refactor: derive settle capability from descriptors
* refactor: trim settle descriptor helpers
* test: split the Android platform test aggregation and share the scripted adb stub
AGENTS.md names the platform index.test.ts aggregations as offenders to
shrink opportunistically; this splits the 2,735-line Android one along
its (already well-factored) source modules, every test moved verbatim
(92 tests before and after):
- ui-hierarchy.test.ts (22): parseUiHierarchy/androidUiNodes
- app-lifecycle-install.test.ts (13): install/resolve/infer/launch
component parsing
- app-lifecycle-open.test.ts (19): open/close, deep links, launch args,
TV category, fallback resolve-activity
- input-actions.test.ts (11): type/fill/swipe/scroll/rotate
- settings.test.ts (14): appearance/clear-app-state/fingerprint/
permissions
- notifications.test.ts (2), app-parsers.test.ts (1)
- keyboard state/dismiss tests (10) appended to the existing
device-input-state.test.ts
Consistency fix folded in: the file carried a local withMockedAdb fork
because it needs scripted per-subcommand adb responses, which the shared
arg-recorder helper cannot express. The fork now lives in
src/__tests__/test-utils/mocked-binaries.ts as withScriptedAdb next to
withMockedAdb, and hands each call a fresh copy of the shared
ANDROID_EMULATOR fixture.
The copy matters: the Android TV test mutated the callback's device
(device.target = 'tv'), which the old per-call object literal absorbed
silently. With a shared fixture that mutation leaked into the next test
and flipped its launch to LEANBACK. The helper now clones per call and
the TV test builds { ...device, target: 'tv' } instead of mutating.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
* test: serialize the scripted-adb group and repoint its slow-test pins
Review follow-up for the android index.test.ts split: the monolith
implicitly serialized the env-mutating adb-stub tests (PATH,
AGENT_DEVICE_TEST_ARGS_FILE) in one worker, and the split let vitest
run them across parallel files. Make the contract explicit:
- new android-adb vitest project runs the six scripted-adb test files
in a single fork (singleFork), keeping the pre-split execution
semantics; ui-hierarchy and app-parsers stay in the parallel unit
project (pure parsing, no env mutation)
- test/test:unit scripts run both projects
- the five slow-test ratchet pins that referenced index.test.ts keys
now point at the split file names, so the pinned real-time offenders
keep their exemption instead of failing at 2x budget under load; the
reporter's own pinned-key fixture updated to match
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
* test: use vitest 4 android adb serialization
* docs: update unit project readiness guidance
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: bound iOS capture stalls and make runner recovery session-preserving (#1105)
Runner (Swift):
- Coalesce duplicate transport sends of one commandId onto the in-flight
execution instead of enqueueing them again behind it (capture pileup).
- Fail fast with RUNNER_BUSY while watchdog-abandoned main-thread work is
draining; escalate to RUNNER_WEDGED past 120s so the daemon recycles.
- Carry the capture-plan deadline into the query-sweep and private-AX
ladder tiers so chained recovery cannot stack past the watchdog.
- Penalize the tree backend after a slow (>5s) or abandoned capture and
lead subsequent regular plans with private-AX for that bundle (sticky,
120s), stamped recovered/budget so the deferral stays observable.
Daemon (TS):
- Per-request runner recycle budget: at most one invalidate+reboot per
request, then fail fast with an actionable, session-preserving hint.
- RUNNER_WEDGED joins the runner-fatal invalidation reasons.
- Interaction commands (click/fill/longpress/press/type/get/is) preserve
the daemon on request timeout like snapshot/wait/find: resetting it
destroyed every healthy app session the daemon owned.
* fix: suppress AX-broken-screen snapshot issues so the runner survives capture
XCTest records 'Failed to get matching snapshot: kAXErrorIllegalArgument'
issues for every XCUIApplication query on AX-broken screens; after a few
of them the test case tears down the moment the in-flight command
completes, killing the long-lived runner after every capture of the
screen (the restart loop behind #1105). The capture plan already
classifies and recovers from AX failures, so this issue class is noise:
swallow exactly it in record(_:); everything else still records and
still drives XCTEST_RECORDED_FAILURE.
* feat: time-slice the XCTest tree capture on a worker thread
The tree snapshot XPC is a single blocking call whose duration moves
with live content (4s to minutes on Bluesky profile screens); no
in-process budget could bound it on the main thread. Run it on a worker
bounded to an 8s slice: on timeout the plan penalizes the tree backend,
skips the XCTest-backed tiers while the abandoned XPC drains (they
would block behind it inside testmanagerd), and recovers through the
private AX backend, which does not use testmanagerd.
* tune: lower the tree-backend penalty threshold to 3s
The Bluesky profile tree grind measures ~4.5s before kAXErrorIllegalArgument,
just under the old 5s threshold, so every capture re-paid the doomed grind
(9s each). At 3s the second capture onward defers to private AX (2.4s
snapshot, 4.9s press on the live repro).
* fix: harden the AX-issue suppression per review
- Require the kAXError token: 'Failed to get matching snapshot: Timed out
while evaluating UI query.' is a genuinely-hung-query signal and must
keep recording (and keep driving XCTEST_RECORDED_FAILURE). Sibling AX
server codes (kAXErrorCannotComplete, ...) are deliberately included:
any AX-server rejection inside a matching-snapshot fetch is the same
capture-plan noise.
- State honestly that the override is suite-global and why (tap-triggered
queries record the same noise; command outcomes stay honest via their
own error paths).
- Lock-guarded suppressed-issue counter following the file's existing
abandoned-work counter pattern, logged with each suppression.
- Unit-test the pure classifier (record(_:) itself is not invoked: the
must-record variants would record real failures in the test run).
* test: split the args.test.ts aggregation along source topology
AGENTS.md file-size tripwires now apply to tests with no exemption, and
test files are expected to mirror source topology 1:1. args.test.ts was
a 2,503-line aggregation in src/utils/__tests__ while the code it
exercises lives in src/cli/parser. Split it into six focused files with
every test moved verbatim (142 tests before and after):
- src/cli/parser/__tests__/args-parse-interaction.test.ts (29 tests):
parseArgs shapes for press/click/swipe/gesture/type/record/screenshot
and friends
- src/cli/parser/__tests__/args-parse-session.test.ts (41 tests):
parseArgs shapes for session/daemon/device flags, passthrough,
install/metro/connect/proxy/auth and friends
- src/cli/parser/__tests__/args-validation.test.ts (17 tests): strict/
compat modes, rejections, deterministic errors
- src/cli/parser/__tests__/cli-help-topics.test.ts (15 tests): global
usage and help topics
- src/cli/parser/__tests__/cli-help-command-usage.test.ts (35 tests):
per-command usage copy
- src/utils/__tests__/command-schema-guards.test.ts (5 tests): schema/
catalog/capability guards and the cli.ts dispatch-literal walk (the
oxc-parser helpers live here)
AGENTS.md testing-matrix and help-source pointers updated to the new
paths, including the stale src/utils/cli-help.ts and cli-flags.ts
locations (both live under src/cli/parser/).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
* docs: fix cli parser paths in agent guide
---------
Co-authored-by: Claude <noreply@anthropic.com>
Follow-ups from the bundler/CI speed work, re-validated against latest
main. The typescript package is gone from the toolchain:
- pnpm typecheck stays on tsgo; the typecheck:tsc escape hatch is
removed along with the typescript devDependency.
- args.test.ts extracted cli.ts dispatch literals through the
TypeScript compiler API - the only remaining consumer. It now walks
the same AST via oxc-parser (matching the OXC lint/format/build
stack); both implementations extract an identical 14-literal set
from cli.ts, verified side by side before the swap. The
substitution-free template case ts.isStringLiteralLike covered is
preserved.
- dts bundling is unaffected: the tsdown build uses the tsgo backend
and builds green with no typescript package installed.
Test fixes for containerized agent environments:
- The missing-binary doctor-guidance web provider test pins Node 24
via the file's existing withNodeRuntimeVersion helper, so it asserts
the setup hint instead of inheriting the host Node and failing on
Node 22 (the supported engines floor).
- The clean-xcuitest cleanup-failure smoke test skips as root: chmod
0o500 cannot force a removal failure when the process bypasses
directory permissions.
AGENTS.md toolchain notes updated to match.
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
Co-authored-by: Claude <noreply@anthropic.com>
* feat: versioned snapshot refs with MCP auto-pinning
Refs are positional indexes into the latest stored session tree; #1093's
coarse snapshotRefsStale marker warns honestly but cannot say WHICH tree
a ref came from. Give the session a monotonically increasing
snapshotGeneration, advanced wherever the stored tree is replaced: the
setSessionSnapshot choke point and the snapshot/diff command path that
bypasses it.
Token economy (non-negotiable): the snapshot tree output is unchanged —
plain e12 refs on every node. Ref-issuing responses (snapshot command,
find ref outputs) carry the generation ONCE as the additive
refsGeneration field. Ref-consuming commands (press/click/fill/longpress/
get/wait) accept both forms: plain @e12 keeps today's behavior including
the coarse #1093 warning; pinned @e12~s3 is clean when the generation
matches the stored tree, gets a precise warning naming both generations
when it does not, and a malformed suffix is INVALID_ARGS with a grammar
hint. Warn-only this release — tightening comes later per the compat
ladder.
The MCP layer auto-pins at zero token cost: it sees snapshot/find
responses before the model does, remembers the last refsGeneration per
session name, and rewrites plain @ref tool arguments to the pinned form
before forwarding. The model never sees or types suffixes; with no
remembered generation, refs pass through unpinned (never guess).
Replay parsing and script writing strip and IGNORE pins — generations
are meaningless outside the session that minted them.
Refs #1076
* docs: CONTEXT.md vocabulary for ref generation pins
Moved from #1097 per the review sequencing note: the term lands with
the behavior it describes.
* docs: teach the ref pin syntax in CLI help
MCP agents get pins transparently (auto-pinning), but CLI-driving
agents only ever met the coarse warning — refsGeneration arrived in
snapshot responses with nothing explaining it, making pins an
undiscoverable feature on the primary agent surface. One help line in
the agent loop guidance closes that; warnings stay short (they fire
repeatedly, teaching belongs in once-read surfaces).
* fix: per-ref MCP pin provenance and seeded generations
Review findings on the first cut:
1. The MCP layer kept ONE refsGeneration per session, so after
snapshot(s12) -> find(s13) a plain @e37 from the pre-find snapshot got
pinned ~s13 and read as current — recreating the find-blessing hole at
the pinning layer. Replace it with per-ref provenance:
Map<pinScope, Map<refBody, generation>>, scoped by state dir + session
name (stateDir is a per-call MCP config field, so one server process
can face multiple daemons). Merge-only updates: refs present in a
ref-issuing response (snapshot nodes, digest refs, the find ref) move
to its generation; absent refs KEEP their older pins — an old pin on a
replaced tree is what makes the daemon warn. Never-issued refs pass
through unpinned; an issuing response without refsGeneration clears
the scope; memory bounded to the ~1000 most recently issued pins.
2. Generations were per-lifetime counters from 1, so a reopened
session's ~s1 collided silently with the previous lifetime's. Seed
the first bump at a random 6-digit base (crypto randomInt):
cross-lifetime collisions become ~1e-6 — probabilistic (seeded), not
identity-based, documented on the field. Pin format unchanged;
within-lifetime comparisons stay exact.
Tests: the MCP blessing scenario (pre-find ref stays pinned to ITS
generation), the daemon half in the provider scenario (find must not
bless a pre-find pin), reopen/reseed at unit + handler level, state-dir
scope isolation, digest-ref merging; generation fixtures made
seed-agnostic (relative bumps, echo the observed seed).
Refs #1076
* test: slow-test ratchet, budget-derived emulator poll, speed guidance from experiments
Measured (2026-07-04, full unit suite: 340 files / 3,210 tests / 48s wall):
wall clock was bounded by the slowest FILE (44.6s android monolith at ~7x
file-level parallelism), and the slowest tests were sleeping through real
production budgets (10.8s proving 'times out' by waiting the constant out,
8s emulator polls at 1Hz, real retry backoff). Two config experiments
rejected with data: --no-isolate exploded the suite to 205s (module state
thrashes across files sharing workers) and --pool=threads changed nothing.
- scripts/vitest-slow-test-reporter.ts: the slow-test ratchet. Unit budget
2.5s / integration 15s; failure at 2x budget (the band between reports
without failing so host-load variance cannot make the gate cry wolf);
36 pinned offenders, exact keys, ratchet-only pin (tracking #1098).
- waitForAndroidEmulatorByAvdName: poll cadence derives from the caller's
budget (min 1s, floor 50ms, ~timeout/20) — devices.test.ts 25.6s -> 2.8s
(9x) in isolation, and short-budget production calls stop sampling at
1Hz against small budgets.
- vitest.config: slowTestThreshold 500 for local visibility; reporter
wired; isolation/pool decisions documented with the measurements.
- docs/agents/testing.md 'Speed rules' + AGENTS.md testing bullet: the
three conversion patterns in preference order (budget-derived cadence,
budget-wiring assertion, fake clocks), the no-seam constraint, and the
file-granularity Amdahl argument that makes the monolith test split a
wall-clock fix, not just navigation.
* fix: fallow findings on the slow-test gate — import edge, factory reporter, unit tests
The string-path reporter wiring read as a dead file (fallow cannot see
vitest's reporter loading); the config now imports the factory, making
the edge real and type-checked. The class shape tripped the
unused-class-members rule (framework callbacks are invisible to
reference analysis) — converted to a factory returning the Reporter
object, with the classification and rendering logic extracted as pure
exported functions. Those functions now carry their own unit tests
(budget bands, integration budgets, pin matching, warn-vs-fail
rendering), which also grounds the CRAP estimate in real references.
Canary re-verified: unpinned 5.2s sleeper fails the run with exit 1;
clean runs exit 0.
* docs: refocus AGENTS.md on principles and gates; index ADRs; extend CONTEXT.md vocabulary
AGENTS.md: replace the routing/command-family prose maps (already
drifting from the code) with pointers to the self-describing,
parity-tested registries; add the two sections agents actually cannot
rediscover cheaply — Principles (one line per incident-backed lesson)
and Enforcement gates (the classify-don't-suppress index); extend the
module-size guidance from raw LOC caps to answer-one-question files,
1:1 test topology mirroring (removing the integration-aggregation
exemption that produced 3,400-line test files), sibling fixture
modules, claim collocation, and boundary-only barrels; record the
dev-loop staleness triple (dist/daemon/adopted-runner), the tsgo
typecheck, the Gatekeeper first-node-exec stall, the DEVICE_IN_USE
signature, and the contention-flake protocol; append the two gate
steps to the new-flag checklist.
CONTEXT.md: vocabulary for the ADR 0011 domain (dispatch path,
guarantee cell, owned waiver, parity table, coverage manifest,
delegation-on-error, ref generation pin) and an architecture paragraph
positioning ADR 0011 as ADR 0008's interaction-semantics counterpart.
docs/adr: flip 0011 to Accepted (implemented through Layer 3) and add
a read-this-when index that names the registries as the living source
of truth over ADR prose.
* docs: defer versioned-ref references to the implementing PR
Review sequencing note on #1097: these lines described #1096 behavior
not yet on main. They move to #1096's branch so docs land with the
implementation and the two PRs merge in any order.
tapTarget/fillTarget are wired solely to the web provider's clickRef
(interaction-runtime.ts); apple/android backends never define them.
Found while designing the #1088 retirement experiment, which this
dissolves: the hypothesized iOS runner round trip does not exist.
Refs are positional indexes into the latest stored session tree; any
selector-based command's resolution capture silently reshuffles them
(#1076). Track an honest marker on the daemon session
(snapshotRefsStale): set wherever the stored snapshot is replaced by a
response that does not hand the new refs to the client, cleared only
where the client demonstrably receives them (snapshot responses, find
ref outputs). Commands consuming @ref arguments while the marker is set
(press/click/longpress/fill/get/wait) attach a warning and still
execute — the geometric guards keep catching detectable drift.
Closes#1076
* feat: direct iOS selector falls back to tree resolution on semantic failures (ADR 0011)
Runner ELEMENT_NOT_FOUND/AMBIGUOUS_MATCH on the direct iOS selector
dispatch now delegate to the tree-based runtime path, which supplies
runtime disambiguation, occlusion refusal, non-hittable
promotion/annotation, and rich selector diagnostics/hints.
Maestro replay dispatches (allowNonHittableCoordinateFallback) keep the
runner-native error shapes: the new delegateSemanticFailures option is
threaded from the dispatch flag, and the query path
(allowElementNotFound at selector-runtime) keeps its exact semantics.
Registry: direct-ios-selector errorTaxonomy/nonHittable/occlusion flip
from gap waivers to delegated cells; the pinned gap list shrinks 6 -> 3.
Success-path parity cells (disambiguation, responseIdentity)
intentionally remain gaps per ADR 0011.
Refs #1081
* test: contract coverage for the three newly-delegated direct-iOS cells
The coverage gate demanded scenarios the moment the rebase flipped
errorTaxonomy/nonHittable/occlusion to delegated — as designed. Three
transcript scenarios prove the delegation end to end: runner
ELEMENT_NOT_FOUND falls back to runtime no-match diagnostics, to the
covered refusal, and to an annotated coordinate tap (the transcript
asserts the fallback tap is coordinate-keyed).
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>
* fix: preserve the runner's non-hittable fallback marker through direct selector press
The direct iOS selector press handler spread successText('Tapped <selector>')
after the runner payload, clobbering the 'tapped via non-hittable coordinate
fallback' message that directIosSelectorFallbackDetails keys on — so
maestroNonHittableCoordinateFallbackUsed could never be true end-to-end (the
existing unit test passes because it mocks dispatchCommand above this layer).
Found by the ADR 0011 Layer-3 maestro-fallback contract scenario.
Share the marker string as MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE and keep it
as the success message when the runner reports fallback usage.
* test: interaction contract suite with registry-driven coverage gate (ADR 0011 Layer 3)
test/integration/interaction-contract/ holds one scenario file per dispatch
path, each with a sibling .coverage.ts manifest declaring which guarantee
matrix cells it proves (scenario strings double as the vitest test titles).
index.ts aggregates the manifests statically, and a new Layer-3 gate in
src/contracts/__tests__/interaction-contract-coverage.test.ts fails when any
enforced (runtime/runner/delegated) cell lacks a scenario or when a scenario
claims a waived/inapplicable cell — coverage of the matrix is by
construction in both directions.
Path forcing is natural (no test-only env switch needed): selector/ref
targets take the runtime path, a tapTarget backend takes the native-ref fast
path, x/y takes the coordinate path, and simple-selector clicks on an iOS
provider transcript take the direct runner path (the transcript itself
proves which path ran via assertComplete).
Fixtures are the permanent Bluesky shapes: closed drawer, drawer + visible
twin, edge-grazing container, covered button, non-hittable cell.
Refs #1081
A timed-out adb invocation has no stderr for the failure classifier to
match, so it fell through to the generic retry hint — retrying a wedged
adb server times out identically. Classify exec-layer timeouts on the
structured timeoutMs detail (the signal createTimeoutError sets) as a
'timeout' family with a kill-server/start-server hint, winning over
untrustworthy partial stderr and losing to site hints as before.
Doctor's android inventory sweep already routes adb failures through
attachAdbFailureHint via listAndroidDeviceEntries, so the device-android
check now surfaces the wedged-server hint; a regression test locks that
path.
Closes#1079
- Remove the runner .tap text arm: the daemon only ever sends
selectorKey/selectorValue or x/y taps (src/platforms/apple/interactions.ts),
and daemon+runner ship in lockstep. findElement(app:text:) stays for the
findText probe.
- Drop settledAfterMs from the wait stable result: it always equaled waitedMs
and never shipped in a release (v0.18.3 predates #1059).
- Add a loading hint when wait stable settles on a tree with fewer than 5
nodes (#1078): stability on a nearly-empty tree is a weak readiness signal.
Refs #1078
* build: migrate the library build from rslib to tsdown (Rolldown)
Replace the Rspack-based rslib build with tsdown, the Rolldown-based
library bundler from the Vite toolchain family, so bundling, testing
(Vitest/Vite), linting (oxlint), and formatting (oxfmt) all run on the
same OXC/Rolldown stack.
Outcome vs the rslib baseline (size-report):
- build time: ~53s -> ~2s
- JS raw +16.2 kB (+1.1%), JS gzip +2.7 kB (+0.6%) - the residual gap
is OXC vs SWC minifier tightness, not chunking
- npm tarball -3.0 kB
- CLI --version startup ~3 ms faster; --help within the +/-5 ms
measurement noise of interleaved A/B runs
Chunk-merging experiments (single shared group, entries-aware groups,
small-module groups) all regressed either total size or --help startup
(a merged shared chunk adds +140 ms), so the default Rolldown split
graph is kept. Custom codeSplitting groups also currently trip a
rolldown-plugin-dts bug that re-emits type-only imports as runtime
imports.
Declarations still bundle per entry via tsgo; dist layout, entry names,
and the internal/ worker/daemon entry resolution contract are unchanged.
@microsoft/api-extractor was only consumed by rslib dts bundling and is
removed together with @rslib/core.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
* ci: only cache the pnpm store when setup installs dependencies
The layering-guard job uses setup-node-pnpm with install-deps: false, so
it never creates a pnpm store. setup-node's post-job cache save then
fails with a path validation error whenever the lockfile hash misses the
cache - which any lockfile-changing PR does. Gate the cache on
install-deps so no-install jobs skip pnpm store caching entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
---------
Co-authored-by: Claude <noreply@anthropic.com>
ADR 0011 Layer 2: the offscreen tap-point rule now has exactly one home per
side of the wire, proven equivalent by a shared golden fixture table.
- contracts/fixtures/tap-point-policy.json: 12 cases including this week's
real bug shapes (closed drawer fully left, 0.07pt edge-grazing container)
plus edge-inclusive and empty-frame fail-open semantics.
- Swift: pure TapPointPolicy.isAllowed(elementFrame:windowFrame:) extracted;
the ELEMENT_OFFSCREEN guard (onScreenWindowFrame stays the frame getter)
and hasTappableFrame (Maestro fallback) both delegate the decision to it.
Gated XCTest asserts every table case (runs in the existing
AGENT_DEVICE_RUNNER_UNIT_TESTS surface CI already compiles).
- TS: isTapPointInsideViewport extracted from isNodeVisibleOnScreen (no
duplicated math); vitest parity test asserts the same table.
- Registry: parityTable wired on direct-ios-selector/offscreen and
maestro-non-hittable-fallback/offscreen.
* feat: warn on out-of-viewport coordinate taps (ADR 0011)
Implements the coordinate/offscreen guarantee: when a point tap target
falls outside the last-known viewport, attach a warning to the result
instead of silently forwarding the tap. This is a WARNING not an error,
maintaining the escape-hatch semantics of coordinate taps.
The warning is generated at the runtime layer in resolvePointInteractionTarget
using the session's last snapshot to infer viewport bounds via resolveViewportRect.
No snapshot is captured (by design), so the implementation reuses the session's
existing snapshot state. When no snapshot is available, no warning is emitted.
Changes:
- Add `warning?: string` field to PressCommandResult and LongPressCommandResult
types (FillCommandResult already had it for --verify evidence)
- Implement tryResolveOutOfBoundsPointWarning in resolution.ts and export it
for registry verification
- Update interaction-guarantees.ts to mark coordinate/offscreen as runtime-enforced
- Remove coordinate/offscreen from the pinned gap list in the test registry
- Add tests: out-of-bounds point with snapshot → warning; in-bounds → no warning;
no snapshot → no warning
- Carry the warning field through daemon handlers for press/click/longpress
Closes#1081 (coordinate/offscreen gap).
* refactor: drop suppressed re-export; registry points at the enforcement carrier
The registry via now references resolveInteractionTarget (the exported
carrier, same precedent as the verifyEvidence cell) instead of forcing a
suppressed unused-export of the internal helper.
The wait timeout bug (#1075) happened because request-envelope budgets and
on-timeout daemon policy lived in two hand-maintained lists in the daemon
client: isExplicitTimeoutCommand (daemon-client.ts) and
DAEMON_PRESERVING_TIMEOUT_COMMANDS / shouldResetDaemonAfterRequestTimeout
(daemon-client-timeout.ts). A command could fall through both without anyone
noticing.
Both lists are deleted. Each command descriptor (ADR 0008 registry) now
declares a required timeoutPolicy:
timeoutPolicy: {
budget: { source: 'none' | 'flag' | 'positional-parser'; parser? };
envelopeMs: number | 'unbounded';
onTimeout: 'preserve-daemon' | 'reset-daemon';
}
The client derives the request envelope and the on-timeout daemon policy from
the declaration; the +30s margin / never-shrink rule for positional budgets is
preserved generically. Envelope constants move from
src/daemon/request-timeouts.ts to src/core/command-descriptor/timeout-policy.ts
next to the policies they parameterize.
A completeness gate (timeout-policy.test.ts) asserts every public command
declares a policy and pins the deviating sets (preserve-daemon = snapshot/
wait/find; flag budget = prepare/replay/snapshot; positional = wait; envelopes
= prepare 240s, install-like 180s, test unbounded) as bounded diffable lists.
The pre-existing oracle tests in src/utils/__tests__/daemon-client.test.ts
pass byte-for-byte unchanged, proving the migration is behaviorally exact.
Every press/click/fill/longpress dispatch branch now builds its result/
responseData payloads through buildInteractionResponseData
(interaction-touch-response.ts), so identity extras (ref/refLabel/
selectorChain/targetHittable/hint/evidence) are composed in exactly one
place. Hand-rolled per-branch assembly is the class of bug that dropped
fill @ref evidence (#1064 review); a guard test now fails any touch
handler that assembles a responseData literal outside the builder.
Wire shapes are byte-identical: the fill @ref backendResult+identity
shape is preserved behind refBackendWireShape, and the daemon +
integration suites pass unchanged.
Registry: all six responseConstruction cells flip from gap waivers to
the shared runtime cell; the pinned gap list shrinks accordingly.
Refs #1081, ADR 0011.
click @ref / fill @ref dispatch straight to backend.tapTarget/fillTarget,
and a backend fast path can silently "succeed" — delegation-on-error never
triggers there. The ref came from the stored session snapshot, so the node
is already in hand: preflightNativeRefInteraction runs the SAME shared
guards the runtime path uses against that node before the backend call.
- occlusion: isSnapshotNodeInteractionBlocked via assertInteractionNotBlocked
(same covered-element error shape as the runtime path)
- offscreen: isNodeVisibleOnScreen via assertVisibleRefTarget (same
offscreen_ref error shape)
- nonHittable: annotation only — targetHittable/hint attach to the result;
promotion stays a runtime-path behavior and the tapped element never changes
No snapshot in the session, an unresolvable ref, or a missing/unusable rect
make the preflight a no-op: it never adds a capture round trip.
Registry: the three native-ref gap cells flip to kind 'runtime' and leave
the pinned gap list.
Refs #1081
* docs+feat: ADR 0011 interaction guarantee contract, Layer-1 registry and gate
Design for making interaction guarantees hold across every dispatch path
(runtime selector/ref, direct iOS selector, native ref, coordinate,
maestro fallback) instead of eroding at path boundaries one incident at
a time — every interaction bug this week was a (path, guarantee) cell
nobody was watching.
Three layers (ADR 0011): declare the path x guarantee matrix as a typed
registry whose completeness is a compile error; share one implementation
per rule on both sides of the wire with golden fixture tables proving
TS/Swift parity; prove every non-waived cell with contract scenarios
generated from the registry.
This lands Layer 1: the registry with an HONEST initial classification —
ten cells are acknowledged gap waivers (direct-path disambiguation/
occlusion/nonHittable/responseFields/errorTaxonomy, native-ref guards,
coordinate bounds) — plus the gate test that keeps entries truthful:
referenced TS symbols must be exported, runner symbols must exist in the
Swift sources, delegations must land on paths that actually enforce the
guarantee, and the gap list is pinned so it can only change explicitly
in a reviewed diff.
* refactor: apply ADR 0011 design review
- Frame Layer 1 as an honesty/completeness gate, not a truth gate:
it proves every path declared a stance and referenced symbols exist;
behavioral parity starts with the Layer-2/3 fixture and scenario work.
- Split responseFields into responseConstruction (one shared response
construction site — a single Layer-2 refactor) and responseIdentity
(which identity fields a path can provide — per-path capability work);
note the anticipated errorTaxonomy split (codes vs diagnostics).
- Encode the hybrid gap-closure strategy: runner-side parity for
geometry-local rules, delegation-on-error for semantic failures (with
the explicit caveat that delegation-on-error is NOT success-path
parity), and a shared runtime preflight for native-ref where a silent
backend success means delegation never triggers.
- Gap waivers now require a trackingIssue (gate-enforced URL); all 16
pinned gaps link the umbrella issue #1081. The honest reclassification
grew the pin list from 10 to 16 — responseConstruction is a gap on
every path including runtime ones, which is exactly the partial
progress the coarser guarantee was hiding.
- Align ADR wording with the code: parityTable is optional until
Layer 3, required once a runner cell claims parity.
* fix: address registry review — maestro disambiguation honesty, command-scoped verify
1. maestro-non-hittable-fallback/disambiguation was overclaimed: the
guarantee is defined as visible-first/deepest/smallest ranking, but
findElement only implements unique-or-ambiguous scanning. Reclassified
as an intentional waiver (deliberate Maestro-semantics divergence),
mirroring how the direct path keeps its success-path parity gap.
2. verifyEvidence was claimed path-wide on paths that dispatch longpress,
which has no --verify. Cells can now be command-scoped via appliesTo
(non-empty strict subset of the path's commands, gate-enforced), and
the three affected cells scope to press/click/fill.
* fix: agent-UX fixes from Bluesky dogfooding
Four fixes found by driving the Bluesky dev build end to end as an agent:
1. wait honors its user-supplied budget in the daemon request envelope.
The wait timeout travels as a positional, so the client never extended
the request timeout: any 'wait ... 180000' died at the 90s default.
resolveDaemonRequestTimeoutMs now parses wait positionals (shared
src/core parser) and extends the envelope to budget + 30s margin.
2. wait/find timeouts no longer reset the daemon. They are repeated
snapshot captures, sharing snapshot's stalled-bridge failure mode, but
sat on the daemon-reset path — one timed-out wait destroyed every
session the daemon owned (observed live: wait timeout -> runner
killed -> SESSION_NOT_FOUND on the next command).
3. Off-screen selector targets are refused instead of silently tapped.
'tap label=Explore' against Bluesky's closed drawer reported success
while tapping (-161, 265) — coordinates that cannot land:
- selector disambiguation now prefers candidates whose CENTER lies in
the root viewport (isNodeVisibleOnScreen; edge-grazing containers
poke fractions of a pixel into the viewport and must not count),
so 'press label=Profile' picks the visible tab over the drawer item;
- selector-resolved interactions get the same off-screen guard refs
already had (reason: offscreen_selector, with a scroll/open hint);
- the runner's direct selector tap refuses matches whose center is
outside the main window frame (app.frame unions transformed
subtrees, so a closed drawer inflates it) with ELEMENT_OFFSCREEN,
which falls back to the tree-based path.
4. The non-hittable 'may have had no visible effect' hint is dropped when
--verify evidence proves the interactive tree changed — the warning
sat directly next to data contradicting it.
Live-verified against Bluesky on the iPhone 17 Pro simulator: offscreen
tap -> offscreen_selector error; ambiguous Profile -> on-screen tab
(355, 817); 100s wait survives the old 90s envelope with the session
intact.
* refactor: dedupe resolved-target tails and off-screen guards, decompose disambiguation
Fallow follow-up on the dogfood fixes: extract the shared ref/selector
resolved-node tail (describeResolvedInteractionNode), the shared
off-screen check-and-throw skeleton (throwIfOffscreenInteractionTarget —
per-caller messages/hints preserved), and the candidate-accumulation
step out of analyzeSelectorMatches. No behavior change; suites and the
fallow gate are green.
* refactor: harden exec failure wrapping end to end
Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:
- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
allowFailure result and throws the curated COMMAND_FAILED (flag set via
execFailureDetails) itself. Result-side by design — tool providers and
executor overrides return results without throwing, so an ExecOptions
knob interpreted at spawn time would silently not fire on those paths.
~30 pure guard-and-throw sites across apple/android/web converted; sites
with tolerance branches, cleanup, or exit-0 reachability keep their
explicit shape.
- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
AppError('COMMAND_FAILED', ...) details literal rebuilds the
stdout/stderr/exitCode trio inline without the helper; intentional
holdouts opt out with a documented exec-guard-allow comment. The guard
immediately found three wrap sites the July audit missed (runtime-hints
run-as probe, device-ready not_ready branch, perf export table) — now
converted.
- coerceExecResult at the provider boundaries (executor overrides, apple
tool provider scope, android adb provider scope): SDK-supplied callbacks
cross an unchecked boundary; coercing once there replaces the per-site
String(result.stdout ?? '') defensiveness, which is removed.
- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
simctl:/error:) before rendering; skip/strip lists stay in the kernel
deliberately (platforms cannot hook normalizeError) with a comment
marking the registry escape hatch if they grow.
- AppErrorDetails exported and documented in kernel/errors.ts: the magic
keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
stdout/stderr/exitCode) now carry types and doc comments.
- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
app-install.ts reuses it instead of duplicating the wrap + hint logic
(its failure hint now falls back to the devicectl default hint).
* fix: reconcile exec hardening with the adb failure classifier
Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:
- Android call sites keep main's androidAdbResultError form — it composes
execFailureDetails with the classified adb hint, which is strictly
richer than a plain requireExecSuccess conversion for adb invocations.
requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
enrichment pass (exec/pull/install), so the existing WeakSet memo also
prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
the perf artifact-tail clone carries fallow-ignore markers on both
platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
compose test and perf sampling reason ('device offline', no 'error:').
* fix(errors): close call-site and consumer gaps around the central error system
Audit + iOS/Android dogfood findings (see docs/adr/0010-error-system.md):
- press/click/fill targets that parse as neither @ref, selector, nor point
now fail with INVALID_ARGS grammar guidance (incl. unquoted multi-word
selector values) instead of UNKNOWN 'Expected x to be a finite number'
- daemon command-input validation throws AppError INVALID_ARGS instead of
bare Error surfacing as UNKNOWN
- selector-no-match and stale-ref failures carry targeted hints
(selectorFailureHint / STALE_REF_HINT)
- retriable/supportedOn survive wire rehydration to CLI --json and SDK
(previously dropped at throwDaemonError / toDaemonHttpRpcError)
- MCP tool errors carry code + hint instead of message-only text
- lease busy/capacity use DEVICE_IN_USE (the retriable code)
- asAppError(err, fallbackCode) replaces cause-dropping coercions in the
Apple runner; new default hints for AMBIGUOUS_MATCH, DEVICE_IN_USE,
UNSUPPORTED_PLATFORM, and a distinct UNKNOWN hint
- ADR 0010 documents the error-system conventions
* fix: format touched files and clear fallow audit gate
- privatize SELECTOR_NO_MATCH_HINT / SELECTOR_NOT_UNIQUE_HINT (consumed
only via selectorFailureHint in the same module) and integerSchema
(only used inside command-input.ts)
- dedupe the resolved-node return tail in interaction resolution into
describeResolvedNode
- extract stringDetail/booleanDetail readers so normalizeError stays
under the complexity threshold
* fix: reject unquoted trailing text after interaction selectors
press/click/longpress positionals like 'press text=Gesture lab' used to
silently drop the leftover tokens and act on the truncated selector
(text=Gesture), potentially hitting the wrong element. Reject non-empty
splitSelectorFromArgs rest with INVALID_ARGS guidance that suggests the
merged quoted form (text="Gesture lab"). Fill keeps consuming rest as
its text payload; wait/is/replay-heal already handle rest explicitly.
* feat: classify adb failures with actionable hints at the executor level
Android COMMAND_FAILED errors surfaced raw adb stderr with only the
generic retry-with-debug hint; the sole stderr classification lived in
snapshot.ts and was retry-only. Add a central classifier in
adb-executor.ts that recognizes the common adb failure families (device
offline/unauthorized/not found, more than one device, no devices,
server version mismatch, connection drops, INSTALL_FAILED_* variants)
and attaches the resolved hint — plus retriable for clearly transient
families — to the AppError details in both executor funnels (local
serial exec and provider-scoped exec), so every adb call site benefits
without per-site changes. The adb devices -l discovery call is wrapped
too, where server-mismatch/no-devices failures actually surface.
snapshot.ts now consumes the shared classifier for its retry decision,
keeping only its snapshot-specific dump-race patterns. normalizeError
lifts details.retriable to the top-level error (mirroring the hint
lift) and the daemon typed-error graft lets a throw-site classification
win over the code-level policy. Also fix the port-reverse removal path
throwing a bare Error that surfaced as UNKNOWN.
Update session-appstate-input-perf.test.ts: its Android sampling-failure
fixture uses stderr "error: device offline", which the classifier now
recognizes, so error.hint carries the actionable adb-reconnect hint
instead of the generic "retry with --debug". The result shape (hint
string, details.metric/package, code) is unchanged — only the hint text
improved, which is this change's intent — so the test expectation moves
to the new hint rather than reverting production.
* feat: classify tolerated adb failures and semantic provider methods
Review follow-up closing two classifier coverage gaps:
1. allowFailure paths bypassed the classifier: the executor wrapper only
enriches THROWN errors, but many Android flows run adb with allowFailure,
inspect the nonzero result, and throw a fresh AppError built from its
stdout/stderr — so e.g. "device offline" during uninstall still surfaced
the generic hint. There was no common construction point for these
errors, so androidAdbResultError in adb-executor.ts now IS that point:
it builds the COMMAND_FAILED error from a tolerated result and runs it
through the classifier, keeping hint strings central. The
result-inspecting throw sites (app launch/uninstall, appearance read,
keyboard/clipboard queries, package listing, logcat capture, helper APK
installs, heap-dump pull, uiautomator dump) route through it; a site
hint (heap-dump pull) still wins because attachAdbFailureHint never
overwrites.
Composes with #1072 rather than replacing it: androidAdbResultError
builds its details via execFailureDetails for nonzero exits, so
normalizeError still suffixes the curated message with the first stderr
line while the classified hint/retriable/adbFailure ride along. Semantic
failures thrown at exit 0 (the am start error-on-stdout path) stay
unflagged, matching #1072's deliberate exit-0 exclusion.
2. Provider-scoped enrichment only wrapped exec, but the transfer
helpers prefer the semantic provider methods, so a provider whose
install rejects with INSTALL_FAILED output kept the generic hint.
Enrichment now lives in normalizeAndroidAdbProvider — the single funnel
every provider passes through (scope installation and explicitly passed
providers alike) — wrapping exec plus pull/install/installBundle; a
WeakSet keeps repeated normalization from stacking wrappers. The local
provider needs no wrap since its methods delegate to the already
enriched serial executor.
Tests: androidAdbResultError classification, hint-plus-excerpt
composition through normalizeError, exit-0 no-excerpt guard, site-hint
precedence, and provider install/pull classification in
adb-executor.test.ts; an allowFailure regression on a real production
path (keyboard state query with a nonzero device-offline result) in
device-input-state.test.ts.
* feat: tag daemon artifacts with semantic types
* Normalize artifact type threading through daemon tracking
* Add timeout error to http server artifact test helper
* test: restore artifact wait helper behavior
* test: fail artifact wait helper on timeout
* refactor: model artifactType as optional on wire shapes
Applies the design-review tweak: producer-owned APIs (reserveOutput,
trackDownloadableArtifact, finalization callbacks) keep the required
'DaemonArtifactType | undefined' form so artifact owners must explicitly
decide, while public/wire/result shapes (DaemonArtifact, both artifact
inventory entry types) become 'artifactType?:' — missing metadata is
valid, JSON drops undefined, and remote/older daemons may omit the
field. Construction sites now omit the key for untyped artifacts, and
the finalization test asserts key absence (toEqual cannot distinguish
absent from explicitly-undefined).
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* feat(interaction): opt-in --verify evidence for press/click/fill (#1047)
Adds an opt-in --verify flag that returns cheap post-action evidence
(foregroundApp, nodeCount, interactiveNodeCount, digest,
changedFromBefore) instead of requiring a full follow-up snapshot to
confirm a mutating command had an effect. The digest hashes the
(type, label, identifier) multiset of an interactive-only capture,
order-independent so it doesn't flip on harmless re-ordering; the
node tree is never serialized back to the client, only the digest and
counts. Default behavior (no --verify) is byte-identical to today.
Implements the approved design from the #1047 issue comment:
- src/utils/ax-digest.ts: new standalone digest module.
- Pre-action digest reuses the snapshot the resolution path already
captures for ref/selector targets (zero extra cost); point targets
opt into one extra baseline capture only when --verify is set.
- Post-action: one interactive-only capture through the same capture
helper, digested and discarded.
- --verify threaded through the CLI flag schema, MCP input schema,
interactionResultExtra allowlist, and MCP output schemas, following
the same plumbing as --double-tap and the #1040 targetHittable
precedent.
- The native-ref/direct-iOS-selector fast paths are skipped when
--verify is set, since they bypass the resolution/capture path
evidence depends on.
* test: cover press --verify with a provider scenario; classify the flag
CI's architecture-progress gate failed on 1 unclassified public flag.
--verify drives real device captures, so it belongs in the
device-observable list backed by an actual provider scenario rather than
the intentionally-outside bucket: the new scenario asserts evidence
(changedFromBefore, digest, nodeCount) on press @ref --verify, that the
verify capture's tree is never serialized into the response, and that
the transcript completes (snapshot -> tap -> verify snapshot; the @ref
path reuses the session snapshot as its baseline, so no extra
resolution capture entry exists).
* fix: type the verify scenario transcript entries
* fix: include interaction extras in the fill @ref response branch
The ref branch of dispatchFillViaRuntime rebuilt responseData from
backendResult/coordinates, dropping interactionResultExtra(result) — so
fill @ref --verify returned no evidence even though the post-action
capture ran (live E2E gap found in PR #1064 review). Spreading the
extras also gives fill @ref the same ref/refLabel/selectorChain (and
conditional targetHittable/hint) fields press @ref already returns.
Adds daemon tests for fill @ref --verify evidence and for the no-verify
path staying evidence-free with no post-action capture.
* fix: report expired daemon resources as expiry, not INVALID_ARGS
TTL/GC'd registry entries (downloadable/uploaded artifacts, resumable
uploads, materialized paths) surfaced as INVALID_ARGS, so agents got
"Check command arguments and run --help" when the resource had simply
expired. Add a shared requireTenantOwnedEntry helper that reports these
as COMMAND_FAILED with reason RESOURCE_EXPIRED and a per-resource
recovery hint, and dedupes the paired wrong-tenant UNAUTHORIZED check.
Also: give request cancellation an explicit "canceled intentionally"
hint instead of the misleading retry default; replace the ad-hoc
INTERNAL_ERROR code (not in the KnownAppErrorCode union) with UNKNOWN
plus a report-a-bug hint; and fix materialized-path TTL expiry of
tenant-owned entries, which threw UNAUTHORIZED in a void-ed promise
(unhandled rejection) and leaked the entry and its temp dir.
* fix: report expired resumable upload tickets as expiry, not INVALID_ARGS
Route requireResumableUpload() through the shared requireTenantOwnedEntry
helper so a missing/expired upload ticket on PUT /upload/direct/:id and
POST /upload/finalize returns the COMMAND_FAILED + RESOURCE_EXPIRED shape
(with a recovery hint) instead of the misleading INVALID_ARGS/400.
Genuinely invalid input (malformed preflight args, bad content-range,
incomplete upload, hash mismatch) stays INVALID_ARGS.