Skip to content

feat(daemon): ADR 0014 session ref-frame lifetime — full implementation#1257

Merged
thymikee merged 14 commits into
mainfrom
claude/dazzling-hawking-73p6kw
Jul 14, 2026
Merged

feat(daemon): ADR 0014 session ref-frame lifetime — full implementation#1257
thymikee merged 14 commits into
mainfrom
claude/dazzling-hawking-73p6kw

Conversation

@thymikee

@thymikee thymikee commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Full implementation of ADR 0014: Session Ref-Frame Lifetime (accepted in #1247). The PR grew from the original steps 1–3 to the complete ADR at the maintainer's request ("do all the scope of the ADR in this PR"). It separates the mutation-authorization namespace (the ref frame) from the latest operational observation, and enforces ref lifetime fail-closed on every platform.

⚠️ Behavior change (was not present in the earlier steps-1–3 head). A mutation through an @ref now expires the session's ref frame, so a later ref mutation without a fresh observation is rejected before dispatch. See the CHANGELOG entry.

What the model does

  • A session owns one ref frame: a frozen epoch (refsGeneration), an immutable source tree, a lifecycle state (active/expired), and an issuance scope (all or a bounded set). Owned solely by src/daemon/ref-frame.ts.
  • @refs resolve against the frame's source tree, never against a newer observation — so an Android freshness (or any read-only) capture cannot retarget an admitted ref by positional coincidence; fresh coordinates are adopted only when the node's local identity still matches.
  • A mutation is admitted only against an active frame whose epoch and scope authorize the ref; rejections carry a typed details.reason and name the lifetime failure.

Migration steps landed

  • 1 — model + admission matrix. ref-frame.ts, order-sensitive admitRefMutation reasons (ref_frame_expiredref_generation_mismatchplain_ref_requires_complete_frameref_not_issued), behind the existing snapshotGeneration/snapshotRefsStale fields; wire names unchanged.
  • 2 — daemon refFrameEffect classification + completeness gate. All daemon-faceted commands classified; request-sensitive resolvers for keyboard/alert; app-switcher facet added.
  • 3 — pre-side-effect seam at every mutating leaf. Interaction runtime, generic leaf, direct-iOS fused selector, mutating find focus/type, and every specialized leaf — keyboard dismiss/enter/return, alert accept/dismiss, settings, React Native overlay dismiss, install/reinstall, open/relaunch, close, and Android blocking-dialog recovery (67272af, addressing the step-3 specialized-leaf review). Each with a focused seam test.
  • 4 — partial issuance scope + MCP + CLI/JSON. find/settled/divergence publish a bounded ref set; scoped-snapshot lineage; MCP pin retention (mutating find non-issuing); pinned partial CLI text with output-economy waivers; response-level refsGeneration unchanged for JSON/Node.
  • 5 — Android freshness decoupled. Frame source tree retained (shared reference, no deep copy); identity-guarded coordinate adoption.
  • 6 — contract/provider evidence. Sequence, settle, find, and cross-platform provider scenarios updated to the new contract; daemon-level test locks evidence fix: skill should work, even if the npm package is not installed #1 (bare + pinned second mutation rejects; fresh snapshot re-authorizes).
  • 7 — fail-closed enforcement across platforms. The admission matrix is enforced before dispatch on every platform with typed reasons and honest messages; frozen frame epoch (refFrameGeneration) so a read-only capture can't falsely reject a valid pin; mutating find re-resolves by locator and bypasses admission while still crossing the seam.
  • 8 (docs) — done. ADR flipped to Accepted; vocabulary promoted into CONTEXT.md; ADR 0012 divergence-ref amendment marked implemented; CHANGELOG entry.

The one deferred item

Per the ADR's own step-8 sequencing, removal of the superseded coarse snapshotRefsStale marker (still backing read-only warnings) follows per-platform enforcement confirmation by fresh live-device evidence — requested in this comment. A seam not exercised on hardware stays an explicit release blocker per the ADR; the marker removal is the clean final commit once those runs land.

Validation

  • tsc clean; oxlint, oxfmt, fallow audit, check:production-exports clean; nothing added to the fallow baseline/allowlist.
  • Full unit-core + provider-integration + output-economy + android-adb green (4084 tests).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB +7.5 kB
JS gzip 546.3 kB 550.1 kB +3.8 kB
npm tarball 660.3 kB 663.4 kB +3.0 kB
npm unpacked 2.3 MB 2.3 MB +7.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 18.2 ms 17.8 ms -0.4 ms
CLI --help 37.7 ms 36.9 ms -0.8 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js -37.3 kB -10.3 kB
dist/src/context.js +32.5 kB +10.2 kB
dist/src/interaction.js +1.9 kB +550 B
dist/src/sdk-batch.js +1.9 kB +137 B
dist/src/session.js +289 B +99 B

@thymikee

Copy link
Copy Markdown
Member Author

CI blocks review on 2c868147: Fallow reports resolveRefFrameEffect in src/daemon/daemon-command-registry.ts:124 as a new production-unused export. Remove the unnecessary export or connect the intended production consumer; do not baseline/allowlist it. Review can resume after the head changes and the gate is green.

@thymikee thymikee changed the title feat(daemon): ADR 0014 ref-frame lifetime — step 2 (effect classification + gate) feat(daemon): ADR 0014 ref-frame lifetime — steps 1–3 (model, classification, seam) Jul 14, 2026

Copy link
Copy Markdown
Member Author

Resolved on the current head (a2b3800), by connecting the consumer rather than baselining — resolveRefFrameEffect was flagged on the intermediate step-2 commit (2c86814), before its consumer existed.

The step-3 commit wires it into production: the generic daemon leaf calls resolveRefFrameEffect(req) to gate the pre-side-effect frame expiry for may-invalidate commands (src/daemon/request-generic-dispatch.ts). Nothing added to the fallow baseline/allowlist.

Fallow Code Quality and check:production-exports are green on the current head (all 22 checks pass). Should be unblocked for review.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head review of a2b380052 found three blockers beyond the resolved Fallow failure:

  1. markSessionSnapshotRefsIssued now calls activateRefFrame, which resets scope to complete/all. Existing callers include find, settled diffs, and replay divergence, but ADR 0014 explicitly defines those as partial publications that must not call a whole-frame marker. As written, a partial result can restore broad mutation authority once enforcement lands.
  2. keyboardRefFrameEffect marks only dismiss as may-invalidate; keyboard enter / return dispatch real mutating return commands but are classified as preserve. The resolver and its completeness tests need to cover every mutating keyboard subaction named by ADR 0014.
  3. ADR migration step 3 requires every leaf side effect, fallback, retry, and recovery to cross the transition. This head wires the interaction runtime and generic leaf, but direct iOS selector dispatch, mutating find focus/type, and Android recovery still bypass expiry. Either complete step 3 here with focused seam tests, or narrow the PR claim and sequence it as a partial prerequisite rather than declaring step 3 landed.

The production export issue is fixed and CI is green, but this head is not ready as ADR 0014 steps 1-3 complete.

Copy link
Copy Markdown
Member Author

Thanks — all three addressed in ce58d83, each with a focused seam test.

1. Partial issuance no longer restores complete authority. You're right: every caller of markSessionSnapshotRefsIssued (find, settled diff, replay divergence) is a partial publication, and it was re-activating a complete all-scope frame. It now only clears the coarse marker; complete re-authorization is reserved for the snapshot command (activateCompleteRefFrame, via buildNextSnapshotSession). Test: a partial publication after a press leaves the frame expired, not re-activated.

2. Keyboard resolver covers every mutating subaction. Fixed — keyboard accepts status/get/dismiss/enter/return; only status/get read, so dismiss/enter/return are now may-invalidate (enter/return dispatch a real return key). Completeness test asserts each.

3. Remaining step-3 seams wired, with tests. Confirmed the gaps and closed them:

  • Direct iOS selector fused dispatch — expire before the fused runner request.
  • Mutating find focus/type — these dispatch directly; find click/fill already delegate through the interaction leaf, so only focus/type needed the seam.
  • Android blocking-dialog recovery — expire before the recovery tap (tapAndroidDialogButton).

Focused seam tests prove the frame expires for each. The PR body's step-3 section is updated to list the full seam set. Enforcement of the expired-frame rejection stays deferred to step 7 behind the per-platform live-evidence gate, so behavior is unchanged.

CI re-running on ce58d83; fallow audit + check:production-exports green locally, full unit-core/provider/android-adb pass.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Verified PR head a2b38005206c0d6f178553d4bc704cb376bbf300 on live iOS and Android targets.

Live verification

  • iOS 26.2 — iPhone 17 Pro simulator
    • Fresh snapshot -i ref opened General through the runtime-ref path.
    • Generic back returned to the Settings root.
    • snapshot -> press @e8 -> press @e9 remained accepted without an intervening capture, confirming this PR does not prematurely enable expired-frame rejection.
    • A fresh snapshot then reissued usable refs; the fresh About ref opened successfully and the resulting tree contained iOS Version, 26.2.
  • Android — Pixel 9 Pro XL emulator
    • Interactive JSON snapshot reported androidSnapshot.backend: android-helper and helperVersion: 0.19.3.
    • A fresh Internet ref succeeded through the runtime-ref path.
    • Generic back returned to Network & internet.
    • A fresh snapshot reissued usable refs; the fresh Navigate up ref returned to the Settings root.

Build / cleanup

  • pnpm build passed.
  • pnpm build:android passed before Android verification.
  • All successfully opened verification sessions were closed; the failed probe against unhealthy emulator-5554 left no active session.

No blocking review findings for the stated steps 1–3 scope. Direct-iOS selector, lifecycle, Android recovery/provider seams, partial issuance, and fail-closed enforcement remain intentionally deferred as described in the PR/ADR.

Copy link
Copy Markdown
Member Author

Thanks for the live verification 🙏 One heads-up on head: you verified a2b3800; the head is now ce58d83 (the three review fixes).

ce58d83 preserves exactly what you verified — the iOS guard stays armed-but-not-enforced, so snapshot -> press @e8 -> press @e9 without an intervening capture is still accepted, and complete snapshot -i re-issuance still re-authorizes. The behavioral change in ce58d83 (partial issuance no longer re-activating a complete frame) only affects find/settled/divergence, which none of your verified flows used; complete-snapshot re-authorization is untouched.

On the deferral note: I took option A from your other review comment and completed the direct-iOS selector, mutating-find focus/type, and Android-recovery seams here (each with a focused seam test), rather than narrowing the claim. What remains deferred is now just the lifecycle open/close seam, partial issuance scope (step 4), and fail-closed enforcement (step 7, gated on this kind of live evidence). The PR body's step-3 section reflects the completed set. A quick re-verify or CI-green on ce58d83 is the remaining gate.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of ce58d833d confirms the prior partial-publication and keyboard-classification findings are fixed, and the new direct-iOS, mutating-find, and Android-recovery seams are covered.

One step-3 blocker remains: classified specialized mutating routes still do not cross expireRefFrame. resolveRefFrameEffect is consumed only by the generic dispatch leaf; specialized paths such as keyboard dismiss/enter/return, alert accept/dismiss, settings mutations, React Native overlay dismissal, install/reinstall, open/relaunch, and close dispatch their side effects without the transition. ADR 0014 migration step 3 requires every leaf side effect, fallback, retry, and recovery to cross that seam; classification alone is explicitly not the transition site.

Add the transition at each specialized leaf and focused seam coverage, or narrow/resequence the PR so it does not claim migration step 3 is complete. CI is green and the prior live evidence remains useful, but this head is not ready under the stated steps 1-3 scope.

Copy link
Copy Markdown
Member Author

Live device verification request (ADR 0014 step 7 enforcement gate)

Fail-closed ref-mutation enforcement is now implemented across platforms (commit 7e41246). Per the ADR's Required-evidence section, before enforcement is enabled for a platform we need fresh-build live evidence exercising every supported production seam. Fixture + provider tests are green, but they don't substitute for this.

Could the local agent run the following against real targets on a fresh build? For each seam, the three-part proof is the same:

  1. fresh ref succeedssnapshot -i, then a ref mutation on a just-issued @eN works;
  2. stale ref rejected before dispatch — a second ref mutation (no re-snapshot) fails with COMMAND_FAILED / details.reason: ref_frame_expired and no device action occurs;
  3. fresh observation restores usability — another snapshot -i re-authorizes and the ref mutation works again.

Seams to cover (skip cleanly any that aren't available on the hardware — just note which):

  • Apple runtime-ref pathsnapshot -i → press @e1 → press @e2 (expect step 2 rejected); also confirm a pinned @e2~s<gen> from the stale generation reports ref_generation_mismatch.
  • Apple direct/native selector path — a direct click label=… fast path, then a stale @ref mutation is rejected.
  • Android helper freshness — with freshness active, confirm a stale @ref cannot retarget by positional coincidence (the fresh capture must not redefine which node @eN authorizes), and a genuinely re-snapshotted ref works.
  • Android blocking-dialog recovery — a recovery tap/relaunch expires the frame; an outstanding @ref action after recovery aborts with ref_frame_expired.
  • Generic / lifecycle mutationrotate / back / app-switcher then a stale @ref mutation is rejected; open/relaunch against an existing session expires its frame.
  • A real provider-backed interaction + one lifecycle op on whatever provider is wired.

For each run, the daemon request log (…/requests/*.ndjson) showing the rejection landed before any device dispatch is the key artifact. A one-line pass/fail per seam (plus which were skipped and why) is enough for me to record the per-platform enablement — a seam not exercised stays an explicit release blocker per the ADR.

Thanks! I'll continue with the remaining scope (contract evidence, docs/CONTEXT.md promotion, coarse-marker removal) in the meantime.


Generated by Claude Code

@thymikee thymikee changed the title feat(daemon): ADR 0014 ref-frame lifetime — steps 1–3 (model, classification, seam) feat(daemon): ADR 0014 session ref-frame lifetime — full implementation Jul 14, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Exact-head review of 0c73a73e found these blockers:

  1. Android recovery continues a stale ref action. press/fill @ref is admitted before ensureAndroidBlockingSystemDialogReady; recovery expires the frame in tapAndroidDialogButton, but runWithAndroidDialogReadinessCheck still calls run() afterward. ADR 0014 requires aborting that original ref action with ref_frame_expired after recovery mutates the UI.
  2. open --relaunch expires after the first side effect. The close-first path awaits relaunchCloseApp() before expireRefFrame(openDispatchSession). A close timeout/failure can therefore mutate while leaving the old frame active. Expire the existing session before the relaunch close dispatch.
  3. Mutation does not clear scoped-snapshot lineage. expireRefFrame() only changes refFrameState; snapshotScopeSource remains available to resolveSnapshotScope, so snapshot -s @ref -> mutation -> snapshot -s @same-ref can borrow stale lineage. Clear lineage at the seam and add that sequence regression.
  4. Missing authorized-frame evidence still falls through to a fresh positional capture. In resolveSnapshotForRef, when the ref is absent/unusable in refFrameSnapshot, lines 575-584 recapture and accept the same body from the new tree. That directly violates the ADR requirement to fail rather than retarget by positional coincidence.
  5. Mutating find still issues refsGeneration. Its action result contains data.ref, then unconditional attachIssuedRefsGeneration marks it issuing; MCP can pin a diagnostic ref after the action even though the contract says mutating find is non-issuing. Branch generation attachment on read-only find actions only.
  6. Empty partial publication mutates stale state. markSessionPartialRefsIssued clears snapshotRefsStale before discovering scope.size === 0; the ADR says an empty partial result leaves existing authority/state untouched. Build the scope first.

Fresh live evidence is also still a release gate: this head enables fail-closed behavior across platforms while the PR body explicitly says the required production seams remain unverified. Fixture/provider tests do not replace that evidence under ADR 0014.

@thymikee

Copy link
Copy Markdown
Member Author

Fresh live verification completed against exact PR head 2d66f5f1505b38556ca2dddc2079eb7b83f0bd21 — this is a new run, not a reuse of the earlier a2b3800 evidence.

Build freshness

  • pnpm build passed.
  • pnpm build:android passed and refreshed the bundled Android helpers.
  • pnpm clean:daemon ran before device verification.

Apple — iPhone 16 simulator, iOS 26.2

  • Runtime ref path: fresh snapshot -i -> press @e8 dispatched successfully. A second mutation through stale @e9, without another observation, failed in 3 ms with COMMAND_FAILED / details.reason: ref_frame_expired.
  • The rejection request log contains only request_start and request_failed; runner.log contains no second tap dispatch at that timestamp.
  • A fresh snapshot advanced the frame from s752889 to s752890. The old @e9~s752889 then failed with ref_generation_mismatch, while fresh @e10 succeeded.
  • Direct/native selector path: click id="BackButton" succeeded with resolution.source: direct-ios; the next mutation through a ref from the preceding frame failed with ref_frame_expired before dispatch.
  • Generic mutation: back expired the frame; a stale ref failed before dispatch.
  • Lifecycle: open settings --relaunch against the existing session expired the frame; a stale ref failed before dispatch, and a fresh snapshot restored mutation.

Key rejection logs:

  • /private/tmp/agent-device-pr1257-ios/sessions/pr1257-ios-runtime/requests/8dc2fd8539ffbb47.ndjson — runtime stale ref
  • /private/tmp/agent-device-pr1257-ios/sessions/pr1257-ios-runtime/requests/2d64edef8b02afde.ndjson — after direct iOS selector
  • /private/tmp/agent-device-pr1257-ios/sessions/pr1257-ios-runtime/requests/8c5707f11c2c1787.ndjson — after generic back
  • /private/tmp/agent-device-pr1257-ios/sessions/pr1257-ios-runtime/requests/f77dd447add52efa.ndjson — after existing-session relaunch

Android — Pixel 9 Pro XL API 37 emulator

  • The fresh interactive snapshot reported androidSnapshot.backend: android-helper, helperVersion: 0.19.3, and helperTransport: persistent-session.
  • Fresh Settings @e9 succeeded and triggered the navigation/freshness path.
  • Stale Settings @e12 (the Connected devices row in the authorized frame) failed in 4 ms with ref_frame_expired before dispatch.
  • The subsequent real helper recapture produced a different tree where positional @e12 meant Chrome. The stale action did not retarget or open Chrome. After that fresh observation reissued the new namespace, fresh @e12 succeeded and opened Chrome.
  • Lifecycle: existing-session open settings --relaunch expired the prior launcher frame; stale @e6 failed in 1 ms with ref_frame_expired, and a fresh snapshot restored mutation.

Key rejection logs:

  • /private/tmp/agent-device-pr1257-android-dedicated/sessions/cwd_259d2f43322547b6_default/requests/b45e9cecb195048a.ndjson — freshness/non-retarget rejection
  • /private/tmp/agent-device-pr1257-android-dedicated/sessions/cwd_259d2f43322547b6_default/requests/76d62b149fbf6409.ndjson — existing-session relaunch rejection

Cleanup

  • All verification sessions were closed; all three isolated state directories report zero active sessions.
  • The dedicated iPhone 16 simulator and Android emulator were returned to shutdown state.
  • An initial Android selector attempt briefly opened Settings on pre-existing emulator-5560; it was detected immediately, closed without --shutdown, and no interaction commands were run on that target. The evidence above came from the separately booted dedicated emulator.

This comment records only the production seams exercised in this run.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of exact head 2d66f5f: the six previously reported defects are substantially addressed, but three blockers remain:

  1. Android dialog recovery now aborts the stale @ref, but its bespoke error only returns details.reason. ADR 0014 requires the standard admission payload (ref, reason, currentGeneration, scope, and pinned mintedGeneration when applicable). Preserve/reuse that structured context through the recovery path.
  2. Add a regression where existing-session relaunch close fails or times out after dispatch and assert the old frame remains expired. The current ordering fix is right, but the ADR-required original failure boundary is not proven.
  3. Fresh live evidence still needs Android blocking-dialog recovery and a real provider-backed interaction plus provider-backed lifecycle operation. ADR 0014 explicitly makes unexercised enabled seams release blockers; the implementation-status claim should not say enablement is confirmed until these runs exist (or the seams remain disabled).

The scoped-lineage, missing-frame, mutating-find, and empty-partial fixes look correct. All current checks are green, but this is not merge-ready yet.

Copy link
Copy Markdown
Member Author

Reconciled the re-review + verdict findings on head eb2ea11:

Recovery rejection now uses the shared admission shape (blocker 1 / verdict P2). runWithAndroidDialogReadinessCheck no longer throws a bespoke error — on recovered it builds the abort via refMutationAdmissionResponse, so the failure carries the full typed context (reason, ref, currentGeneration, scope, and mintedGeneration when pinned), byte-identical to every other expired-frame rejection (evidence #19). Regression interaction-android-recovery-abort.test.ts proves recovery → abort with that shape → no press dispatch.

Relaunch failure-boundary regression added (blocker 2). New test: existing-session open --relaunch where the close dispatch throws after dispatch — asserts the old frame was expired before the close and stays expired (no rollback).

Enablement claim corrected (blocker 3). The ADR implementation-status note now records Android blocking-dialog recovery and a real provider-backed interaction + lifecycle op as unexercised release blockers, not confirmed enablement — enforcement stays on in code, but those two seams are explicitly not claimed verified until their live runs exist. Everything else is backed by your fresh live evidence. So those two runs remain the outstanding gate (my request in 4969256180 still stands for exactly those two).

Snapshot refsGeneration preserved on JSON/Node (verdict P1). Added refsGeneration to CaptureSnapshotResult, serializeSnapshotResult, and normalizeSnapshotResult, with focused serializer + Node-client tests — default snapshot -i --json and Node results now retain the response-level generation to pin against.

The scoped-lineage, missing-frame, mutating-find, and empty-partial fixes you confirmed are unchanged. Full unit-core + provider-integration green (3993 tests), tsc/lint/fallow clean; CI re-running on eb2ea11.

The two live seams (Android recovery, real provider) are the only thing between this and merge-ready — happy to have the local agent run just those two when convenient.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Fresh live verification against current head eb2ea11429bfb819aedd5022e841511a652fd9c0 (not reused from the pre-enforcement run):

  • Rebuilt current head with pnpm build and pnpm build:android; Android helper artifacts report package version 0.19.3.
  • Used a previously powered-off dedicated AVD (Pixel_9_Pro_XL_API_37, runtime serial emulator-5556) with an isolated state directory. Existing booted devices were not used.
  • Fresh snapshot -i --json --debug used the production helper seam: androidSnapshot.backend = android-helper, helperVersion = 0.19.3.
  • Default structured snapshot output now includes refsGeneration (614171), directly covering the JSON serialization regression fixed in this head.
  • Fresh press @e9 succeeded and dispatched to the observed Network & internet row.
  • Without another capture, press @e12 was rejected before dispatch with details.reason = ref_frame_expired and the shared structured fields ref = @e12, currentGeneration = 614171, and scope = all.
  • A fresh snapshot advanced refsGeneration to 614172; fresh press @e2 then succeeded, proving observation restores authorization.

Focused regressions on this head also pass: result serialization, Node client normalization, Android recovery abort/no-dispatch, and relaunch-close failure boundary (58 tests across 4 files).

Cleanup verified: the isolated session list is empty and emulator-5556 is powered off. The Android blocking-dialog recovery seam and a real external provider-backed lifecycle/interaction seam were not exercised by this run and remain explicit release blockers as recorded in the ADR.

@thymikee

thymikee commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Fresh live evidence — exact head eb2ea11

Provider seam: PASS

Target: AWS Device Farm, Google Pixel 9 Pro XL (Android 17), provider session arn:aws:devicefarm:us-west-2:322325783185:session:b2c33eb9-2059-4793-a9ad-1dbdeb0d032a/37b105e1-ec4d-4415-a80f-9dd7c2a5621e/00000.

Public commands from the exact-head worktree:

pnpm ad connect aws-device-farm --platform android --device "Google Pixel 9 Pro XL" --run-id pr1257-provider-head-20260714 --tenant pr1257-provider-head --no-login
pnpm ad open com.android.settings --platform android --session pr1257-provider-head-20260714
pnpm ad snapshot -i --platform android --session pr1257-provider-head-20260714
pnpm ad press @e28 --json --debug --platform android --session pr1257-provider-head-20260714
pnpm ad press @e28 --json --debug --platform android --session pr1257-provider-head-20260714
pnpm ad snapshot -i --platform android --session pr1257-provider-head-20260714
pnpm ad press @e9 --json --debug --platform android --session pr1257-provider-head-20260714
pnpm ad open com.android.settings --relaunch --platform android --session pr1257-provider-head-20260714
pnpm ad press @e38 --json --debug --platform android --session pr1257-provider-head-20260714
pnpm ad snapshot -i --platform android --session pr1257-provider-head-20260714
pnpm ad press @e38 --json --debug --platform android --session pr1257-provider-head-20260714

Observed:

  • Fresh @e28 interaction succeeded through backend: webdriver, targetKind: ref, resolution: {source: ref, phase: pre-action, kind: exact}.
  • Immediate stale @e28 failed before dispatch: COMMAND_FAILED, details.reason: ref_frame_expired, ref: @e28, currentGeneration: 826250, scope: all. Request log: /private/tmp/agent-device-pr1257-provider-head/sessions/pr1257-provider-head_pr1257-provider-head-20260714/requests/746514083397d087.ndjson.
  • Fresh snapshot -i (80 nodes) issued @e9; pressing it succeeded.
  • Existing-session open --relaunch was the lifecycle mutation. Stale @e38 then failed before dispatch with ref_frame_expired, currentGeneration: 826252, scope: all. Request log: /private/tmp/agent-device-pr1257-provider-head/sessions/pr1257-provider-head_pr1257-provider-head-20260714/requests/cd89752ef1de5d0b.ndjson.
  • Fresh snapshot -i (81 nodes) reissued @e38; pressing it succeeded.
  • AWS session status after disconnect: COMPLETED; no cloud artifact URL was ready at collection time. Local request NDJSON is retained under the session path above.

Android blocking-dialog recovery: BLOCKED

Fresh build preparation passed: pnpm build, pnpm build:android, and pnpm clean:daemon.

The only clearly free powered-off local target GestureLab_API36 was selected by Android serial, but emulator startup timed out waiting for sys.boot_completed=1 and it remained powered off. The available com.callstack.agentdevicelab/repo harness exposes no deterministic app-owned ANR trigger, so I did not fabricate one or use raw-device commands. No recovery evidence was claimed.

Safe handoff needs a bootable free Android target plus a deterministic app-owned ANR control/input. The next public flow is:

pnpm ad open com.callstack.agentdevicelab --platform android --serial <free-booted-serial> --session pr1257-android-recovery --relaunch
pnpm ad snapshot -i --platform android --session pr1257-android-recovery
# invoke the supplied app-owned ANR trigger here
pnpm ad snapshot -i --json --debug --platform android --session pr1257-android-recovery
# continue with the outstanding @ref action, then fresh snapshot -i

Cleanup: provider disconnected and completed; PR-specific local session lists are empty; GestureLab_API36 is powered off. An early selector-lock probe accidentally rebound an open request to the pre-existing booted Pixel 9 Pro XL; it was closed immediately without snapshot/interaction and was not shut down. That pre-existing device remains booted.

claude added 13 commits July 14, 2026 21:13
…0014 step 2)

Add the ADR 0014 `refFrameEffect` trait to the daemon command descriptor
facet: every command that reaches a session-owning daemon leaf declares how
it relates to the session's authorized ref frame — `preserve`,
`may-invalidate`, `delegated`, or a request-sensitive resolver for
subaction-dependent commands (keyboard status vs dismiss, alert get/wait vs
accept/dismiss).

This is the honesty/completeness guard, not the transition site: a
`may-invalidate` command still calls the (future) ref-frame module only when
its mutating path runs. No runtime behavior changes here.

- `RefFrameEffect` / `DaemonRefFrameEffect` types and a `resolveRefFrameEffect`
  accessor honoring the resolver form, mirroring the existing closure traits.
- Classify all 58 daemon-faceted commands; `find` is the honest superset
  (`may-invalidate`) pending a read/mutate resolver during enforcement wiring.
- Give `app-switcher` a daemon facet (route unchanged) so the generic-fallback
  escape hatch the ADR calls out is covered instead of silently unclassified;
  drop it from parity's UNROUTED set.
- Completeness gate (`ref-frame-effect.test.ts`): every daemon-projected
  command classifies an effect, every public command is classified or in the
  explicit non-daemon allowlist (`install-from-source`, which projects via the
  `install_source` internal command), and the resolvers/app-switcher resolve as
  declared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
… step 1)

Introduce `src/daemon/ref-frame.ts` as the single owner of the ADR 0014
ref-frame model — the authorization namespace for mutation refs, kept distinct
from the latest operational observation (`session.snapshot`). It defines the
frame's issuance scope and lifecycle state and the pure mutation-admission
matrix (`admitRefMutation`) with the ADR's typed, order-sensitive reasons:
ref_frame_expired, ref_generation_mismatch, plain_ref_requires_complete_frame,
ref_not_issued.

The frame is introduced behind the existing `snapshotGeneration` (epoch) and
`snapshotRefsStale` (coarse client-stale) fields, whose wire-visible names
(`refsGeneration`, the `@e12~s42` pin grammar) are unchanged. New
`refFrameState`/`refFrameScope` session fields default to active/all, so the
matrix currently reduces to the generation-pin check the iOS path already did —
no behavior change. Expiration at the side-effect seam and non-`all` scope land
in later steps.

The existing #1241 iOS stale-ref guard now routes its decision through
`admitRefMutation` (plus the transitional coarse-stale check for plain refs),
so the module is production-live; the external error contract is identical.
Adds a unit test covering the full admission matrix and reason ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
… 0014 step 3)

Route device mutations through the idempotent ref-frame transition. A leaf
expires the current frame synchronously, immediately before awaiting the device
operation, so success, timeout, cancellation, or connection loss all leave it
expired — there is no success-only rollback.

Seams wired:
- interaction runtime backend closures (tap/click, fill, longPress, native web
  clickRef/fillRef, gesture, type) — post-resolution, pre-dispatch, so a
  resolution failure before the seam preserves the frame;
- the generic daemon leaf (back/home/rotate/scroll/tv-remote/app-switcher/
  viewport/focus, ...), gated by the daemon `refFrameEffect` classification via
  `resolveRefFrameEffect`, which is that resolver's first production consumer.

Re-authorization: issuing a complete namespace re-activates the frame —
`markSessionSnapshotRefsIssued` and the snapshot command's
`buildNextSnapshotSession` — so a fresh capture between mutations restores
usability. A diff or kept tree preserves the prior authorization state; internal
read captures never re-authorize.

Enforcement of the new expired-frame rejection is intentionally deferred to step
7, which the ADR gates on fresh live device evidence per platform. The iOS
#1239 guard therefore stays armed-but-not-enforced here: it consults the
admission matrix but still rejects only on the pre-existing conditions (pinned
generation mismatch, coarse plain-ref stale marker), so behavior is unchanged.
Tests prove the transition is wired (a press expires the frame; a re-issue
re-activates it) alongside the idempotency and re-authorization unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…am coverage

Exact-head review found three blockers; all fixed with focused seam tests.

1. Partial issuance no longer restores complete authority. Every caller of
   `markSessionSnapshotRefsIssued` (find, settled diff, replay divergence) is a
   PARTIAL publication, but it re-activated a complete `all`-scope frame. It now
   only clears the coarse marker; complete re-authorization is reserved for the
   snapshot command (`activateCompleteRefFrame`, from `buildNextSnapshotSession`).

2. Keyboard resolver covers every mutating subaction. keyboard accepts
   status/get/dismiss/enter/return; only status/get read, so dismiss/enter/return
   (enter/return dispatch a real return key) are now `may-invalidate`. Alert reads
   are likewise a named set. Completeness test extended.

3. Remaining step-3 leaf seams wired: the direct iOS selector fused dispatch, the
   direct `find` focus/type dispatches (find click/fill already delegate through
   the interaction leaf), and Android blocking-dialog recovery (expire before the
   recovery tap). Focused seam tests for each prove the frame expires.

Enforcement of the expired-frame rejection remains deferred to step 7 behind the
ADR's per-platform live-evidence gate; behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…0014 step 3 complete)

Wire expireRefFrame at the remaining may-invalidate leaves so EVERY mutating
daemon leaf crosses the side-effect transition, not just the interaction/generic
paths:

- keyboard dismiss/enter/return, push, trigger-app-event (shared session leaf) —
  gated by resolveRefFrameEffect so keyboard status/get preserve the frame;
- alert accept/dismiss (get/wait preserve, via the alert resolver);
- settings mutations;
- React Native overlay dismissal;
- install / reinstall (deploy op);
- open / relaunch — expires the reused session's frame before the launch;
- close — expires for uniformity, though a successful close deletes the whole
  session (and its frame) anyway.

Seam tests: keyboard dismiss expires while status preserves (proves the
resolver-gated pattern), and RN overlay dismissal expires. Enforcement stays
deferred; behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
… refs (ADR 0014 step 4)

A find/settled-diff/divergence result publishes only the refs it returned, so it
now activates a bounded PARTIAL frame authorizing exactly those ref bodies
(`markSessionPartialRefsIssued`) instead of nothing — a plain ref then requires a
complete frame and a pinned ref outside the set is rejected. An empty partial
result leaves prior authority intact.

- read-only find publishes its one ref; settled diff publishes its added lines +
  `refs` + `tail`; divergence publishes its capped, non-covered, non-chrome
  digest set.
- MCP: a mutating `find` returns no `refsGeneration` and is explicitly
  non-issuing — it no longer hits the missing-generation branch that wiped the
  whole per-session pin scope (forwarding the old pin is how the daemon produces
  a precise stale rejection).
- Human-CLI partial results render reusable refs in ready-to-copy `@eN~s<gen>`
  form (find + settled tail); JSON/Node keep plain bodies + one response-level
  generation, and MCP stays plain (it auto-pins). Output-economy waiver covers
  the +8-byte tail-pin increase with an ADR justification; the workflow oracle
  treats a pinned ref as surfacing its plain body.

Enforcement of the frame's expiry and partial-scope rejections stays deferred to
step 7 (behind the ADR's per-platform live-evidence gate), so this is
behavior-preserving; the iOS guard now consumes the admission verdict for a
typed `details.reason` on the rejections it already emitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…4 step 5)

Retain the ref frame's immutable source tree (shared reference, no deep
copy) and resolve a `@ref` against it rather than the latest operational
observation. An Android freshness — or any read-only — capture advances
`session.snapshot` without disturbing the frame tree, so the two
intentionally diverge.

At resolution, adopt the fresh observation's node (its current on-screen
coordinates) ONLY when its local identity still matches the authorized
node — the legitimate "element moved" case. If a different element now
sits at that index, keep the authorized frame node so a positional
coincidence cannot retarget the action.

Expose the frame tree to the command runtime through
`CommandSessionRecord.refFrameSnapshot`; pre-frame sessions fall back to
`snapshot` and behave exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…ADR 0014 step 7)

Enforce the ref-frame admission matrix on every platform before dispatch:
an expired frame, a superseded generation pin, a plain ref against a
partial frame, or an unissued pinned ref is now rejected with a typed
`details.reason` and an honest message that names the lifetime failure
instead of claiming the ref was missing or lacked bounds. The prior
iOS-only, coarse-marker guard is replaced.

Freeze the frame epoch at issuance (`refFrameGeneration`) so a later
read-only capture that advances the observation counter cannot falsely
reject a correct pin from the issuing frame; staleness warnings compare
against the same frame epoch.

A mutating `find` re-resolves its target by locator against a fresh
capture, so its internal leaf dispatch carries `internal.findResolvedTarget`
and skips ref admission (it still crosses the seam and expires the frame).

Update unit and provider-integration scenarios to the new contract:
multi-mutation ref sequences re-observe between mutations, settled refs are
consumed in pinned form, and rejections assert the typed reason.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…status

Flip ADR 0014 to Accepted, promote the ref-frame / frame-expiry-seam /
mutation-admission vocabulary into CONTEXT.md, correct the `@ref`
resolution note to the frame-tree model, record the migration status
(steps 1–7 landed; coarse-marker removal follows live-evidence
confirmation), update ADR 0012's divergence-ref amendment to accepted,
and add a CHANGELOG entry for the fail-closed ref lifetime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
Add a daemon-level sequence test proving the canonical contract: after an
unobserved first ref mutation, a second mutation rejects both bare and
pinned with ref_frame_expired, and a fresh snapshot re-authorizes. Refresh
the ref-frame module header and seam-expiry test comment now that
enforcement is live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
1. Android dialog recovery aborts an outstanding ref action: a ref
   press/fill admitted against the pre-recovery frame now fails with
   ref_frame_expired when before-command recovery mutates the UI, instead
   of continuing against the recovered screen (selector/coordinate actions
   still re-resolve and continue).
2. open --relaunch expires the existing session's frame BEFORE the close
   dispatch, so a close timeout/failure that already tore the app down
   still leaves the old frame expired.
3. expireRefFrame clears scoped-snapshot lineage (snapshotScopeSource) at
   the seam, so snapshot -s @ref -> mutation -> snapshot -s @same-ref can
   no longer borrow stale lineage across a device side effect.
4. Missing authorized-frame evidence fails closed: resolveSnapshotForRef no
   longer recaptures and accepts the same ref body from a newer tree by
   positional coincidence. A mutating find's internal dispatch resolves
   against its own fresh capture (omitRefFrameSnapshot), not the frame.
5. Mutating find omits refsGeneration — its acted ref is diagnostic
   pre-action identity and must not be pinnable after the action.
6. An empty partial publication leaves all session state untouched
   (including the coarse marker), instead of clearing it before finding
   there were no refs to issue.

Adds focused regressions (lineage-cleared sequence, empty-partial no-op,
fail-closed on unusable bounds, in-frame label recovery, mutating-find
non-issuance) and extracts the find action dispatch to keep complexity in
budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…R 0014 re-review)

P1: structured JSON/Node snapshot results now retain the response-level
refsGeneration. It was declared on the daemon response but dropped by the
public CaptureSnapshotResult type, the serializer, and the Node normalizer,
so default `snapshot -i --json` emitted refs with no generation to pin
against. Added to the type, serializer, normalizer, plus CLI/Node tests.

P2: Android dialog-recovery abort now reuses the SHARED admission rejection
(refMutationAdmissionResponse) instead of a bespoke error, so the failure
carries the full typed context (reason, ref, currentGeneration, scope,
mintedGeneration) identical to every other expired-frame rejection across
platforms. Removes the now-unused AppError/refFrameState imports. Adds a
regression proving recovery aborts the outstanding ref action before any
press dispatch.

Also adds the relaunch failure-boundary regression (existing-session close
fails after dispatch → old frame stays expired), and corrects the ADR
implementation-status note so Android blocking-dialog recovery and a real
provider-backed interaction/lifecycle are recorded as unexercised release
blockers rather than confirmed enablement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…ry sole blocker

The provider-backed interaction + lifecycle seam is now confirmed by fresh
live evidence (AWS Device Farm, webdriver backend). Update the ADR
implementation-status note so only Android blocking-dialog recovery remains
an unexercised release blocker — and note it is blocked on a bootable free
Android target plus a deterministic app-owned ANR trigger, not on any code gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
@thymikee thymikee force-pushed the claude/dazzling-hawking-73p6kw branch from 45da8d5 to c769eed Compare July 14, 2026 19:15
Per the review decision: the Android blocking-dialog recovery seam has no
deterministic app-owned ANR repro in the harness, so it was not live-
exercised. The team accepted shipping without a live run for it — its
transition/abort logic is covered by fixture regressions and it is enforced
in code identically to the verified paths. Reword the status note from an
open release blocker to a documented, accepted evidence gap, which unblocks
step 8's coarse-marker removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
@thymikee thymikee merged commit 54977f3 into main Jul 14, 2026
22 checks passed
@thymikee thymikee deleted the claude/dazzling-hawking-73p6kw branch July 14, 2026 19:25
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-14 19:25 UTC

thymikee added a commit that referenced this pull request Jul 14, 2026
…ef frame

Rebase reconciliation. #1257 (ADR-0014 session ref-frame lifetime) landed on
main and changed captureDivergenceObservation to activate a PARTIAL ref frame
(markSessionPartialRefsIssued) authorizing exactly the divergence screen's
emitted refs — computing that "digestBodies" set with its own document-order,
non-covered-only filter. My #1264 change made buildReplayDivergenceScreenRefs
emit a DIFFERENT set (ranked, occlusion-fallback, meaningful-filtered), so the
authorized frame would no longer match the shown screen: in the mass-covered
fallback the screen surfaces covered refs that #1257's non-covered-only frame
filter excluded, leaving the agent a ref the screen advertised but the frame
rejects.

Extract selectDivergenceScreenRefNodes as the single source of truth for which
nodes screen.refs publishes and in what order. Both the rendered digest
(buildReplayDivergenceScreenRefs) and the partial-frame authorization
(captureDivergenceObservation -> markSessionPartialRefsIssued) derive from it,
so the frame authorizes exactly the emitted set — preserving BOTH #1257's
ADR-0014 intent and #1264's ranking/occlusion intent. Also refresh the
captureDivergenceObservation doc to the partial-frame sequence.

Test: assert the partial ref frame scope (session.refFrameScope) equals the
emitted screen.refs set in the mass-covered fallback (covered refs included) —
verified to fail on #1257's original non-covered-only digestBodies.

Refs #1264 #1257
thymikee added a commit that referenced this pull request Jul 14, 2026
#1265)

* fix: unify divergence screen capture with snapshot's full-window scope

Route captureDivergenceObservation through captureSnapshotData — the same
function the snapshot command itself builds its capture with (Android's
snapshot-helper full-window route with its graceful app-scoped fallback,
iOS's bounded system-modal probe path, macOS/Linux surface-scoped branches)
— instead of a parallel hand-rolled dispatchCommand call. The chrome filter
and meaningful-target filter stay layered on top as filters over that full
capture, never as a scoping.

Amends ADR-0012 decision 4 to state the invariant: an agent must never see a
healthier `screen` in a divergence report than a plain `snapshot` would show
it, so a separate-window system overlay (volume dialog, quick-settings
shade, permission dialog) must survive into `screen.refs` exactly as
`snapshot` would present it.

Also fixes the synthetic `volume_dialog_slider` id in
snapshot-chrome-android-statusbar.test.ts to the real, live-verified
`volume_new_ringer_active_icon_container` id and rewords the test comment to
read as a filter-logic unit test rather than a live-capture-path claim, and
adds unit coverage for the invariant itself.

Fixes #1264

* fix: rank divergence screen.refs within the cap so overlays are not buried

The #1264 root cause is cap burial, not capture scope: buildReplayDivergenceScreenRefs
sliced candidates in document order, so a fully-captured separate-window
overlay (volume dialog, QS shade, permission dialog) that enumerates after
the app window's ~77 nodes lands past position 20 and is truncated away —
the report shows a healthy-looking app under a covering overlay it cannot see
(archived evidence: screen.truncated: true, zero volume refs).

- Rank within the cap instead of document-order slicing: foreign-window
  (non-app-bundleId) hittable nodes — the dismiss targets for whatever covers
  the app — are promoted ahead of app content, otherwise stable (document
  order preserved within each tier; equal-priority app nodes never reshuffled).
  The 20-cap is a byte bound, not a first-20-in-tree-order policy.
- Occlusion fallback: when a system overlay mass-covers the app (every app
  node annotated interactionBlocked: 'covered'), surface those covered nodes
  rather than emitting an empty screen.refs — a report whose capture holds
  meaningful nodes but whose refs is empty is broken by construction.
- repairHint/suggestions consume the full captured node list, not the capped
  refs slice, so hint routing is unaffected; only screen.refs selection changes.

Detection keys off node.bundleId (Android-only, from the a11y package); iOS/macOS
leave per-node bundleId undefined, so ranking degrades to document order there
(safe — those platforms surface modals via the probe path, not by cap-competing).
Guarded on a known appBundleId so a sessionless capture never reorders.

Tests: replaces the small-fixture #1264 test (which the overlay fit inside the
cap regardless of order, so it did not prove the invariant) with a realistic
full fixture (24 app controls + overlay dismiss-target captured LAST) that
fails on document-order slicing and passes with ranking; plus occlusion tests
(mass-covered app -> overlay surfaces, refs non-empty; bare-scrim fallback ->
covered app nodes surfaced, refs non-empty). Both were verified to fail before
the fix. ADR-0012 decision 4 amendment reworded to cover ref-selection ranking
and the occlusion guarantee, not only capture scope.

Refs #1264

* fix: route divergence capture through captureSnapshot wrapper + clean flags policy

Completes the #1264 capture unification. The prior round routed
captureDivergenceObservation through captureSnapshotData (the inner single-shot
capture), but plain `snapshot`'s backend calls the HIGHER captureSnapshot
wrapper, which owns Android freshness + post-action retry. A divergence could
therefore consume the first stale/app-scoped dump while a plain `snapshot`
retries to the fresh full-window tree — a divergence staler/narrower than
`snapshot`, violating the invariant.

- Route the divergence capture through the same `captureSnapshot` wrapper as
  plain snapshot, so it inherits freshness/post-action retry parity. No fork:
  the wrapper's params (device, session, flags, logPath) are all suppliable
  from the divergence path.
- Build the divergence capture's flags from a clean, fixed policy
  (`divergenceCaptureFlags`: full-window, non-raw, default depth) instead of
  spreading the failed action's flags — so a failed `snapshot --raw`/scoped/`-d`
  action can no longer narrow the diagnostic tree. Only the interactive-only
  policy is carried (extracted as a helper so captureDivergenceObservation
  stays within complexity budget).

Tests: a freshness-retry regression (session carries an active Android
freshness marker; capture-1 is a stale near-empty dump that trips sharp-drop,
capture-2 holds the overlay — asserts the divergence uses the retried fresh
tree and dispatched twice), and a clean-flags regression (a failed
raw/scoped/depth action — asserts the snapshot dispatch context drops
snapshotRaw/scope/depth while still applying interactive-only). Both verified
to fail on the pre-fix code. ADR-0012 decision 4 amendment updated to state the
same-wrapper (freshness parity) and clean-flags guarantees.

Live overlay acceptance remains a maintainer device step (env down): unit
fixtures prove ref SELECTION after nodes are supplied, not that the Android
helper returns the separate-window overlay at divergence time.

Refs #1264

* test: stub the freshness-retry sleep so the capture-parity test doesn't real-wait

The #1264 capture-parity regression exercised the real Android sharp-drop
retry, which awaited the real ~250 ms `sleep` delay — repo guidance forbids
real-time waits in unit tests. Mock `sleep` (the delay the retry path in
snapshot-capture.ts awaits) to a no-op at the module level, so the retry
BRANCH still executes (loop runs, retries, re-captures) without a wall-clock
wait. The test still proves the branch: two on-device dispatches and use of
the retried fresh tree (overlay present). Verified it still fails on the
pre-fix single-shot path (1 dispatch) with the delay stubbed, so the stub does
not make it vacuous. No production change; the delay stub needs no DI seam
since `sleep` is a plain module export.

Refs #1264

* fix: reconcile divergence ref selection with #1257 ADR-0014 partial ref frame

Rebase reconciliation. #1257 (ADR-0014 session ref-frame lifetime) landed on
main and changed captureDivergenceObservation to activate a PARTIAL ref frame
(markSessionPartialRefsIssued) authorizing exactly the divergence screen's
emitted refs — computing that "digestBodies" set with its own document-order,
non-covered-only filter. My #1264 change made buildReplayDivergenceScreenRefs
emit a DIFFERENT set (ranked, occlusion-fallback, meaningful-filtered), so the
authorized frame would no longer match the shown screen: in the mass-covered
fallback the screen surfaces covered refs that #1257's non-covered-only frame
filter excluded, leaving the agent a ref the screen advertised but the frame
rejects.

Extract selectDivergenceScreenRefNodes as the single source of truth for which
nodes screen.refs publishes and in what order. Both the rendered digest
(buildReplayDivergenceScreenRefs) and the partial-frame authorization
(captureDivergenceObservation -> markSessionPartialRefsIssued) derive from it,
so the frame authorizes exactly the emitted set — preserving BOTH #1257's
ADR-0014 intent and #1264's ranking/occlusion intent. Also refresh the
captureDivergenceObservation doc to the partial-frame sequence.

Test: assert the partial ref frame scope (session.refFrameScope) equals the
emitted screen.refs set in the mass-covered fallback (covered refs included) —
verified to fail on #1257's original non-covered-only digestBodies.

Refs #1264 #1257
thymikee pushed a commit that referenced this pull request Jul 15, 2026
…er (ADR 0014 step 8)

The coarse `snapshotRefsStale` client-stale marker is fully superseded by the
ref-frame model and is removed:

- `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it —
  replacing the latest observation is a read that never touches the frame.
- Read-only ref staleness now derives from frame state: a plain ref warns once
  the frame has EXPIRED (a device side effect changed the screen), and a
  read-only capture no longer marks refs stale because it does not expire the
  frame. Pinned-ref warnings keep comparing against the frozen frame epoch.
- Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker)
  and the `session.snapshotRefsStale` field.

Migrates every test off the marker to the frame model (frame-expiry drives the
read warning; complete/partial activation drives admission), and updates the
ADR status + module docs to record step 8 as landed. Ships as follow-up to the
merged #1257 since that PR closed before this step.

Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
thymikee pushed a commit that referenced this pull request Jul 15, 2026
…er (ADR 0014 step 8)

The coarse `snapshotRefsStale` client-stale marker is fully superseded by the
ref-frame model and is removed:

- `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it —
  replacing the latest observation is a read that never touches the frame.
- Read-only ref staleness now derives from frame state: a plain ref warns once
  the frame has EXPIRED (a device side effect changed the screen), and a
  read-only capture no longer marks refs stale because it does not expire the
  frame. Pinned-ref warnings keep comparing against the frozen frame epoch.
- Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker)
  and the `session.snapshotRefsStale` field.

Migrates every test off the marker to the frame model (frame-expiry drives the
read warning; complete/partial activation drives admission), and updates the
ADR status + module docs to record step 8 as landed. Ships as follow-up to the
merged #1257 since that PR closed before this step.

Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
thymikee pushed a commit that referenced this pull request Jul 15, 2026
…er (ADR 0014 step 8)

The coarse `snapshotRefsStale` client-stale marker is fully superseded by the
ref-frame model and is removed:

- `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it —
  replacing the latest observation is a read that never touches the frame.
- Read-only ref staleness now derives from frame state: a plain ref warns once
  the frame has EXPIRED (a device side effect changed the screen), and a
  read-only capture no longer marks refs stale because it does not expire the
  frame. Pinned-ref warnings keep comparing against the frozen frame epoch.
- Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker)
  and the `session.snapshotRefsStale` field.

Migrates every test off the marker to the frame model (frame-expiry drives the
read warning; complete/partial activation drives admission), and updates the
ADR status + module docs to record step 8 as landed. Ships as follow-up to the
merged #1257 since that PR closed before this step.

Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
thymikee added a commit that referenced this pull request Jul 15, 2026
…er (ADR 0014 step 8) (#1268)

* refactor(daemon): remove the superseded coarse snapshotRefsStale marker (ADR 0014 step 8)

The coarse `snapshotRefsStale` client-stale marker is fully superseded by the
ref-frame model and is removed:

- `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it —
  replacing the latest observation is a read that never touches the frame.
- Read-only ref staleness now derives from frame state: a plain ref warns once
  the frame has EXPIRED (a device side effect changed the screen), and a
  read-only capture no longer marks refs stale because it does not expire the
  frame. Pinned-ref warnings keep comparing against the frozen frame epoch.
- Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker)
  and the `session.snapshotRefsStale` field.

Migrates every test off the marker to the frame model (frame-expiry drives the
read warning; complete/partial activation drives admission), and updates the
ADR status + module docs to record step 8 as landed. Ships as follow-up to the
merged #1257 since that PR closed before this step.

Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

* fix(daemon): resolve @ref reads from the frame tree; scope find's internal warning

Address three review blockers on the coarse-marker removal (ADR 0014 step 8):

1. @ref reads now bind against the authorized frame tree
   (`refFrameSnapshot ?? snapshot`) in `requireSnapshotSession`, so an
   internal read-only capture that replaced the observation cannot let a
   plain `@eN` resolve a different element by positional coincidence.
   Missing frame evidence fails instead of falling through to a newer
   observation.

2. A mutating find's internal leaf dispatch (`internal.findResolvedTarget`)
   no longer attaches a stale-ref warning in either the press or fill path —
   the caller never consumed a `@ref`, so the public find response must not
   claim it did.

3. `resolveRefStalenessWarning` checks frame expiry FIRST, matching the
   admission order: an expired frame is stale for any ref, even a pin that
   matches the epoch (a matching pin proves identity within the retained
   frame, not that the UI is current).

Regressions: divergent observation-vs-frame trees resolve from the frame
tree or fail when evidence is missing; a locator-based mutating find from an
expired frame carries no stale-ref warning; the reordered resolver unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

* fix: correct stale-ref warning comments and ADR-0014 present-tense marker refs

The get/wait dispatch comments in selector-runtime.ts still described the
superseded coarse snapshotRefsStale marker ("warn when that tree was
replaced since the client last received refs") even though staleness is
now derived from ref-frame expiry (ADR 0014 migration step 8). Reworded
both to describe the frame-derived mechanism actually implemented by
resolveRefStalenessWarning.

session-snapshot.ts's early-return comment in markSessionPartialRefsIssued
referenced "the coarse marker" as something still left untouched, but that
field no longer exists — reworded to name the ref frame fields it actually
preserves.

ADR-0014's "Ref frames are separate from operational observations" section
still described snapshotRefsStale as part of "the existing... implementation"
in present tense, contradicting the Decision section's own note (line 39)
that migration step 8 already removed it. Reworded to keep the historical
mention while stating the removal.

* fix: frame-lifetime wording for the stale-ref warning and read comments

Address the follow-up review blocker plus the co-located terminology cleanup
(ADR 0014 step 8):

- STALE_SNAPSHOT_REFS_WARNING no longer claims "the session snapshot changed";
  it now describes frame lifetime in terms valid for both read warnings and
  mutation rejection — the UI may have changed since the refs were issued, so
  take a new snapshot before relying on or interacting with them. The warning
  fires on frame expiry, including device side effects where no stored snapshot
  changed.
- selector-runtime.ts: the get/wait @ref comments now say the read binds to the
  retained ref-frame evidence and its staleness is frame-derived, not a property
  of the stored snapshot or the live polling capture.
- settle.ts: an unsettled stored capture replaces the observation without
  touching the ref frame; read staleness is driven by side-effect-seam expiry,
  not by storing a fresh observation.
- interaction-settle.test.ts: renamed the settle test off the removed
  stale-marker language to "activates a partial ref frame" (what it asserts).

Comments/test-name/warning-text only — no runtime behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

* fix(daemon): name the ref-frame epoch in the pinned-stale-ref warning

The pinned-ref warning is compared against refFrameEpoch(session) — the frozen
frame epoch — not the latest observation generation, and after a read-only
capture those two diverge. The message still said "the session tree is now sN",
which is ambiguous once the observation counter has advanced past the frame
epoch. Name the ref-frame epoch instead:

  Ref @E12 was minted from snapshot s3 but the session's ref frame is now s15 —
  re-run snapshot -i.

Renames the builder param to `currentFrameEpoch` and corrects its doc comment to
say the pin is compared against the frame epoch, not the stored tree generation.

Regression: `resolveRefStalenessWarning` names the frozen frame epoch, not the
bumped observation generation — a read-only `setSessionSnapshot` advances the
observation counter (15 -> 16) while the frame epoch stays frozen at 15; a pin
at s15 is clean and a pin at s12 names s15, never s16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants