@buoy-gg/agent-core 7.0.43 → 7.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/catalog/catalog.g.js +1 -1
- package/lib/commonjs/catalog/catalog.source.json +1242 -401
- package/lib/commonjs/catalog/catalog.types.g.js +1 -1
- package/lib/commonjs/context/buildContextPack.js +1 -1
- package/lib/commonjs/engine/systemPrompt.js +1 -1
- package/lib/module/catalog/catalog.g.js +1 -1
- package/lib/module/catalog/catalog.source.json +1242 -401
- package/lib/module/catalog/catalog.types.g.js +1 -1
- package/lib/module/context/buildContextPack.js +1 -1
- package/lib/module/engine/systemPrompt.js +1 -1
- package/lib/typescript/catalog/catalog.g.d.ts +3 -3
- package/lib/typescript/catalog/catalog.types.g.d.ts +5 -2
- package/lib/web/index.mjs +27 -27
- package/package.json +1 -1
|
@@ -4,4 +4,4 @@ DANGER: sources is \`?? []\`. Calling it with no params (or sources: []) unsubsc
|
|
|
4
4
|
|
|
5
5
|
Pass GRANULAR EventSource values; they are mapped to parent discovery ids by EVENT_SOURCE_TO_DISCOVERY_ID (storage-async and storage-mmkv both -> 'storage'; the three react-query values -> 'react-query'; route -> 'route-events'). The default when a dashboard starts watching is 'all' minus the high-frequency 'render' source, which is deliberately excluded because it fires per component render and would flush the 200-event buffer (packages/events/src/stores/unifiedEventStore.ts:35). The underlying store also accepts the literal string "all" even though the adapter types this as an array \u2014 prefer an explicit list.
|
|
6
6
|
|
|
7
|
-
RELEASE CAVEAT: asking for "render" is inert in a release build. HighlightUpdatesController.enableBackgroundTracking() returns immediately when !__DEV__ (packages/highlight-updates/src/highlight-updates/utils/HighlightUpdatesController.ts:1905), and React Native only installs __REACT_DEVTOOLS_GLOBAL_HOOK__ under __DEV__, so no render events are ever produced \u2014 the echo still lists it. Every other source (network, storage, redux, zustand, jotai, route, react-query) records normally in release.`,"requires":["@buoy-gg/events installed and auto-discovered by FloatingDevTools","the package behind each requested source installed in the app (otherwise the request is silently a no-op)"],"armsCapture":true},{"action":"clearEvents","summary":"Wipe the entire unified events timeline on the device (all 200 buffered events, every source). Irreversible.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Calls unifiedEventStore.clearEvents(): empties the event array, clears the activeSources set and the network request->unified id map, then notifies listeners and any onClear subscribers (packages/events/src/stores/unifiedEventStore.ts:440). Returns nothing. There is no undo and no snapshot to restore from \u2014 export first if the buffer might matter.\n\nScope: it clears only the AGGREGATED timeline. The per-tool stores keep their own logs, so network requests, redux actions and storage writes are still readable through their own tools after this. Conversely, clearing a source tool does not clear this timeline.\n\nUse it to get a clean baseline right before reproducing a bug (clearEvents -> reproduce -> exportEvents), not as routine cleanup.","requires":["@buoy-gg/events installed and auto-discovered by FloatingDevTools"]}],"unavailableWhen":`The app doesn't have @buoy-gg/events installed/mounted (then toolId "events" is absent from the device's adapter map), or the device is not connected to the broker at all. In a release bundle (__DEV__ === false) the whole sync channel is off unless the app passed externalSync.enableInRelease AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/FloatingDevTools.tsx:707) \u2014 that gates every Buoy action, not just these. Individual sources are also absent when their package isn't installed: auto-discovery require()s @buoy-gg/storage, /redux, /network, /react-query, /route-events, /zustand, /jotai, /highlight-updates and silently skips whichever are missing (packages/events/src/utils/autoDiscoverEventSources.ts:935).`},{"toolId":"network","title":"Network","summary":`Read and act on the app's captured HTTP traffic \u2014 getSnapshot lists the requests, getEventBody fetches one body, and author response-override rules that force matching requests to return a chosen status/body, fail, or arrive late. 17 actions. Two things gate honesty: (1) the device only RECORDS while something holds a capture subscription, so a cold read can be legitimately empty \u2014 call getCaptureStatus before telling anyone "no requests happened"; (2) in a RELEASE build every override write still returns ok:true and the rule still persists and appears in listOverrideRules, but engine.ts:74 refuses to apply it to real traffic, so nothing changes \u2014 call debugOverrides and read engine.devFlag before claiming an override took effect. Boot-time capture (requests fired before anything subscribed) is also DEV-only (preset.tsx:52).`,"actions":[{"action":"getSnapshot","summary":"List the app's captured HTTP requests \u2014 method, url, status, duration, error. The read half of the network tool; there is no action that lists requests.","description":"Returns `{ totalCaptured, shown, requests: [{id, method, url, status, durationMs, error}] }`, newest last. Bodies are NOT included \u2014 take an `id` from here and call `getEventBody` for one response. Narrow with `failedOnly`, `pattern` (substring of the url) and `limit`. The device only RECORDS while something holds a capture subscription, so an empty list can be legitimate: call `getCaptureStatus` before telling anyone no requests happened.","params":{"type":"object","properties":{"limit":{"type":"number","description":"Most-recent N requests after filtering. Default 25."},"failedOnly":{"type":"boolean","description":"Only requests that errored or returned status >= 400. Default false."},"pattern":{"type":"string","description":"Case-insensitive substring the url must contain."}},"additionalProperties":false},"effect":"read","release":"works"},{"action":"getCaptureStatus","summary":"Is the device actually recording right now, and why not \u2014 call this before reporting an empty request list.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns {capturing:boolean, subscribers:number, interceptorInstalled:boolean|null, interceptorLive:boolean|null, listenerCount:number|null, eventCount:number}. `capturing` is driven by the event store's subscriber count, NOT by whether the interceptor is installed: an enabled override rule pins the interceptor without anything recording, so installed:true + capturing:false is a real and common state. installed:true + live:false means something re-assigned fetch/XHR over the interceptor (it self-heals on the next snapshot). null for the interceptor fields means there is no interceptable runtime here. Nothing is recorded while no one is subscribed, so an empty list usually means capture was not armed \u2014 reading arms it, so trigger the traffic and read again. Separately, requests fired before anything subscribed (session bootstrap, first queries) are only buffered in a DEV build.","requires":["@buoy-gg/network mounted in the app","in a release build there is NO boot capture \u2014 packages/network/src/preset.tsx:52 gates startBootCapture on __DEV__, so requests fired before the first watch are unrecoverable"]},{"action":"getEventBody","summary":"Full un-stripped request/response bodies and headers for one request id.","params":{"type":"object","properties":{"id":{"type":"string","description":"Request id from the network snapshot. Live ids, or `saved:<key>` ids from an earlier app run."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Returns {requestData, responseData, requestHeaders, responseHeaders} (each null when absent), or null when no event with that id exists in either the live store or the saved store. Needed because the snapshot withholds bodies over 16KB, header values over 64 chars, and (past a 1.25MB per-snapshot budget) the bodies of older rows \u2014 those rows carry requestBodyOmitted/responseBodyOmitted/headersOmitted:true, which is the signal to call this. Accepts a live id or a `saved:<key>` id from a previous app run.","requires":["the event must still exist \u2014 live capture list (500-request cap) or the saved store"],"armsCapture":true},{"action":"setPinned","summary":"Pin or unpin one request; a pinned request is a full snapshot that survives Clear, the 500-cap and app restarts.","params":{"type":"object","properties":{"id":{"type":"string","description":"Request id from the network snapshot."},"pinned":{"type":"boolean","description":"true pins. false OR OMITTED unpins \u2014 always send this explicitly."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Pinning writes a persisted snapshot of the request, held at the top of the developer\'s Network list regardless of their filters. This is the human<->agent handoff channel: a human pins the call they think is broken, or you pin one for them to look at. WARNING on optionality: the handler reads `pinned` with a truthiness cast, so omitting it (or sending false) means UNPIN. Returns null when no request with that id exists (cleared or aged out \u2014 re-list), {ok:true} when already in the requested state, {ok:true,active:boolean} on a real toggle, or {ok:false,reason:"pin-cap"|"invalid-event"} \u2014 pin-cap is the 25-pin ceiling (lower on some license tiers); clear some first.',"requires":["the event must still exist in the live or saved store"],"armsCapture":true},{"action":"setSaved","summary":"Save or unsave one request to the developer's favorites; persisted, survives Clear and app restarts.","params":{"type":"object","properties":{"id":{"type":"string","description":"Request id from the network snapshot."},"saved":{"type":"boolean","description":"true saves. false OR OMITTED unsaves \u2014 always send this explicitly."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Same mechanics as setPinned but writes the \'saved\'/bookmark flag instead of the pin. Same optionality hazard: omitting `saved` (or sending false) means UNSAVE. Returns null when no request with that id exists, {ok:true} when already in the requested state, {ok:true,active:boolean} on a toggle, or {ok:false,reason:"saved-cap"|"invalid-event"} \u2014 saved-cap is the 50-record ceiling (lower on some license tiers).',"requires":["the event must still exist in the live or saved store"],"armsCapture":true},{"action":"removeSavedRecord","summary":"Permanently delete one pinned/saved record by its stable `key` (not a request id).","params":{"type":"object","properties":{"key":{"type":"string","description":"Kept-record key from the snapshot's `saved` array (e.g. `<sessionId>_3`). Not a request id."}},"required":["key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Takes the record `key` from the snapshot's `saved` list \u2014 NOT a request id; passing a request id silently matches nothing. Deletes the persisted snapshot outright whatever its pin/save flags, and it cannot be recovered: this may be the only remaining copy of a request the live list already dropped. Returns {ok:true} (also when the key matched nothing), or null when `key` is missing."},{"action":"clearSavedRequests","summary":"Unsave every saved request; records that are also pinned stay (still pinned).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Clears the 'saved'/favorites flag across the persisted store. Records that carry only the saved flag are deleted for good \u2014 those snapshots may be the last copy of requests the live list already dropped, and there is no undo. Records that are also pinned survive with saved:false. Returns {ok:true}. This is the developer's curated list, not scratch data \u2014 do not call it to tidy up."},{"action":"clearPinnedRequests","summary":"Unpin every pinned request; records that are also saved stay (still saved).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Clears the pin flag across the persisted store. Pin-only records are deleted permanently, with no undo \u2014 and a pin is how a human flags 'this is the broken call', so clearing them destroys that signal. Records that are also saved survive with pinned:false. Returns {ok:true}. Use this only when told to, or to free room after a pin-cap rejection."},{"action":"clearEvents","summary":"Wipe the live captured-request list (pinned/saved snapshots are unaffected).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Empties the in-memory event list and its pending-request map. Irreversible \u2014 anything not pinned or saved is gone. Useful to get a clean baseline before reproducing a bug: clear, drive the app, then read. Returns undefined (no receipt)."},{"action":"listOverrideRules","summary":"Read every override rule back in full, with hit counts, bodies, and the master-switch state.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns the whole OverrideRulesState: {enabled:boolean, autoPaused:boolean, rules:[{id, name, urlPattern, methods, kind, status, statusText, headers, body, failKind, delayMs, times, alternate, enabled, hits, seen, createdAt}]}. Unlike the periodic snapshot, this includes full rule bodies and un-coalesced hit counts. Read `enabled:false` carefully: `autoPaused:true` means the launch-safety guard turned overrides off by ITSELF after 3 launches with rules armed and untouched \u2014 nobody flipped that switch, and it must be re-armed with setOverridesEnabled({enabled:true}). A rule where `seen` > `hits` matched but did not apply (an `alternate` rule in its off phase, or a spent `times` budget) \u2014 that is the explanation for 'it matched but nothing happened'."},{"action":"getOverrideRuleBody","summary":"The full response body of one override rule, for surfaces the snapshot withheld it from.","params":{"type":"object","properties":{"id":{"type":"string","description":"Override rule id (e.g. `ovr_ltx4k2_1`), from listOverrideRules."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Returns {body:string|null}, or null when no rule has that id. Needed because rule bodies over 16KB are stripped from every snapshot and flagged with bodyOmitted:true \u2014 a rule seeded from a real response (fromRequestId) is routinely hundreds of KB. Reads the raw rule list, so it works even while the master switch is off."},{"action":"debugOverrides","summary":"Why a rule is or isn't firing \u2014 and the ONLY action that reveals whether overrides can work in this build at all.","params":{"type":"object","properties":{"url":{"type":"string","description":"Concrete URL to test the rules against, query string included. Defaults to 'https://example.com/', which matches nothing useful \u2014 always pass the real URL you expect to be overridden."}},"additionalProperties":false},"effect":"read","release":"works","description":"Probes the engine against one URL (method is always GET) without recording a hit, so it never burns a rule's `times` budget or nudges an alternating rule's phase. Returns {interceptorInstalled, listenerCount, engine:{hooksInstalled, devFlag, ruleCount, matches}, store:{enabled, ruleCount, rulesVisibleToEngine}}. READ engine.devFlag FIRST: if it is `false`, this is a release build and NO rule will ever be applied to real traffic no matter what the add/enable actions reported. `rulesVisibleToEngine` < `ruleCount` means the master switch is off. `matches:false` almost always means the glob missed the query string \u2014 a pattern must match the WHOLE url. Caution: unlike getCaptureStatus this probes the listener unguarded, so it throws in a runtime with no XMLHttpRequest."},{"action":"upsertOverrideRule","summary":'Create (or replace by id) a rule that forces matching requests to return a chosen status/body, fail outright, or arrive late. Pick this over query.setQueryData when the change must survive a refetch or reload, or when the ask is about what the API returns; follow it with query.invalidate so the mounted screen refetches through the rule. To change ONE value of the real response send fromRequestId plus `bodyPath` and `bodyValue` (bodyPath:"sprites.front_default", or "stats[stat.name=hp].base_stat" for one row of a list) \u2014 there is no nesting to get wrong, which matters most here because you read that body through a wire that truncates at 24,000 characters. For several fields use `bodyPatch` with ONLY those fields; never paste a whole captured body back. For "just the NEXT call" / "once, then let it work again", send rule.times:1 \u2014 the rule auto-disables after one match. Without times it overrides EVERY matching request until deleted. Never fake one-shot by triggering the request yourself and deleting the rule: that spends the failure the user wanted to see.',"params":{"type":"object","properties":{"fromRequestId":{"type":"string","description":"Build the rule from a captured request id \u2014 seeds urlPattern (query string replaced with `*`), method, status and the REAL response body. Anything you send explicitly wins over the prefill."},"rule":{"type":"object","description":"The rule itself. Same fields are accepted flat at the top level, but send them here.","properties":{"id":{"type":"string","description":"Replace this existing rule instead of creating one. Omit to create."},"urlPattern":{"type":"string","description":"Glob matched against the WHOLE request URL, query string included. `*` is the only wildcard, e.g. '*pokeapi.co*'. Required unless fromRequestId supplies it."},"enabled":{"type":"boolean","description":"Default true; only an explicit false creates the rule switched off. An enabled rule also forces the master switch ON."},"name":{"type":"string","description":"Label shown in the developer's rule list."},"methods":{"type":"array","items":{"type":"string"},"description":"Restrict to these HTTP methods (upper-cased for you). Omit for any method."},"kind":{"type":"string","enum":["respond","fail","delay"],"description":"respond = return your status/body, never hitting the network (default). fail = transport failure, as if offline (`fail:true` is accepted for this). delay = run the real request, just late. Anything else falls back to respond."},"status":{"type":"number","description":"kind 'respond': HTTP status, clamped to 200-599. Use kind 'fail' for connection errors, not status 0."},"statusText":{"type":"string","description":"kind 'respond': cosmetic only \u2014 React Native's XMLHttpRequest has no statusText, so the app never sees it."},"headers":{"type":"object","description":"kind 'respond': response headers, string values."},"body":{"description":"kind 'respond': the COMPLETE response body. A string is used as-is; an object or array is JSON.stringify'd for you \u2014 do not pre-stringify. To change a few fields of the real response use bodyPatch instead \u2014 bodies you read over the wire are truncated past 24K chars, and pasting a torn body back gives the app a response missing half its fields."},"failKind":{"type":"string","enum":["timeout"],"description":"kind 'fail': ONLY the literal 'timeout' is honored; anything else (including 'network') means a plain network error."},"delayMs":{"type":"number","description":"Latency before the outcome, in ms. Works with every kind."},"times":{"type":"number","description":'Auto-disable after N matches. **Set times:1 whenever the ask is about the NEXT call** \u2014 "make the next request fail", "just once", "then let it work again". Omitted = the rule overrides EVERY matching request until something deletes it, and the result will tell you so. `once:true`, `failOnce:true` and `maxHits:N` are accepted as ways of saying this.'},"alternate":{"type":"boolean","description":"Apply to every OTHER matching request: the first goes through, the second is overridden, and so on. How you reach retry/flaky-endpoint paths."},"bodyOmitted":{"type":"boolean","description":"Wire flag: send true WITH an `id` when re-saving a rule whose body the snapshot withheld, so the stored body is preserved instead of erased."},"bodyPatch":{"type":"object","description":"Fields to change in the real captured response (needs fromRequestId, or the id of an existing rule) as a TYPED LEAF EDIT \u2014 like the devtools editor: same-type changes to existing fields, no new fields, no type changes, no null-ing a rendered list/object. A list may gain/lose items but each item must match the existing item shape (to change one item resend the whole list). Refused with the exact field on a violation. Ignored when `body` is sent. To change ONE ROW of a list in the response, address it by its own id instead of resending the array: {results:{pikachu:{name:'test123'}}} edits that row and leaves every other row exactly as the server sent it. That is the only correct form when you read the response through a view that truncated it."},"force":{"type":"boolean","description":"Bypass the typed-edit safety on bodyPatch and write the patch raw. Default false \u2014 a violating patch is refused."},"bodyPath":{"type":"string","description":'Set ONE value inside the real response, named by its path ("sprites.front_default", "stats[stat.name=hp].base_stat"). Needs fromRequestId or an existing rule id to address. Always goes through the same typed-edit guard as bodyPatch.'},"bodyValue":{"description":"The value `bodyPath` is set to. Required whenever bodyPath is sent."}},"additionalProperties":false}},"additionalProperties":false},"effect":"write","release":"noop","description":'Create (or replace by id) a rule that forces matching requests to return a chosen status/body, fail outright, or arrive late. Pick this over query.setQueryData when the change must survive a refetch or reload, or when the ask is about what the API returns; follow it with query.invalidate so the mounted screen refetches through the rule. To change ONE value of the real response send fromRequestId plus `bodyPath` and `bodyValue` (bodyPath:"sprites.front_default", or "stats[stat.name=hp].base_stat" for one row of a list) \u2014 there is no nesting to get wrong, which matters most here because you read that body through a wire that truncates at 24,000 characters. For several fields use `bodyPatch` with ONLY those fields; never paste a whole captured body back. For "just the NEXT call" / "once, then let it work again", send rule.times:1 \u2014 the rule auto-disables after one match. Without times it overrides EVERY matching request until deleted. Never fake one-shot by triggering the request yourself and deleting the rule: that spends the failure the user wanted to see.',"releaseNote":"packages/network/src/network/overrides/engine.ts:74 \u2014 overrideForRequest returns null when __DEV__ === false. The rule is still created, persisted and listed, and this action still returns ok:true, but it is NEVER applied to real traffic in a release build. Confirm with debugOverrides \u2192 engine.devFlag before telling anyone the override took effect.","requires":["a __DEV__ build for the rule to actually apply","something holding the interceptor open (an enabled rule pins it itself)","Buoy Pro only to keep more than 1 rule \u2014 and only in the on-device UI; this action is not license-gated"]},{"action":"setOverrideRuleEnabled","summary":"Arm or silence ONE override rule by id, keeping the rule.","params":{"type":"object","properties":{"id":{"type":"string","description":"Override rule id from listOverrideRules."},"enabled":{"type":"boolean","description":"true arms this rule. false OR OMITTED disables it \u2014 always send this explicitly."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"noop","description":'Flips a single rule\'s enabled flag without touching the other rules or the master switch. Use it to stage a rule and arm it later, or to silence one you want to keep. WARNING on optionality: `enabled` is read with a truthiness cast, so omitting it means DISABLE. Returns {ok:true} even when no rule has that id \u2014 it does not verify; confirm with listOverrideRules. Returns {ok:false,error:"Missing rule id."} when `id` is absent. Note that arming a rule here does NOT turn the master switch on (unlike upsertOverrideRule), so a rule can read as enabled and still be dark.',"releaseNote":"packages/network/src/network/overrides/engine.ts:74 \u2014 overrideForRequest returns null when __DEV__ === false, so arming a rule in a release build changes the flag and nothing else. The action still returns ok:true.","requires":["a __DEV__ build for the rule to actually apply"],"undo":{"action":"setOverrideRuleEnabled","note":"Call again with the same id and the inverted `enabled` value."}},{"action":"setOverridesEnabled","summary":"Master switch for ALL override rules \u2014 off silences every rule without deleting any.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true arms all enabled rules. false OR OMITTED silences every rule (rules are kept) \u2014 always send this explicitly."}},"required":[],"additionalProperties":false},"effect":"write","release":"noop","description":"One call changes how every matching request in the app behaves, so treat it as a broad-blast action rather than a toggle. WARNING on optionality: `enabled` is read with a truthiness cast, so calling this with no params DISABLES all overrides. Returns {ok:true, enabled:<the resulting state>} \u2014 read that back rather than assuming. This is also the re-arm for the auto-pause state: when listOverrideRules reports autoPaused:true the launch guard turned overrides off by itself after 3 launches with rules armed and untouched, and only this puts them back. Individual rules keep their own enabled flags underneath.","releaseNote":"packages/network/src/network/overrides/engine.ts:74 \u2014 overrideForRequest returns null when __DEV__ === false, so flipping the master switch in a release build changes nothing about real traffic in either direction. The action still returns ok:true with the new flag.","requires":["a __DEV__ build for rules to actually apply"],"undo":{"action":"setOverridesEnabled","note":"Call again with the inverted `enabled` value. Per-rule enabled flags are untouched either way."}},{"action":"deleteOverrideRule","summary":"Delete one override rule by id \u2014 the undo for upsertOverrideRule.","params":{"type":"object","properties":{"id":{"type":"string","description":"Override rule id from listOverrideRules or from an upsertOverrideRule receipt."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Removes the rule from the persisted list permanently; there is no recovery, and a rule seeded from a real response cannot be reconstructed without that request still being in the store. This is what you call to clean up after driving a test \u2014 always remove rules you added, since a forgotten rule looks exactly like a real bug. Returns {ok:true} even when no rule has that id (it does not verify), or {ok:false,error:"Missing rule id."} when `id` is absent.'},{"action":"clearOverrideRules","summary":"Delete EVERY override rule; traffic becomes real again.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Wipes the whole persisted rule list at once \u2014 including rules a human authored and is mid-investigation on, which cannot be recovered. Prefer deleteOverrideRule({id}) for rules you created yourself, or setOverridesEnabled({enabled:false}) when you only need to silence them while keeping them. Returns {ok:true}."},{"action":"getNetworkConditions","summary":"Read the effective network condition and whether activation is available.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works"},{"action":"setNetworkConditions","summary":"Set memory-only network conditions for new intercepted HTTP requests. Pass profile: normal, offline, slow (+500 ms) or verySlow (+2000 ms). Normal clears activation and lets requests through at full speed.","params":{"type":"object","properties":{"profile":{"type":"string","enum":["normal","offline","slow","verySlow"],"description":"Normal, Offline, 500 ms latency, or 2000 ms latency."}},"required":["profile"],"additionalProperties":false},"effect":"write","release":"throws","releaseNote":"Release builds refuse active conditions. Normal can still clear the profile."}],"unavailableWhen":"The app does not mount the network tool (the @buoy-gg/network preset is absent from the FloatingDevTools apps list), or the runtime has no XMLHttpRequest/fetch to intercept (Node/headless/desktop-mirror \u2014 there getCaptureStatus reports interceptorInstalled:null and debugOverrides throws). The override actions are additionally Pro-gated by the MCP/dashboard layer (requireProDevice)."},{"toolId":"js-top","title":"JS TOP (JS-thread task manager)","summary":"Task Manager for the React Native JS thread: it wraps timers/rAF/microtasks/Promise reactions/legacy-bridge call-ins and books exclusive ms per scheduling origin, plus a calibrated busy probe (thread busy%) and PerformanceObserver('longtask') freeze attribution (blocks >=50ms). Reach for it when the app feels laggy/janky and you need to know WHAT is eating the JS thread (a forgotten setInterval, a hot rAF loop, a chatty Promise chain). `sample` is the main entry point \u2014 everything else is control (setEnabled/pause/resume/clear) or drill-down (getOriginDetail). NOT for \"components re-render too often\" \u2014 this measures TASKS, not renders.","actions":[{"action":"sample","summary":"Run the engine for a few seconds and return the ranked JS-thread task table (busy%, per-origin ms, freezes). This is the action to start with.","params":{"type":"object","properties":{"durationMs":{"type":"number","description":"How long to observe before returning the table. Default 3000; clamped to 250-30000. The call blocks for this long."},"clearFirst":{"type":"boolean","description":"Reset the whole task table, busy history and freeze history before sampling, so the result attributes exactly this window/interaction. Destructive: prior measurements are gone. Default false."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":'Self-arming one-shot measurement \u2014 the only action that works on a cold device. Ensures sampling is on (turning it on itself if nobody is watching), waits durationMs, returns a JsTopSnapshot, then restores the previous sampling state. Snapshot shape: { ts, paused, busyPct, busyHistory, tiers:{timers,promises,bridge,longtask}, rows[], totals:{attributedWindowMs,unattributedWindowMs,windowMs}, freezeSummary:{count,worstMs}, longtasks[] }. Each row = { key, label, api, windowMs, pctOfBusy, totalMs, calls, avgMs, maxMs, lastSeenAgoMs, freezes, worstFreezeMs } with api one of timeout|interval|immediate|raf|microtask|then|bridge|other|unattributed. IMPORTANT window semantics: busyPct, windowMs and pctOfBusy describe only the TRAILING 5s (20 x 250ms buckets), so a durationMs above 5000 does not widen them \u2014 only totalMs/calls accumulate across the whole sample (since engine start or last clear). A pinned "unattributed" row absorbs event/React/native-call-in work; on New Architecture devices tiers.bridge is false and that row is expected to be large. freezeSummary covers a trailing 60s window. To profile one specific interaction, pass clearFirst:true and drive the UI (tap_element / run_flow) while the sample runs. Blocks for the full durationMs.',"requires":["@buoy-gg/js-top installed and imported in the app","device connected to the Buoy broker (external sync)"]},{"action":"getOriginDetail","summary":"Drill into one task origin: schedules vs runs, avg/max/total ms, a 5s 250ms-bucket activity histogram, freeze attribution, and the captured scheduling stack.","params":{"type":"object","properties":{"key":{"type":"string","description":'Origin key from a snapshot row, e.g. "interval|pollFeed", "raf|anonymous", or the literal "unattributed" system row. Required \u2014 a missing key throws.'}},"required":["key"],"additionalProperties":false},"effect":"read","release":"works","description":'Pass a `key` copied from a snapshot row. Keys are either the cheap form `${api}|${functionName}` (e.g. "interval|pollFeed", "timeout|anonymous") or the refined form `${api}|${name}@${caller}:${file}:${line}`, plus the literal "unattributed" for the pinned system row (which returns a synthetic detail built from busy-probe residuals). Returns { key, label, api, caller?, file?, line?, frames?, promoted, scheduleCount, calls, totalMs, avgMs, maxMs, windowMs, pctOfBusy, lastSeenAgoMs, buckets[20], freezes, worstFreezeMs }, or NULL when the key was never tracked, was cleared, or was evicted (the registry caps at 500 origins and evicts the coldest) \u2014 re-sample for current keys. caller/file/line only appear once an origin is "promoted" (25+ schedules or 50ms+ total; intervals capture eagerly). In a RELEASE build the file/line/component labels are absent: source symbolication posts to Metro\'s /symbolicate and is hard-gated on __DEV__ (packages/js-top/src/js-top/engine/symbolicate.ts:42 `if (!isDev()) return;`), so labels stay as raw Hermes function names. All numeric stats are still correct.',"requires":["@buoy-gg/js-top installed and imported in the app","the engine must have been recording \u2014 call `sample` (or setEnabled true) first","a __DEV__ build with Metro reachable for symbolicated labels/file/line \u2014 in a release build origins come back as raw unsymbolicated frames"],"armsCapture":true},{"action":"setEnabled","summary":"Turn silent remote sampling on/off \u2014 runs the accounting engine with no visible change on the device.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true = start silent remote sampling (engine runs, device UI unchanged); false = stop it. Required \u2014 the handler reads params.enabled with no guard, so omitting params throws."}},"required":["enabled"],"additionalProperties":false},"effect":"write","release":"works","description":"Calls JsTopController.setRemoteSampling(enabled). enabled:true starts the engine (installs timer/microtask/Promise/bridge patches on first start, starts the busy probe) and keeps it running across calls so snapshots stay live; enabled:false stops it. This does NOT show or hide the device's own HUD pill \u2014 that is a separate on-device toggle not exposed over the wire. Use this when you want the engine warm across several MCP calls; prefer `sample` for a single measurement, since it arms and disarms itself. Leaving remote sampling on costs continuous instrumentation, so turn it back off when done.","requires":["@buoy-gg/js-top installed and imported in the app"],"armsCapture":true},{"action":"pause","summary":"Freeze task accounting while keeping the collected table intact.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Sets the engine inactive and stops the busy probe, so no new time is booked to any origin; the existing rows, totals and freeze history are preserved and the snapshot reports paused:true. No-op if already paused. Use it to hold a table steady while reading it. Note: a paused engine also means a subsequent `sample` observes nothing until you `resume` \u2014 `sample` does not auto-resume.","requires":["@buoy-gg/js-top installed and imported in the app"]},{"action":"resume","summary":"Resume task accounting after a pause.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Reactivates booking and restarts the busy probe if the engine is running (i.e. something is subscribed or remote sampling is on). No-op if not currently paused. Accumulated totals continue from where they stopped \u2014 the pause gap is not counted as busy time.","requires":["@buoy-gg/js-top installed and imported in the app"]},{"action":"clear","summary":"Wipe the whole task table, busy history and freeze history \u2014 starts a fresh measurement window.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Resets the origin registry (all rows, keys, captured scheduling stacks and totals), the busy/attribution aggregator, and the recorded longtask/freeze entries, then emits an empty snapshot. Irreversible \u2014 there is no saved copy, so read anything you need before calling it. Any `key` you were holding for getOriginDetail becomes invalid. Prefer `sample` with clearFirst:true when the goal is to scope a measurement to one interaction, since that clears and re-measures in a single call.","requires":["@buoy-gg/js-top installed and imported in the app"]}],"unavailableWhen":'@buoy-gg/js-top is not installed/imported in the app (the adapter is never registered under "js-top", so every action is unroutable), or the app is a release build that has not explicitly opted into desktop sync \u2014 FloatingDevTools does not dial the broker when __DEV__ is false unless the app passes the allow-in-release externalSync option, so the device never connects and no action arrives at all.'},{"toolId":"app","title":"App","summary":"Device-level pseudo-tool from @buoy-gg/core itself (packages/devtools-floating-menu/src/floatingMenu/sync/appSyncAdapter.ts) \u2014 registered unconditionally for EVERY connected Buoy app, so it works with no tool package and no native deps installed. Two actions: `ping` (cheap liveness probe that also returns which JS realm is answering) and `reloadApp` (restart the JS bundle). Reach for it to check the app is alive, to learn the dev-server origin, or to reload after an edit / to clear leaked in-memory state before a measurement. Its static snapshot (v3) also reports `{startedAt, devServerOrigin, device:{platform,isTV,isTVOS}, reload:{available,strategy}}` \u2014 read `reload.available` before promising a user a reload will work.","actions":[{"action":"ping","summary":"Liveness probe: returns {ok:true, startedAt, devServerOrigin}. `startedAt` identifies WHICH JS realm answered \u2014 the only reliable way to confirm a reload really happened.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":'Takes no params. Only a live JS realm can answer, so a timeout means the app is unresponsive or crashed. `startedAt` is the module-load timestamp of this JS realm; it CHANGES across a reload, so the pattern is: ping (remember startedAt) -> reloadApp -> wait ~1.2s -> ping until startedAt differs. A reply carrying the OLD startedAt means the doomed realm answered and the app is not back yet. `devServerOrigin` is the Metro/Expo dev server that served the bundle (e.g. "http://192.168.1.20:8081"), or null in a release bundle (file:// scriptURL) and on web \u2014 capture it while the app is healthy, because after a crash it is the only remaining way to recover the app.',"requires":["@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)","external-sync socket connected to the broker on :42831"]},{"action":"reloadApp","summary":"Restart the app's JS bundle (DevSettings.reload() in dev, expo-updates otherwise). ALL in-memory state is destroyed \u2014 read anything you need first.","params":{"type":"object","properties":{"strategy":{"type":"string","enum":["auto","dev-settings","expo-updates"],"description":"Reload mechanism. 'auto' (default) uses React Native's DevSettings.reload() in dev builds and expo-updates otherwise. Force one only when debugging the reload itself; 'dev-settings' throws in a release bundle, 'expo-updates' throws unless the host app installed expo-updates (and always fails in Expo Go)."},"delayMs":{"type":"number","description":"Milliseconds to wait before the reload actually fires, so this action's result can be flushed over the sync socket first. Default 250 (REMOTE_RELOAD_DELAY_MS); negative values are clamped to 0. Leave unset unless the ack is being lost."}},"required":[],"additionalProperties":false},"effect":"destructive","release":"throws","description":"Arms a reload and returns {scheduled:true, strategy, delayMs} BEFORE it fires (default 250ms later), because the realm dies the instant it does. The ack is NOT proof the app came back \u2014 confirm with `ping` and a changed `startedAt`. Destroys every in-memory store (redux/zustand/jotai/react-query caches, console buffer, network log, unsaved form state); persisted storage survives. Read get_console / get_network_requests / get_storage BEFORE calling. Params: `strategy` (\"auto\" default \u2014 dev-settings in dev builds, expo-updates otherwise; force one only when debugging the reload itself) and `delayMs` (ack-flush window, default 250, clamped to >= 0). Check the snapshot's `reload.available` first: when no mechanism exists this THROWS rather than silently no-op'ing, which is the correct and honest outcome to report.","releaseNote":'packages/shared/src/utils/reloadApp.ts:80 \u2014 `if (__DEV__ && loadDevSettings())` means "auto" can only resolve to dev-settings in a dev build; in release it falls through to `loadExpoUpdates()`, which returns null unless the host app installed `expo-updates`. resolveReloadStrategy then returns null and scheduleReloadApp throws at line 140 WITHOUT scheduling anything. So in a release build: works only if expo-updates is installed, otherwise a hard error \u2014 never a fake success. Explicit strategy "dev-settings" throws in release regardless.',"requires":["@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)","A dev build (for DevSettings.reload) OR the host app has `expo-updates` installed","Snapshot `reload.available === true` \u2014 check before promising a reload"]}],"unavailableWhen":'The app\'s bundle is a release build (`__DEV__ === false`) that did not opt into `externalSync.enableInRelease` AND hold a real Pro license \u2014 FloatingDevTools.tsx:719 then never mounts AutoExternalSync, so no `app` action reaches the device at all. Also effectively unavailable after a fatal render error: the React tree that answers sync actions is gone, so both actions time out (recovery is the dev-server reload path, using a `devServerOrigin` learned from an earlier successful ping). Remote drivers going through the Buoy MCP server additionally hit a Pro gate (SyncClient.requireProDevice: "Buoy Pro is required to use the MCP").'},{"toolId":"time-machine","title":"Time Machine","summary":"Save and restore the app's entire client-side state (device storage, redux, zustand, jotai, react-query) as named snapshots (\"restore points\"), plus the route it was captured on. Reach for it to put the app back into a known state before re-running a flow, to preview exactly what a restore would change before committing, or to wipe the app to fresh-install state. Snapshot payloads stay on-device \u2014 `list` returns metadata only; use `inspect` for one source's actual captured data and `preview` for item-level diffs. Swift supports live restore of registered providers, with UserDefaults and MMKV supplied by default. Keychain is excluded. Read provider detail and restoreModes. Native capabilities omit captureBaseline and wipeAll, and mode reload is rejected before writes. Snapshots include a safety copy before restoration; this is not a full process reset.","actions":[{"action":"list","summary":"List all restore points plus which state sources can currently be captured/restored and whether the device can navigate routes.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":'Same payload as the tool\'s synced snapshot. Returns { snapshots, busy, lastOutcome, providers, route }. `snapshots[]` = { id (e.g. "snap_m1x2y3_4"), name, createdAt, sizeBytes, baseline?, route?:{pathname,href}, restoreRoute?, scope?, excludedKeys?, sources:{ [sourceId]: { warnings:string[] } } } \u2014 METADATA ONLY, no captured values. `providers[]` = { id, label, canCapture, canRestore, detail } for the registered sources: "storage", "redux", "zustand", "jotai", "query" (plus any custom ones the app registered). Read `detail` before reporting a source as broken \u2014 e.g. redux says "Auto-instrumented store cannot be restored" when the app didn\'t wrap its root reducer. `route.available` is false on apps without expo-router (those snapshots record no route). `lastOutcome` is the previous restore\'s per-source result. Call this first \u2014 every other action needs an `id` from here. Swift supports live restore of registered providers, with UserDefaults and MMKV supplied by default. Keychain is excluded. Read provider detail and restoreModes. Native capabilities omit captureBaseline and wipeAll, and mode reload is rejected before writes. Snapshots include a safety copy before restoration; this is not a full process reset.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"capture","summary":"Save the app's current state as a new restore point and return its metadata.","params":{"type":"object","properties":{"name":{"type":"string","description":'Label for the restore point. Omit or pass "" to auto-name it "<current pathname> \xB7 HH:MM".'},"sourceIds":{"type":"array","items":{"type":"string"},"description":'Capture only these source ids ("storage", "redux", "zustand", "jotai", "query"). Omit to capture every capturable source.'}},"additionalProperties":false},"effect":"write","release":"works","description":'Captures every source whose provider reports canCapture (or only `sourceIds` if given), serializes it into the on-device vault (@react_buoy_time_machine:snap:<id>), and stamps the current route on it. Returns { captured:true, snapshot: { id, name, sizeBytes, route?, sources:{[id]:{warnings}} } }. ALWAYS surface `sources[*].warnings` \u2014 capture is lossy by design in known places (MMKV ArrayBuffer values, biometric-protected SecureStore keys, unregistered SecureStore keys) and a per-source `capture failed: ...` warning is how a silently-empty source shows up. An empty/omitted `name` auto-names it "<pathname> \xB7 HH:MM". Does NOT modify app state. Do this before driving a flow you want to re-run.',"requires":["at least one state source registered (storage/redux/zustand/jotai/react-query package installed and instrumented)","expo-router for the route stamp (optional)"]},{"action":"captureBaseline","summary":"Create an EMPTY 'Fresh install' restore point whose later restore wipes app state and reloads.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Takes no reading of current state \u2014 it writes a zero-byte snapshot named \"Fresh install\" with baseline:true. Safe and non-destructive by itself; the destruction happens later when you `restore` its id (which clears every clearable source and reloads). Returns { captured:true, snapshot }. Use it to give a QA user a one-tap 'back to fresh install' point.","requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"wipeAll","summary":"DESTRUCTIVE: clear every clearable state source to fresh-install state, then reload the app. No snapshot involved and no undo.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":'Calls clear() on every registered provider (app AsyncStorage/MMKV/SecureStore keys, redux, zustand, jotai, react-query cache \u2014 Buoy\'s own @react_buoy* keys are excluded) and then schedules a JS bundle reload. Returns a RestoreOutcome { snapshotId:"wipe-all", willReload, results:{[sourceId]:{ok,applied,skipped,warnings}} }. There is NO undo \u2014 take a `capture` first if the state matters. CHECK `willReload` in the result before telling anyone the app restarted (see releaseNote).',"requires":["expo-updates installed for the reload leg to work in a release build"]},{"action":"restore","summary":"DESTRUCTIVE: put the app back into a snapshot's state, overwriting and deleting live state. Run `preview` first.","params":{"type":"object","properties":{"id":{"type":"string","description":'Snapshot id from `list`, e.g. "snap_m1x2y3_4". Required.'},"mode":{"type":"string","enum":["live","reload"],"description":'"live" (default) replaces state in place; "reload" restores persisted storage only and reloads the JS bundle. Baseline snapshots always reload regardless.'},"excludeKeys":{"type":"array","items":{"type":"string"},"description":'Items to LEAVE UNTOUCHED, as compound "sourceId::itemKey" strings using the item keys from `preview`: storage::async:@cart, storage::mmkv:<instanceId>/<key>, storage::secure:<key>, redux::<sliceName>, zustand::<storeName>, jotai::<atomLabel>, query::<queryHash> (e.g. query::["pokemon"]). Passing this array overrides \u2014 and disables \u2014 the snapshot\'s saved exclusions and saved scope.'},"restoreRoute":{"type":"boolean","description":`Also navigate back to the route the snapshot was captured on. OMITTING THIS IS NOT false \u2014 it falls back to the snapshot's saved setting, which is ON whenever the snapshot has a route. Pass false to force the app to stay on the current screen. With mode "reload" the navigation is deferred to the next boot (status "deferred", 60s TTL).`}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Requires `id` (throws "restore requires a snapshot `id`" without it). mode "live" (default) replaces state in place source by source in a fixed order (storage \u2192 redux \u2192 zustand \u2192 jotai \u2192 query); mode "reload" restores ONLY persisted storage and then reloads the JS bundle so in-memory stores rebuild themselves. A baseline snapshot ALWAYS takes the clear+reload path regardless of mode. Storage restore is a diff \u2014 app keys missing from the snapshot are DELETED. Returns a RestoreOutcome: read `results[sourceId].applied` / `.skipped[].reason` / `.warnings` rather than assuming success (a source with canRestore:false comes back skipped with a reason, not an error), and read `willReload` and `route.status` ("navigated" | "deferred" | "failed" | "unavailable"). TWO GOTCHAS: (1) omitting `excludeKeys` makes the snapshot\'s own SAVED excludedKeys apply, and a saved `scope` narrows the restore to only the scoped items \u2014 passing an explicit `excludeKeys` array disables that saved scope. (2) omitting `restoreRoute` does NOT mean false: it falls back to the snapshot\'s own setting, which is ON whenever the snapshot captured a route. Pass restoreRoute:false to keep the app on the current screen. Swift supports live restore of registered providers, with UserDefaults and MMKV supplied by default. Keychain is excluded. Read provider detail and restoreModes. Native capabilities omit captureBaseline and wipeAll, and mode reload is rejected before writes. Snapshots include a safety copy before restoration; this is not a full process reset.',"requires":["a source's provider must report canRestore:true or that source is skipped with a reason","expo-router for the route leg","expo-updates for the reload leg in a release build"]},{"action":"preview","summary":"Compute the exact blast radius of restoring a snapshot \u2014 per-item added/removed/changed/wont-apply verdicts \u2014 WITHOUT changing anything.","params":{"type":"object","properties":{"id":{"type":"string","description":"The snapshot being previewed (the restore target). Required."},"compareTo":{"type":"string","description":"Another snapshot id to diff against instead of the app's live current state. Omit to compare against live state \u2014 which is what a restore would actually overwrite."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Requires `id` (throws "preview requires a snapshot `id`"). Diffs the snapshot against the LIVE current state, or against another snapshot when `compareTo` is given. Returns { left, right, builtAt, sources:[{ id, label, notes:string[], error?, items:[{ key, label, verdict, reason?, oldValue?, newValue?, valueOmitted? }] }] }. Verdicts are the whole point: "added"/"removed"/"changed"/"too-large" WILL be applied by a restore; "unchanged" and "recomputes" won\'t; "wont-apply" means a real difference exists that restore CANNOT apply and `reason` says why (e.g. an auto-instrumented redux store, a tool that isn\'t installed) \u2014 surfacing that reason is more useful than the count. `key` values are exactly what `restore`\'s excludeKeys / `setScope` / `setExclusions` want, once prefixed with "<sourceId>::". Values larger than ~4KB are stripped and flagged valueOmitted:true, so an empty oldValue/newValue does not mean the value was empty. Side effect: it rebuilds the on-device preview panel, replacing whatever a human has open in the Time Machine UI. Always run this before a `restore` you cannot undo.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"inspect","summary":"Read back one source's actual captured payload from a snapshot, deserialized.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"sourceId":{"type":"string","description":'Which captured source to read: "storage", "redux", "zustand", "jotai", "query", or a custom provider id. Must be a key of that snapshot\'s `sources` \u2014 required.'}},"required":["id","sourceId"],"additionalProperties":false},"effect":"read","release":"works","description":"Requires both `id` and `sourceId` (throws \"inspect requires `id` and `sourceId`\"; also throws when the snapshot doesn't exist or never captured that source). Returns { id, sourceId, warnings:string[], data } where `data` is the source's full captured tree with Date/Map/Set rehydrated. This is the only way to see actual snapshot VALUES \u2014 `list` is metadata-only. Storage `data` is shaped { async:[key,value][], mmkv:{[instanceId]:[{key,value,valueType}]}, secure:{[key]:string|null} }. Payloads can be large (up to the 8MB action budget), so ask for one source at a time.","requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"delete","summary":"DESTRUCTIVE: permanently remove a restore point from the device vault. No remote undo.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Requires `id` (throws "delete requires a snapshot `id`"). Removes the snapshot\'s vault row and index entry. Returns { deleted:true, id }. The device keeps one in-memory copy so a HUMAN can tap Undo in the Time Machine UI, but that undo is NOT exposed as an action \u2014 over the wire this is irreversible. A restore point can represent state that took ten minutes and a cooperative backend to build; confirm with the user before calling.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"rename","summary":"Change a restore point's display name.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"name":{"type":"string","description":"New display name. Required and must be non-empty."}},"required":["id","name"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires BOTH `id` and a non-empty `name` (throws "rename requires `id` and `name`"). Metadata only \u2014 the captured payload is untouched. Returns { renamed:true, id, name }.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"duplicate","summary":"Copy a restore point (payload included) so a variation can branch off a known-good base.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id to copy, from `list`. Required."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "duplicate requires `id`"; throws `No snapshot with id "..."` if it isn\'t in the vault). Creates a full copy named "<original name> copy" with a NEW id and returns { snapshot }. Does not touch app state. Use it before editing a point\'s scope/exclusions so the original stays intact.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"setExclusions","summary":"Persist the items every FUTURE restore of this snapshot must leave untouched (the saved form of the preview's unchecked boxes).","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"excludedKeys":{"items":{"type":"string"},"description":'Compound "sourceId::itemKey" strings from `preview` (e.g. storage::async:@cart, query::["pokemon"], redux::cart). null or an empty array clears the saved exclusions.',"anyOf":[{"type":"array"},{"type":"null"}]}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "setExclusions requires `id`"). Saves compound "sourceId::itemKey" strings onto the snapshot\'s metadata; a later `restore` that omits its own excludeKeys will honour them. Pass null or an empty array to clear. Returns { id, excluded:<count> }. Metadata only \u2014 nothing in the app changes now; the effect lands on the next restore.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"setScope","summary":"Persist a targeted-restore scope \u2014 the ONLY items this restore point will ever touch.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"scope":{"items":{"type":"string"},"description":'Compound "sourceId::itemKey" strings from `preview` \u2014 the only items a restore may touch. null or an empty array clears the scope (full restore).',"anyOf":[{"type":"array"},{"type":"null"}]}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "setScope requires a snapshot `id`"). The inverse of setExclusions: a scoped point restores nothing outside its list, and sources entirely outside the scope are left untouched and unreported in the outcome. Pass null or an empty array to clear the scope and go back to a full restore. Returns { scoped:true, id, count }. Note that a `restore` call passing an explicit excludeKeys array IGNORES the saved scope.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"setRestoreRoute","summary":"Turn a restore point's 'also navigate back to the captured screen' behaviour on or off.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"restoreRoute":{"type":"boolean","description":"true to navigate back to the captured route on restore, false to skip it. Only an explicit false turns it off \u2014 omitting it turns it ON."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "setRestoreRoute requires `id`"). Persists the flag onto the snapshot. NOTE THE COERCION: the handler stores `restoreRoute !== false`, so omitting the param \u2014 or sending anything other than exactly false \u2014 turns it ON. Returns { id, restoreRoute }. Only meaningful for snapshots that captured a route (see `route` on the snapshot and `route.available` from `list`).',"requires":["expo-router on the device for the flag to have any effect"]}],"unavailableWhen":"The `@buoy-gg/time-machine` tool is not in the app's FloatingDevTools tool list, or the app is a release build that has not opted into `externalSync.enableInRelease` with a real Pro license (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:190 \u2014 no socket, so no action reaches the device at all). Individual state sources are also absent unless their tool package is installed and instrumented: check `providers[].canCapture`/`canRestore` from `list` before assuming a source is covered."},{"toolId":"clock","title":"Clock","summary":"Override the app's own clock without touching the device: move it to any date (setTime), jump it forward or back (shift), stop it (freeze/resume), run it faster (setRate), or go back to real time (reset). Reach for it to test anything time-based on the client: expired offers and coupons, countdowns, trial and renewal dates, day or month rollovers, 'last seen' labels, token expiry the app checks itself. What moves: Date.now(), new Date(), Date() and Intl.DateTimeFormat format() with no date; a forward shift also runs the app's setTimeout/setInterval callbacks that come due inside the jump, once each. What stays real: performance.now(), animations, native timers and scheduled notifications, the time zone, and the backend's clock, so a server that checks expiry itself still uses real time. Values the app computed before the change keep their old time until the screen re-reads the clock or the app reloads (the override survives reloads). It also reads the app's sign-in tokens from Network's captured requests (JWTs, PASETO, opaque tokens whose login response gave an expiry, session cookies) and tests the app's token refresh: jumpToTokenExpiry for apps that refresh before expiry, failNextRequest for apps that refresh after a 401. Raw tokens are never returned. Every action returns the same state object as getState: { mode:'real'|'running'|'frozen', active, rate, virtualNow, virtualIso, realNow, offsetMs, timeZone, timers:{total,timeouts,intervals,nextTimeoutInMs}, lastJump:{byMs,firedTimers,at}|null, settings:{persist,showChip,fireTimersOnJump}, tokens:{available, tokens:[{id,label,host,formatLabel,hint,claims,expiresAt,serverExpiresAt,expirySource,lifetimeMs,issuedBy,refresh,uses,refreshes,looping,expiredSends,rejected}], check:{kind:'expiry'|'401',tokenId,status:'waiting'|'refreshed'|'loop',forcedAt,refreshedAt,newLifetimeMs,newIssuedBy,newTokens,staleAfterFailure}|null}, patched:{date,intl,timers,animatedOnRealClock} }. expiresAt is on the app's clock; serverExpiresAt on real time.","unavailableWhen":"Needs @buoy-gg/clock installed (FloatingDevTools auto-discovers it). In release builds the saved override and timer tracking load when the Clock tool is first opened rather than at app start.","actions":[{"action":"getState","summary":"Read what the app's clock says right now, how far it is from real time, and the pending timers.","description":"Returns the state object described in the tool summary. Read virtualIso (the app's time) against realNow before reporting what 'now' is for the app; offsetMs is app minus real. mode 'real' means no override is on. timers counts the app's pending setTimeout/setInterval callbacks, which is how many a forward shift could run.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"setTime","summary":'Move the app\'s clock to `time` (ISO 8601, epoch ms, or relative like "+3d"); pass `freeze` true to stop it there.',"description":'`time` accepts "2026-12-31T23:59:50" (device-local when no zone is given), "2026-12-31 23:59", "2026-12-31T23:59:50Z", epoch milliseconds, or a signed duration from the app\'s current time ("+30d", "-2h"). The clock keeps running from there at the current speed unless `freeze` is true. Unlike shift, setTime never runs timers. Throws a message naming the accepted formats when `time` cannot be read. Returns the new state.',"effect":"write","release":"works","undo":{"action":"reset","note":"reset returns to the device's real clock. If an override was already on before this call, reset does not bring that one back: read getState first and set it again with setTime/setRate."},"params":{"type":"object","properties":{"time":{"description":'Target time: ISO 8601 ("2026-12-31T23:59:50"), "YYYY-MM-DD HH:MM", epoch ms, or relative ("+1d", "-2h").',"anyOf":[{"type":"string"},{"type":"number"}]},"freeze":{"type":"boolean","description":"Stop the clock at `time` instead of letting it run. Default false."}},"required":["time"],"additionalProperties":false}},{"action":"shift","summary":'Jump the app\'s clock `by` a duration ("+1d", "-2h30m", "90s" or ms); forward jumps also run app timers that come due unless `fireTimers` is false.',"description":"Moves the clock relative to what it reads now; a frozen clock stays frozen. Units: ms, s, m, h, d, w, mo (30 days), y (365 days). A forward jump runs, once each and soonest first, every app setTimeout/setInterval callback due within the jump (Playwright fastForward rules) and shortens what is left of the others; lastJump.firedTimers reports how many ran. Buoy's own timers are never run. Backward jumps never run timers. Returns the new state.","effect":"write","release":"works","undo":{"action":"reset","note":"reset returns to the device's real clock. If an override was already on before this call, reset does not bring that one back: read getState first and set it again with setTime/setRate."},"params":{"type":"object","properties":{"by":{"description":'Signed duration: "+1d", "-2h30m", "90s", "1.5 hours", or a number of ms.',"anyOf":[{"type":"string"},{"type":"number"}]},"fireTimers":{"type":"boolean","description":"Run app timers that come due inside a forward jump. Default: the fireTimersOnJump setting (on)."}},"required":["by"],"additionalProperties":false}},{"action":"freeze","summary":"Stop the app's clock where it is, or at `time` if given.","description":"The app reads the same instant until resume, shift, setTime or reset. Timers keep running in real time and animations keep moving; only reads of the time stop. `time` takes the same formats as setTime. Returns the new state.","effect":"write","release":"works","undo":{"action":"resume","note":"resume starts the clock again from the frozen instant; it does not return to the time before the freeze. Use reset for real time."},"params":{"type":"object","properties":{"time":{"description":"Optional instant to freeze at (same formats as setTime). Omit to freeze at the current app time.","anyOf":[{"type":"string"},{"type":"number"}]}},"additionalProperties":false}},{"action":"resume","summary":"Start a frozen app clock again from the instant it was frozen at.","description":"No-op when the clock is not frozen. The clock continues at its current speed. Returns the new state.","effect":"write","release":"works","undo":{"action":"freeze","note":"freeze stops it again, at the current app time."},"params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"setRate","summary":"Run the app's clock `rate` times as fast as real time (1 = normal, 60 = a minute per second, 3600 = an hour per second).","description":"Starts from what the app's clock reads now, so nothing jumps. Only reads of the time speed up: timer delays stay real, so taps, animations and polling behave; code that recomputes from Date.now() on each tick (most countdowns) shows the faster time. Range 0.1 to 86400. Returns the new state.","effect":"write","release":"works","undo":{"action":"setRate","note":"Call again with rate 1. That keeps the time already gained; use reset for real time."},"params":{"type":"object","properties":{"rate":{"type":"number","description":"Speed multiplier, 0.1-86400. 1 is normal speed."}},"required":["rate"],"additionalProperties":false}},{"action":"reset","summary":"Put the app back on the device's real clock.","description":"Removes the Date and Intl patches entirely, clears the saved override and closes the clock chip. Screens that already computed a time keep it until they re-read the clock or the app reloads. Returns the new state (mode 'real').","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"updateSettings","summary":"Change the Clock tool's settings: `persist` (keep the override across reloads), `showChip` (floating reminder), `fireTimersOnJump`.","description":"Only the booleans given are changed. persist=false still applies the override now but forgets it on the next reload. Returns the new state with settings.","effect":"write","release":"works","params":{"type":"object","properties":{"persist":{"type":"boolean","description":"Keep the override across reloads and restarts."},"showChip":{"type":"boolean","description":"Show the floating clock chip while an override is on."},"fireTimersOnJump":{"type":"boolean","description":"Whether forward shifts run app timers that come due."}},"additionalProperties":false}},{"action":"jumpToTokenExpiry","summary":"Move the app's clock to just before a sign-in token expires (default 10 s before), then watch for the app to fetch a new token.","description":"Tests apps that refresh their token before it expires by comparing its expiry with Date.now(). Shifts the app's clock forward (running timers that come due, like a scheduled refresh) to `leadMs` before the token's expiry, and starts a refresh check. The server keeps real time, so it still accepts the old token: only the app's own refresh logic is exercised. The app has to send a request (or run its refresh timer) before a new token shows up; read getState's tokens.check for the result: 'refreshed' with the new token's lifetime and issuing endpoint, or 'loop' when new tokens keep arriving because they already look expired to the moved clock. Throws when no token has been seen, when the token's expiry is unreadable (then use failNextRequest), or when it has already expired on the app's clock.","effect":"write","release":"works","undo":{"action":"reset","note":"reset puts the app back on real time. The app keeps any token it fetched meanwhile, which is harmless."},"params":{"type":"object","properties":{"token":{"type":"string","description":"Token id from getState's tokens[].id. Default: the most recently used token that has a readable expiry."},"leadMs":{"type":"number","description":"Land this many ms before expiry. Default 10000."}},"additionalProperties":false}},{"action":"failNextRequest","summary":"Answer the app's next request that carries a sign-in token with a 401 (or `status` 403), once or `times` times, then watch for the app to fetch a new token.","description":"Tests apps that refresh their token after the server rejects it. Adds a one-shot Network override on the token's origin, limited to requests that send the token's header, so the 401 never lands on the app's own token request. It answers with `WWW-Authenticate: Bearer error=\"invalid_token\"` and an invalid_token JSON body, and starts a refresh check. Needs @buoy-gg/network. The app has to send a request for the 401 to reach it; getState's tokens.check then shows forcedAt, whether a new token arrived, staleAfterFailure (requests that re-sent the old token after the 401) and failedRefreshes (token requests that failed). Replaces an earlier 401 that has not fired yet.","effect":"write","release":"works","undo":{"action":"stopTokenCheck","note":"stopTokenCheck withdraws the 401 if it has not reached the app yet. A 401 the app already received cannot be taken back."},"params":{"type":"object","properties":{"token":{"type":"string","description":"Token id from getState's tokens[].id. Default: the most recently used token that has a readable expiry."},"status":{"type":"number","description":"401 (default) or 403."},"times":{"type":"number","description":"How many requests to fail, 1-10. Default 1."}},"additionalProperties":false}},{"action":"stopTokenCheck","summary":"End the refresh check, withdrawing a forced 401 that has not reached the app yet.","description":"Clears tokens.check. When the check was started by failNextRequest and the 401 has not fired, removes that Network override so it cannot fail a later request. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}}]},{"toolId":"storage","title":"Storage","summary":"Read and write the app's persisted state across all three backends \u2014 AsyncStorage, every registered MMKV instance, and registered Expo SecureStore keys \u2014 plus a recorded timeline of storage writes with per-event undo/jump. Reach for this first when a value is wrong, stale, missing, or only broken after a restart/upgrade: the bad value is usually sitting in storage. Buoy's own devtool keys (@react_buoy*, @buoy*, buoy-*) are stripped from every read path, so results are app data only. Nothing here is __DEV__-gated \u2014 every action really runs in a release build.","actions":[{"action":"getRequiredKeys","summary":"List the storage keys the app declares as required, with their expected types and backends.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns the requiredStorageKeys array given to createStorageTool (or the FloatingDevTools requiredStorageKeys prop): each entry is a key, or an object with key, expectedType or expectedValue, description and storageType (async, mmkv or secure). Returns [] when the app declared none. It reports the configuration only; read the keys themselves to see whether they are present and valid. Swift returns the requirements configured through BuoyStorageModule.configure(requiredKeys:)."},{"action":"async.getAllKeys","summary":"List every AsyncStorage key in the app (Buoy's own devtool keys stripped).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns string[]. The cheapest first call: get the key names, then read the ones that matter with async.multiGet. Keys matching @react_buoy*, @buoy*, buoy-*, or legacy dev prefixes are filtered out at the source, so the count can be lower than the app's real key count.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.multiGet","summary":"Batch-read AsyncStorage values; returns [key, value|null][] tuples.","params":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":'Keys to read, e.g. ["session","user.prefs"]. Required \u2014 the handler reads params.keys with no guard and throws if params is missing.'}},"required":["keys"],"additionalProperties":false},"effect":"read","release":"works","description":"Values are always strings (or null when unset) \u2014 JSON.parse them yourself. Works on async-storage v2 and v3 (translated to getMany internally) and always preserves the requested key order. Any requested key that is a Buoy devtool key is dropped from the RESULT entirely, so the returned array can be shorter than `keys`.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.getItem","summary":"Read one AsyncStorage key; returns the string value or null.","params":{"type":"object","properties":{"key":{"type":"string","description":"The AsyncStorage key to read."}},"required":["key"],"additionalProperties":false},"effect":"read","release":"works","description":"Prefer async.multiGet when you want more than one key. Returns null (never an error) for a key that is unset AND for any Buoy devtool key (@react_buoy*, @buoy*, buoy-*) even when that key really is set \u2014 so a null here does not prove the app never wrote it if the key is Buoy-prefixed.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.setItem","summary":"Write one AsyncStorage key to a string value.","params":{"type":"object","properties":{"key":{"type":"string","description":"The AsyncStorage key to write."},"value":{"type":"string","description":"The string value to store. JSON-encode objects/arrays yourself."}},"required":["key","value"],"additionalProperties":false},"effect":"write","release":"works","description":`Values are strings only \u2014 JSON.stringify objects yourself, or the app will read back garbage. Unlike the read paths this is NOT key-filtered: it will happily overwrite Buoy's own @react_buoy*/@buoy*/buoy-* settings keys, so never point it at one. Emits a setItem event (with the old value as prevValue) while a dashboard is subscribed, which makes it undoable via timeTravel.undo. TRAP: if this key is a live store's saved copy (the grounding marks these as "persists to <key>", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead \u2014 zustand.setState, redux.dispatch, jotai.setAtom.`,"requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.removeItem","summary":"Delete one AsyncStorage key.","params":{"type":"object","properties":{"key":{"type":"string","description":"The AsyncStorage key to delete."}},"required":["key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Permanently removes the key from the device. No confirmation and no result payload \u2014 it resolves to undefined whether or not the key existed. Read the value first if you might need it back.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.multiRemove","summary":"Delete several AsyncStorage keys in one call.","params":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":"Keys to delete."}},"required":["keys"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Batch form of async.removeItem (translated to removeMany on async-storage v3). Deletes exactly the keys you name \u2014 it does NOT filter Buoy devtool keys, so do not pass @react_buoy*/@buoy*/buoy-* keys. To wipe app data wholesale use clearAppStorage instead, which protects those.","requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"]},{"action":"async.multiSet","summary":"Write several AsyncStorage key/value pairs in one call.","params":{"type":"object","properties":{"pairs":{"type":"array","description":'Array of two-element [key, value] arrays, e.g. [["session","abc"],["count","3"]].',"items":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2}}},"required":["pairs"],"additionalProperties":false},"effect":"write","release":"works","description":`Takes v2-shaped tuples [[key, value], ...] and translates to setMany on async-storage v3. All values must be strings. Same caveat as async.setItem: not key-filtered, so never include a Buoy devtool key. TRAP: if this key is a live store's saved copy (the grounding marks these as "persists to <key>", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead \u2014 zustand.setState, redux.dispatch, jotai.setAtom.`,"requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"]},{"action":"async.clear","summary":"Wipe ALL of AsyncStorage, including Buoy's own devtool settings.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Raw AsyncStorage.clear() \u2014 the nuclear option. It destroys Buoy's own @react_buoy*/@buoy*/buoy-* keys too, resetting the dev tools' own settings along with app data. Almost always the wrong choice: use clearAppStorage, which does the same thing to app data while preserving Buoy's keys. Prefer it unless someone explicitly asked to reset the dev tools as well.","requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open (a storage events read/watch) AT THE MOMENT OF THE WRITE \u2014 otherwise no event with prevPairs is recorded and the undo is impossible"]},{"action":"clearAppStorage","summary":"Delete every app AsyncStorage key while preserving Buoy's own devtool keys.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"The safe reset: getAllKeys, drop anything matching @react_buoy*/@buoy*/buoy-*/legacy dev prefixes, removeMany the rest. This is what a 'clear all storage' / 'reset the app's data' request means. Resolves to undefined and reports no count. Use it instead of async.clear.","requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open at the moment of the call \u2014 the wipe goes through removeMany, so with no subscriber there is no multiRemove event and the wipe is unrecoverable"]},{"action":"getEventDetail","summary":"Fetch one recorded storage event's full value/prevValue/pairs by event id.","params":{"type":"object","properties":{"id":{"type":"string","description":"Event id from the storage event timeline, format se-<epochMs>-<counter>."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'The streamed event timeline replaces any value over 16KB with the marker object {__buoyValueOnDevice:true}; this is the on-demand channel for the real payload (itself capped at 8MB, above which it returns {__buoyTruncated:true}). Call it before timeTravel.undo/jump on an event whose values are still markers \u2014 those actions refuse to replay markers. Never throws: returns {found:false, reason:"missing id"} or {found:false, reason:"unknown id"}. Event ids look like "se-1755000000000-42".',"armsCapture":true},{"action":"clearEvents","summary":"Wipe the recorded storage-event timeline (in-memory only; app storage untouched).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Empties the 500-event ring buffer. No device storage is written or deleted \u2014 but every event id disappears, so getEventDetail and timeTravel.undo/jump lose all their targets, permanently. Worth knowing: the initial key scan that seeds the timeline with the app's PRE-EXISTING keys runs only once per store lifetime, so after clearEvents the timeline does not re-list existing keys \u2014 it only refills with new writes. Use async.getAllKeys to see current keys instead.","armsCapture":true},{"action":"timeTravel.undo","summary":"Revert one recorded AsyncStorage write, restoring the value it overwrote.","params":{"type":"object","properties":{"id":{"type":"string","description":"Id of the AsyncStorage event to undo (se-<epochMs>-<counter>)."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Addresses a single event by id and restores its prevValue/prevPairs (removing the key when it did not exist before). AsyncStorage events only \u2014 throws "Time travel supports AsyncStorage events only" for MMKV events, throws when the event has no captured previous value, and throws when prevValue is still an on-device marker (fetch getEventDetail first). Unlike the in-app UNDO button, failures throw with the real reason instead of silently doing nothing. The restore itself emits a normal storage event, so it is visible and re-undoable.',"armsCapture":true},{"action":"timeTravel.jump","summary":"Rewind storage to its state as of one recorded event by replaying history.","params":{"type":"object","properties":{"id":{"type":"string","description":"Id of the AsyncStorage event to jump to (se-<epochMs>-<counter>)."},"scope":{"type":"string","enum":["key","all"],"description":`"key" (default) replays only that key's history; "all" replays every captured AsyncStorage event and can rewrite/delete many keys.`}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Replays the captured AsyncStorage timeline up to and including `id`, then reconciles the keys that timeline governs \u2014 writing the replayed values and DELETING keys that only exist later than the target. scope:"key" (the default, matching the in-app JUMP button) replays only the target event\'s own key. scope:"all" replays every captured AsyncStorage event, so it can rewrite and delete many unrelated keys at once \u2014 treat that as a bulk data change and confirm before using it. Buoy\'s own devtool keys are never touched. Throws for an unknown id, for an id not in the chosen timeline, or when any replayed event still holds an on-device value marker (fetch getEventDetail first). Returns {jumped, scope, replayed, of, key}.',"armsCapture":true},{"action":"mmkv.snapshot","summary":"Dump every registered MMKV instance with all its keys, values, and value types.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns [{id, encrypted, readOnly, entries:[{key, value, valueType}]}] where valueType is string|number|boolean|buffer. One round trip for all instances \u2014 the right MMKV read unless you need a single oversized value. Values over 16KB are replaced with {__buoyValueOnDevice:true}; fetch those via mmkv.get. Buoy devtool keys are stripped. Returns [] when the app never called registerMMKVInstance(); an instance that throws on read comes back with entries: [] rather than failing the call.","requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"mmkv.get","summary":"Read one MMKV key's full value from a named instance.","params":{"type":"object","properties":{"instanceId":{"type":"string","description":"Registered MMKV instance id, as listed by mmkv.snapshot."},"key":{"type":"string","description":"The key to read."}},"required":["instanceId","key"],"additionalProperties":false},"effect":"read","release":"works","description":'The size-guarded single-key channel for values mmkv.snapshot omitted. Returns {found:true, instanceId, key, value, valueType} \u2014 or, without throwing, {found:false, reason:"missing instanceId/key"} or {found:false, reason:"unknown instance"}. valueType is auto-detected as string|number|boolean|buffer. instanceId is the id the app registered, e.g. "mmkv.default" or "user-prefs" \u2014 get the exact ids from mmkv.snapshot.',"requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"mmkv.set","summary":"Write one key in a registered MMKV instance (string, number, or boolean).","params":{"type":"object","properties":{"instanceId":{"type":"string","description":"Registered MMKV instance id (see mmkv.snapshot)."},"key":{"type":"string","description":"The key to write."},"value":{"description":"Typed value \u2014 stored as string, number, or boolean exactly as passed.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["instanceId","key","value"],"additionalProperties":false},"effect":"write","release":"works","description":`Unlike AsyncStorage, MMKV is typed: pass a real number or boolean and it is stored as that type \u2014 do not stringify. Throws 'No MMKV instance registered as "<id>"' for an unknown instance and 'MMKV instance "<id>" is registered read-only' for one the app declared immutable, so a success means the write really landed. Returns nothing. MMKV writes are recorded in the event timeline but are NOT undoable via timeTravel (AsyncStorage only). TRAP: if this key is a live store's saved copy (the grounding marks these as "persists to <key>", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead \u2014 zustand.setState, redux.dispatch, jotai.setAtom.`,"requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"mmkv.remove","summary":"Delete one key from a registered MMKV instance.","params":{"type":"object","properties":{"instanceId":{"type":"string","description":"Registered MMKV instance id (see mmkv.snapshot)."},"key":{"type":"string","description":"The key to delete."}},"required":["instanceId","key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Calls remove() on react-native-mmkv v4 and falls back to delete() on older versions. Throws for an unknown instance id or one registered read-only; otherwise returns nothing, whether or not the key existed. Not recoverable through timeTravel \u2014 that covers AsyncStorage only.","requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"secure.keys","summary":"List the registered Expo SecureStore keys (names and flags, no values).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns [{key, description, keychainService, requireAuthentication}]. SecureStore has no key-enumeration API, so only keys the app declared via registerSecureStoreKeys(...) are visible \u2014 an empty [] means nothing was registered, NOT that the keychain is empty. requireAuthentication:true marks a biometric-protected key whose value Buoy never reads.","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.snapshot","summary":"List every registered SecureStore key WITH its value in one round trip.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"The SecureStore counterpart to mmkv.snapshot, and the right read when you want values \u2014 one call instead of secure.keys plus N secure.get. Returns [{key, description, keychainService, requireAuthentication, value}]. value is null when unset, when the read failed, and always for biometric-protected keys (reading those would fire an auth prompt on the user's device, so they are skipped). Values over 16KB come back as {__buoyValueOnDevice:true}.","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.get","summary":"Read one registered SecureStore key's value.","params":{"type":"object","properties":{"key":{"type":"string","description":"A registered SecureStore key (see secure.keys)."}},"required":["key"],"additionalProperties":false},"effect":"read","release":"works","description":"Reads with the exact options (keychainService) the key was registered with, which is required or the read returns null. Resolves to null \u2014 never an error \u2014 in four different cases: the value is unset, the key is not registered, no SecureStore module was registered, or the key is biometric-protected (requireAuthentication, deliberately never read). Check secure.keys before concluding from a null that the app never stored anything. Prefer secure.snapshot for more than one key.","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.set","summary":"Write a registered SecureStore key (string value).","params":{"type":"object","properties":{"key":{"type":"string","description":"A registered, non-biometric SecureStore key."},"value":{"type":"string","description":"The string value to store. JSON-encode objects yourself."}},"required":["key","value"],"additionalProperties":false},"effect":"write","release":"works","description":`Goes through the registry so the value is written with the SAME options it was registered with \u2014 writing with a different keychainService silently creates a second entry the app cannot read. Throws rather than no-ops: 'No SecureStore module is registered', 'SecureStore key "<k>" is not registered', or '...is biometric-protected \u2014 DevTools never writes it'. Values are strings; JSON-encode objects yourself. SecureStore changes are NOT in the event timeline, so there is no timeTravel undo.`,"requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.delete","summary":"Delete a registered SecureStore key's value from the keychain.","params":{"type":"object","properties":{"key":{"type":"string","description":"A registered SecureStore key (see secure.keys)."}},"required":["key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Permanently deletes the keychain entry using the options the key was registered with. Beware the silent path: if the key is not registered or no SecureStore module was registered it resolves with NO error and NO deletion \u2014 a success here is not proof anything was deleted, so verify with secure.get/secure.snapshot afterwards. Deleting an auth token or session key logs the user out; not recoverable (SecureStore is not in the event timeline).","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]}],"unavailableWhen":'The app does not have @buoy-gg/storage installed alongside <FloatingDevTools/> \u2014 autoExternalSync only registers the "storage" adapter when that module resolves (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:320). Individual backends degrade instead of erroring: with no registerMMKVInstance() call mmkv.snapshot returns [], and with no registerSecureStoreKeys() call secure.keys/secure.snapshot return [].'},{"toolId":"highlight-updates","title":"Highlight Updates","summary":"Read and drive the live screen with describeScreen, tapElement, and waitFor. RN also exposes React render tracking; native Swift does not. Inspect the connected device\u2019s actions before calling a platform-specific operation. The touch-capture actions support Scenarios recording. Native capture is restricted to development builds and excludes Buoy controls and secure text inputs.","actions":[{"action":"describeScreen","summary":"List every meaningful/interactive element currently on screen, with normalized tap points \u2014 the read half of driving the app. The ONLY way to read what is on screen but in no store: text, values and names held in component state, which no other Buoy tool can see.","params":{"type":"object","properties":{"includeBuoy":{"type":"boolean","description":"Also list Buoy's own overlay (the dial, tool sheets, Ask Buoy's own chat). Default false \u2014 those are the tool, not the app."}},"additionalProperties":false},"effect":"read","release":"empty","description":"Walks the live React fiber tree across ALL renderers and measures each candidate. Returns {screen:{width,height}, count, elements[]} where each element has: nativeTag, name (owning component, not the host View), role, testID, label, text, interactive, control ('toggle'|'slider'|'text', omitted for a plain press), value (current value for toggle/slider/text), longPressable, tap:{x,y} and frame:{x,y,width,height} both normalized to 0-1. Sorted top-to-bottom then left-to-right. Prunes offscreen/inactive react-native-screens and hidden Offscreen subtrees, so it reflects the CURRENT screen only \u2014 re-run after every navigation, positions and tags move. No screenshot and no tracking needed; it does not require the highlight overlay to be enabled. Call this before tapElement to get an exact testID/nativeTag instead of guessing a fuzzy query. Buoy's OWN overlay is left out \u2014 the dial, tool sheets and your own chat sheet are the tool, not the app under test \u2014 and `hiddenBuoy` counts what was omitted; pass includeBuoy:true only when the task is about Buoy itself.","releaseNote":"packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:57 \u2014 getReactDevToolsHook() reads __REACT_DEVTOOLS_GLOBAL_HOOK__, which RN installs only under __DEV__; getAllFiberRoots() then returns [] and collectCandidates() short-circuits at line 539, so the result is a valid-looking {count:0, elements:[]} rather than an error.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"tapElement","summary":'Interact with one on-screen element by invoking its real handler in JS: press, long-press, toggle/slider, or type into a text input. This is how you make the app FETCH MORE \u2014 press its own "next", "load more" or a list row, waitFor, then read: the app fetches through its own code, so the data arrives in the shape its own screens render.',"params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"Exact nativeTag from describeScreen. Most precise; wins over testID/query."},"testID":{"type":"string","description":"Exact testID of the element."},"query":{"type":"string","description":"Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Sign in'. Least precise \u2014 verify with describeScreen first."},"value":{"description":"For a toggle/switch: true/false (omit to flip the current value). For a slider: the numeric value, clamped to minimumValue/maximumValue (omit to jump to the far end).","anyOf":[{"type":"number"},{"type":"boolean"}]},"text":{"type":"string","description":"For a text input: the string to set via onChangeText."},"longPress":{"type":"boolean","description":"Invoke onLongPress instead of onPress. Returns tapped:false if the element has no onLongPress."},"scrollIntoView":{"type":"boolean","description":"Scroll an ancestor ScrollView so the target is visible before acting. Default true \u2014 set false to avoid moving the user's screen."},"includeBuoy":{"type":"boolean","description":"Let a fuzzy `query` match Buoy's own overlay. Default false. An exact nativeTag/testID always may."}},"additionalProperties":false},"effect":"write","release":"empty","description":"WARNING: this fires the app's ACTUAL handler, so it can trigger irreversible flows (delete, purchase, logout, submit). Matching by fuzzy `query` can select the wrong element \u2014 prefer an exact nativeTag or testID from describeScreen, and confirm the target before firing anything consequential. Resolution order: the element's OWN onValueChange (toggle/slider) or onChangeText (text input) wins; otherwise the nearest onPress walking up the fiber chain (up to 25 levels). Returns {tapped, reason?, scrolled?, matched:{nativeTag,name,testID,label,text,via,value}, candidates?} \u2014 `via` is onPress|onLongPress|onValueChange|onChangeText. Trap: passing `text` to something that is not a text input silently runs its onPress instead, so check `matched.via` in the result. Elements driven only by react-native-gesture-handler GestureDetector have no JS handler and return tapped:false with a clear reason. If the target is off-screen it is scrolled into view first (this moves the user's screen). Provide exactly one of nativeTag / testID / query. A fuzzy query never matches Buoy's own overlay unless includeBuoy:true (your chat sheet echoes the words you search for; that echo used to be the best match). READ THE RESULT BEFORE TRUSTING THE TAP: `effect.commits` is how many React commits followed inside the settle window, and 0 means the handler ran but NOTHING re-rendered \u2014 the match was probably a screen still mounted underneath the current one (a stack keeps them), or an inert control \u2014 so treat it as not done and pick another target from describeScreen. `alsoMatched` lists elements that matched equally well; non-empty means the choice was a coin toss, so re-tap by nativeTag.","releaseNote":'packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:793 \u2014 collectCandidates() returns [] with no DevTools hook, so it returns {tapped:false, reason:"No React fiber roots / elements found on screen."}; it reports the failure honestly rather than claiming a tap.',"requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"waitFor","summary":"Block until an element is on screen (or gone), then report how long it took. Use it between navigating and tapping.","description":"Returns {ok, waitedMs, polls, matched?, reason?}. USE THIS AFTER ANY ACTION THAT STARTS A LOAD \u2014 `route-events.navigate` returns the moment the route is pushed, not when the screen has data, so tapping straight after it hits a loading skeleton and fails for a reason that looks like a bad selector. Matching is `tapElement`'s exactly (same fields, same ranking), so a wait can never resolve on an element the tap then cannot find. Presence means ON SCREEN: a candidate matching by name is measured before it counts, so a previous screen still mounted behind this one does not satisfy the wait. `gone:true` waits for absence instead \u2014 and returns immediately when nothing matched in the first place, which is a real answer, not a failure. Like tapElement, a fuzzy query ignores Buoy's own overlay unless includeBuoy:true; an exact nativeTag/testID always counts. Defaults: timeoutMs 5000 (capped at 30000), pollMs 150 (floored at 50, because this polls on the JS thread the app renders on). Provide exactly one of nativeTag / testID / query.","params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"Exact nativeTag from describeScreen. Most precise."},"testID":{"type":"string","description":"Exact testID of the element."},"query":{"type":"string","description":"Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Fire Grill'."},"gone":{"type":"boolean","description":"Wait for the element to DISAPPEAR instead of appear \u2014 a spinner, a skeleton, a modal. Default false."},"timeoutMs":{"type":"number","description":"Give up after this long. Default 5000, capped at 30000."},"pollMs":{"type":"number","description":"Gap between scans. Default 150, floored at 50."},"includeBuoy":{"type":"boolean","description":"Let a fuzzy `query` match Buoy's own overlay. Default false. An exact nativeTag/testID always may."}},"additionalProperties":false},"effect":"read","release":"empty","releaseNote":"Walks the same fiber tree describeScreen does, and React Native installs __REACT_DEVTOOLS_GLOBAL_HOOK__ only under `if (__DEV__)`, so outside a dev build nothing is ever found and every wait runs to its timeout.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"beginMeasurement","summary":"Open an invisible render-capture window (CommitProfiler) \u2014 pair with endMeasurement around the interaction you want to measure.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":"Starts a commit-level capture with detail:true. Backed by CommitProfiler, NOT the highlight overlay's RenderTracker, and that difference is the point: it walks COMPOSITE fibers, so it answers about the component you named (which of ITS props changed, by value or identity only, and which parent dragged it along) instead of about the native View inside it. Touches none of the user's state \u2014 nothing is enabled, nothing is cleared, nothing is drawn on screen; the user sees no change. Discards any previous unclaimed capture. Returns {ok:true} or {ok:false, reason} \u2014 always check `ok` before driving the interaction. Usage: beginMeasurement -> wait ~400ms to settle -> drive the UI with tapElement -> endMeasurement.","releaseNote":'packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts:458 \u2014 isSupported() returns false when !__DEV__, so this returns {ok:false, reason:"render capture needs a dev build with the React DevTools hook available"}. Honest self-report, unlike the toggle actions.',"requires":["@buoy-gg/highlight-updates at adapter version 3+ (older apps have no beginMeasurement)","dev build (__DEV__ === true)"]},{"action":"endMeasurement","summary":"Close the capture window and return the per-component render summary (cost in ms, cause, wasted renders).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":"Returns {summary: RenderCaptureSummary | null}. The summary carries totalCommits, totalRenders, totalRenderMs, wastedRenders (parent cascades + identity-only prop churn \u2014 the removable share), aggregate `causes`, and topComponents[] (capped) with {name, renders, totalMs, maxMs, avgMs, causes, changedProps, parentName}. Cause labels: mount (first render), hooks (own state changed), props (props changed by value), propsUnstable (props changed by IDENTITY only \u2014 a fresh function/object from the parent, fix upstream), parent (pure cascade, nothing of its own changed). Read topComponents sorted by totalMs, not by render count: a component rendering 40x for 0.4ms is noise; one rendering 4x for 38ms is the answer. summary is null when beginMeasurement was never called or the app reloaded mid-window.","releaseNote":"packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts \u2014 stopCapture() returns null when the window was never started, and beginMeasurement can never start one in release (isSupported() false at line 458), so this always yields {summary:null}.","requires":["@buoy-gg/highlight-updates at adapter version 3+","a prior successful beginMeasurement on the same device"]},{"action":"locateComponent","summary":"Resolve a component to a fresh on-screen rectangle (in points) plus pixel scale \u2014 used to crop a simulator screenshot to exactly that component.","params":{"type":"object","properties":{"query":{"type":"string","description":"Fuzzy match against testID / nativeID / accessibilityLabel / component name / view type, e.g. 'submit-button'. Exact field match wins, then prefix, then substring."},"nativeTag":{"type":"number","description":"Exact native tag \u2014 skips fuzzy matching and wins over query."},"scrollIntoView":{"type":"boolean","description":"Scroll an ancestor ScrollView to bring the component into view before measuring. Default true \u2014 this visibly moves the user's screen."},"margin":{"type":"number","description":"Gap in points to leave above the component when scrolling it in. Default 12."}},"additionalProperties":false},"effect":"write","release":"empty","description":"BEWARE the side effect: by default this SCROLLS the user's app so the target sits near the top of its ScrollView before measuring. Pass scrollIntoView:false for a pure read. Returns {matched, reason?, live?, scrolled?, inView?, rect:{x,y,width,height}, scale (PixelRatio, multiply points by it for pixels), screen:{width,height}, nativeTag, componentName, testID, candidates[]}. Two resolution paths: if the render tracker has data (highlights or silent tracking were on) it ranks tracked components by testID/nativeID/accessibilityLabel/componentName/viewType/nativeTag and re-measures the live node; if the tracker is empty it falls back to the same fiber walk as describeScreen, so it still works cold. `reason` is one of no-renders | no-match | no-measurement. When matched is false, read `candidates` and retry with an exact nativeTag.","releaseNote":'HighlightUpdatesController.ts:1744 \u2014 initialize() bails when !__DEV__ so the tracker is empty, and the describeScreen fallback finds no fiber roots; result is {matched:false, reason:"no-match", candidates:[]}.',"requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"getRenderDetail","summary":"Fetch one tracked component's full renderHistory and lastRenderCause (including hook-change values), which the synced snapshot omits.","params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"The component's native tag, from the synced renders[] list or describeScreen. Omitting it returns {found:false, reason:'missing nativeTag'}."}},"required":["nativeTag"],"additionalProperties":false},"effect":"read","release":"empty","description":"Snapshots strip the per-component history buffer (up to 20 events) and hookChanges to keep the ~5x/sec sync small; this is the on-demand fetch for a single row the user clicked. Returns {found:true, nativeTag, render} or {found:false, reason} where reason is 'missing nativeTag' or 'unknown nativeTag'. Keyed by nativeTag \u2014 get one from the snapshot's renders[] or from describeScreen. Only returns data for components the RenderTracker has actually seen, which means highlights (setEnabled/toggle) or setSilentTracking must have been on while the component rendered; a cold call on a fresh app returns found:false even though the component exists on screen.","releaseNote":'HighlightUpdatesController.ts:1819/1854 \u2014 enable()/disable() no-op when !__DEV__, so RenderTracker never records and every lookup returns {found:false, reason:"unknown nativeTag"}.',"requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)","render tracking was on while the component rendered"]},{"action":"setEnabled","summary":"Turn the user-visible render highlighting on or off explicitly (colored boxes flash on the device around every re-rendering component).","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true draws the highlight boxes on the device and starts tracking; false stops both."}},"required":["enabled"],"additionalProperties":false},"effect":"write","release":"noop","description":"Prefer this over `toggle` \u2014 it is idempotent, so the agent never has to know the current state. Enabling starts RenderTracker (populating the synced renders[] list and enabling getRenderDetail) AND draws colored boxes on the DEVICE screen: cyan for few renders through yellow for many, with a count badge. It also clears any leftover highlight suppression from a silent-tracking session. This is visible to whoever is holding the phone \u2014 say so before enabling. Returns undefined on success. Always send a params object: the handler reads params.enabled without a null guard, so calling with no params throws (surfaced as ok:false with a TypeError message).","releaseNote":"HighlightUpdatesController.ts:1819 (enable) and :1854 (disable) both `if (!__DEV__) return;` before doing anything. The wire still reports ok:true, so this action LIES in a release build \u2014 never tell a QA user highlighting is on without confirming a dev build.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"toggle","summary":"Flip render highlighting on/off \u2014 same visible effect as setEnabled but state-dependent.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"noop","description":"Calls enable() when off, disable() when on. Prefer setEnabled({enabled}) unless you genuinely want a flip, because this action's outcome depends on state you may not have read. Read the synced snapshot's `enabled` flag first if the distinction matters. Initializes the controller on first use. Draws colored boxes on the DEVICE screen \u2014 visible to the person holding the phone. Takes no params. Returns undefined.","releaseNote":"HighlightUpdatesController.ts:1954 \u2014 `if (!__DEV__) return;` at the top of toggle(). Reports ok:true and does nothing.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"setSilentTracking","summary":"Track and measure renders WITHOUT drawing any highlight boxes \u2014 invisible tracking for screenshot/locate flows.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true = track + measure with the overlay hidden; false = stop tracking and restore normal state. Omitted defaults to false (disable)."}},"additionalProperties":false},"effect":"write","release":"noop","description":"Turns the render tracker on (so renders[] populates, measurements sync, and getRenderDetail/locateComponent have data) while keeping the visual overlay hidden, so nothing appears on the user's screen. This is the right way to arm tracking when you only need data \u2014 use setEnabled only when the user actually wants to SEE the boxes. Idempotent and safe to call before every locateComponent. Disabling restores normal state (also disables tracking). Note: enabling any visual highlighting afterwards clears the suppression, so the boxes come back. Returns undefined. Always send a params object \u2014 the handler reads params.enabled without a null guard and throws if params is omitted; `{}` is treated as enabled:false.","releaseNote":"Reaches HighlightUpdatesController.setSilentTracking -> enable(), which returns early at HighlightUpdatesController.ts:1819 when !__DEV__. Reports ok:true; no tracking is armed and every later getRenderDetail/locateComponent comes back empty.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"toggleFreeze","summary":"Freeze the highlight boxes on screen so they stop fading, or unfreeze to resume normal fade-out.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"noop","description":"Freeze mode keeps existing highlight boxes on the device screen instead of letting them fade, so a human can read which components lit up during a burst. New renders are still captured. Unfreezing clears whatever boxes are currently drawn. State-dependent: read the snapshot's `frozen` flag before calling if you need a specific end state. Freeze is a view state on a LIVE overlay \u2014 disabling highlighting entirely also clears it. Takes no params. Returns undefined.","releaseNote":"toggleFreeze() itself has no gate, but both branches do: freeze() at HighlightUpdatesController.ts:2068 and unfreeze() at :2082 return early when !__DEV__. Reports ok:true and nothing changes.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)","highlighting already enabled (there are no boxes to freeze otherwise)"]},{"action":"setSpotlight","summary":"Draw a distinct spotlight highlight around one component on the device screen (or clear it with null).","params":{"type":"object","properties":{"nativeTag":{"description":"Native tag of the component to spotlight, or null to clear the current spotlight.","anyOf":[{"type":"number"},{"type":"null"}]}},"required":["nativeTag"],"additionalProperties":false},"effect":"write","release":"noop","description":"Used when someone is browsing a component's detail from the desktop dashboard and wants to see WHICH component that row is, physically on the phone. Draws on the DEVICE screen \u2014 visible to whoever is holding it. Pass nativeTag:null to clear the spotlight; always clear it when you're done, or a stale box stays on the user's screen. Requires the highlight overlay to be mounted (i.e. highlighting enabled) for anything to appear. Returns undefined. Always send a params object \u2014 the handler reads params.nativeTag without a null guard and throws if params is omitted entirely.","releaseNote":"setSpotlight has no __DEV__ gate of its own, but the overlay that renders the spotlight only exists once the tool is enabled, and enable() is gated at HighlightUpdatesController.ts:1819. It sets a variable, draws nothing, and reports ok:true.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)","highlighting enabled so the overlay is mounted"]},{"action":"clearRenderCounts","summary":"Wipe ALL tracked render data and per-component counters \u2014 irreversible, and it destroys data someone may be collecting.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"noop","description":"Clears the nativeTag->count map and empties RenderTracker's entire renders store. There is no undo and no snapshot: any render history a human was watching accumulate, or that a colleague armed tracking to collect, is gone. Only call this when the user explicitly asks to reset counts, typically to get a clean baseline before measuring an interaction. Prefer beginMeasurement/endMeasurement for measuring \u2014 that opens its own window and never touches the user's overlay data. Takes no params. Returns undefined.","releaseNote":"packages/highlight-updates/src/highlight-updates/utils/HighlightUpdatesController.ts:1744 (initialize), :1819 (enable), :1905 (enableBackgroundTracking) all return early when __DEV__===false, and the only writers of nodeRenderCounts/RenderTracker are the DevTools-hook interceptors (ProfilerInterceptor.ts:107, CommitProfiler.ts:74). In a release build those stores are permanently empty, so clearRen","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>"]},{"action":"startTouchCapture","summary":"Start recording supported host interactions.","description":"Returns capture status and a reason if unavailable. RN uses its touch stream; Swift uses UIKit events and native-driver interactions in development builds. Read the status before driving the app. This does not save a scenario.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty"},{"action":"stopTouchCapture","summary":"Stop capturing new interactions and retain recorded data.","description":"Read retained records with readTouchCapture. Stopping does not save a scenario.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"},{"action":"clearTouchCapture","summary":"Clear retained interactions and reset the sequence number.","description":"Existing recording data is removed. Start subsequent reads with sinceSeq:0.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works"},{"action":"readTouchCapture","summary":"Read captured interactions after a sequence number.","description":"Returns {status, reason, records, seq}. Pass the last returned seq as sinceSeq on the next read. Coverage depends on platform; records do not prove every gesture was captured or replayable.","params":{"type":"object","properties":{"sinceSeq":{"type":"number","description":"Return records with a sequence number greater than this value. Default 0."}},"additionalProperties":false},"effect":"read","release":"works"}],"unavailableWhen":"RN requires @buoy-gg/highlight-updates registered with FloatingDevTools; React inspection and render tracking depend on a development build. Swift exposes native interaction and touch-capture actions under this ID, without React render tracking. Inspect the connected device\u2019s available actions. Swift touch capture refuses production builds."},{"toolId":"scenarios","title":"Scenarios","summary":'Named, parameterized app states ("out of stock at store 220", "expired token") stored as a list of steps that each call one other Buoy tool action; running one bends the app into that state deterministically and `deactivate` reverses what is reversible. Reach for it to put the app in a known state BEFORE driving a flow, and to check whether what is on screen is real or simulated \u2014 if `active` is non-null, the data the user is looking at is partly fake. Authoring flow: save (always lands as an inert DRAFT) \u2192 a human accepts it on the device (or acceptDraft) \u2192 preview \u2192 run \u2192 deactivate.',"actions":[{"action":"listScenarios","summary":"List every scenario on the device \u2014 code, device-saved, and inert drafts \u2014 plus which one is currently active.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":'Returns {code[], device[], drafts[], active, runState, hasLoaded}. Each entry has id, name, description, version, source, author, tags, vars, stepCount, expectedOutcome, authoredAgainst, usage.runs, plus `steps` (raw {tool,action,params} objects; any step whose params exceed 8KB is sent with params dropped and paramsOmitted:true) and `stepLines` [{label, durability}] \u2014 the same plain-English sentences the device UI shows. durability is one of durable | session | transient | one-shot. `active` non-null means the app is showing SIMULATED state: say so before reporting anything observed on the device. `drafts` are INERT \u2014 they cannot run until accepted. Identical payload to the tool\'s sync snapshot. HYDRATION TRAP: the store loads @react_buoy_scenarios_library lazily on first subscribe and this handler does not await it, so if hasLoaded is false, empty `device`/`drafts` means "not loaded yet", not "none saved" \u2014 read again once the Scenarios panel or a dashboard has subscribed.',"requires":["Scenarios tool installed in the app"]},{"action":"getScenario","summary":"Full record for one scenario id, including untruncated step params and undoSteps.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id, from listScenarios. Blank/whitespace counts as missing and throws."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Returns {scenario}. Looks in code scenarios first, then device-saved, then drafts (a code scenario wins an id clash). Use this when listScenarios truncated a step (paramsOmitted:true) or you need undoSteps/vars in full. THROWS `No scenario "<id>"` if the id is unknown \u2014 and can throw that on a cold device purely because the persisted library has not hydrated yet (see listScenarios).',"requires":["Scenarios tool installed in the app"]},{"action":"save","summary":"Write a new scenario onto the device \u2014 it ALWAYS lands in the draft inbox and cannot run until accepted.","params":{"type":"object","properties":{"scenario":{"type":"object","description":"The scenario to save. Always stored as a draft.","properties":{"id":{"type":"string","description":"Stable kebab-case id, e.g. 'out-of-stock-item'."},"name":{"type":"string","description":"Human name; may contain {{variable}} placeholders."},"description":{"type":"string"},"expectedOutcome":{"type":"string","description":"What SHOULD happen once applied \u2014 how a non-developer tells a bug from expected behavior without asking an engineer. Write it."},"version":{"type":"number","description":"Defaults to 1; bumped automatically when replacing an existing draft."},"author":{"type":"string","description":"Defaults to 'remote'."},"authoredAgainst":{"type":"string","description":"App version this was authored against, shown for staleness."},"tags":{"type":"array","items":{"type":"string"}},"vars":{"type":"array","description":"Declare a variable for every value a tester might change (store id, user, status code) instead of freezing it into steps. Referenced in step params as {{key}}.","items":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["string","number","boolean","enum"]},"options":{"type":"array","description":"enum only.","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"default":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["key","type"]}},"steps":{"type":"array","description":"At least one, executed in order.","items":{"type":"object","properties":{"id":{"type":"string"},"tool":{"type":"string","description":"Adapter tool id: network | storage | impersonate | route-events | query | time-machine, or 'scenario' for the engine-local 'wait' step. Never 'scenarios' \u2014 recursion is refused at pre-flight."},"action":{"type":"string","description":"Action on that tool, e.g. upsertOverrideRule, deleteOverrideRule, setOverridesEnabled, async.setItem, async.removeItem, async.multiSet, mmkv.set, startImpersonation, stopImpersonation, navigate, setQueryData, invalidate, restore. A reloadApp/reload step is only allowed as the LAST step."},"params":{"type":"object","description":"JSON params for that action; string leaves may contain {{variable}}. Max 64KB serialized per step.","additionalProperties":true},"label":{"type":"string","description":"Plain-English override for the humanized sentence."},"timeoutMs":{"type":"number","description":"Per-step budget; default 10000."}},"required":["tool","action"]}},"undoSteps":{"type":"array","description":"How to reverse effects with no automatic inverse (any storage write/remove). They run on deactivate and clear the unrestored-keys warning.","items":{"type":"object","properties":{"id":{"type":"string"},"tool":{"type":"string"},"action":{"type":"string"},"params":{"type":"object","additionalProperties":true},"label":{"type":"string"},"timeoutMs":{"type":"number"}},"required":["tool","action"]}},"folder":{"type":"string","description":'The flow this belongs to \u2014 "Checkout", "Login". Reuse a name from `folders` in listScenarios; a scenario nobody can find is a scenario nobody runs.'}},"required":["id","name","steps"]},"accept":{"type":"boolean","description":"IGNORED \u2014 the handler never reads it. The save always lands as a draft."}},"required":["scenario"],"additionalProperties":false},"effect":"write","release":"works","description":'Returns {ok:true, savedAsDraft:true, id, message} on success, or {ok:false, error} / {ok:false, errors:[...]} on rejection \u2014 it does not throw for validation. Rejects unless: `id` is a string, `name` is a string, `steps` is a non-empty array, every step has both `tool` and `action`, `vars` (if present) is an array, each step\'s params serialize under 64KB, and the whole scenario is under 256KB. Source is forced to "draft", author defaults to "remote", unsavedToRepo is set true; saving over an existing DRAFT id replaces it and bumps its version. Tell the user the draft is inert and someone must open Scenarios on the device and tap Accept to library (or call acceptDraft). The `accept` field is read into the params type but never used by the handler \u2014 passing it does nothing. Prefer a network `upsertOverrideRule` step over a `query.setQueryData` poke: the override survives refetch and restart, the poke dies on the next fetch. Supply `undoSteps` for anything that writes or removes storage, or deactivation will honestly report the key as unrestored.',"requires":["Scenarios tool installed in the app"]},{"action":"acceptDraft","summary":"Promote a reviewed draft into the runnable device library \u2014 this is the human-consent gate, so only do it when the user says to.","params":{"type":"object","properties":{"id":{"type":"string","description":"Draft id, from listScenarios `drafts`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Moves the draft out of the inbox with source:"device" and unsavedToRepo:true, making it runnable. Returns {ok:true, id}. THROWS `No draft "<id>"`. Drafts exist precisely so a scenario pushed from chat never becomes runnable on someone\'s phone without them seeing what it will do \u2014 accepting on their behalf skips that review. Ask first, or tell them to tap "Accept to library" on the device instead.',"requires":["Scenarios tool installed in the app"]},{"action":"discardDraft","summary":"Permanently delete a draft from the review inbox.","params":{"type":"object","properties":{"id":{"type":"string","description":"Draft id, from listScenarios `drafts`."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Removes the draft and rewrites the persisted library. Returns {ok:true, id} even when no draft with that id existed \u2014 a success here is not proof anything was deleted. There is no undo: the steps are gone unless you exported the JSON first.","requires":["Scenarios tool installed in the app"]},{"action":"preview","summary":"Dry-run a scenario: what it WOULD change, with variables resolved, without touching the app.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id, from listScenarios."},"params":{"type":"object","description":"Variable values, e.g. {storeId:'220', outOfStock:true}. Only string/number/boolean values are kept \u2014 objects and arrays are silently dropped. Declared defaults fill in the rest.","additionalProperties":true}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Returns {willDo:[{label,durability}], values, ok, errors, warnings, conflict, expectedOutcome}. `values` is declared defaults merged with the values you supplied (supplied wins), and the labels are rendered THROUGH those values, so no {{placeholder}} survives. `conflict` is non-null when the currently-active scenario touches an overlapping URL pattern / storage key / the impersonation session \u2014 running anyway SWAPS (the active one is deactivated first). ok:false lists every blocking reason at once: draft not yet accepted, a step whose tool is not installed in this app, an unknown action, an undeclared or unset {{variable}}, a reload that is not the last step. Always run this before running an unfamiliar scenario. Changes nothing.","requires":["Scenarios tool installed in the app"]},{"action":"run","summary":"Apply a scenario for real \u2014 installs network overrides, writes storage, starts impersonation, navigates \u2014 and resolve only once every step has landed.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id, from listScenarios. Must not be a draft."},"params":{"type":"object","description":"Variable values, e.g. {storeId:'220'}. Only string/number/boolean values are kept \u2014 objects and arrays are silently dropped. Declared defaults fill in the rest.","additionalProperties":true}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"throws","description":"Atomic pre-flight runs first: on failure it returns {ok:false, preflightErrors:[...], conflict} and NOTHING is applied. Otherwise returns a RunReport {ok, scenarioId, name, steps:[{label, ok, error, skipped, ms}], effects:[{kind,label,...}]}. Steps run in order and STOP at the first failure \u2014 later steps are marked skipped, and the steps before it already applied, leaving the device in a partial state (call deactivate). Exclusive activation: running a different scenario while one is active deactivates the active one first. Budgets are 10s per step and 60s overall. Effects vary in reversibility \u2014 a network override rule is removed on deactivate, but a storage write has NO automatic inverse and will be reported as unrestored. Drafts cannot run. After this, the device shows a SIMULATED banner: anything the user observes is partly fake until deactivate.","releaseNote":'packages/scenarios/src/store/scenariosStore.ts:98-104 (isRunnableInThisBuild) \u2014 in a release bundle checkBeforeRun injects "Scenarios cannot run in a production build.", so the adapter returns {ok:false, preflightErrors:[...]} and applies nothing; scenariosStore.run() at :346-348 throws the same message. Verified by packages/scenarios/src/__tests__/runGate.release.test.ts. Report the refusal \u2014 never claim the scenario was applied.',"requires":["Scenarios tool installed in the app","every tool a step targets must be installed on the device (network, storage, impersonate, route-events, query, time-machine)","a development build \u2014 release builds refuse"]},{"action":"deactivate","summary":"Reverse the active scenario and report honestly what could NOT be restored.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":"Returns {ok:true, report:{removedRules, stoppedImpersonation, clearedTransients, unrestoredStorageKeys[], undoStepsRan, errors[]}}. Deletes the override rules the run created, stops impersonation, and runs the author's undoSteps if any (which clears unrestoredStorageKeys). `unrestoredStorageKeys` names keys the app still holds scenario values for \u2014 surface those verbatim to the user, they are real leftover state. Safe when nothing is active: returns an all-zero report. Also the right call after a failed/partial run.","releaseNote":"Not itself __DEV__-gated, but run is (packages/scenarios/src/store/scenariosStore.ts:98-104), so a release build can never have an active scenario \u2014 expect an all-zero report rather than a real reversal.","requires":["Scenarios tool installed in the app"]},{"action":"delete","summary":"Permanently remove a device-saved scenario from the library.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id from listScenarios `device` \u2014 code scenarios and drafts are unaffected."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Deactivates it first if it happens to be the active scenario, then drops it from the device list and rewrites persistent storage. Returns {ok:true, id}. Only touches DEVICE-saved scenarios: an id that is a code scenario (registered via defineScenario in app source) or a draft still returns ok:true while deleting nothing \u2014 code scenarios come back from the app bundle, drafts need discardDraft. Not recoverable; call export first if the definition matters.","requires":["Scenarios tool installed in the app"]},{"action":"setFolder","summary":'File a saved scenario under a flow folder ("Checkout", "Login"), or clear its folder.',"params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id from listScenarios `device` or `drafts`."},"folder":{"type":"string","description":"Folder name. Omit or pass an empty string to un-file it (it then shows under Ungrouped). Trimmed, inner whitespace collapsed, capped at 32 characters."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Returns {ok:true, id, folder} where folder is the name it actually landed in, or null. A folder is just a string on the scenario \u2014 there is no folder record, so it exists exactly as long as something is filed in it and un-filing the last member makes it disappear. A name matching an existing folder case-insensitively snaps to that folder\'s spelling, so "checkout" joins "Checkout" instead of splitting it; read `folders` from listScenarios and reuse a name rather than inventing a near-duplicate. THROWS for a `code` scenario: those are filed by the `folder` field in defineScenario() in app source, so the QA menu looks the same on every device. Does not bump `version` \u2014 filing is organization, not an edit to what the scenario does.',"requires":["Scenarios tool installed in the app"]},{"action":"listFolders","summary":"Every folder currently in use, alphabetical.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns {folders:[...]} \u2014 the same derived, case-insensitively deduped list the device's folder bar renders, across code, device AND draft scenarios. listScenarios already includes this as `folders`; call this only when the folder names are all you need.","requires":["Scenarios tool installed in the app"]},{"action":"getActive","summary":"Cheap check of whether the app is currently showing simulated state.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":'Returns {active} \u2014 null, or {scenarioId, name, version, source, runAt, varValues, effects[{kind,label,...}], expiresAt}. Call this before trusting any data read off the device: if active is non-null, prices/inventory/user/session may be bent by the listed effects, and the answer to "is this a bug?" is probably "a scenario is running". Same hydration caveat as listScenarios \u2014 a cold read before the store has loaded @react_buoy_scenarios_active reports null.',"requires":["Scenarios tool installed in the app"]},{"action":"export","summary":"Get one scenario as pretty-printed JSON, to paste into the repo.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id from listScenarios (code, device, or draft)."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Returns {json} \u2014 JSON.stringify of the full Scenario (steps, undoSteps, vars, metadata) with 2-space indent. Use it to move a device-authored scenario into app source as a defineScenario(...) entry, or to back one up before delete/discardDraft. THROWS `No scenario "<id>"` for an unknown id, including when the library has not hydrated yet.',"requires":["Scenarios tool installed in the app"]}],"unavailableWhen":'The app does not install the Scenarios tool (no `createScenariosTool` / scenarios preset passed to `<FloatingDevTools />`), so "scenarios" is absent from the device\'s capability list. Driven through MCP or the desktop dashboard, the device must also be on a Pro license (`requireProDevice`).'},{"toolId":"perf-monitor","title":"Bench","summary":'Measures runtime performance on the device: live JS/UI FPS, CPU and memory sampled every 250ms, plus recorded "benchmark runs" saved to disk and an automation mode that navigates a screen with different query params and ranks the variants. Reach for it to answer "is this screen slow / which variant is faster / did my fix land", not to inspect data \u2014 it reads no app state. Two traps: live metrics are all zeros until `setEnabled {enabled:true}` arms sampling, and `startAutomation` swallows every validation error, so a bad config returns ok and simply never runs.',"actions":[{"action":"setEnabled","summary":"Arm/disarm live perf sampling (JS FPS, UI FPS, CPU, memory) on the device.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true starts silent sampling for the remote viewer; false stops it (unless the on-device HUD or a recording is holding it open)."}},"required":["enabled"],"additionalProperties":false},"effect":"write","release":"works","description":"Starts SILENT remote sampling: the device samples every 250ms and streams live.snapshot (current values + a 120-sample / 30s history ring) but its own on-device HUD stays hidden. This is the arming step for every live read \u2014 before it, live.snapshot is zeros with an empty history, and after setEnabled{false} the values freeze at their last tick. Always call with enabled:true before reading live metrics, and turn it back off when done (sampling costs a 250ms JS timer). Works with or without react-native-performance-toolkit; without the native module it falls back to a pure-JS sampler and cpuUsage reads 0."},{"action":"startRecording","summary":"Start a manual benchmark recording (auto-named, current route captured).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Begins accumulating perf samples into an in-memory run. The device auto-names it and tags the current route (when @buoy-gg/route-events is installed); the desktop/agent cannot pass a name here \u2014 naming happens at savePending. Self-arms sampling, so setEnabled is not required first. No-op if a recording is already active. Per-component render capture rides along only when @buoy-gg/highlight-updates is installed AND the build is a dev build \u2014 in a release build CommitProfiler.isSupported() returns false (packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts:458), so the saved report has FPS/CPU/memory but renders:null plus a diagnostic explaining why. Pair with stopRecording + savePending.","requires":["@buoy-gg/highlight-updates for render-commit data (dev builds only)"]},{"action":"stopRecording","summary":"Stop the active recording and hold it unsaved, awaiting a name.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Stops sampling for the run and parks the report in memory instead of persisting it. Returns { defaultName, sampleCount } so you can prompt for a name, or null when there was nothing to keep (no active recording, or zero samples because it was stopped almost immediately). The held run also shows up as live.pendingSave on the next snapshot. Nothing is on disk until savePending \u2014 a stopRecording followed by neither savePending nor discardPending leaves the run dangling and it is lost on reload."},{"action":"savePending","summary":"Persist the run held by stopRecording under a chosen name.","params":{"type":"object","properties":{"name":{"type":"string","description":"Name for the saved run. Empty or whitespace-only falls back to the device's auto-generated name."}},"additionalProperties":false},"effect":"write","release":"works","description":"Writes the held report to persistent storage (@react_buoy/perf-monitor/report/<id>) and appends it to the index at @react_buoy/perf-monitor/index, then clears live.pendingSave. An empty/whitespace name falls back to the auto-generated one. Silently does nothing if no run is held (stopRecording was never called, or it returned null). The saved id is what loadReport / deleteReport / the MCP compare_reports tool take."},{"action":"discardPending","summary":"Throw away the just-stopped recording without saving it.","params":{"type":"object","properties":{"label":{"type":"string"}},"additionalProperties":false},"effect":"destructive","release":"works","description":"Drops the report held by stopRecording and clears live.pendingSave. The samples are gone \u2014 they were never written to disk and cannot be recovered. Use when the user cancels the name prompt or the run was junk."},{"action":"mark","summary":"Drop a labelled timeline marker into the in-flight recording.","params":{"type":"object","properties":{"label":{"type":"string","description":'Human label for this moment, e.g. "tapped checkout". Optional; an unlabelled marker is just a timestamp.'}},"additionalProperties":false},"effect":"write","release":"works","description":'Appends { timestamp, label } to the active run so the timeline view can line a spike up with an action ("tapped submit", "list scrolled"). SILENT NO-OP when no recording is active \u2014 BenchmarkRecorder.mark returns immediately if !isRecording, so this returns ok even when nothing was recorded. Check live.isRecording first before telling a user a marker landed.'},{"action":"startAutomation","summary":"Run a multi-case benchmark batch: navigate, record, and save a run per variant.","params":{"type":"object","properties":{"config":{"type":"object","description":"Full batch config. Not merged with the device's saved settings \u2014 whatever you omit takes the runner's own fallback, so read getAutomationConfig first if you want the user's tuned profile.","properties":{"targetRoute":{"type":"string","description":'Pathname every case navigates to, e.g. "/perf-test". Required unless every case sets its own route. The screen must render its variant from the query params.'},"bounceRoute":{"type":"string","description":'Pathname visited between runs to force the target screen to remount. MUST differ from every case route or the whole batch is rejected silently. Typically "/".'},"cases":{"type":"array","description":"The variants to compare, in order; case #0 is the baseline column. At least one required.","items":{"type":"object","properties":{"id":{"type":"string","description":"Optional editor-stable id; the runner derives its own case id from batchId+index if omitted."},"name":{"type":"string","description":"Display name; becomes the saved run's name."},"params":{"type":"object","description":'Query params applied via router.replace, e.g. { "renderer": "v2", "count": "100" }. String values only.',"additionalProperties":true},"route":{"type":"string","description":"Per-case route override; defaults to targetRoute."}},"required":["name"]}},"perCaseDurationMs":{"type":"number","description":"Recording length per run. Device default 5000 (clamped 500-120000 when persisted)."},"settleMs":{"type":"number","description":"Idle gap after navigation lands before recording starts. Default 600."},"navTimeoutMs":{"type":"number","description":"Max wait for the route-change event after replace(). Default 5000."},"runsPerCase":{"type":"number","description":"Runs per case; the median becomes the canonical result. Default 3, range 1-10."},"coolDownMs":{"type":"number","description":"Idle between runs and cases so thermals recover. Device default 8000 \u2014 raising this is the fix when later cases score worse than earlier ones."},"discardWarmupRuns":{"type":"number","description":"Drop the first N runs of each case before taking the median. Default 0."},"discardWarmupCase":{"type":"boolean","description":"Insert one throwaway case at the front and drop it, so the batch's cold start does not poison whichever case runs first. Acts only on an explicit true."},"shuffleCases":{"type":"boolean","description":"Interleave and shuffle case order (deterministic per batchId) so thermal drift cannot systematically favour early cases."},"reloadBetweenCases":{"type":"boolean","description":"Fully reload the JS bundle between cases. Kills all in-memory app state and needs <AutomationResumer/> mounted; adds ~1-3s per case."},"reloadStrategy":{"type":"string","enum":["auto","dev-settings","expo-updates"],"description":"How to reload. auto = DevSettings.reload() in dev, Updates.reloadAsync() otherwise."},"postReloadSettleMs":{"type":"number","description":"Extra settle for the first case after a reload. Defaults to settleMs * 2."},"captureRenders":{"type":"boolean","description":"Capture per-component render counts/durations per run. Needs @buoy-gg/highlight-updates and a dev build; produces nothing in release."},"captureRenderDetail":{"type":"boolean","description":"Also capture changed prop keys and parent names. Heavier; default false."}},"required":["targetRoute","bounceRoute","cases"]}},"required":["config"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Fire-and-forget. The DEVICE owns the loop: for each case it bounces to bounceRoute, navigates to targetRoute with that case\'s query params, settles, records for perCaseDurationMs, saves the run tagged with a shared batchId, cools down, and repeats runsPerCase times. Returns immediately with no result \u2014 poll live.automation.phase (idle/navigating/recording/reloading/done/cancelled), live.automationCompleted, or the growing index to follow it; a batch takes minutes. THE BIG TRAP: every failure is swallowed by the adapter (`void AutomationRunner.start(config).catch(() => {})`), so it returns ok and nothing happens when config is missing, cases is empty, expo-router is absent, targetRoute is empty, or any case route equals bounceRoute (validateConfig throws on that \u2014 no remount would happen). If live.automation.phase never leaves "idle", the config was rejected, not slow. reloadBetweenCases tears down the JS realm between cases and needs <AutomationResumer/> mounted at the router root; in a release build without expo-updates the reload throws and the batch quietly finishes in-process instead. captureRenders yields no render data in a release build (React DevTools hook is dev-only).',"requires":["expo-router (navigation) \u2014 without it this is a silent no-op","@buoy-gg/route-events for reliable navigation waits (otherwise it falls back to a fixed sleep)","<AutomationResumer/> mounted at the router root when reloadBetweenCases is true","a dev build (DevSettings) OR expo-updates installed when reloadBetweenCases is true \u2014 in a release build without expo-updates the reload silently fails and the batch continues in-process with leak risk","@buoy-gg/highlight-updates + a dev build for captureRenders data"]},{"action":"cancelAutomation","summary":"Request cancellation of the in-flight benchmark batch.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":`Asks the runner to stop after the current step and clears the persisted pending-batch state so a reload cannot resume it. No-op when no batch is running. Runs already saved remain on disk under the batch's batchId \u2014 delete them with deleteBatch if you want the batch gone. The status settles to phase "cancelled" and stays sticky in live.automationCompleted until acknowledgeAutomation.`},{"action":"acknowledgeAutomation","summary":"Reset a finished/cancelled batch to idle and clear the sticky completion flag.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":'The handshake that says "I have seen this batch finish": clears live.automationCompleted and puts live.automation back to phase "idle" so the same batch cannot re-trigger a navigation. Call it once after you have read the results, and on tool-open to discard a stale prior completion. Careful: it is global \u2014 clearing it also blinds any other connected dashboard that was waiting on that flag, which is why index-based completion detection (watching new batchIds appear) is more reliable than polling automationCompleted.'},{"action":"refreshIndex","summary":"Force a re-read of the saved-recordings index and push a fresh snapshot.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Re-reads @react_buoy/perf-monitor/index from disk and re-emits the snapshot. Returns nothing \u2014 read the result from snapshot.index (newest first: id, name, createdAt, route, durationMs, sampleCount, jsFpsAvg, uiFpsAvg, cpuAvg, memMaxMb, jank counts, plus batchId/batchIndex/caseId/runIndex/isMedianRun and renderCommits/renderWasted/topRenderers for batch runs). Call it on tool-open: if the device booted before storage was ready the cached index can be empty, and without this it stays empty until the next save or delete."},{"action":"getAutomationConfig","summary":"Read the device's saved benchmark profile (durations, runs, cooldown, toggles).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Loads and returns the persisted AutomationConfig from @react_buoy/perf-monitor/automation \u2014 perCaseDurationMs, settleMs, navTimeoutMs, runsPerCase, coolDownMs, discardWarmupRuns/Case, shuffleCases, reloadBetweenCases, reloadStrategy, captureRenders, plus the user's last cases and targetRoute. Do this before startAutomation so a run inherits the user's tuned pacing instead of invented defaults, and ALWAYS before setAutomationConfig so you can merge rather than clobber. Note the snapshot's own automationConfig field omits `cases`; this action returns them."},{"action":"setAutomationConfig","summary":"Overwrite the device's persisted benchmark profile (FULL REPLACE, not a merge).","params":{"type":"object","properties":{"config":{"type":"object","description":"The COMPLETE config to persist \u2014 merge your changes onto the getAutomationConfig result, since omitted fields are reset to defaults (and omitted `cases` are erased).","properties":{"targetRoute":{"type":"string","description":"Saved target pathname. Omitting it blanks the user's saved route."},"bounceRoute":{"type":"string","description":'Saved bounce pathname. Defaults to "/" when omitted.'},"cases":{"type":"array","description":"The user's saved case list. OMITTING THIS DELETES IT.","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","description":'Blank names are stored as "Untitled".'},"params":{"type":"object","description":"Query params; non-string values are coerced to strings.","additionalProperties":true},"route":{"type":"string","description":'Per-case override; ignored unless it starts with "/".'}},"required":["name"]}},"perCaseDurationMs":{"type":"number","description":"Recording length per run (clamped 500-120000, default 5000)."},"settleMs":{"type":"number","description":"Idle after navigation before recording (0-30000, default 600)."},"navTimeoutMs":{"type":"number","description":"Navigation wait cap (500-60000, default 5000)."},"runsPerCase":{"type":"number","description":"Runs per case (1-10, default 3)."},"coolDownMs":{"type":"number","description":"Idle between runs/cases (0-30000, default 8000)."},"discardWarmupRuns":{"type":"number","description":"Warmup runs discarded per case (0-9, default 0)."},"discardWarmupCase":{"type":"boolean","description":"Prepend a throwaway case and drop it. Default true in the shipped profile."},"shuffleCases":{"type":"boolean","description":"Interleave + shuffle case order. Default true."},"reloadBetweenCases":{"type":"boolean","description":"Reload the JS bundle between cases. Default true in the shipped profile."},"reloadStrategy":{"type":"string","enum":["auto","dev-settings","expo-updates"],"description":'Anything else is coerced to "auto".'},"postReloadSettleMs":{"type":"number","description":"Extra settle after a reload (0-60000)."},"captureRenders":{"type":"boolean","description":"Per-component render capture. Default true; produces no data in release builds."},"captureRenderDetail":{"type":"boolean","description":"Changed prop keys + parent names. Default false."}}}},"additionalProperties":false},"effect":"destructive","release":"works","description":"Sanitizes and writes the whole config to @react_buoy/perf-monitor/automation, then returns the stored result. THIS IS A FULL REPLACE: sanitize() rebuilds every field, so a config passed without `cases` wipes the user's saved case matrix and one without `targetRoute` blanks it (packages/perf-monitor/src/perf-monitor/utils/automationSettings.ts sanitize + saveAutomationConfig). Read getAutomationConfig, spread your overrides onto it, and send the merged object. Numbers are clamped (perCaseDurationMs 500-120000, runsPerCase 1-10, coolDownMs 0-30000, settleMs 0-30000, navTimeoutMs 500-60000). Only for changes the user wants to STICK \u2014 for a one-off, pass overrides to startAutomation instead. Passing no config is a no-op that just returns the current one."},{"action":"loadReport","summary":"Fetch one full saved benchmark report (all samples + aggregate stats) by id.","params":{"type":"object","properties":{"id":{"type":"string","description":'Report id from snapshot.index, e.g. "bench-1779574310712-a1b2c3".'}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Reads @react_buoy/perf-monitor/report/<id> and returns the whole BenchmarkReport: metadata (name, route, batch fields, environmentSignals like thermalState/battery/network), every 250ms sample, markers, diagnostics, aggregate stats (avg/p95 FPS-CPU-memory, jank counts) and `renders` when render capture ran. Returns null for an unknown id. Payloads are large \u2014 use the index summary fields for lists and only load a report when you need the timeline or a head-to-head comparison."},{"action":"deleteReport","summary":"Permanently delete one saved benchmark run.","params":{"type":"object","properties":{"id":{"type":"string","description":"Report id from snapshot.index."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Removes @react_buoy/perf-monitor/report/<id> and drops its index entry, then re-emits the index. Irreversible \u2014 the samples are gone. Deleting one run of a multi-run case leaves the rest of the batch in place, which can skew a later re-ranking of that batchId; prefer deleteBatch for a whole batch."},{"action":"deleteBatch","summary":"Permanently delete every run belonging to one automation batch.","params":{"type":"object","properties":{"batchId":{"type":"string","description":`Batch id from an index entry's batchId or from live.automation/automationCompleted, e.g. "batch-1779574310712-ghww".`}},"required":["batchId"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Cascade-deletes all reports whose index entry carries this batchId and returns how many were removed (0 when the batchId matches nothing). Irreversible. This is the right cleanup after a botched or cancelled batch \u2014 it removes every run and failure placeholder in one serialized index mutation, instead of fanning out parallel deleteReport calls."},{"action":"clearAll","summary":"Delete EVERY saved benchmark recording on the device.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Wipes all @react_buoy/perf-monitor/report/* blobs and empties the index. Irreversible and total \u2014 every manual recording and every batch, including baselines someone may be comparing against. Only run on an explicit, unambiguous request to clear all recordings; deleteReport or deleteBatch cover every narrower case."}],"unavailableWhen":"The host app does not have @buoy-gg/perf-monitor installed \u2014 FloatingDevTools only registers this adapter when the package resolves (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:382). Automation actions additionally need expo-router; without it startAutomation is a silent no-op."},{"toolId":"assets","title":"Assets","summary":'Inventory of what the app SHIPS \u2014 every bundled asset (images, fonts, video, audio, data) with dimensions, @1x/@2x/@3x scale coverage, content hash, byte size, loaded-vs-never-loaded state, audit findings (duplicates, unused, WebP candidates, over-decode, heavy GIFs/fonts, scale gaps), and a diff vs a saved baseline. Reach for it for bundle-size questions ("what\'s making the app big", "did this branch add megabytes", "which images ship but are never used"); use the images tool instead for what the app actually RENDERS. Nothing here is __DEV__-gated (zero `__DEV__` in packages/assets/src), but the Metro dev server is what supplies the full-bundle graph and real byte sizes \u2014 in an embedded/release bundle the inventory shrinks to registry-only records with estimated sizes. Swift inventories loose resources in the main bundle and explicitly registered app resource bundles. Buoy resource bundles are excluded. Compiled Assets.car files are aggregate records, not per-image inventory. Native loaded status requires host calls to BuoyAssets.markLoaded; unobserved does not mean unused. Native scale and decoded-memory insight arrays are currently empty. Check scanStatus.coverage and warnings.',"actions":[{"action":"list","summary":"The whole asset inventory, largest-first, plus stats, scan status, audit insights and the baseline diff in one call.","params":{"type":"object","properties":{"limit":{"type":"number","description":"Max records to return, largest first. Default 50, clamped to 1..500."},"kind":{"type":"string","enum":["image","font","video","audio","data","other"],"description":"Only assets of this kind. Any other value throws 'kind must be one of: image, font, video, audio, data, other'."},"loadedOnly":{"type":"boolean","description":"true keeps ONLY assets loaded at runtime. There is no 'unusedOnly' param here \u2014 for shipped-but-never-loaded assets read insights.neverLoaded (ids) or fetch with a high limit and filter loaded===false."}},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":'Returns { stats, scanStatus, insights, diff, total, records }. `records` is sorted by size descending and sliced to `limit`; `total` is the count AFTER kind/loadedOnly filtering but BEFORE the slice. Each record: { id, key, registryId, name, type, kind, hash, location, width, height, scales, uri, loaded, sizeBytes, sizeSource }. `sizeSource` is "measured" (real bytes from the Metro dev server) or "estimate" (decoded RGBA memory, width*scale*height*scale*4) \u2014 never present them as the same number. `insights` carries id lists: duplicates (grouped by content hash, with wastedBytes), neverLoaded, webpCandidates (png/jpg >= 50KB), overDecode (decodes to >2 screenfuls), heavyGifs (>= 100KB), heavyFonts (>= 150KB), scaleGaps, scaleAnomalies. If `scanStatus.lastScanAt` is null or `graphCount` is null, run `rescan` first \u2014 the inventory is registry-only until the Metro graph is merged. If `scanStatus.measuring` is true, byte totals are partial; re-run shortly. Swift inventories loose resources in the main bundle and explicitly registered app resource bundles. Buoy resource bundles are excluded. Compiled Assets.car files are aggregate records, not per-image inventory. Native loaded status requires host calls to BuoyAssets.markLoaded; unobserved does not mean unused. Native scale and decoded-memory insight arrays are currently empty. Check scanStatus.coverage and warnings.',"requires":["@buoy-gg/assets imported in the app","Metro dev server (for full-bundle coverage, never-loaded detection and measured bytes)"]},{"action":"getDetail","summary":"Full metadata for one asset id: source dir, per-file graph paths, per-scale byte sizes, resolved URI, decoded-memory estimate.","params":{"type":"object","properties":{"id":{"type":"number","description":"Numeric record id from a `list` response. A non-number throws 'Missing numeric `id` param'; an unknown id throws 'Asset record N not found \u2014 re-run the list action for current ids.'"}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Everything `list` returns for that record plus origin ("registry" | "graph"), fileSystemLocation (the source directory on the dev machine \u2014 Metro-graph only), files (per-scale source file paths), sizesByScale ({ "1": bytes, "2": bytes, ... }), and estDecodedBytes. Use it to answer "where does this file live" and "which scale variant is the fat one". Ids come from `list` and are per-session: they are stable across a `rescan` (records are keyed by location/name.type) but NOT across `clearRecords`, which renumbers \u2014 re-run `list` before calling this if anything was cleared.',"requires":["@buoy-gg/assets imported in the app","a prior `list` call for a valid id"]},{"action":"rescan","summary":"Full refresh: re-walk the runtime registry, merge the Metro bundle graph, refresh Expo fonts, then kick off size measurement in the background.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Run this FIRST when the inventory looks empty, stale, or has no never-loaded data. Returns { ok: true, scanStatus } immediately; the measurement pass is fired with `void measureSizes()` and keeps running after the response, so scanStatus.measuring is usually true on return \u2014 call `list` or `getScanStatus` again a moment later for final byte totals. Merges into existing records (ids and any measured sizes are preserved); it never wipes. `ok:true` only means the scan ran \u2014 read scanStatus.graphError and graphCount to see whether the bundle graph actually loaded."},{"action":"measureSizes","summary":"Re-measure real byte sizes by fetching each scale variant from the Metro dev server; skips already-measured records.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":'Returns { ok, measured, total } where ok is `measured > 0`. Fetches every scale variant (HEAD for Content-Length, GET-blob fallback) 4 at a time and sums them onto the record as sizeSource:"measured". Records it cannot fetch are marked sizeSource:"estimate". Safe to re-run \u2014 it only visits records that aren\'t measured yet. If a call is already in flight it returns the current counts immediately without starting a second pass.',"releaseNote":'packages/assets/src/capture/measure.ts:74-75 \u2014 variantURLs() needs an http(s) resolved URI or a live dev-server origin; in a release bundle assets resolve to file:// paths or Android resource ids, so it returns [] and measure.ts:118 marks every record "estimate". The result is { ok:false, measured:0, total:N }. Byte sizes are simply not obtainable from JS in a release build \u2014 say that instead of retrying.',"requires":["Metro dev server reachable from the device"]},{"action":"saveBaseline","summary":"Persist the current inventory as the before/after comparison point; OVERWRITES any existing baseline.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Returns { ok: true, savedAt, assets }. Writes a slim {hash, sizeBytes, name, type} snapshot per asset to persistentStorage under '@react_buoy/assets/baseline' (FileSystem -> AsyncStorage -> memory). The workflow is: measure sizes, save baseline, make the change, then `list`/`getDiff` reports added / removed / grown (>=1KB) / netBytes. Two warnings worth surfacing before calling: (1) it silently replaces a previously saved baseline and the old one cannot be recovered \u2014 ask first if a comparison may already be in progress; (2) only sizeSource===\"measured\" bytes are stored, so saving before a measurement pass finishes produces a baseline whose later netBytes is null."},{"action":"clearBaseline","summary":"Delete the saved baseline permanently, so diffs stop being reported.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Returns { ok: true, message: \"Baseline cleared\" }. Removes '@react_buoy/assets/baseline' from persistent storage and nulls it in memory; after this `getDiff` returns null and `list().diff` is null. Irreversible \u2014 the saved snapshot is gone, and re-saving captures TODAY's inventory, not the one you deleted. Only call it when the user explicitly wants to stop comparing or start a fresh comparison."},{"action":"getDiff","summary":"Change vs the saved baseline: added ids, removed assets, grown assets, net bytes. Null when no baseline exists.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns { baselineSavedAt, added: number[], removed: [{key, name, type}], grown: [{id, deltaBytes}] sorted biggest-first, netBytes } or null if nothing was ever saved with saveBaseline. `grown` only counts increases of >=1KB (below that is codec noise) and only for assets measured both then and now. netBytes is null whenever no asset pair was comparable \u2014 that means 'sizes unknown', NOT 'no change'; `list` already embeds this same object as `diff`, so calling both is redundant."},{"action":"clearRecords","summary":"Wipe the entire in-memory inventory, including all measured byte sizes; a rescan rebuilds it.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":'Returns { ok: true, message: "Inventory cleared \u2014 rescan to rebuild" }. Empties records/byKey/byId and resets measuredCount and graphCount. Everything measured is lost and must be re-fetched from the dev server, and because the id counter is NOT reset, a following `rescan` assigns brand-new ids \u2014 any id from an earlier `list` is dead afterwards. Never a diagnostic step; only run it when the user asks for a clean slate. The saved baseline survives (use clearBaseline for that).'},{"action":"getScanStatus","summary":"Cheap health check for the capture: is the registry patched, did the Metro graph load, how many sizes are measured, is measurement still running.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns { registryAvailable, patched, registryCount, graphSupported, graphCount, graphError, lastScanAt, measuring, measuredCount, fontFamilies: string[], localAssetCount }. Use it to explain a thin or surprising inventory before drawing conclusions: registryAvailable:false means the RN asset registry could not be required at all; graphCount:null with a graphError means only runtime-registered assets are known (so 'never loaded' is unanswerable); measuring:true means byte totals are still landing. lastScanAt:null means nothing has scanned yet \u2014 call rescan. Swift inventories loose resources in the main bundle and explicitly registered app resource bundles. Buoy resource bundles are excluded. Compiled Assets.car files are aggregate records, not per-image inventory. Native loaded status requires host calls to BuoyAssets.markLoaded; unobserved does not mean unused. Native scale and decoded-memory insight arrays are currently empty. Check scanStatus.coverage and warnings."}],"unavailableWhen":"The app doesn't import @buoy-gg/assets (autoExternalSync only registers the adapter when the module resolves \u2014 packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:391), or the app is a release/production JS bundle that didn't opt in with `externalSync.enableInRelease` plus a real Pro license, in which case the device never connects to the broker at all."},{"toolId":"tv-remote","title":"TV Remote","summary":'Capture-only observer for Apple TV / Android TV apps: it reports the D-pad, select, menu, media and long-press events the app ACTUALLY received, which is how you tell "the app handled that press" apart from "something swallowed it". It cannot press anything \u2014 presses are injected host-side by Buoy Desktop (`adb shell input keyevent` / `idb ui key`), so never tell a user this tool moved focus. Reach for it to arm capture before a remote press happens, then poll getEventsSince for the echo. Inert on phones/tablets/web.',"actions":[{"action":"arm","summary":"Start capturing remote events into the 50-event ring buffer. Idempotent. Nothing is recorded until this runs.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"Attaches the native TVEventHandler listener and returns the fresh TvRemoteState: { supported, platform, captureArmed, listening, menuCaptureArmed, seq, recent[] }. CHECK `listening`, not `captureArmed`: on a non-TV build or an RN build without TVEventHandler, arm() still sets captureArmed:true while listening stays false and no event will ever arrive \u2014 reporting 'armed' there would make every replay step look swallowed. Buffer holds the last 50 events; key-DOWN (eventKeyAction 0) and continuous gestures (pan/swipeUp/swipeDown/swipeLeft/swipeRight) are dropped so one press = exactly one echoed event. Arming renders nothing on screen and costs one listener. This does NOT press any button.","requires":["@buoy-gg/tv-remote installed in the app","FloatingDevTools mounted (auto-discovery registers the adapter)","react-native-tvos build with Platform.isTV === true for listening to become true"]},{"action":"getEventsSince","summary":"Cursor-paged read of captured remote events newer than `seq`. The echo poll \u2014 use this, not the snapshot, to time whether a press landed.","params":{"type":"object","properties":{"seq":{"type":"number","description":"Cursor: return only events whose seq is strictly greater. Omit or 0 for the whole buffer; 9007199254740991 for the current cursor with no events."}},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":'Returns { seq, events: [{ seq, t, eventType, eventKeyAction }] }, where `events` are strictly newer than the passed cursor and `seq` is the store\'s current counter. eventType is the raw RN name: up | down | left | right | select | playPause | menu | longSelect | longLeft | \u2026 . Pure read \u2014 it never consumes or advances the buffer. Pattern: call with seq: 9007199254740991 (Number.MAX_SAFE_INTEGER) to grab the current cursor with zero events, have the press injected, then poll with that cursor. Omitted seq (or 0) returns everything buffered, max 50. TRAPS: a non-number (a string "5") silently falls back to 0 and returns EVERYTHING; a non-finite value (Infinity/NaN) also returns everything rather than nothing. Injected TEXT and Home never echo at all (they ride the platform keyboard/button path, not the TV event pipe) \u2014 absence of an event for those is expected, not a bug.',"requires":["arm() must have been called first \u2014 an unarmed store records nothing and returns an empty list"]},{"action":"setMenuCapture","summary":"tvOS only: route the Menu key to JS so it can be recorded, instead of letting it pop the nav stack. Changes how the app behaves.","params":{"type":"object","properties":{"on":{"type":"boolean","description":"true routes the Menu key to JS (Menu stops popping the nav stack). Anything else hands Menu back to the platform."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":'Calls TVEventControl.enableTVMenuKey/disableTVMenuKey and returns the fresh TvRemoteState. Pass { on: true } to capture Menu; ANY other value (omitted, "true", 1, null) disables it \u2014 the handler is a strict `params?.on === true`. WARN THE USER BEFORE ENABLING: with capture on, Menu no longer navigates back, so at the root of the app the remote\'s Back/Menu button stops working the way they expect until it is turned off. Hard no-op (returns ok, changes nothing) on Android TV, on non-TV builds, and on RN builds without TVEventControl \u2014 check `menuCaptureArmed` in the returned state to see whether it actually took. Always undone automatically by disarm().',"requires":['tvOS (Platform.OS === "ios") with Platform.isTV === true',"react-native-tvos TVEventControl present"]},{"action":"disarm","summary":"Stop capturing and hand the Menu key back to the platform. Any in-flight recording or replay stops echoing.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"Removes the native listener, turns menu capture off if it was on, and returns the fresh TvRemoteState. The 50-event buffer is NOT wiped \u2014 previously captured events stay readable via getEventsSince. Do not call this while someone is recording a macro or a replay is running on this device: an unarmed device echoes nothing, which reads as every press being swallowed. Idempotent and safe to call on a non-TV app."},{"action":"clear","summary":"Wipe the captured-event ring buffer. Irreversible \u2014 the recorded presses are gone.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"destructive","release":"works","description":"Empties the in-memory buffer and returns the fresh TvRemoteState (recent: []). The `seq` counter is NOT reset, so cursors held by an in-flight replay stay valid but their events vanish. This destroys the evidence a recording-from-the-real-remote session just collected \u2014 never call it to 'tidy up' while a macro is being recorded or a replay is polling for echoes; ask first. Capture state is untouched: still armed after clearing. No-ops when the buffer is already empty."}],"unavailableWhen":"The app is not a react-native-tvos TV build (`Platform.isTV !== true`): every action still returns ok, but the snapshot says `supported: false`, `listening` stays false, and no event is ever captured. Also absent if `@buoy-gg/tv-remote` isn't installed, or if FloatingDevTools isn't mounted (auto-discovery registers the adapter). In a release build the whole Buoy sync transport only connects when the app passes `externalSync={{ enableInRelease: true }}` AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts:38) \u2014 but if an action is reachable at all, its code path works in release."},{"toolId":"focus-inspector","title":"TV Focus Inspector","summary":`Debugs D-pad/remote focus on Android TV and tvOS: what holds focus right now, the observed focus-transition history, and detected dead ends, traps, invisible stops (TVFocusGuideView) and traversal coverage. Reach for it when a user says the remote won't move, focus is stuck inside one row, focus skips a button, or focus "disappeared". Unlike most timeline tools it needs NO arming \u2014 the focus/key observers attach at app launch, so history is already there when you first read it; but it is TV-ONLY (on a phone/tablet/web build snapshot.supported is false and nothing is ever observed), and in a RELEASE build the fiber half dies while the observation half survives (see per-action release notes).`,"actions":[{"action":"rescan","summary":"Re-walk the fiber tree and rebuild the on-screen focusable inventory (names, testIDs, frames, guide props).","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"read","release":"empty","description":"Returns {ok:true, focusables:N}. Refreshes snapshot.focusables / screen / scannedAt and the internal nativeTag->instance table that focusElement depends on. Call it after the app navigates to a new screen, and always before a focusElement. Inventory is capped at 400 nodes, sorted in reading order, frames normalized to [0,1]; off-window nodes are KEPT (a tile below a ScrollView fold is still a real D-pad target). Two gotchas: (1) it CLEARS the inventory and instance table before rebuilding, so a rescan fired while nothing is mounted leaves focusables:0 and breaks focusElement until you rescan again; (2) detected flags are judged against this scan's timestamp, so rescanning discards trap/invisible-stop evidence collected before it. Concurrent calls are coalesced \u2014 a rescan while one is in flight returns the previous scan.","releaseNote":"packages/focus-inspector/src/scan/focusableScanner.ts:109 reads global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which React Native installs ONLY under __DEV__ (node_modules/react-native/Libraries/Core/setUpReactDevTools.js:32). In a release build getAllFiberRoots() returns [], collectCandidates() returns [], and the action still answers {ok:true, focusables:0} \u2014 do not report that as an app with no focusable elements. Knock-on effects in release: snapshot.focusables is empty, current.node is null (only the raw tag is known), coverage.focusables is 0, and flags.traps / flags.invisibleStops are always empty because both need frames or a non-empty inventory.","requires":["Platform.isTV === true for the results to mean anything (the scan itself runs on a phone but nothing is ever observed there)","A __DEV__ build \u2014 a release build returns focusables:0"]},{"action":"focusElement","summary":"Move focus onto a specific element by native tag, then watch where the D-pad goes from there.","params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"Native view tag of the element to focus, taken from snapshot.focusables[].nativeTag or snapshot.current.tag in the LATEST scan. Absent or non-number returns {ok:false, reason:'nativeTag is required.'}"}},"required":["nativeTag"],"additionalProperties":false},"effect":"write","release":"empty","description":"The tool's ONLY write into the host app \u2014 it calls the element's requestTVFocus(). Send {nativeTag: 166}, where the tag comes from snapshot.focusables[].nativeTag or snapshot.current.tag AND from the most recent rescan (the instance table is rebuilt on every scan, so a tag from an older scan is stale). Returns {ok:true} or {ok:false, reason} and the reason strings are exact and worth relaying verbatim: 'nativeTag is required.' (missing or non-number param), 'Not a TV build \u2014 there is no focus engine.' (Platform.isTV false), 'Unknown tag \u2014 rescan and try again.' (tag not in the current scan's instance table), or 'This element exposes no requestTVFocus().' (the host node is not a View \u2014 Text and Image never get one). Note the adapter hand-casts this as (params as {nativeTag?: number}), i.e. structurally optional on the wire, but the handler hard-rejects when it is absent.","releaseNote":"packages/focus-inspector/src/scan/focusableScanner.ts:550 looks the tag up in instancesByTag, which is populated ONLY by the DEV-gated fiber scan (focusableScanner.ts:454, reached via the __REACT_DEVTOOLS_GLOBAL_HOOK__ read at :109). In a release build that map is permanently empty, so every call returns {ok:false, reason:'Unknown tag \u2014 rescan and try again.'} no matter how many times you rescan. Do not loop on the rescan advice in the reason string \u2014 in release it can never succeed; say so and drive the app with the actual remote instead.","requires":["Platform.isTV === true","A __DEV__ build","A rescan must have run and included this tag"]},{"action":"setTracking","summary":"Pause or resume recording of focus transitions and D-pad probes, without detaching the native listeners.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true resumes recording, false pauses it. OMITTING THIS MEANS TRUE (resume) \u2014 always send it explicitly."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"Returns {tracking:boolean} \u2014 the value actually in effect. GOTCHA THAT WILL BITE: `enabled` defaults to TRUE. The handler is `setTracking(params?.enabled !== false)`, so calling setTracking with no params, or with anything other than exactly false, RESUMES recording. To pause you must explicitly send {enabled:false}. Pausing leaves the RawEventEmitter and TVEventHandler listeners attached and keeps the currently-focused element, so nothing is forgotten; it only stops new focus/blur/key events from being appended to the history. Use it when a person is driving the app by hand and does not want that traversal scored. The current value is also visible as snapshot.tracking."},{"action":"clearHistory","summary":"Permanently wipe the recorded focus history \u2014 transitions, D-pad probes, visited tags and counters.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"destructive","release":"works","description":"Returns {ok:true}. Resets transitions, probes, visited, lostCount and the seq counters to zero, which also blanks the desktop timeline and zeroes every derived number (coverage.visited, stats.transitions, all dead-end/trap/invisible-stop flags, since they are computed from that stream). IRREVERSIBLE \u2014 there is no snapshot of the old history anywhere. It deliberately KEEPS the currently focused element as the only visited tag, so the Now card does not blank out. The legitimate use is starting a clean traversal run: clear, then have the QA user walk the screen with the remote, then read the flags. Do not call it just to tidy up \u2014 you are destroying the evidence the tool exists to collect, and focus history cannot be re-derived because focus can only be watched arriving, never queried."}],"unavailableWhen":'The app is not a TV build \u2014 `Platform.isTV !== true` means the focus and D-pad observers are never attached (focusInspectorSyncAdapter.ts:117), so `snapshot.supported` is false, `presence` stays "never-observed", transitions/current stay empty forever, and focusElement refuses. Also absent entirely if `@buoy-gg/focus-inspector` isn\'t installed, since @buoy-gg/core only registers the "focus-inspector" capability when that optional require resolves (autoExternalSync.tsx:397). There is deliberately NO on-device UI \u2014 a focusable overlay would insert itself into the host app\'s focus order and corrupt the measurement \u2014 so everything is read through this adapter.'},{"toolId":"images","title":"Images","summary":"Live registry of every image the app has loaded \u2014 RN core <Image> and expo-image \u2014 with cache verdict (memory/disk/network), load ms, decoded-vs-displayed pixel size plus oversize/wasted-KB math, error codes, and cross-record insights (duplicate URLs, retry storms, missing alt text, layout shifters). Also drives per-image and app-wide simulation: force error / force loading / blank / URL swap / offline / cold-start, plus locate-flash and cache clears. Reach for it whenever an image is broken, blank, blurry, slow, or suspected of memory bloat: image HTTP never passes through the JS network stack, so this is the ONLY visibility into image loading.","actions":[{"action":"list","summary":"List captured image loads, newest first, with stats, insights, capture status and active simulation modes.","params":{"type":"object","properties":{"limit":{"type":"number","description":"Max records to return, newest first. Defaults to 50; clamped to 1-200."},"status":{"type":"string","enum":["pending","loading","loaded","error"],"description":"Return only records in this state. 'error' gives the failure log. Any other string matches nothing and returns zero records."}},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":"Returns { stats, captureStatus, globalModes, insights, total, records }. stats = {total, loading, loaded, errors, networkLoads, estDecodedBytes, estWastedBytes}. Each record carries id, lib ('rn'|'expo'), uri, kind ('network'|'asset'|'file'|'data'|'other'), status, mounted, cache verdict, ms, intrinsic px, layout dp, neededPx, oversizeFactor, decodedKB, wastedKB, error/errorCode, loadCount, overrideLabel, hasAltText, layoutShifts, ageMs. `data:` URIs and URIs over 2KB arrive truncated to a stub, never in full. insights flags duplicate URLs, retry storms (loadCount >= 5), iOS queue saturation (>4 RN images loading), missing alt text and layout shifters. The registry keeps the last 500 records and only holds images that mounted AFTER capture installed \u2014 an empty result means either nothing rendered yet or capture is not wired (call getCaptureStatus). Use the returned ids for every other action.","requires":["@buoy-gg/images installed in the app",'capture installed (import "@buoy-gg/images/register" as the first entry import, or <ImagesRoot/> mounted)']},{"action":"getDetail","summary":"Full wire detail for one image record, including the iOS error response headers that `list` omits.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`. Must be a number; a string throws."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Same fields as a `list` record plus `errorHeaders` (iOS RN core only \u2014 the HTTP response headers captured from onError, the way to see a 403 body/auth header on a failing CDN image). Throws 'Missing numeric `id` param' if id is not a number, and throws 'Image record <id> not found (cleared or evicted)' when the id aged out of the 500-record buffer or was dropped by clearRecords \u2014 re-run `list` for current ids.","requires":["@buoy-gg/images installed in the app"]},{"action":"getCaptureStatus","summary":"Whether image capture actually installed, and whether the RN <Image> hook landed in time. Call this first when `list` looks empty.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns { installed, rnDecoratorActive, rnDecoratorTooLate, expoPatched, expoAvailable }. rnDecoratorTooLate === true is the single most common cause of a missing/partial registry: RN latches its Image decorator at module evaluation, so `import \"@buoy-gg/images/register\"` must be the FIRST import of the app entry file. expo-image capture is timing-immune and unaffected. Never report 'the app loads no images' without checking this.","requires":["@buoy-gg/images installed in the app"]},{"action":"retry","summary":"Plain fresh load attempt for one mounted image \u2014 no cache bypass, nothing cleared.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"RN core: bumps the wrapper's key so the native view remounts and re-runs the load. expo-image: calls the live instance's reloadAsync(). The safe 'try loading it again' action \u2014 prefer it over hardReload unless you specifically need to prove a cache is stale. Returns { ok:false, message:'Instance unmounted \u2014 cannot reload' } when the image left the screen, or 'Live instance not reachable' for an expo record whose instance was not registered. Give it ~1.5s before re-reading the record.","requires":["@buoy-gg/images installed in the app","the image must still be mounted on screen"]},{"action":"flash","summary":"Draw a 3px red border on the on-screen image for 2.5s so a human can find it.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"Purely visual and self-reverting (the border clears itself after ~2.5s, no undo needed). Returns { ok:true, message:'Flashing for 2.5s' } unconditionally \u2014 including for an unmounted record, where nothing will actually be visible; check `mounted` on the record first. The right way to answer 'which image on screen is #42?'.","requires":["@buoy-gg/images installed in the app","the image must be mounted and on screen to be visible"]},{"action":"proveSavings","summary":"Re-encode the image at its displayed size as WebP ON DEVICE and return real byte savings plus a previewable file:// URI.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`. Best used on a record whose oversizeFactor is well above 1."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"Turns the estimated wastedKB into measured bytes: resizes to neededPx (layout dp x device pixel ratio, capped at the intrinsic width) and saves WebP at quality 0.8 via expo-image-manipulator, then stats the output. Original size is resolved in tiers: observed download bytes -> expo disk-cache file size -> an HTTP HEAD Content-Length (so it may issue one network request). Returns { ok, message, originalBytes, originalBytesSource ('download'|'cacheFile'|'head'), optimizedBytes, optimizedUri, optimizedDims, savedBytes, savedPct }. Feed optimizedUri into setOverride {kind:'url'} to A/B the optimized variant in place. Fails with ok:false when expo-image-manipulator is missing, when the record has no loadable URI (kind must be network/asset/file), or when the image has not laid out yet.","requires":["expo-image-manipulator installed","expo-file-system (to stat the output; without it optimizedBytes is unknown)","the image must have rendered at least once so its layout size is known"]},{"action":"setOverride","summary":"Simulate a failure state on ONE image: force error, force forever-loading, blank it, or swap in a different URL.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."},"kind":{"type":"string","enum":["error","hang","blank","url"],"description":"error = instant load failure; hang = loads forever; blank = no image rendered; url = replace the source with `uri`."},"uri":{"type":"string","description":"Replacement source URL. Required when kind is 'url' (throws without it); ignored otherwise."}},"required":["id","kind"],"additionalProperties":false},"effect":"write","release":"works","description":"kind:'error' points the source at a nonexistent file:// so native fires onError instantly (offline-safe). kind:'hang' points at a blackhole IP so the load never settles (permanent skeleton/spinner state). kind:'blank' renders expo-image with source=null; RN core has no safe empty source, so it blanks via opacity:0 (visual only \u2014 the decoded bitmap stays resident). kind:'url' requires `uri` and swaps the source in place. IMPORTANT: for error/hang/blank the action returns { ok:false, message:'Instance unmounted \u2014 overrides need the image on screen' } and does nothing when the image is not mounted; the 'url' path does NOT perform that check and reports ok:true even for an unmounted record. Unknown kinds throw. The override sticks until clearOverride (or massAction 'restore') \u2014 always tell the user how to undo it.","requires":["@buoy-gg/images installed in the app","the image must be mounted on screen for kinds error/hang/blank"]},{"action":"clearOverride","summary":"Remove the simulation override from one image and restore its original source.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"Deletes the per-record override and clears the record's overrideLabel, then re-renders just that image. Always returns { ok:true, message:'Override removed \u2014 original source restored' }, even for an id that had no override. This is the undo for setOverride.","requires":["@buoy-gg/images installed in the app"]},{"action":"setNetworkMode","summary":"App-wide image network simulation: 'offline' (every network image fails), 'cold' (every image bypasses caches), 'normal' to reset.","params":{"type":"object","properties":{"mode":{"type":"string","enum":["normal","offline","cold"],"description":"normal = simulation off; offline = every network image load fails instantly; cold = every image bypasses memory+disk caches."}},"required":["mode"],"additionalProperties":false},"effect":"write","release":"works","description":"'offline' swaps every NETWORK-kind source for a nonexistent file:// so it fails immediately on both libs \u2014 bundled/local assets keep loading, exactly like a real offline device showing shipped images. 'cold' injects RN source.cache:'reload' / expo cachePolicy:'none' so every load refetches: first-launch behavior WITHOUT clearing any cache. 'normal' resets. Any other value throws. Applies to every image in the app, persists until reset, and already-displayed images need a remount/navigation (or massAction 'reload') before the effect is visible. Returns { ok:true, modes:{ network, blank } }. list/getSnapshot surface the active mode in globalModes \u2014 say so out loud, since a stuck 'offline' looks exactly like a real app bug.","requires":["@buoy-gg/images installed in the app"]},{"action":"setBlankImages","summary":"Chrome-style 'disable images' app-wide: render every image with no source.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true blanks every image app-wide. Omitted / anything but true turns it off."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"expo-image gets source=null (its placeholder keeps showing); RN core gets opacity:0 because RN has no safe empty source (visual only \u2014 the bitmap is still decoded and resident, so this does NOT prove memory savings). Good for checking layout/alt-text without imagery. Returns { ok:true, modes:{ network, blank } }. NOTE the param cast is `enabled === true`: calling with no params, or with anything other than boolean true, turns the mode OFF \u2014 always pass `enabled` explicitly.","requires":["@buoy-gg/images installed in the app"]},{"action":"hardReload","summary":"Cache-busting reload of one image \u2014 for expo records this ALSO clears the app's entire expo-image memory cache.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"RN core: remounts with source.cache:'reload' injected (honored on both platforms) so the HTTP cache is bypassed. expo-image: deletes that URI's disk-cache file, then calls Image.clearMemoryCache() which wipes the WHOLE app's expo-image memory cache (no per-entry memory eviction exists), then reloads. Non-network sources (asset/file/data) have no HTTP cache and silently fall back to a plain `retry`. Returns { ok:false, message:'Instance unmounted \u2014 cannot reload' } when the image is off screen. Prefer `retry` unless the point is to prove a stale cache; re-read the record after ~1.5s to see the new cache verdict.","requires":["@buoy-gg/images installed in the app","the image must be mounted on screen","expo-image + expo-file-system for the disk-entry eviction half (expo records only)"]},{"action":"evictDisk","summary":"Delete this image's expo-image disk-cache file. Cache data only \u2014 irreversible, the image refetches next load.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Resolves the entry via expo-image's getCachePathAsync and deletes the file with expo-file-system. Returns { ok:true, message:'Disk cache entry deleted' } or { ok:false, message:'No disk entry found (expo-image + expo-file-system required)' } \u2014 that same ok:false covers 'neither package installed', 'not an expo-image record', and 'nothing was cached', so do not read it as a hard error. No effect at all on RN core <Image> records or on the memory cache.","requires":["expo-image installed","expo-file-system installed (legacy or main entry)","record must be an expo-image load with a cached disk entry"]},{"action":"massAction","summary":"Apply one action to EVERY mounted image at once: force error/loading/blank, hard-reload all, flash all, or restore all.","params":{"type":"object","properties":{"kind":{"type":"string","enum":["error","loading","blank","reload","flash","restore"],"description":"error/loading/blank = mass simulation override; reload = cache-busting hard reload of all mounted images; flash = red-border all; restore = clear every override."}},"required":["kind"],"additionalProperties":false},"effect":"destructive","release":"works","description":"kind 'error'|'loading'|'blank' set that override on every mounted record (note: per-record 'hang' is spelled 'loading' here) and return { ok: n>0, message:'... on N images' }. 'flash' red-borders everything the tool tracks for 2.5s. 'restore' clears every active override and is the undo for the three simulation kinds. 'reload' fans `hardReload` out across every mounted image \u2014 which for expo records means disk-entry evictions plus a full expo-image memory-cache clear, hence the destructive rating on this whole action. Unknown kinds throw. ok:false just means zero images were mounted. Whole-screen effect: confirm with the user before firing, and always report how to restore.","requires":["@buoy-gg/images installed in the app","images must be mounted on screen \u2014 a background screen yields ok:false with 0 affected"]},{"action":"clearRecords","summary":"Wipe the captured image registry (keeps only still-in-flight loads). Irreversible evidence loss.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Drops every record that is unmounted or already settled (loaded/error); records that are still mounted and still loading survive so in-flight events do not orphan. Returns { ok:true, message:'Registry cleared' }. The captured history cannot be recovered \u2014 take a `list` first if the user might need it. Legitimate use: clear, then have the user re-do the broken step, so the registry contains only the repro.","requires":["@buoy-gg/images installed in the app"]},{"action":"clearExpoCaches","summary":"Clear expo-image's memory and/or disk caches app-wide. Cache data only, but irreversible.","params":{"type":"object","properties":{"memory":{"type":"boolean","description":"Clear the memory cache. Defaults to true; only an explicit false skips it."},"disk":{"type":"boolean","description":"Clear the disk cache. Defaults to true; only an explicit false skips it."}},"required":[],"additionalProperties":false},"effect":"destructive","release":"works","description":"Calls expo-image's Image.clearMemoryCache() and Image.clearDiskCache(). Both default to true \u2014 the cast is `p.memory !== false`, so omitting params clears BOTH; pass false explicitly to skip one. Returns { ok:true, message:'Cleared expo-image memory + disk cache' } or { ok:false, message:'expo-image not installed' }. Affects the whole app, not one image, and does nothing for RN core <Image> (whose caches are native/Fresco/NSURLCache and unreachable from JS). Use it to make the next loads genuinely cold; `setNetworkMode {mode:'cold'}` simulates the same thing without deleting anything.","requires":["expo-image installed (otherwise returns ok:false and does nothing)"]}],"unavailableWhen":'@buoy-gg/images is not installed in the app, or capture never installed (no `import "@buoy-gg/images/register"`, no <ImagesRoot/>, and the Images tool UI never opened). RN core <Image> specifically goes uncaptured when that register import is not the FIRST import of the entry file \u2014 expo-image is captured regardless; check getCaptureStatus.rnDecoratorTooLate before concluding "no images". Separately, in a release build the sync transport itself is off unless the app passes externalSync={{enableInRelease:true}} with a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts), in which case no action reaches the device at all.'},{"toolId":"ask-buoy","title":"Ask Buoy","summary":'Ask Buoy\'s OWN session \u2014 the changes it has made in this conversation, and the undo for them. Call listChanges when the user asks "what did you change?"; call undoAll when they say "undo that" / "put it back" / "clean up". Undo restores exactly what the ledger captured at write time: override rules Ask Buoy created are deleted, impersonation is stopped, storage keys are restored to their pre-write values, and a query cache edit (setQueryData) is put back to the data read just before the write \u2014 unless the app has refetched it since, in which case the server\'s data is already back and undo says so. Changes listed with reversible:false (state writes, wipes, one-shot actions) cannot be automatically reversed \u2014 say so honestly. NEVER improvise a reverse-write with a guessed shape instead of calling undoAll; a hand-rolled "undo" that writes invented data is worse than telling the user a change is permanent. Its retrieve action re-reads any earlier tool result in FULL: every result over 24,000 characters is cut with a `[truncated \u2014 \u2026; ref ev_N]` marker and every result compressed out of memory leaves an `[earlier result \u2014 \u2026; ref ev_N]` marker \u2014 call retrieve with that ref (and a path or pattern) instead of asking the user for a narrower slice or calling the tool again for a value you already had.',"actions":[{"action":"listChanges","summary":'List what Ask Buoy has changed in this conversation and whether each change can be undone. Use it to answer "what did you change?" before offering undo.',"params":{"type":"object","properties":{},"additionalProperties":false,"description":"No parameters."},"effect":"read","release":"works","description":'Returns `{changes:[{toolId, action, kind, label, reversible}], returned}` \u2014 outstanding (not yet undone) changes only, oldest first. `reversible:true` means undoAll can restore that change exactly. `kind` "transient" is a one-shot action (a navigation, a tap) that changed no persistent state.'},{"action":"undoAll","summary":"Undo every reversible change Ask Buoy made this conversation \u2014 deletes override rules it created, stops impersonation, restores storage keys to their prior values. The safe direction: use it whenever the user asks to undo or clean up.","params":{"type":"object","properties":{},"additionalProperties":false,"description":"No parameters."},"effect":"write","release":"works","description":"Returns `{ok, reverted, failed:[{label, error}], skippedTransients, permanent}`. Report the numbers honestly: `failed` entries were attempted and could not be restored (tell the user which, using the labels); `permanent` is how many changes were never undoable (state writes, refetches) and are still applied \u2014 say so, they are not failures; `skippedTransients` are one-shot actions that never needed undoing. This undoes ALL reversible changes from this conversation, newest first \u2014 there is no per-change undo action, so if the user wants to keep one change, say so instead of calling this."},{"action":"retrieve","summary":'Re-read part of an earlier tool result by its ref (ev_N from a [truncated \u2026] or [earlier result \u2026] marker). With only `ref` it returns the result\'s SHAPE (top-level keys with types and sizes); add `path` to get one value ("stats.0.base_stat", "moves.3.move.name"), `slice` for a window of an array or string, or `pattern` for a literal case-insensitive substring search with `contextLines` of surrounding text around each match. The kept copy is exactly what you were shown when it arrived \u2014 for the app\'s CURRENT value call the original tool again.',"params":{"type":"object","properties":{"ref":{"type":"string","description":'The ref from the marker, e.g. "ev_7".'},"path":{"type":"string","description":'Dotted path into the JSON result; arrays index by number: "stats.0.base_stat", "data.items.2.name".'},"slice":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2,"description":"[start, end) window of items when the value at path is an array, or of characters when it is a string."},"pattern":{"type":"string","description":"Literal, case-insensitive text to find. Returns up to 20 matching lines with context. Not a regex."},"contextLines":{"type":"number","description":"Lines shown around each pattern match. Default 2, max 10."}},"required":["ref"],"additionalProperties":false,"description":"ref is required; add exactly what you need \u2014 a bare ref shows the shape, then path/slice/pattern read a part."},"effect":"read","release":"works","servedBy":"engine","description":"Returns `{ref, from, capturedAt, totalChars, \u2026}` plus `shape` (no selector), `value` (path), `value`+`sliced`+`of` (slice), or `matches:[{line,text}]`+`totalMatches` (pattern). `{ok:false, error}` when the ref is unknown or was evicted to make room (the store keeps the most recent ~4 MB) \u2014 then call the original tool again. Results are capped at 24,000 characters like any other; narrow with path or pattern rather than asking for the whole thing."},{"action":"openProcedure","summary":"Open one of this app's developer-written procedures by `id` (the ids are listed in the system prompt under PROCEDURES, each with a one-line summary). Returns the full playbook: preconditions, the stores and keys involved, the steps in order, and what done looks like. Call it FIRST when a request matches a procedure's summary, then follow it with the ordinary tools.","params":{"type":"object","properties":{"id":{"type":"string","description":'The procedure id from the PROCEDURES list, e.g. "expire-subscription".'}},"required":["id"],"additionalProperties":false,"description":"Just the id."},"effect":"read","release":"works","servedBy":"engine","description":"Returns `{id, title, version?, requires?, body, note}`; `{ok:false, error}` naming the available ids when the id is unknown or the app has none. A procedure grants nothing: each step still runs through the same catalog, policy, approval card and undo as any other call."}],"unavailableWhen":"Only present while an Ask Buoy session is running \u2014 which is exactly when this catalog is in use, so in practice always available to you."},{"toolId":"push-notifications","title":"Push Notifications","summary":"Inspect notification receipt, responses, presentation decisions, background-task results and tokens from an explicitly configured Expo capture adapter. Use test IDs to correlate stages. Simulator sending lives in the desktop host and is not a device action. Real-provider sending is not implemented.","unavailableWhen":"Capture requires @buoy-gg/notifications and early installExpoNotificationCapture setup with the app SDK. Release capture needs an explicit enableInRelease option; release desktop sync separately requires its own opt-in and Pro. Background evidence requires the app task wrapper. Native delivery validation covers Expo 56 on iOS.","actions":[{"action":"getSnapshot","summary":"Read captured notification evidence and tokens.","description":"Returns the bounded journal, session, provider capabilities, permissions and token observations. A missing callback does not prove that no OS alert appeared.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"getCapabilities","summary":"Check notification capture setup.","description":"Returns provider/version, app ID, platform, supported operations and durable-storage state. No device tokens are registered by this action.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"getEvent","summary":"Read one captured event by id.","description":"Returns an event by its Buoy record ID, or null after eviction. Native notification ID and test ID are separate fields.","effect":"read","release":"works","params":{"type":"object","properties":{"id":{"type":"string","description":"Buoy event record id from getSnapshot.events."}},"additionalProperties":false,"required":["id"]}},{"action":"getPermissions","summary":"Refresh notification permission settings.","description":"Reads the existing OS settings through Expo. Does not display a permission prompt.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"listPresented","summary":"Inspect notifications currently in the system tray.","description":"Reads a current snapshot through Expo. This is not a historical record and does not dismiss notifications.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"refreshToken","summary":'Register a token with type "device" or "expo". Expo registration requires a configured projectId.',"description":"May register with APNs/FCM or contact Expo Push Service. Expo tokens require projectId in app setup. Never invoke merely to open the inspector.","effect":"write","release":"works","params":{"type":"object","properties":{"type":{"type":"string","enum":["device","expo"],"description":"device returns APNs on iOS or FCM on Android; expo requests an Expo token."}},"additionalProperties":false,"required":["type"]}},{"action":"setCaptureSession","summary":"Set runId and optional durationMs to arm capture, or runId:null to stop.","description":"Arm while the app is connected, before backgrounding or stopping it. Await durable:true before relying on cold-launch recovery. Sessions last at most one hour.","effect":"write","release":"works","params":{"type":"object","properties":{"runId":{"description":"A test-run label of 1 to 128 characters, or null to stop capture.","anyOf":[{"type":"string"},{"type":"null"}]},"durationMs":{"type":"number","description":"Capture duration in milliseconds. Default 1800000."}},"additionalProperties":false,"required":["runId"]}},{"action":"clearCapturedEvents","summary":"Clear Buoy notification history.","description":"Deletes retained captured events. Leaves device tokens, OS notifications and the capture session unchanged.","effect":"destructive","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"scheduleLocal","summary":"Schedule a local notification with title, body, optional data and a delay in seconds.","description":"This tests local notification behavior, not APNs or FCM delivery. The app must be connected before scheduling. Uses the app SDK and notification settings.","effect":"write","release":"works","params":{"type":"object","properties":{"title":{"type":"string","description":"Displayed title."},"body":{"type":"string","description":"Displayed message."},"data":{"type":"object","description":"Custom notification data. Include __buoyTestId to correlate with a test run."},"seconds":{"type":"number","description":"Delay before local delivery."}},"additionalProperties":false,"required":["title","body","seconds"]}}]},{"toolId":"image-overlay","title":"Image Overlay","summary":"Control the Swift design-image overlay. Uses the same state as the on-device controls. Read getSnapshot after loadImage to check loading and error. Available on Swift iOS only; check device capabilities.","actions":[{"action":"getSnapshot","summary":"Read overlay loading, image presence, placement, selected target and settings.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works"},{"action":"listTargets","summary":"Scan visible app image-overlay targets and return their ids, labels and frames.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works"},{"action":"selectTarget","summary":"Attach the overlay to a visible target.","params":{"type":"object","properties":{"id":{"type":"string","description":"Target id returned by listTargets."}},"additionalProperties":false,"required":["id"]},"effect":"write","release":"works"},{"action":"loadImage","summary":"Start loading a design image; returns {scheduled:true}. Check getSnapshot for completion or error.","params":{"type":"object","properties":{"url":{"type":"string","description":"HTTP(S) image URL."}},"additionalProperties":false,"required":["url"]},"effect":"write","release":"works"},{"action":"setSettings","summary":"Update overlay settings. Invalid batches fail before any settings change.","params":{"type":"object","properties":{"visible":{"type":"boolean","description":"Show the overlay."},"locked":{"type":"boolean","description":"Lock direct manipulation."},"flipped":{"type":"boolean","description":"Flip horizontally."},"flippedY":{"type":"boolean","description":"Flip vertically."},"showOutline":{"type":"boolean","description":"Show the target outline."},"autoTrack":{"type":"boolean","description":"Follow the selected target."},"opacity":{"type":"number","description":"Opacity from 0 to 1."},"scale":{"type":"number","description":"Positive scale factor."},"offsetX":{"type":"number","description":"Horizontal offset in points."},"offsetY":{"type":"number","description":"Vertical offset in points."}},"additionalProperties":false},"effect":"write","release":"works"},{"action":"fitToScreen","summary":"Fit the image to the screen width while preserving its aspect ratio.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"},{"action":"resetSettings","summary":"Reset opacity, flips, scale and offsets.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"},{"action":"remove","summary":"Remove the image and target; cancel pending image loading.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"}],"unavailableWhen":"The native Swift image-overlay adapter is not registered. React Native does not expose these actions."}];const CATALOG_TOOL_COUNT=exports.CATALOG_TOOL_COUNT=27;const CATALOG_ACTION_COUNT=exports.CATALOG_ACTION_COUNT=229;
|
|
7
|
+
RELEASE CAVEAT: asking for "render" is inert in a release build. HighlightUpdatesController.enableBackgroundTracking() returns immediately when !__DEV__ (packages/highlight-updates/src/highlight-updates/utils/HighlightUpdatesController.ts:1905), and React Native only installs __REACT_DEVTOOLS_GLOBAL_HOOK__ under __DEV__, so no render events are ever produced \u2014 the echo still lists it. Every other source (network, storage, redux, zustand, jotai, route, react-query) records normally in release.`,"requires":["@buoy-gg/events installed and auto-discovered by FloatingDevTools","the package behind each requested source installed in the app (otherwise the request is silently a no-op)"],"armsCapture":true},{"action":"clearEvents","summary":"Wipe the entire unified events timeline on the device (all 200 buffered events, every source). Irreversible.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Calls unifiedEventStore.clearEvents(): empties the event array, clears the activeSources set and the network request->unified id map, then notifies listeners and any onClear subscribers (packages/events/src/stores/unifiedEventStore.ts:440). Returns nothing. There is no undo and no snapshot to restore from \u2014 export first if the buffer might matter.\n\nScope: it clears only the AGGREGATED timeline. The per-tool stores keep their own logs, so network requests, redux actions and storage writes are still readable through their own tools after this. Conversely, clearing a source tool does not clear this timeline.\n\nUse it to get a clean baseline right before reproducing a bug (clearEvents -> reproduce -> exportEvents), not as routine cleanup.","requires":["@buoy-gg/events installed and auto-discovered by FloatingDevTools"]}],"unavailableWhen":`The app doesn't have @buoy-gg/events installed/mounted (then toolId "events" is absent from the device's adapter map), or the device is not connected to the broker at all. In a release bundle (__DEV__ === false) the whole sync channel is off unless the app passed externalSync.enableInRelease AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/FloatingDevTools.tsx:707) \u2014 that gates every Buoy action, not just these. Individual sources are also absent when their package isn't installed: auto-discovery require()s @buoy-gg/storage, /redux, /network, /react-query, /route-events, /zustand, /jotai, /highlight-updates and silently skips whichever are missing (packages/events/src/utils/autoDiscoverEventSources.ts:935).`},{"toolId":"network","title":"Network","summary":`Read and act on the app's captured HTTP traffic \u2014 getSnapshot lists the requests, getEventBody fetches one body, and author response-override rules that force matching requests to return a chosen status/body, fail, or arrive late. 17 actions. Two things gate honesty: (1) the device only RECORDS while something holds a capture subscription, so a cold read can be legitimately empty \u2014 call getCaptureStatus before telling anyone "no requests happened"; (2) in a RELEASE build every override write still returns ok:true and the rule still persists and appears in listOverrideRules, but engine.ts:74 refuses to apply it to real traffic, so nothing changes \u2014 call debugOverrides and read engine.devFlag before claiming an override took effect. Boot-time capture (requests fired before anything subscribed) is also DEV-only (preset.tsx:52).`,"actions":[{"action":"getSnapshot","summary":"List the app's captured HTTP requests \u2014 method, url, status, duration, error. The read half of the network tool; there is no action that lists requests.","description":"Returns `{ totalCaptured, shown, requests: [{id, method, url, status, durationMs, error}] }`, newest last. Bodies are NOT included \u2014 take an `id` from here and call `getEventBody` for one response. Narrow with `failedOnly`, `pattern` (substring of the url) and `limit`. The device only RECORDS while something holds a capture subscription, so an empty list can be legitimate: call `getCaptureStatus` before telling anyone no requests happened.","params":{"type":"object","properties":{"limit":{"type":"number","description":"Most-recent N requests after filtering. Default 25."},"failedOnly":{"type":"boolean","description":"Only requests that errored or returned status >= 400. Default false."},"pattern":{"type":"string","description":"Case-insensitive substring the url must contain."}},"additionalProperties":false},"effect":"read","release":"works"},{"action":"getCaptureStatus","summary":"Is the device actually recording right now, and why not \u2014 call this before reporting an empty request list.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns {capturing:boolean, subscribers:number, interceptorInstalled:boolean|null, interceptorLive:boolean|null, listenerCount:number|null, eventCount:number}. `capturing` is driven by the event store's subscriber count, NOT by whether the interceptor is installed: an enabled override rule pins the interceptor without anything recording, so installed:true + capturing:false is a real and common state. installed:true + live:false means something re-assigned fetch/XHR over the interceptor (it self-heals on the next snapshot). null for the interceptor fields means there is no interceptable runtime here. Nothing is recorded while no one is subscribed, so an empty list usually means capture was not armed \u2014 reading arms it, so trigger the traffic and read again. Separately, requests fired before anything subscribed (session bootstrap, first queries) are only buffered in a DEV build.","requires":["@buoy-gg/network mounted in the app","in a release build there is NO boot capture \u2014 packages/network/src/preset.tsx:52 gates startBootCapture on __DEV__, so requests fired before the first watch are unrecoverable"]},{"action":"getEventBody","summary":"Full un-stripped request/response bodies and headers for one request id.","params":{"type":"object","properties":{"id":{"type":"string","description":"Request id from the network snapshot. Live ids, or `saved:<key>` ids from an earlier app run."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Returns {requestData, responseData, requestHeaders, responseHeaders} (each null when absent), or null when no event with that id exists in either the live store or the saved store. Needed because the snapshot withholds bodies over 16KB, header values over 64 chars, and (past a 1.25MB per-snapshot budget) the bodies of older rows \u2014 those rows carry requestBodyOmitted/responseBodyOmitted/headersOmitted:true, which is the signal to call this. Accepts a live id or a `saved:<key>` id from a previous app run.","requires":["the event must still exist \u2014 live capture list (500-request cap) or the saved store"],"armsCapture":true},{"action":"setPinned","summary":"Pin or unpin one request; a pinned request is a full snapshot that survives Clear, the 500-cap and app restarts.","params":{"type":"object","properties":{"id":{"type":"string","description":"Request id from the network snapshot."},"pinned":{"type":"boolean","description":"true pins. false OR OMITTED unpins \u2014 always send this explicitly."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Pinning writes a persisted snapshot of the request, held at the top of the developer\'s Network list regardless of their filters. This is the human<->agent handoff channel: a human pins the call they think is broken, or you pin one for them to look at. WARNING on optionality: the handler reads `pinned` with a truthiness cast, so omitting it (or sending false) means UNPIN. Returns null when no request with that id exists (cleared or aged out \u2014 re-list), {ok:true} when already in the requested state, {ok:true,active:boolean} on a real toggle, or {ok:false,reason:"pin-cap"|"invalid-event"} \u2014 pin-cap is the 25-pin ceiling (lower on some license tiers); clear some first.',"requires":["the event must still exist in the live or saved store"],"armsCapture":true},{"action":"setSaved","summary":"Save or unsave one request to the developer's favorites; persisted, survives Clear and app restarts.","params":{"type":"object","properties":{"id":{"type":"string","description":"Request id from the network snapshot."},"saved":{"type":"boolean","description":"true saves. false OR OMITTED unsaves \u2014 always send this explicitly."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Same mechanics as setPinned but writes the \'saved\'/bookmark flag instead of the pin. Same optionality hazard: omitting `saved` (or sending false) means UNSAVE. Returns null when no request with that id exists, {ok:true} when already in the requested state, {ok:true,active:boolean} on a toggle, or {ok:false,reason:"saved-cap"|"invalid-event"} \u2014 saved-cap is the 50-record ceiling (lower on some license tiers).',"requires":["the event must still exist in the live or saved store"],"armsCapture":true},{"action":"removeSavedRecord","summary":"Permanently delete one pinned/saved record by its stable `key` (not a request id).","params":{"type":"object","properties":{"key":{"type":"string","description":"Kept-record key from the snapshot's `saved` array (e.g. `<sessionId>_3`). Not a request id."}},"required":["key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Takes the record `key` from the snapshot's `saved` list \u2014 NOT a request id; passing a request id silently matches nothing. Deletes the persisted snapshot outright whatever its pin/save flags, and it cannot be recovered: this may be the only remaining copy of a request the live list already dropped. Returns {ok:true} (also when the key matched nothing), or null when `key` is missing."},{"action":"clearSavedRequests","summary":"Unsave every saved request; records that are also pinned stay (still pinned).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Clears the 'saved'/favorites flag across the persisted store. Records that carry only the saved flag are deleted for good \u2014 those snapshots may be the last copy of requests the live list already dropped, and there is no undo. Records that are also pinned survive with saved:false. Returns {ok:true}. This is the developer's curated list, not scratch data \u2014 do not call it to tidy up."},{"action":"clearPinnedRequests","summary":"Unpin every pinned request; records that are also saved stay (still saved).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Clears the pin flag across the persisted store. Pin-only records are deleted permanently, with no undo \u2014 and a pin is how a human flags 'this is the broken call', so clearing them destroys that signal. Records that are also saved survive with pinned:false. Returns {ok:true}. Use this only when told to, or to free room after a pin-cap rejection."},{"action":"clearEvents","summary":"Wipe the live captured-request list (pinned/saved snapshots are unaffected).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Empties the in-memory event list and its pending-request map. Irreversible \u2014 anything not pinned or saved is gone. Useful to get a clean baseline before reproducing a bug: clear, drive the app, then read. Returns undefined (no receipt)."},{"action":"listOverrideRules","summary":"Read every override rule back in full, with hit counts, bodies, and the master-switch state.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns the whole OverrideRulesState: {enabled:boolean, autoPaused:boolean, rules:[{id, name, urlPattern, methods, kind, status, statusText, headers, body, failKind, delayMs, times, alternate, enabled, hits, seen, createdAt}]}. Unlike the periodic snapshot, this includes full rule bodies and un-coalesced hit counts. Read `enabled:false` carefully: `autoPaused:true` means the launch-safety guard turned overrides off by ITSELF after 3 launches with rules armed and untouched \u2014 nobody flipped that switch, and it must be re-armed with setOverridesEnabled({enabled:true}). A rule where `seen` > `hits` matched but did not apply (an `alternate` rule in its off phase, or a spent `times` budget) \u2014 that is the explanation for 'it matched but nothing happened'."},{"action":"getOverrideRuleBody","summary":"The full response body of one override rule, for surfaces the snapshot withheld it from.","params":{"type":"object","properties":{"id":{"type":"string","description":"Override rule id (e.g. `ovr_ltx4k2_1`), from listOverrideRules."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Returns {body:string|null}, or null when no rule has that id. Needed because rule bodies over 16KB are stripped from every snapshot and flagged with bodyOmitted:true \u2014 a rule seeded from a real response (fromRequestId) is routinely hundreds of KB. Reads the raw rule list, so it works even while the master switch is off."},{"action":"debugOverrides","summary":"Why a rule is or isn't firing \u2014 and the ONLY action that reveals whether overrides can work in this build at all.","params":{"type":"object","properties":{"url":{"type":"string","description":"Concrete URL to test the rules against, query string included. Defaults to 'https://example.com/', which matches nothing useful \u2014 always pass the real URL you expect to be overridden."}},"additionalProperties":false},"effect":"read","release":"works","description":"Probes the engine against one URL (method is always GET) without recording a hit, so it never burns a rule's `times` budget or nudges an alternating rule's phase. Returns {interceptorInstalled, listenerCount, engine:{hooksInstalled, devFlag, ruleCount, matches}, store:{enabled, ruleCount, rulesVisibleToEngine}}. READ engine.devFlag FIRST: if it is `false`, this is a release build and NO rule will ever be applied to real traffic no matter what the add/enable actions reported. `rulesVisibleToEngine` < `ruleCount` means the master switch is off. `matches:false` almost always means the glob missed the query string \u2014 a pattern must match the WHOLE url. Caution: unlike getCaptureStatus this probes the listener unguarded, so it throws in a runtime with no XMLHttpRequest."},{"action":"upsertOverrideRule","summary":'Create (or replace by id) a rule that forces matching requests to return a chosen status/body, fail outright, or arrive late. Pick this over query.setQueryData when the change must survive a refetch or reload, or when the ask is about what the API returns; follow it with query.invalidate so the mounted screen refetches through the rule. To change ONE value of the real response send fromRequestId plus `bodyPath` and `bodyValue` (bodyPath:"sprites.front_default", or "stats[stat.name=hp].base_stat" for one row of a list) \u2014 there is no nesting to get wrong, which matters most here because you read that body through a wire that truncates at 24,000 characters. For several fields use `bodyPatch` with ONLY those fields; never paste a whole captured body back. For "just the NEXT call" / "once, then let it work again", send rule.times:1 \u2014 the rule auto-disables after one match. Without times it overrides EVERY matching request until deleted. Never fake one-shot by triggering the request yourself and deleting the rule: that spends the failure the user wanted to see.',"params":{"type":"object","properties":{"fromRequestId":{"type":"string","description":"Build the rule from a captured request id \u2014 seeds urlPattern (query string replaced with `*`), method, status and the REAL response body. Anything you send explicitly wins over the prefill."},"rule":{"type":"object","description":"The rule itself. Same fields are accepted flat at the top level, but send them here.","properties":{"id":{"type":"string","description":"Replace this existing rule instead of creating one. Omit to create."},"urlPattern":{"type":"string","description":"Glob matched against the WHOLE request URL, query string included. `*` is the only wildcard, e.g. '*pokeapi.co*'. Required unless fromRequestId supplies it."},"enabled":{"type":"boolean","description":"Default true; only an explicit false creates the rule switched off. An enabled rule also forces the master switch ON."},"name":{"type":"string","description":"Label shown in the developer's rule list."},"methods":{"type":"array","items":{"type":"string"},"description":"Restrict to these HTTP methods (upper-cased for you). Omit for any method."},"kind":{"type":"string","enum":["respond","fail","delay"],"description":"respond = return your status/body, never hitting the network (default). fail = transport failure, as if offline (`fail:true` is accepted for this). delay = run the real request, just late. Anything else falls back to respond."},"status":{"type":"number","description":"kind 'respond': HTTP status, clamped to 200-599. Use kind 'fail' for connection errors, not status 0."},"statusText":{"type":"string","description":"kind 'respond': cosmetic only \u2014 React Native's XMLHttpRequest has no statusText, so the app never sees it."},"headers":{"type":"object","description":"kind 'respond': response headers, string values."},"body":{"description":"kind 'respond': the COMPLETE response body. A string is used as-is; an object or array is JSON.stringify'd for you \u2014 do not pre-stringify. To change a few fields of the real response use bodyPatch instead \u2014 bodies you read over the wire are truncated past 24K chars, and pasting a torn body back gives the app a response missing half its fields."},"failKind":{"type":"string","enum":["timeout"],"description":"kind 'fail': ONLY the literal 'timeout' is honored; anything else (including 'network') means a plain network error."},"delayMs":{"type":"number","description":"Latency before the outcome, in ms. Works with every kind."},"times":{"type":"number","description":'Auto-disable after N matches. **Set times:1 whenever the ask is about the NEXT call** \u2014 "make the next request fail", "just once", "then let it work again". Omitted = the rule overrides EVERY matching request until something deletes it, and the result will tell you so. `once:true`, `failOnce:true` and `maxHits:N` are accepted as ways of saying this.'},"alternate":{"type":"boolean","description":"Apply to every OTHER matching request: the first goes through, the second is overridden, and so on. How you reach retry/flaky-endpoint paths."},"bodyOmitted":{"type":"boolean","description":"Wire flag: send true WITH an `id` when re-saving a rule whose body the snapshot withheld, so the stored body is preserved instead of erased."},"bodyPatch":{"type":"object","description":"Fields to change in the real captured response (needs fromRequestId, or the id of an existing rule) as a TYPED LEAF EDIT \u2014 like the devtools editor: same-type changes to existing fields, no new fields, no type changes, no null-ing a rendered list/object. A list may gain/lose items but each item must match the existing item shape (to change one item resend the whole list). Refused with the exact field on a violation. Ignored when `body` is sent. To change ONE ROW of a list in the response, address it by its own id instead of resending the array: {results:{pikachu:{name:'test123'}}} edits that row and leaves every other row exactly as the server sent it. That is the only correct form when you read the response through a view that truncated it."},"force":{"type":"boolean","description":"Bypass the typed-edit safety on bodyPatch and write the patch raw. Default false \u2014 a violating patch is refused."},"bodyPath":{"type":"string","description":'Set ONE value inside the real response, named by its path ("sprites.front_default", "stats[stat.name=hp].base_stat"). Needs fromRequestId or an existing rule id to address. Always goes through the same typed-edit guard as bodyPatch.'},"bodyValue":{"description":"The value `bodyPath` is set to. Required whenever bodyPath is sent."}},"additionalProperties":false}},"additionalProperties":false},"effect":"write","release":"noop","description":'Create (or replace by id) a rule that forces matching requests to return a chosen status/body, fail outright, or arrive late. Pick this over query.setQueryData when the change must survive a refetch or reload, or when the ask is about what the API returns; follow it with query.invalidate so the mounted screen refetches through the rule. To change ONE value of the real response send fromRequestId plus `bodyPath` and `bodyValue` (bodyPath:"sprites.front_default", or "stats[stat.name=hp].base_stat" for one row of a list) \u2014 there is no nesting to get wrong, which matters most here because you read that body through a wire that truncates at 24,000 characters. For several fields use `bodyPatch` with ONLY those fields; never paste a whole captured body back. For "just the NEXT call" / "once, then let it work again", send rule.times:1 \u2014 the rule auto-disables after one match. Without times it overrides EVERY matching request until deleted. Never fake one-shot by triggering the request yourself and deleting the rule: that spends the failure the user wanted to see.',"releaseNote":"packages/network/src/network/overrides/engine.ts:74 \u2014 overrideForRequest returns null when __DEV__ === false. The rule is still created, persisted and listed, and this action still returns ok:true, but it is NEVER applied to real traffic in a release build. Confirm with debugOverrides \u2192 engine.devFlag before telling anyone the override took effect.","requires":["a __DEV__ build for the rule to actually apply","something holding the interceptor open (an enabled rule pins it itself)","Buoy Pro only to keep more than 1 rule \u2014 and only in the on-device UI; this action is not license-gated"]},{"action":"setOverrideRuleEnabled","summary":"Arm or silence ONE override rule by id, keeping the rule.","params":{"type":"object","properties":{"id":{"type":"string","description":"Override rule id from listOverrideRules."},"enabled":{"type":"boolean","description":"true arms this rule. false OR OMITTED disables it \u2014 always send this explicitly."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"noop","description":'Flips a single rule\'s enabled flag without touching the other rules or the master switch. Use it to stage a rule and arm it later, or to silence one you want to keep. WARNING on optionality: `enabled` is read with a truthiness cast, so omitting it means DISABLE. Returns {ok:true} even when no rule has that id \u2014 it does not verify; confirm with listOverrideRules. Returns {ok:false,error:"Missing rule id."} when `id` is absent. Note that arming a rule here does NOT turn the master switch on (unlike upsertOverrideRule), so a rule can read as enabled and still be dark.',"releaseNote":"packages/network/src/network/overrides/engine.ts:74 \u2014 overrideForRequest returns null when __DEV__ === false, so arming a rule in a release build changes the flag and nothing else. The action still returns ok:true.","requires":["a __DEV__ build for the rule to actually apply"],"undo":{"action":"setOverrideRuleEnabled","note":"Call again with the same id and the inverted `enabled` value."}},{"action":"setOverridesEnabled","summary":"Master switch for ALL override rules \u2014 off silences every rule without deleting any.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true arms all enabled rules. false OR OMITTED silences every rule (rules are kept) \u2014 always send this explicitly."}},"required":[],"additionalProperties":false},"effect":"write","release":"noop","description":"One call changes how every matching request in the app behaves, so treat it as a broad-blast action rather than a toggle. WARNING on optionality: `enabled` is read with a truthiness cast, so calling this with no params DISABLES all overrides. Returns {ok:true, enabled:<the resulting state>} \u2014 read that back rather than assuming. This is also the re-arm for the auto-pause state: when listOverrideRules reports autoPaused:true the launch guard turned overrides off by itself after 3 launches with rules armed and untouched, and only this puts them back. Individual rules keep their own enabled flags underneath.","releaseNote":"packages/network/src/network/overrides/engine.ts:74 \u2014 overrideForRequest returns null when __DEV__ === false, so flipping the master switch in a release build changes nothing about real traffic in either direction. The action still returns ok:true with the new flag.","requires":["a __DEV__ build for rules to actually apply"],"undo":{"action":"setOverridesEnabled","note":"Call again with the inverted `enabled` value. Per-rule enabled flags are untouched either way."}},{"action":"deleteOverrideRule","summary":"Delete one override rule by id \u2014 the undo for upsertOverrideRule.","params":{"type":"object","properties":{"id":{"type":"string","description":"Override rule id from listOverrideRules or from an upsertOverrideRule receipt."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Removes the rule from the persisted list permanently; there is no recovery, and a rule seeded from a real response cannot be reconstructed without that request still being in the store. This is what you call to clean up after driving a test \u2014 always remove rules you added, since a forgotten rule looks exactly like a real bug. Returns {ok:true} even when no rule has that id (it does not verify), or {ok:false,error:"Missing rule id."} when `id` is absent.'},{"action":"clearOverrideRules","summary":"Delete EVERY override rule; traffic becomes real again.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Wipes the whole persisted rule list at once \u2014 including rules a human authored and is mid-investigation on, which cannot be recovered. Prefer deleteOverrideRule({id}) for rules you created yourself, or setOverridesEnabled({enabled:false}) when you only need to silence them while keeping them. Returns {ok:true}."},{"action":"getNetworkConditions","summary":"Read the effective network condition and whether activation is available.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works"},{"action":"setNetworkConditions","summary":"Set memory-only network conditions for new intercepted HTTP requests. Pass profile: normal, offline, slow (+500 ms) or verySlow (+2000 ms). Normal clears activation and lets requests through at full speed.","params":{"type":"object","properties":{"profile":{"type":"string","enum":["normal","offline","slow","verySlow"],"description":"Normal, Offline, 500 ms latency, or 2000 ms latency."}},"required":["profile"],"additionalProperties":false},"effect":"write","release":"throws","releaseNote":"Release builds refuse active conditions. Normal can still clear the profile."}],"unavailableWhen":"The app does not mount the network tool (the @buoy-gg/network preset is absent from the FloatingDevTools apps list), or the runtime has no XMLHttpRequest/fetch to intercept (Node/headless/desktop-mirror \u2014 there getCaptureStatus reports interceptorInstalled:null and debugOverrides throws). The override actions are additionally Pro-gated by the MCP/dashboard layer (requireProDevice)."},{"toolId":"js-top","title":"JS TOP (JS-thread task manager)","summary":"Task Manager for the React Native JS thread: it wraps timers/rAF/microtasks/Promise reactions/legacy-bridge call-ins and books exclusive ms per scheduling origin, plus a calibrated busy probe (thread busy%) and PerformanceObserver('longtask') freeze attribution (blocks >=50ms). Reach for it when the app feels laggy/janky and you need to know WHAT is eating the JS thread (a forgotten setInterval, a hot rAF loop, a chatty Promise chain). `sample` is the main entry point \u2014 everything else is control (setEnabled/pause/resume/clear) or drill-down (getOriginDetail). NOT for \"components re-render too often\" \u2014 this measures TASKS, not renders.","actions":[{"action":"sample","summary":"Run the engine for a few seconds and return the ranked JS-thread task table (busy%, per-origin ms, freezes). This is the action to start with.","params":{"type":"object","properties":{"durationMs":{"type":"number","description":"How long to observe before returning the table. Default 3000; clamped to 250-30000. The call blocks for this long."},"clearFirst":{"type":"boolean","description":"Reset the whole task table, busy history and freeze history before sampling, so the result attributes exactly this window/interaction. Destructive: prior measurements are gone. Default false."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":'Self-arming one-shot measurement \u2014 the only action that works on a cold device. Ensures sampling is on (turning it on itself if nobody is watching), waits durationMs, returns a JsTopSnapshot, then restores the previous sampling state. Snapshot shape: { ts, paused, busyPct, busyHistory, tiers:{timers,promises,bridge,longtask}, rows[], totals:{attributedWindowMs,unattributedWindowMs,windowMs}, freezeSummary:{count,worstMs}, longtasks[] }. Each row = { key, label, api, windowMs, pctOfBusy, totalMs, calls, avgMs, maxMs, lastSeenAgoMs, freezes, worstFreezeMs } with api one of timeout|interval|immediate|raf|microtask|then|bridge|other|unattributed. IMPORTANT window semantics: busyPct, windowMs and pctOfBusy describe only the TRAILING 5s (20 x 250ms buckets), so a durationMs above 5000 does not widen them \u2014 only totalMs/calls accumulate across the whole sample (since engine start or last clear). A pinned "unattributed" row absorbs event/React/native-call-in work; on New Architecture devices tiers.bridge is false and that row is expected to be large. freezeSummary covers a trailing 60s window. To profile one specific interaction, pass clearFirst:true and drive the UI (tap_element / run_flow) while the sample runs. Blocks for the full durationMs.',"requires":["@buoy-gg/js-top installed and imported in the app","device connected to the Buoy broker (external sync)"]},{"action":"getOriginDetail","summary":"Drill into one task origin: schedules vs runs, avg/max/total ms, a 5s 250ms-bucket activity histogram, freeze attribution, and the captured scheduling stack.","params":{"type":"object","properties":{"key":{"type":"string","description":'Origin key from a snapshot row, e.g. "interval|pollFeed", "raf|anonymous", or the literal "unattributed" system row. Required \u2014 a missing key throws.'}},"required":["key"],"additionalProperties":false},"effect":"read","release":"works","description":'Pass a `key` copied from a snapshot row. Keys are either the cheap form `${api}|${functionName}` (e.g. "interval|pollFeed", "timeout|anonymous") or the refined form `${api}|${name}@${caller}:${file}:${line}`, plus the literal "unattributed" for the pinned system row (which returns a synthetic detail built from busy-probe residuals). Returns { key, label, api, caller?, file?, line?, frames?, promoted, scheduleCount, calls, totalMs, avgMs, maxMs, windowMs, pctOfBusy, lastSeenAgoMs, buckets[20], freezes, worstFreezeMs }, or NULL when the key was never tracked, was cleared, or was evicted (the registry caps at 500 origins and evicts the coldest) \u2014 re-sample for current keys. caller/file/line only appear once an origin is "promoted" (25+ schedules or 50ms+ total; intervals capture eagerly). In a RELEASE build the file/line/component labels are absent: source symbolication posts to Metro\'s /symbolicate and is hard-gated on __DEV__ (packages/js-top/src/js-top/engine/symbolicate.ts:42 `if (!isDev()) return;`), so labels stay as raw Hermes function names. All numeric stats are still correct.',"requires":["@buoy-gg/js-top installed and imported in the app","the engine must have been recording \u2014 call `sample` (or setEnabled true) first","a __DEV__ build with Metro reachable for symbolicated labels/file/line \u2014 in a release build origins come back as raw unsymbolicated frames"],"armsCapture":true},{"action":"setEnabled","summary":"Turn silent remote sampling on/off \u2014 runs the accounting engine with no visible change on the device.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true = start silent remote sampling (engine runs, device UI unchanged); false = stop it. Required \u2014 the handler reads params.enabled with no guard, so omitting params throws."}},"required":["enabled"],"additionalProperties":false},"effect":"write","release":"works","description":"Calls JsTopController.setRemoteSampling(enabled). enabled:true starts the engine (installs timer/microtask/Promise/bridge patches on first start, starts the busy probe) and keeps it running across calls so snapshots stay live; enabled:false stops it. This does NOT show or hide the device's own HUD pill \u2014 that is a separate on-device toggle not exposed over the wire. Use this when you want the engine warm across several MCP calls; prefer `sample` for a single measurement, since it arms and disarms itself. Leaving remote sampling on costs continuous instrumentation, so turn it back off when done.","requires":["@buoy-gg/js-top installed and imported in the app"],"armsCapture":true},{"action":"pause","summary":"Freeze task accounting while keeping the collected table intact.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Sets the engine inactive and stops the busy probe, so no new time is booked to any origin; the existing rows, totals and freeze history are preserved and the snapshot reports paused:true. No-op if already paused. Use it to hold a table steady while reading it. Note: a paused engine also means a subsequent `sample` observes nothing until you `resume` \u2014 `sample` does not auto-resume.","requires":["@buoy-gg/js-top installed and imported in the app"]},{"action":"resume","summary":"Resume task accounting after a pause.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Reactivates booking and restarts the busy probe if the engine is running (i.e. something is subscribed or remote sampling is on). No-op if not currently paused. Accumulated totals continue from where they stopped \u2014 the pause gap is not counted as busy time.","requires":["@buoy-gg/js-top installed and imported in the app"]},{"action":"clear","summary":"Wipe the whole task table, busy history and freeze history \u2014 starts a fresh measurement window.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Resets the origin registry (all rows, keys, captured scheduling stacks and totals), the busy/attribution aggregator, and the recorded longtask/freeze entries, then emits an empty snapshot. Irreversible \u2014 there is no saved copy, so read anything you need before calling it. Any `key` you were holding for getOriginDetail becomes invalid. Prefer `sample` with clearFirst:true when the goal is to scope a measurement to one interaction, since that clears and re-measures in a single call.","requires":["@buoy-gg/js-top installed and imported in the app"]}],"unavailableWhen":'@buoy-gg/js-top is not installed/imported in the app (the adapter is never registered under "js-top", so every action is unroutable), or the app is a release build that has not explicitly opted into desktop sync \u2014 FloatingDevTools does not dial the broker when __DEV__ is false unless the app passes the allow-in-release externalSync option, so the device never connects and no action arrives at all.'},{"toolId":"app","title":"App","summary":"Device-level pseudo-tool from @buoy-gg/core itself (packages/devtools-floating-menu/src/floatingMenu/sync/appSyncAdapter.ts) \u2014 registered unconditionally for EVERY connected Buoy app, so it works with no tool package and no native deps installed. Two actions: `ping` (cheap liveness probe that also returns which JS realm is answering) and `reloadApp` (restart the JS bundle). Reach for it to check the app is alive, to learn the dev-server origin, or to reload after an edit / to clear leaked in-memory state before a measurement. Its static snapshot (v3) also reports `{startedAt, devServerOrigin, device:{platform,isTV,isTVOS}, reload:{available,strategy}}` \u2014 read `reload.available` before promising a user a reload will work.","actions":[{"action":"ping","summary":"Liveness probe: returns {ok:true, startedAt, devServerOrigin}. `startedAt` identifies WHICH JS realm answered \u2014 the only reliable way to confirm a reload really happened.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":'Takes no params. Only a live JS realm can answer, so a timeout means the app is unresponsive or crashed. `startedAt` is the module-load timestamp of this JS realm; it CHANGES across a reload, so the pattern is: ping (remember startedAt) -> reloadApp -> wait ~1.2s -> ping until startedAt differs. A reply carrying the OLD startedAt means the doomed realm answered and the app is not back yet. `devServerOrigin` is the Metro/Expo dev server that served the bundle (e.g. "http://192.168.1.20:8081"), or null in a release bundle (file:// scriptURL) and on web \u2014 capture it while the app is healthy, because after a crash it is the only remaining way to recover the app.',"requires":["@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)","external-sync socket connected to the broker on :42831"]},{"action":"reloadApp","summary":"Restart the app's JS bundle (DevSettings.reload() in dev, expo-updates otherwise). ALL in-memory state is destroyed \u2014 read anything you need first.","params":{"type":"object","properties":{"strategy":{"type":"string","enum":["auto","dev-settings","expo-updates"],"description":"Reload mechanism. 'auto' (default) uses React Native's DevSettings.reload() in dev builds and expo-updates otherwise. Force one only when debugging the reload itself; 'dev-settings' throws in a release bundle, 'expo-updates' throws unless the host app installed expo-updates (and always fails in Expo Go)."},"delayMs":{"type":"number","description":"Milliseconds to wait before the reload actually fires, so this action's result can be flushed over the sync socket first. Default 250 (REMOTE_RELOAD_DELAY_MS); negative values are clamped to 0. Leave unset unless the ack is being lost."}},"required":[],"additionalProperties":false},"effect":"destructive","release":"throws","description":"Arms a reload and returns {scheduled:true, strategy, delayMs} BEFORE it fires (default 250ms later), because the realm dies the instant it does. The ack is NOT proof the app came back \u2014 confirm with `ping` and a changed `startedAt`. Destroys every in-memory store (redux/zustand/jotai/react-query caches, console buffer, network log, unsaved form state); persisted storage survives. Read get_console / get_network_requests / get_storage BEFORE calling. Params: `strategy` (\"auto\" default \u2014 dev-settings in dev builds, expo-updates otherwise; force one only when debugging the reload itself) and `delayMs` (ack-flush window, default 250, clamped to >= 0). Check the snapshot's `reload.available` first: when no mechanism exists this THROWS rather than silently no-op'ing, which is the correct and honest outcome to report.","releaseNote":'packages/shared/src/utils/reloadApp.ts:80 \u2014 `if (__DEV__ && loadDevSettings())` means "auto" can only resolve to dev-settings in a dev build; in release it falls through to `loadExpoUpdates()`, which returns null unless the host app installed `expo-updates`. resolveReloadStrategy then returns null and scheduleReloadApp throws at line 140 WITHOUT scheduling anything. So in a release build: works only if expo-updates is installed, otherwise a hard error \u2014 never a fake success. Explicit strategy "dev-settings" throws in release regardless.',"requires":["@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)","A dev build (for DevSettings.reload) OR the host app has `expo-updates` installed","Snapshot `reload.available === true` \u2014 check before promising a reload"]}],"unavailableWhen":'The app\'s bundle is a release build (`__DEV__ === false`) that did not opt into `externalSync.enableInRelease` AND hold a real Pro license \u2014 FloatingDevTools.tsx:719 then never mounts AutoExternalSync, so no `app` action reaches the device at all. Also effectively unavailable after a fatal render error: the React tree that answers sync actions is gone, so both actions time out (recovery is the dev-server reload path, using a `devServerOrigin` learned from an earlier successful ping). Remote drivers going through the Buoy MCP server additionally hit a Pro gate (SyncClient.requireProDevice: "Buoy Pro is required to use the MCP").'},{"toolId":"time-machine","title":"Time Machine","summary":"Save and restore the app's entire client-side state (device storage, redux, zustand, jotai, react-query) as named snapshots (\"restore points\"), plus the route it was captured on. Reach for it to put the app back into a known state before re-running a flow, to preview exactly what a restore would change before committing, or to wipe the app to fresh-install state. Snapshot payloads stay on-device \u2014 `list` returns metadata only; use `inspect` for one source's actual captured data and `preview` for item-level diffs. Swift supports live restore of registered providers, with UserDefaults and MMKV supplied by default. Keychain is excluded. Read provider detail and restoreModes. Native capabilities omit captureBaseline and wipeAll, and mode reload is rejected before writes. Snapshots include a safety copy before restoration; this is not a full process reset.","actions":[{"action":"list","summary":"List all restore points plus which state sources can currently be captured/restored and whether the device can navigate routes.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":'Same payload as the tool\'s synced snapshot. Returns { snapshots, busy, lastOutcome, providers, route }. `snapshots[]` = { id (e.g. "snap_m1x2y3_4"), name, createdAt, sizeBytes, baseline?, route?:{pathname,href}, restoreRoute?, scope?, excludedKeys?, sources:{ [sourceId]: { warnings:string[] } } } \u2014 METADATA ONLY, no captured values. `providers[]` = { id, label, canCapture, canRestore, detail } for the registered sources: "storage", "redux", "zustand", "jotai", "query" (plus any custom ones the app registered). Read `detail` before reporting a source as broken \u2014 e.g. redux says "Auto-instrumented store cannot be restored" when the app didn\'t wrap its root reducer. `route.available` is false on apps without expo-router (those snapshots record no route). `lastOutcome` is the previous restore\'s per-source result. Call this first \u2014 every other action needs an `id` from here. Swift supports live restore of registered providers, with UserDefaults and MMKV supplied by default. Keychain is excluded. Read provider detail and restoreModes. Native capabilities omit captureBaseline and wipeAll, and mode reload is rejected before writes. Snapshots include a safety copy before restoration; this is not a full process reset.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"capture","summary":"Save the app's current state as a new restore point and return its metadata.","params":{"type":"object","properties":{"name":{"type":"string","description":'Label for the restore point. Omit or pass "" to auto-name it "<current pathname> \xB7 HH:MM".'},"sourceIds":{"type":"array","items":{"type":"string"},"description":'Capture only these source ids ("storage", "redux", "zustand", "jotai", "query"). Omit to capture every capturable source.'}},"additionalProperties":false},"effect":"write","release":"works","description":'Captures every source whose provider reports canCapture (or only `sourceIds` if given), serializes it into the on-device vault (@react_buoy_time_machine:snap:<id>), and stamps the current route on it. Returns { captured:true, snapshot: { id, name, sizeBytes, route?, sources:{[id]:{warnings}} } }. ALWAYS surface `sources[*].warnings` \u2014 capture is lossy by design in known places (MMKV ArrayBuffer values, biometric-protected SecureStore keys, unregistered SecureStore keys) and a per-source `capture failed: ...` warning is how a silently-empty source shows up. An empty/omitted `name` auto-names it "<pathname> \xB7 HH:MM". Does NOT modify app state. Do this before driving a flow you want to re-run.',"requires":["at least one state source registered (storage/redux/zustand/jotai/react-query package installed and instrumented)","expo-router for the route stamp (optional)"]},{"action":"captureBaseline","summary":"Create an EMPTY 'Fresh install' restore point whose later restore wipes app state and reloads.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Takes no reading of current state \u2014 it writes a zero-byte snapshot named \"Fresh install\" with baseline:true. Safe and non-destructive by itself; the destruction happens later when you `restore` its id (which clears every clearable source and reloads). Returns { captured:true, snapshot }. Use it to give a QA user a one-tap 'back to fresh install' point.","requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"wipeAll","summary":"DESTRUCTIVE: clear every clearable state source to fresh-install state, then reload the app. No snapshot involved and no undo.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":'Calls clear() on every registered provider (app AsyncStorage/MMKV/SecureStore keys, redux, zustand, jotai, react-query cache \u2014 Buoy\'s own @react_buoy* keys are excluded) and then schedules a JS bundle reload. Returns a RestoreOutcome { snapshotId:"wipe-all", willReload, results:{[sourceId]:{ok,applied,skipped,warnings}} }. There is NO undo \u2014 take a `capture` first if the state matters. CHECK `willReload` in the result before telling anyone the app restarted (see releaseNote).',"requires":["expo-updates installed for the reload leg to work in a release build"]},{"action":"restore","summary":"DESTRUCTIVE: put the app back into a snapshot's state, overwriting and deleting live state. Run `preview` first.","params":{"type":"object","properties":{"id":{"type":"string","description":'Snapshot id from `list`, e.g. "snap_m1x2y3_4". Required.'},"mode":{"type":"string","enum":["live","reload"],"description":'"live" (default) replaces state in place; "reload" restores persisted storage only and reloads the JS bundle. Baseline snapshots always reload regardless.'},"excludeKeys":{"type":"array","items":{"type":"string"},"description":'Items to LEAVE UNTOUCHED, as compound "sourceId::itemKey" strings using the item keys from `preview`: storage::async:@cart, storage::mmkv:<instanceId>/<key>, storage::secure:<key>, redux::<sliceName>, zustand::<storeName>, jotai::<atomLabel>, query::<queryHash> (e.g. query::["pokemon"]). Passing this array overrides \u2014 and disables \u2014 the snapshot\'s saved exclusions and saved scope.'},"restoreRoute":{"type":"boolean","description":`Also navigate back to the route the snapshot was captured on. OMITTING THIS IS NOT false \u2014 it falls back to the snapshot's saved setting, which is ON whenever the snapshot has a route. Pass false to force the app to stay on the current screen. With mode "reload" the navigation is deferred to the next boot (status "deferred", 60s TTL).`}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Requires `id` (throws "restore requires a snapshot `id`" without it). mode "live" (default) replaces state in place source by source in a fixed order (storage \u2192 redux \u2192 zustand \u2192 jotai \u2192 query); mode "reload" restores ONLY persisted storage and then reloads the JS bundle so in-memory stores rebuild themselves. A baseline snapshot ALWAYS takes the clear+reload path regardless of mode. Storage restore is a diff \u2014 app keys missing from the snapshot are DELETED. Returns a RestoreOutcome: read `results[sourceId].applied` / `.skipped[].reason` / `.warnings` rather than assuming success (a source with canRestore:false comes back skipped with a reason, not an error), and read `willReload` and `route.status` ("navigated" | "deferred" | "failed" | "unavailable"). TWO GOTCHAS: (1) omitting `excludeKeys` makes the snapshot\'s own SAVED excludedKeys apply, and a saved `scope` narrows the restore to only the scoped items \u2014 passing an explicit `excludeKeys` array disables that saved scope. (2) omitting `restoreRoute` does NOT mean false: it falls back to the snapshot\'s own setting, which is ON whenever the snapshot captured a route. Pass restoreRoute:false to keep the app on the current screen. Swift supports live restore of registered providers, with UserDefaults and MMKV supplied by default. Keychain is excluded. Read provider detail and restoreModes. Native capabilities omit captureBaseline and wipeAll, and mode reload is rejected before writes. Snapshots include a safety copy before restoration; this is not a full process reset.',"requires":["a source's provider must report canRestore:true or that source is skipped with a reason","expo-router for the route leg","expo-updates for the reload leg in a release build"]},{"action":"preview","summary":"Compute the exact blast radius of restoring a snapshot \u2014 per-item added/removed/changed/wont-apply verdicts \u2014 WITHOUT changing anything.","params":{"type":"object","properties":{"id":{"type":"string","description":"The snapshot being previewed (the restore target). Required."},"compareTo":{"type":"string","description":"Another snapshot id to diff against instead of the app's live current state. Omit to compare against live state \u2014 which is what a restore would actually overwrite."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Requires `id` (throws "preview requires a snapshot `id`"). Diffs the snapshot against the LIVE current state, or against another snapshot when `compareTo` is given. Returns { left, right, builtAt, sources:[{ id, label, notes:string[], error?, items:[{ key, label, verdict, reason?, oldValue?, newValue?, valueOmitted? }] }] }. Verdicts are the whole point: "added"/"removed"/"changed"/"too-large" WILL be applied by a restore; "unchanged" and "recomputes" won\'t; "wont-apply" means a real difference exists that restore CANNOT apply and `reason` says why (e.g. an auto-instrumented redux store, a tool that isn\'t installed) \u2014 surfacing that reason is more useful than the count. `key` values are exactly what `restore`\'s excludeKeys / `setScope` / `setExclusions` want, once prefixed with "<sourceId>::". Values larger than ~4KB are stripped and flagged valueOmitted:true, so an empty oldValue/newValue does not mean the value was empty. Side effect: it rebuilds the on-device preview panel, replacing whatever a human has open in the Time Machine UI. Always run this before a `restore` you cannot undo.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"inspect","summary":"Read back one source's actual captured payload from a snapshot, deserialized.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"sourceId":{"type":"string","description":'Which captured source to read: "storage", "redux", "zustand", "jotai", "query", or a custom provider id. Must be a key of that snapshot\'s `sources` \u2014 required.'}},"required":["id","sourceId"],"additionalProperties":false},"effect":"read","release":"works","description":"Requires both `id` and `sourceId` (throws \"inspect requires `id` and `sourceId`\"; also throws when the snapshot doesn't exist or never captured that source). Returns { id, sourceId, warnings:string[], data } where `data` is the source's full captured tree with Date/Map/Set rehydrated. This is the only way to see actual snapshot VALUES \u2014 `list` is metadata-only. Storage `data` is shaped { async:[key,value][], mmkv:{[instanceId]:[{key,value,valueType}]}, secure:{[key]:string|null} }. Payloads can be large (up to the 8MB action budget), so ask for one source at a time.","requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"delete","summary":"DESTRUCTIVE: permanently remove a restore point from the device vault. No remote undo.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Requires `id` (throws "delete requires a snapshot `id`"). Removes the snapshot\'s vault row and index entry. Returns { deleted:true, id }. The device keeps one in-memory copy so a HUMAN can tap Undo in the Time Machine UI, but that undo is NOT exposed as an action \u2014 over the wire this is irreversible. A restore point can represent state that took ten minutes and a cooperative backend to build; confirm with the user before calling.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"rename","summary":"Change a restore point's display name.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"name":{"type":"string","description":"New display name. Required and must be non-empty."}},"required":["id","name"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires BOTH `id` and a non-empty `name` (throws "rename requires `id` and `name`"). Metadata only \u2014 the captured payload is untouched. Returns { renamed:true, id, name }.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"duplicate","summary":"Copy a restore point (payload included) so a variation can branch off a known-good base.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id to copy, from `list`. Required."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "duplicate requires `id`"; throws `No snapshot with id "..."` if it isn\'t in the vault). Creates a full copy named "<original name> copy" with a NEW id and returns { snapshot }. Does not touch app state. Use it before editing a point\'s scope/exclusions so the original stays intact.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"setExclusions","summary":"Persist the items every FUTURE restore of this snapshot must leave untouched (the saved form of the preview's unchecked boxes).","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"excludedKeys":{"items":{"type":"string"},"description":'Compound "sourceId::itemKey" strings from `preview` (e.g. storage::async:@cart, query::["pokemon"], redux::cart). null or an empty array clears the saved exclusions.',"anyOf":[{"type":"array"},{"type":"null"}]}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "setExclusions requires `id`"). Saves compound "sourceId::itemKey" strings onto the snapshot\'s metadata; a later `restore` that omits its own excludeKeys will honour them. Pass null or an empty array to clear. Returns { id, excluded:<count> }. Metadata only \u2014 nothing in the app changes now; the effect lands on the next restore.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"setScope","summary":"Persist a targeted-restore scope \u2014 the ONLY items this restore point will ever touch.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"scope":{"items":{"type":"string"},"description":'Compound "sourceId::itemKey" strings from `preview` \u2014 the only items a restore may touch. null or an empty array clears the scope (full restore).',"anyOf":[{"type":"array"},{"type":"null"}]}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "setScope requires a snapshot `id`"). The inverse of setExclusions: a scoped point restores nothing outside its list, and sources entirely outside the scope are left untouched and unreported in the outcome. Pass null or an empty array to clear the scope and go back to a full restore. Returns { scoped:true, id, count }. Note that a `restore` call passing an explicit excludeKeys array IGNORES the saved scope.',"requires":["@buoy-gg/time-machine registered in FloatingDevTools"]},{"action":"setRestoreRoute","summary":"Turn a restore point's 'also navigate back to the captured screen' behaviour on or off.","params":{"type":"object","properties":{"id":{"type":"string","description":"Snapshot id from `list`. Required."},"restoreRoute":{"type":"boolean","description":"true to navigate back to the captured route on restore, false to skip it. Only an explicit false turns it off \u2014 omitting it turns it ON."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Requires `id` (throws "setRestoreRoute requires `id`"). Persists the flag onto the snapshot. NOTE THE COERCION: the handler stores `restoreRoute !== false`, so omitting the param \u2014 or sending anything other than exactly false \u2014 turns it ON. Returns { id, restoreRoute }. Only meaningful for snapshots that captured a route (see `route` on the snapshot and `route.available` from `list`).',"requires":["expo-router on the device for the flag to have any effect"]}],"unavailableWhen":"The `@buoy-gg/time-machine` tool is not in the app's FloatingDevTools tool list, or the app is a release build that has not opted into `externalSync.enableInRelease` with a real Pro license (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:190 \u2014 no socket, so no action reaches the device at all). Individual state sources are also absent unless their tool package is installed and instrumented: check `providers[].canCapture`/`canRestore` from `list` before assuming a source is covered."},{"toolId":"clock","title":"Clock","summary":"Override the app's own clock without touching the device: move it to any date (setTime), jump it forward or back (shift), stop it (freeze/resume), run it faster (setRate), or go back to real time (reset). Reach for it to test anything time-based on the client: expired offers and coupons, countdowns, trial and renewal dates, day or month rollovers, 'last seen' labels, token expiry the app checks itself. What moves: Date.now(), new Date(), Date() and Intl.DateTimeFormat format() with no date; a forward shift also runs the app's setTimeout/setInterval callbacks that come due inside the jump, once each. What stays real: performance.now(), animations, native timers and scheduled notifications, the time zone, and the backend's clock, so a server that checks expiry itself still uses real time. Values the app computed before the change keep their old time until the screen re-reads the clock or the app reloads (the override survives reloads). It also reads the app's sign-in tokens from Network's captured requests (JWTs, PASETO, opaque tokens whose login response gave an expiry, session cookies) and tests the app's token refresh: jumpToTokenExpiry for apps that refresh before expiry, failNextRequest for apps that refresh after a 401. Raw tokens are never returned. Every action returns the same state object as getState: { mode:'real'|'running'|'frozen', active, rate, virtualNow, virtualIso, realNow, offsetMs, timeZone, timers:{total,timeouts,intervals,nextTimeoutInMs}, lastJump:{byMs,firedTimers,at}|null, settings:{persist,showChip,fireTimersOnJump}, tokens:{available, tokens:[{id,label,host,formatLabel,hint,claims,expiresAt,serverExpiresAt,expirySource,lifetimeMs,issuedBy,refresh,uses,refreshes,looping,expiredSends,rejected}], check:{kind:'expiry'|'401',tokenId,status:'waiting'|'refreshed'|'loop',forcedAt,refreshedAt,newLifetimeMs,newIssuedBy,newTokens,staleAfterFailure}|null}, patched:{date,intl,timers,animatedOnRealClock} }. expiresAt is on the app's clock; serverExpiresAt on real time.","unavailableWhen":"Needs @buoy-gg/clock installed (FloatingDevTools auto-discovers it). In release builds the saved override and timer tracking load when the Clock tool is first opened rather than at app start.","actions":[{"action":"getState","summary":"Read what the app's clock says right now, how far it is from real time, and the pending timers.","description":"Returns the state object described in the tool summary. Read virtualIso (the app's time) against realNow before reporting what 'now' is for the app; offsetMs is app minus real. mode 'real' means no override is on. timers counts the app's pending setTimeout/setInterval callbacks, which is how many a forward shift could run.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"setTime","summary":'Move the app\'s clock to `time` (ISO 8601, epoch ms, or relative like "+3d"); pass `freeze` true to stop it there.',"description":'`time` accepts "2026-12-31T23:59:50" (device-local when no zone is given), "2026-12-31 23:59", "2026-12-31T23:59:50Z", epoch milliseconds, or a signed duration from the app\'s current time ("+30d", "-2h"). The clock keeps running from there at the current speed unless `freeze` is true. Unlike shift, setTime never runs timers. Throws a message naming the accepted formats when `time` cannot be read. Returns the new state.',"effect":"write","release":"works","undo":{"action":"reset","note":"reset returns to the device's real clock. If an override was already on before this call, reset does not bring that one back: read getState first and set it again with setTime/setRate."},"params":{"type":"object","properties":{"time":{"description":'Target time: ISO 8601 ("2026-12-31T23:59:50"), "YYYY-MM-DD HH:MM", epoch ms, or relative ("+1d", "-2h").',"anyOf":[{"type":"string"},{"type":"number"}]},"freeze":{"type":"boolean","description":"Stop the clock at `time` instead of letting it run. Default false."}},"required":["time"],"additionalProperties":false}},{"action":"shift","summary":'Jump the app\'s clock `by` a duration ("+1d", "-2h30m", "90s" or ms); forward jumps also run app timers that come due unless `fireTimers` is false.',"description":"Moves the clock relative to what it reads now; a frozen clock stays frozen. Units: ms, s, m, h, d, w, mo (30 days), y (365 days). A forward jump runs, once each and soonest first, every app setTimeout/setInterval callback due within the jump (Playwright fastForward rules) and shortens what is left of the others; lastJump.firedTimers reports how many ran. Buoy's own timers are never run. Backward jumps never run timers. Returns the new state.","effect":"write","release":"works","undo":{"action":"reset","note":"reset returns to the device's real clock. If an override was already on before this call, reset does not bring that one back: read getState first and set it again with setTime/setRate."},"params":{"type":"object","properties":{"by":{"description":'Signed duration: "+1d", "-2h30m", "90s", "1.5 hours", or a number of ms.',"anyOf":[{"type":"string"},{"type":"number"}]},"fireTimers":{"type":"boolean","description":"Run app timers that come due inside a forward jump. Default: the fireTimersOnJump setting (on)."}},"required":["by"],"additionalProperties":false}},{"action":"freeze","summary":"Stop the app's clock where it is, or at `time` if given.","description":"The app reads the same instant until resume, shift, setTime or reset. Timers keep running in real time and animations keep moving; only reads of the time stop. `time` takes the same formats as setTime. Returns the new state.","effect":"write","release":"works","undo":{"action":"resume","note":"resume starts the clock again from the frozen instant; it does not return to the time before the freeze. Use reset for real time."},"params":{"type":"object","properties":{"time":{"description":"Optional instant to freeze at (same formats as setTime). Omit to freeze at the current app time.","anyOf":[{"type":"string"},{"type":"number"}]}},"additionalProperties":false}},{"action":"resume","summary":"Start a frozen app clock again from the instant it was frozen at.","description":"No-op when the clock is not frozen. The clock continues at its current speed. Returns the new state.","effect":"write","release":"works","undo":{"action":"freeze","note":"freeze stops it again, at the current app time."},"params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"setRate","summary":"Run the app's clock `rate` times as fast as real time (1 = normal, 60 = a minute per second, 3600 = an hour per second).","description":"Starts from what the app's clock reads now, so nothing jumps. Only reads of the time speed up: timer delays stay real, so taps, animations and polling behave; code that recomputes from Date.now() on each tick (most countdowns) shows the faster time. Range 0.1 to 86400. Returns the new state.","effect":"write","release":"works","undo":{"action":"setRate","note":"Call again with rate 1. That keeps the time already gained; use reset for real time."},"params":{"type":"object","properties":{"rate":{"type":"number","description":"Speed multiplier, 0.1-86400. 1 is normal speed."}},"required":["rate"],"additionalProperties":false}},{"action":"reset","summary":"Put the app back on the device's real clock.","description":"Removes the Date and Intl patches entirely, clears the saved override and closes the clock chip. Screens that already computed a time keep it until they re-read the clock or the app reloads. Returns the new state (mode 'real').","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"updateSettings","summary":"Change the Clock tool's settings: `persist` (keep the override across reloads), `showChip` (floating reminder), `fireTimersOnJump`.","description":"Only the booleans given are changed. persist=false still applies the override now but forgets it on the next reload. Returns the new state with settings.","effect":"write","release":"works","params":{"type":"object","properties":{"persist":{"type":"boolean","description":"Keep the override across reloads and restarts."},"showChip":{"type":"boolean","description":"Show the floating clock chip while an override is on."},"fireTimersOnJump":{"type":"boolean","description":"Whether forward shifts run app timers that come due."}},"additionalProperties":false}},{"action":"jumpToTokenExpiry","summary":"Move the app's clock to just before a sign-in token expires (default 10 s before), then watch for the app to fetch a new token.","description":"Tests apps that refresh their token before it expires by comparing its expiry with Date.now(). Shifts the app's clock forward (running timers that come due, like a scheduled refresh) to `leadMs` before the token's expiry, and starts a refresh check. The server keeps real time, so it still accepts the old token: only the app's own refresh logic is exercised. The app has to send a request (or run its refresh timer) before a new token shows up; read getState's tokens.check for the result: 'refreshed' with the new token's lifetime and issuing endpoint, or 'loop' when new tokens keep arriving because they already look expired to the moved clock. Throws when no token has been seen, when the token's expiry is unreadable (then use failNextRequest), or when it has already expired on the app's clock.","effect":"write","release":"works","undo":{"action":"reset","note":"reset puts the app back on real time. The app keeps any token it fetched meanwhile, which is harmless."},"params":{"type":"object","properties":{"token":{"type":"string","description":"Token id from getState's tokens[].id. Default: the most recently used token that has a readable expiry."},"leadMs":{"type":"number","description":"Land this many ms before expiry. Default 10000."}},"additionalProperties":false}},{"action":"failNextRequest","summary":"Answer the app's next request that carries a sign-in token with a 401 (or `status` 403), once or `times` times, then watch for the app to fetch a new token.","description":"Tests apps that refresh their token after the server rejects it. Adds a one-shot Network override on the token's origin, limited to requests that send the token's header, so the 401 never lands on the app's own token request. It answers with `WWW-Authenticate: Bearer error=\"invalid_token\"` and an invalid_token JSON body, and starts a refresh check. Needs @buoy-gg/network. The app has to send a request for the 401 to reach it; getState's tokens.check then shows forcedAt, whether a new token arrived, staleAfterFailure (requests that re-sent the old token after the 401) and failedRefreshes (token requests that failed). Replaces an earlier 401 that has not fired yet.","effect":"write","release":"works","undo":{"action":"stopTokenCheck","note":"stopTokenCheck withdraws the 401 if it has not reached the app yet. A 401 the app already received cannot be taken back."},"params":{"type":"object","properties":{"token":{"type":"string","description":"Token id from getState's tokens[].id. Default: the most recently used token that has a readable expiry."},"status":{"type":"number","description":"401 (default) or 403."},"times":{"type":"number","description":"How many requests to fail, 1-10. Default 1."}},"additionalProperties":false}},{"action":"stopTokenCheck","summary":"End the refresh check, withdrawing a forced 401 that has not reached the app yet.","description":"Clears tokens.check. When the check was started by failNextRequest and the 401 has not fired, removes that Network override so it cannot fail a later request. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}}]},{"toolId":"lifecycle","title":"Lifecycle","summary":"Put the app through what happens to it on a phone and see what its code does: send it to the background and back (background, returnToApp), interrupt it like the app switcher or a call (interrupt), send a memory warning, deliver a deep link while it runs (openUrl), press Android's back button (pressBack), change the color scheme, set battery level and low power mode (setPower, only when the app has expo-battery or react-native-device-info), or relaunch it and check whether it reopens where it was (relaunch). Recipes chain these (runRecipe: phone-call, quick-switch, away-31m, overnight, low-memory, dying-battery, relaunch); away-31m moves the app clock with @buoy-gg/clock to test session timeouts. Simulations emit the same events native code sends, so the app's own listeners run unchanged, but they reach JS listeners only: timers, requests, Reanimated and native code keep running, and the next real OS event ends the simulation. Every action returns the same state object: { platform, host:{isSimulator,deviceName,bundleId,os}, realAppState, away:{kind:'background'|'inactive',startedAt,returnAt,skippedMs}|null, power|null, scheme|null, listeners:{appState,focus,memoryWarning,deepLink,battery} (counts of the APP's own listeners; 0 means nothing in the app reacts to that signal), batteryLibraries, clockAvailable, report:{label,kind,collectingUntil,counts,events:[{source,title,subtitle,status,at,whileAway}],finding?,note?,unavailable?}|null, relaunch:{before,after,tookMs,restored}|null, log, recipes, settings, active }. The report collects requests, query updates, route changes and store writes from the start of a simulation until a few seconds after the app returns; `finding` names the notable result, such as network requests made while in the background. Read getState again after the window to see the full report. reset ends everything.","unavailableWhen":"Needs @buoy-gg/lifecycle installed (FloatingDevTools auto-discovers it). The report needs @buoy-gg/events; skipping the wait needs @buoy-gg/clock; relaunch reports need expo-router to read the route.","actions":[{"action":"getState","summary":"Read what is simulated, the app's listener counts per signal, the last report and the relaunch check.","description":"Returns the state object described in the tool summary, with listener counts read fresh. Read it a few seconds after returnToApp or a one-shot signal: report.collectingUntil is non-null while it is still collecting.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"background","summary":"Send the app to the background (iOS: inactive then background; Android: blur then background), optionally for a `duration`.","description":"Emits the platform's real sequence to the app's AppState listeners and starts a report. With `duration` it returns by itself; with `skipWait` and a duration it moves the app clock forward instead and returns at once. Throws when skipWait has no duration or @buoy-gg/clock is missing. Returns the new state.","effect":"write","release":"works","undo":{"action":"returnToApp","note":"Brings the app back to active. reset also ends it."},"params":{"type":"object","properties":{"duration":{"description":'Return by itself after this long: "5s", "5m", "2h", or ms. Omit to stay away until returnToApp.',"anyOf":[{"type":"string"},{"type":"number"}]},"skipWait":{"type":"boolean","description":"Move the app clock forward by `duration` and return at once instead of waiting (needs @buoy-gg/clock). Tests session timeouts in one call."}},"additionalProperties":false}},{"action":"interrupt","summary":"Make the app inactive, like the app switcher, Control Center or an incoming call (Android: window blur).","description":"iOS emits 'inactive'; Android takes window focus away without changing AppState. Same `duration` and `skipWait` as background. Returns the new state.","effect":"write","release":"works","undo":{"action":"returnToApp","note":"Brings the app back to active. reset also ends it."},"params":{"type":"object","properties":{"duration":{"description":'Return by itself after this long: "5s", "5m", "2h", or ms. Omit to stay away until returnToApp.',"anyOf":[{"type":"string"},{"type":"number"}]},"skipWait":{"type":"boolean","description":"Move the app clock forward by `duration` and return at once instead of waiting (needs @buoy-gg/clock). Tests session timeouts in one call."}},"additionalProperties":false}},{"action":"returnToApp","summary":"Bring the app back to active after background or interrupt.","description":"Emits 'active' (and focus on Android) and keeps the report collecting for the report window. Does nothing when the app is not away. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"memoryWarning","summary":"Send a memory warning to the app's AppState 'memoryWarning' listeners.","description":"Starts a report. Android never sends this event to React Native, so on Android the report carries a note saying the listeners ran anyway. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"openUrl","summary":"Deliver a deep link to the running app, as the OS would (Linking 'url').","description":"Reaches Linking.addEventListener('url'), expo-router and React Navigation linking. getInitialURL stays unchanged. Throws when `url` has no scheme. Returns the new state with a report of what the app did (usually a route change).","effect":"write","release":"works","params":{"type":"object","properties":{"url":{"type":"string","description":'The link with its scheme, e.g. "myapp://orders/42".'}},"additionalProperties":false,"required":["url"]}},{"action":"pressBack","summary":"Press Android's hardware back button without ever leaving the app; reports whether a screen handled it.","description":"Android only (throws on iOS). The result has `handled`: false means no screen took the press and a real press would have exited the app. Returns the new state plus `handled`.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"setColorScheme","summary":"Switch the app to `scheme` light or dark as if the system setting changed, or back to the system value.","description":"mode 'js' (default) emits the change to Appearance and useColorScheme; mode 'native' calls Appearance.setColorScheme so native colors change too. Returns the new state.","effect":"write","release":"works","undo":{"action":"reset","note":"reset ends every simulation and puts the real values back."},"params":{"type":"object","properties":{"scheme":{"type":"string","enum":["light","dark","system"],"description":"The scheme to switch to. 'system' ends the simulation and puts the real value back."},"mode":{"type":"string","enum":["js","native"],"description":"'js' (default) or 'native'."}},"additionalProperties":false,"required":["scheme"]}},{"action":"setPower","summary":"Set battery `level`, charging `state` and `lowPowerMode` for expo-battery and react-native-device-info listeners.","description":"Emits each library's own events and wraps its getters so reads agree until resetPower. Fields you omit keep their current simulated value. Throws when neither library is installed. Returns the new state; power.realGetters lists getters that could not be wrapped.","effect":"write","release":"works","undo":{"action":"resetPower","note":"resetPower restores the real getters and re-emits the real values."},"params":{"type":"object","properties":{"level":{"type":"number","description":"Battery level 0-1 (a value above 1 is read as a percentage)."},"lowPowerMode":{"type":"boolean","description":"Low power mode on or off."},"state":{"type":"string","enum":["unplugged","charging","full","unknown"],"description":"Charging state."}},"additionalProperties":false}},{"action":"resetPower","summary":"Put the real battery values back.","description":"Restores wrapped getters and re-emits the real values. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"relaunch","summary":"Restart the app's JS and check whether it reopens the screen it was on.","description":"Records the current route, ends every simulation and reloads the JS runtime about 250 ms later. The native process survives, so this tests JS state restoration and persisted storage. After the reload, getState's relaunch shows before, after and restored (about 2.5 s after start). Unsaved in-memory state is lost.","effect":"destructive","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"runRecipe","summary":"Run the interruption named by `id`: phone-call, quick-switch, away-31m, overnight, low-memory, dying-battery or relaunch.","description":"Each recipe is a short sequence of the actions above; getState lists them with `unavailable` when a dependency is missing (away-31m and overnight need @buoy-gg/clock, dying-battery needs a battery library). Returns the new state.","effect":"write","release":"works","undo":{"action":"reset","note":"reset ends every simulation and puts the real values back."},"params":{"type":"object","properties":{"id":{"type":"string","description":'Recipe id, e.g. "away-31m".'}},"additionalProperties":false,"required":["id"]}},{"action":"clearReport","summary":"Stop and clear the current report.","description":"Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"reset","summary":"End every simulation: return to the app, restore battery and color scheme.","description":"Does not undo a Clock tool change made by skipWait or a recipe; reset the clock with the clock tool. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}}]},{"toolId":"location","title":"Location","summary":"Tell the app it is somewhere else without touching the device: pin it to a place (setLocation), move it along a route with real heading and speed (startRoute, pauseRoute, resumeRoute, seekRoute, setSpeed, stopRoute), cross a geofence (enterRegion/exitRegion), set the signal quality (setConditions: good, weak, no-signal, off), drop the GPS signal (setSignal) or switch location services off (setServices), then go back to the real location (reset). Reach for it to test anything location-based on the client: nearest-store lists and distances, 'you are here' labels, delivery radius checks, geofence check-ins, background tracking, and how screens handle no signal or services off. What changes: every expo-location call (getCurrentPositionAsync, watchPositionAsync, getLastKnownPositionAsync, heading, hasServicesEnabledAsync, geofencing and background location tasks via expo-task-manager) and @react-native-community/geolocation / react-native-geolocation-service. What stays real: permission (the device or the Permissions tool decides; a denied permission still fails, and approximate location snaps positions to ~3 km), native map views' own blue dot, native SDKs, and the backend. The override survives reloads. Every action returns the same state object as getState: { active, simulating, override:{mode:'real'|'fixed'|'route', place:{latitude,longitude,label}, accuracy, jitter, route:{name,id,points,speed,loop,gaps}|null, playing, signal, services, interval}, current:{fix:{latitude,longitude,altitude,accuracy,heading,speed}|null, reason:'real'|'signal'|'gap'|'services'|null, precision:'full'|'reduced'}, permission:{status:'granted'|'denied'|'undetermined', background, precision}|null, position:{latitude,longitude}|null, progress:{along,length,finished,leg,etaMs}|null, watches:[{library,id,kind,updates}], tasks:[{name,kind:'geofencing'|'locationUpdates',intercepted,runs,regions:[{identifier,label,latitude,longitude,radius,state:'unknown'|'inside'|'outside',distance}]}], log:[{at,kind,library,text,detail}], libraries, places, routes, builtInPlaces, presets, settings:{persist,showChip} }. Distances are meters, speeds meters per second.","unavailableWhen":"Needs @buoy-gg/location installed (FloatingDevTools auto-discovers it) and a supported location library in the app. Pro only. In release builds the libraries are patched when the Location tool is first opened rather than at app start.","actions":[{"action":"getState","summary":"Read where the app thinks it is, its live location watches, its geofence regions and recent location events.","description":"Returns the state object described in the tool summary. active false means the app gets the device's real location. current.fix is what the app receives right now; null with a reason means updates are stopped. tasks lists geofencing regions with inside/outside state and the distance from the current position, which is where to find a region identifier for enterRegion. places and routes are the app's own named places and routes; builtInPlaces and presets are always available.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"setLocation","summary":'Pin the app to one place: `latitude` + `longitude` (with an optional `label`), a `query` ("lat, lon" or a map link), or a `place` name.',"description":"Stops any route. `place` matches the app's registered places first, then built-ins (Apple Park, Times Square, Trafalgar Square, Eiffel Tower, Shibuya Crossing, Sydney Opera House, Null Island at 0,0). Live watches get the new position within one update interval, and geofence tasks fire for any region the move crosses. Throws with the list of place names when `place` doesn't match.","effect":"write","release":"works","params":{"type":"object","properties":{"latitude":{"type":"number","description":"Latitude, -90 to 90."},"longitude":{"type":"number","description":"Longitude, -180 to 180."},"label":{"type":"string","description":"Name to show for the pinned place."},"query":{"type":"string","description":'"37.3349, -122.0090" or a Google/Apple Maps link.'},"place":{"type":"string","description":"A place name from getState places or builtInPlaces."}},"additionalProperties":false}},{"action":"startRoute","summary":"Move the app along `points` (optionally `loop`), a preset or app `route` (presets head north unless `heading` is set), or in a straight line `to` a place, at `speed` m/s.","description":"Presets start where the app is now: walk (1 km), run (3 km), drive (4 km with turns), highway (30 km), tunnel (3 km with a 600 m stretch of no signal), loop (400 m square, repeats). Heading and speed in each update follow the route; a finished route parks at its last point. Geofence tasks fire as the route crosses regions, so a route that passes a store is how to test an enter then exit.","effect":"write","release":"works","params":{"type":"object","properties":{"points":{"type":"array","items":{"type":"object","properties":{"latitude":{"type":"number"},"longitude":{"type":"number"}},"required":["latitude","longitude"],"additionalProperties":false},"minItems":2,"description":"Two or more points to travel through."},"route":{"type":"string","description":"A preset id (walk, run, drive, highway, tunnel, loop) or an app route id from getState routes."},"to":{"type":"string","description":'Travel in a straight line to this place name or "lat, lon".'},"speed":{"type":"number","description":"Meters per second: 1.4 walking, 4 cycling, 13.4 city driving, 30 highway."},"heading":{"type":"number","description":"For a preset: direction to head, degrees from north. Default 0 (north)."},"loop":{"type":"boolean","description":"With points: start over after the last point."}},"additionalProperties":false}},{"action":"pauseRoute","summary":"Stop moving along the route; the app keeps the current position.","description":"Updates stop changing until resumeRoute. No-op when no route is playing.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"resumeRoute","summary":"Continue a paused route, or play a finished one again from the start.","description":"No-op when no route is loaded.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"stopRoute","summary":"End the route and keep the app pinned where it got to.","description":"Switches from the route to a fixed position at the route's current point, including inside a dead zone. No-op when no route is loaded.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"seekRoute","summary":"Jump to a point along the route by `meters` from the start or `fraction` (0 to 1).","description":"Keeps playing or paused as it was. Throws when no route is loaded.","effect":"write","release":"works","params":{"type":"object","properties":{"meters":{"type":"number","description":"Meters from the route's start."},"fraction":{"type":"number","description":"0 = start, 1 = end."}},"additionalProperties":false}},{"action":"setSpeed","summary":"Change the route's speed to `speed` meters per second without losing progress.","description":"Throws when no route is loaded or speed is not positive.","effect":"write","release":"works","params":{"type":"object","properties":{"speed":{"type":"number","description":"Meters per second."}},"additionalProperties":false,"required":["speed"]}},{"action":"enterRegion","summary":"Move just inside the geofence region `identifier`; the app's geofencing task runs with an enter event.","description":"Pins the position at the region's center. Region identifiers come from getState tasks[].regions. Pass `task` when two geofencing tasks use the same identifier. Throws when no monitored region has that identifier.","effect":"write","release":"works","params":{"type":"object","properties":{"identifier":{"type":"string","description":"The region identifier."},"task":{"type":"string","description":"The geofencing task name, when identifiers repeat."}},"additionalProperties":false,"required":["identifier"]}},{"action":"exitRegion","summary":"Move just outside the geofence region `identifier`; the app's geofencing task runs with an exit event.","description":"Pins the position past the region's edge on the side of the current position. Same rules as enterRegion.","effect":"write","release":"works","params":{"type":"object","properties":{"identifier":{"type":"string","description":"The region identifier."},"task":{"type":"string","description":"The geofencing task name, when identifiers repeat."}},"additionalProperties":false,"required":["identifier"]}},{"action":"setConditions","summary":"Set the signal quality in one step: `conditions` good, weak, no-signal or off.","description":"good: \xB15 m fixes, no drift. weak: \xB165 m fixes that wander by up to 50 m (indoors, city canyons). no-signal: same as setSignal on=false. off: same as setServices on=false. no-signal and off keep the current accuracy and drift, so switching back restores them. weak only changes simulated positions.","effect":"write","release":"works","params":{"type":"object","properties":{"conditions":{"type":"string","enum":["good","weak","no-signal","off"],"description":"Which signal conditions the app should see."}},"additionalProperties":false,"required":["conditions"]}},{"action":"setSignal","summary":"Turn the GPS signal off (`on` false) or back on.","description":"Off: live watches stop receiving updates and position requests fail with a location-unavailable error after about a second. The position mode stays as it was.","effect":"write","release":"works","params":{"type":"object","properties":{"on":{"type":"boolean","description":"true = signal, false = no signal."}},"additionalProperties":false,"required":["on"]}},{"action":"setServices","summary":"Report location services as disabled (`on` false) or enabled.","description":"Off: hasServicesEnabledAsync returns false, provider status reports locationServicesEnabled false, requests fail with a services-disabled error, and live watches get one error event.","effect":"write","release":"works","params":{"type":"object","properties":{"on":{"type":"boolean","description":"true = enabled, false = disabled."}},"additionalProperties":false,"required":["on"]}},{"action":"tune","summary":"Set the reported `accuracy`, GPS `jitter` (drift), `altitude`, pinned `heading`, or update `interval`.","description":"All fields optional; send only what changes. jitter moves each update up to that many meters from the true point. interval is how often live watches get an update, in ms (default 1000).","effect":"write","release":"works","params":{"type":"object","properties":{"accuracy":{"type":"number","description":"Reported accuracy radius in meters."},"jitter":{"type":"number","description":"Drift in meters; 0 turns it off."},"altitude":{"type":"number","description":"Meters above sea level."},"heading":{"type":"number","description":"Heading reported while pinned, degrees from north."},"interval":{"type":"number","description":"Milliseconds between updates, 100 to 60000."}},"additionalProperties":false}},{"action":"useRealLocation","summary":"Go back to the device's real position but keep the signal and services switches.","description":"Use reset to end every override at once.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"reset","summary":"End every location override; the app gets the device's real location again.","description":"Watches and geofence or background tasks the app started under the override are started natively with the app's own options.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"updateSettings","summary":"Set `persist` (keep the override across reloads) and `showChip` (floating status while overridden).","description":"Both default to true.","effect":"write","release":"works","params":{"type":"object","properties":{"persist":{"type":"boolean","description":"Keep the override across reloads."},"showChip":{"type":"boolean","description":"Show the floating location chip while overridden."}},"additionalProperties":false}},{"action":"clearLog","summary":"Clear the recent location events shown in getState log.","description":"Does not change the override.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}}]},{"toolId":"permissions","title":"Permissions","summary":"Override what the app sees when it asks for a permission, without touching the device: make location, camera, notifications, photos, contacts and others read as not asked, allowed, limited (approximate location, selected photos or contacts, provisional notifications), denied, blocked (Android 'don't ask again') or restricted (iOS parental controls). Reach for it to test the permission flows on the client: the pre-prompt screen, what happens after 'Don't Allow', the 'turn it on in Settings' path, approximate location, and a returning user who already refused. Covers Expo modules (expo-location, expo-camera, expo-notifications, expo-image-picker, expo-media-library, expo-contacts, expo-calendar, expo-tracking-transparency, expo-audio, expo-sensors, expo-maps), React Native's PermissionsAndroid and react-native-permissions. While a permission is overridden the real system prompt never shows: a request from 'not asked' is answered by the requestAnswer setting (ask shows Buoy's own prompt on the device and waits for a tap; allow, limited and deny answer at once) and moves the state the way the OS would (iOS: one refusal is final; Android: a second refusal blocks). Linking.openSettings and app-settings: URLs open Buoy's stand-in for Settings. A change sends the app background then active, like a return from Settings, so screens that re-check on foreground update; hooks that read only on mount update on their next read. With enforce on, calls that need a refused permission (current or last known position, launching the camera, media library reads) fail with the native module's error; approximate location coarsens positions. Native code that checks the OS itself still sees the real state, and requestReal shows the real system prompt once so the OS can grant what the override pretends. Every action returns the same state object as getState: { platform, host:{isSimulator,deviceName,bundleId}, libraries:[string], permissions:[{ id, label, states:[state], override, real, effective, sources:[string], calls, warning, canRequestReal }], overriddenCount, settings:{ requestAnswer, notifyApp, interceptSettings, enforce, persist, showChip }, log:[{ at, source, method, permission, kind, real, returned, overridden, answer?, error? }] }.","unavailableWhen":"Needs @buoy-gg/permissions installed (FloatingDevTools auto-discovers it). Rows appear only for permission libraries the app has. In release builds the overrides install when the Permissions tool is first opened rather than at app start.","actions":[{"action":"getState","summary":"Read each permission the app can reach: the override, what the OS says, which libraries asked, and the recent permission calls.","description":"Returns the state object described in the tool summary, after re-reading the OS state from each Expo module. `effective` is what the app sees now. `states` lists the states this platform allows for that permission. `log` is newest first and shows what each call returned and whether the override answered it.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"setOverride","summary":"Make `permission` read as `state` for the app, without changing the OS.","description":"`state` is one of undetermined, granted, limited, denied, blocked, restricted, and must be in that permission's `states` (limited only exists for location, photos, contacts, and iOS notifications; iOS has no blocked, so blocked becomes denied; Android has no restricted). Throws naming the allowed states otherwise. The app sees it on its next permission read; with notifyApp on, the app also gets a background \u2192 active round trip right away. Returns the new state.","effect":"write","release":"works","undo":{"action":"clearOverride","note":"clearOverride with the same permission returns it to the OS answer. If it was already overridden before this call, read getState first and set the old state again instead."},"params":{"type":"object","properties":{"permission":{"type":"string","description":"Permission id: location, locationBackground, camera, microphone, notifications, photos, contacts, calendar, reminders (iOS), tracking (iOS), motion, bluetooth, or android:<android.permission.NAME>."},"state":{"type":"string","enum":["undetermined","granted","limited","denied","blocked","restricted"],"description":"What the app sees for this permission."}},"required":["permission","state"],"additionalProperties":false}},{"action":"clearOverride","summary":"Stop overriding `permission`; the app sees the OS answer again.","description":"Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{"permission":{"type":"string","description":"Permission id: location, locationBackground, camera, microphone, notifications, photos, contacts, calendar, reminders (iOS), tracking (iOS), motion, bluetooth, or android:<android.permission.NAME>."}},"required":["permission"],"additionalProperties":false}},{"action":"resetAll","summary":"Stop every override.","description":"Every permission reads the OS answer again. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"requestReal","summary":"Show the real system prompt for `permission`, ignoring the override, so the OS can grant it.","description":"Use when the override says granted or limited but `real` is undetermined, so native calls (a position, the camera) would fail. The system only prompts from not asked; after that it answers without a prompt. Someone has to tap the real prompt on the device. Returns the new state with the updated `real`.","effect":"write","release":"works","params":{"type":"object","properties":{"permission":{"type":"string","description":"Permission id: location, locationBackground, camera, microphone, notifications, photos, contacts, calendar, reminders (iOS), tracking (iOS), motion, bluetooth, or android:<android.permission.NAME>."}},"required":["permission"],"additionalProperties":false}},{"action":"updateSettings","summary":"Change how overrides behave: requestAnswer (how requests are answered), notifyApp (return from Settings on change), interceptSettings (the Settings stand-in), enforce, showChip and persist.","description":"Pass only the fields to change. requestAnswer 'ask' shows Buoy's prompt on the device and waits for a person to tap; use allow, limited or deny when nobody is at the device. Returns the new state.","effect":"write","release":"works","params":{"type":"object","properties":{"requestAnswer":{"type":"string","enum":["ask","allow","limited","deny"],"description":"How a request from 'not asked' (or Android 'denied') is answered while overridden."},"notifyApp":{"type":"boolean","description":"Send background \u2192 active after each change, like a return from Settings."},"interceptSettings":{"type":"boolean","description":"Show Buoy's stand-in when the app opens its Settings page while overridden."},"enforce":{"type":"boolean","description":"Fail calls that need a refused permission with the native error, and coarsen approximate positions."},"persist":{"type":"boolean","description":"Keep overrides across reloads."},"showChip":{"type":"boolean","description":"Show the floating chip while any override is on."}},"additionalProperties":false}},{"action":"simulateReturnFromSettings","summary":"Send the app background \u2192 active, as a real return from Settings does.","description":"Screens that re-check permissions when the app comes to the foreground run their check. Returns the state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"clearLog","summary":"Clear the recent permission calls and their counts.","description":"Returns the state.","effect":"write","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}}]},{"toolId":"storage","title":"Storage","summary":"Read and write the app's persisted state across all three backends \u2014 AsyncStorage, every registered MMKV instance, and registered Expo SecureStore keys \u2014 plus a recorded timeline of storage writes with per-event undo/jump. Reach for this first when a value is wrong, stale, missing, or only broken after a restart/upgrade: the bad value is usually sitting in storage. Buoy's own devtool keys (@react_buoy*, @buoy*, buoy-*) are stripped from every read path, so results are app data only. Nothing here is __DEV__-gated \u2014 every action really runs in a release build.","actions":[{"action":"getRequiredKeys","summary":"List the storage keys the app declares as required, with their expected types and backends.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns the requiredStorageKeys array given to createStorageTool (or the FloatingDevTools requiredStorageKeys prop): each entry is a key, or an object with key, expectedType or expectedValue, description and storageType (async, mmkv or secure). Returns [] when the app declared none. It reports the configuration only; read the keys themselves to see whether they are present and valid. Swift returns the requirements configured through BuoyStorageModule.configure(requiredKeys:)."},{"action":"async.getAllKeys","summary":"List every AsyncStorage key in the app (Buoy's own devtool keys stripped).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns string[]. The cheapest first call: get the key names, then read the ones that matter with async.multiGet. Keys matching @react_buoy*, @buoy*, buoy-*, or legacy dev prefixes are filtered out at the source, so the count can be lower than the app's real key count.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.multiGet","summary":"Batch-read AsyncStorage values; returns [key, value|null][] tuples.","params":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":'Keys to read, e.g. ["session","user.prefs"]. Required \u2014 the handler reads params.keys with no guard and throws if params is missing.'}},"required":["keys"],"additionalProperties":false},"effect":"read","release":"works","description":"Values are always strings (or null when unset) \u2014 JSON.parse them yourself. Works on async-storage v2 and v3 (translated to getMany internally) and always preserves the requested key order. Any requested key that is a Buoy devtool key is dropped from the RESULT entirely, so the returned array can be shorter than `keys`.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.getItem","summary":"Read one AsyncStorage key; returns the string value or null.","params":{"type":"object","properties":{"key":{"type":"string","description":"The AsyncStorage key to read."}},"required":["key"],"additionalProperties":false},"effect":"read","release":"works","description":"Prefer async.multiGet when you want more than one key. Returns null (never an error) for a key that is unset AND for any Buoy devtool key (@react_buoy*, @buoy*, buoy-*) even when that key really is set \u2014 so a null here does not prove the app never wrote it if the key is Buoy-prefixed.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.setItem","summary":"Write one AsyncStorage key to a string value.","params":{"type":"object","properties":{"key":{"type":"string","description":"The AsyncStorage key to write."},"value":{"type":"string","description":"The string value to store. JSON-encode objects/arrays yourself."}},"required":["key","value"],"additionalProperties":false},"effect":"write","release":"works","description":`Values are strings only \u2014 JSON.stringify objects yourself, or the app will read back garbage. Unlike the read paths this is NOT key-filtered: it will happily overwrite Buoy's own @react_buoy*/@buoy*/buoy-* settings keys, so never point it at one. Emits a setItem event (with the old value as prevValue) while a dashboard is subscribed, which makes it undoable via timeTravel.undo. TRAP: if this key is a live store's saved copy (the grounding marks these as "persists to <key>", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead \u2014 zustand.setState, redux.dispatch, jotai.setAtom.`,"requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.removeItem","summary":"Delete one AsyncStorage key.","params":{"type":"object","properties":{"key":{"type":"string","description":"The AsyncStorage key to delete."}},"required":["key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Permanently removes the key from the device. No confirmation and no result payload \u2014 it resolves to undefined whether or not the key existed. Read the value first if you might need it back.","requires":["@react-native-async-storage/async-storage installed in the app"]},{"action":"async.multiRemove","summary":"Delete several AsyncStorage keys in one call.","params":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":"Keys to delete."}},"required":["keys"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Batch form of async.removeItem (translated to removeMany on async-storage v3). Deletes exactly the keys you name \u2014 it does NOT filter Buoy devtool keys, so do not pass @react_buoy*/@buoy*/buoy-* keys. To wipe app data wholesale use clearAppStorage instead, which protects those.","requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"]},{"action":"async.multiSet","summary":"Write several AsyncStorage key/value pairs in one call.","params":{"type":"object","properties":{"pairs":{"type":"array","description":'Array of two-element [key, value] arrays, e.g. [["session","abc"],["count","3"]].',"items":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2}}},"required":["pairs"],"additionalProperties":false},"effect":"write","release":"works","description":`Takes v2-shaped tuples [[key, value], ...] and translates to setMany on async-storage v3. All values must be strings. Same caveat as async.setItem: not key-filtered, so never include a Buoy devtool key. TRAP: if this key is a live store's saved copy (the grounding marks these as "persists to <key>", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead \u2014 zustand.setState, redux.dispatch, jotai.setAtom.`,"requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"]},{"action":"async.clear","summary":"Wipe ALL of AsyncStorage, including Buoy's own devtool settings.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Raw AsyncStorage.clear() \u2014 the nuclear option. It destroys Buoy's own @react_buoy*/@buoy*/buoy-* keys too, resetting the dev tools' own settings along with app data. Almost always the wrong choice: use clearAppStorage, which does the same thing to app data while preserving Buoy's keys. Prefer it unless someone explicitly asked to reset the dev tools as well.","requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open (a storage events read/watch) AT THE MOMENT OF THE WRITE \u2014 otherwise no event with prevPairs is recorded and the undo is impossible"]},{"action":"clearAppStorage","summary":"Delete every app AsyncStorage key while preserving Buoy's own devtool keys.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"The safe reset: getAllKeys, drop anything matching @react_buoy*/@buoy*/buoy-*/legacy dev prefixes, removeMany the rest. This is what a 'clear all storage' / 'reset the app's data' request means. Resolves to undefined and reports no count. Use it instead of async.clear.","requires":["@react-native-async-storage/async-storage installed in the app","for undoAction timeTravel.undo: storage capture must be held open at the moment of the call \u2014 the wipe goes through removeMany, so with no subscriber there is no multiRemove event and the wipe is unrecoverable"]},{"action":"getEventDetail","summary":"Fetch one recorded storage event's full value/prevValue/pairs by event id.","params":{"type":"object","properties":{"id":{"type":"string","description":"Event id from the storage event timeline, format se-<epochMs>-<counter>."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'The streamed event timeline replaces any value over 16KB with the marker object {__buoyValueOnDevice:true}; this is the on-demand channel for the real payload (itself capped at 8MB, above which it returns {__buoyTruncated:true}). Call it before timeTravel.undo/jump on an event whose values are still markers \u2014 those actions refuse to replay markers. Never throws: returns {found:false, reason:"missing id"} or {found:false, reason:"unknown id"}. Event ids look like "se-1755000000000-42".',"armsCapture":true},{"action":"clearEvents","summary":"Wipe the recorded storage-event timeline (in-memory only; app storage untouched).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Empties the 500-event ring buffer. No device storage is written or deleted \u2014 but every event id disappears, so getEventDetail and timeTravel.undo/jump lose all their targets, permanently. Worth knowing: the initial key scan that seeds the timeline with the app's PRE-EXISTING keys runs only once per store lifetime, so after clearEvents the timeline does not re-list existing keys \u2014 it only refills with new writes. Use async.getAllKeys to see current keys instead.","armsCapture":true},{"action":"timeTravel.undo","summary":"Revert one recorded AsyncStorage write, restoring the value it overwrote.","params":{"type":"object","properties":{"id":{"type":"string","description":"Id of the AsyncStorage event to undo (se-<epochMs>-<counter>)."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Addresses a single event by id and restores its prevValue/prevPairs (removing the key when it did not exist before). AsyncStorage events only \u2014 throws "Time travel supports AsyncStorage events only" for MMKV events, throws when the event has no captured previous value, and throws when prevValue is still an on-device marker (fetch getEventDetail first). Unlike the in-app UNDO button, failures throw with the real reason instead of silently doing nothing. The restore itself emits a normal storage event, so it is visible and re-undoable.',"armsCapture":true},{"action":"timeTravel.jump","summary":"Rewind storage to its state as of one recorded event by replaying history.","params":{"type":"object","properties":{"id":{"type":"string","description":"Id of the AsyncStorage event to jump to (se-<epochMs>-<counter>)."},"scope":{"type":"string","enum":["key","all"],"description":`"key" (default) replays only that key's history; "all" replays every captured AsyncStorage event and can rewrite/delete many keys.`}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Replays the captured AsyncStorage timeline up to and including `id`, then reconciles the keys that timeline governs \u2014 writing the replayed values and DELETING keys that only exist later than the target. scope:"key" (the default, matching the in-app JUMP button) replays only the target event\'s own key. scope:"all" replays every captured AsyncStorage event, so it can rewrite and delete many unrelated keys at once \u2014 treat that as a bulk data change and confirm before using it. Buoy\'s own devtool keys are never touched. Throws for an unknown id, for an id not in the chosen timeline, or when any replayed event still holds an on-device value marker (fetch getEventDetail first). Returns {jumped, scope, replayed, of, key}.',"armsCapture":true},{"action":"mmkv.snapshot","summary":"Dump every registered MMKV instance with all its keys, values, and value types.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns [{id, encrypted, readOnly, entries:[{key, value, valueType}]}] where valueType is string|number|boolean|buffer. One round trip for all instances \u2014 the right MMKV read unless you need a single oversized value. Values over 16KB are replaced with {__buoyValueOnDevice:true}; fetch those via mmkv.get. Buoy devtool keys are stripped. Returns [] when the app never called registerMMKVInstance(); an instance that throws on read comes back with entries: [] rather than failing the call.","requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"mmkv.get","summary":"Read one MMKV key's full value from a named instance.","params":{"type":"object","properties":{"instanceId":{"type":"string","description":"Registered MMKV instance id, as listed by mmkv.snapshot."},"key":{"type":"string","description":"The key to read."}},"required":["instanceId","key"],"additionalProperties":false},"effect":"read","release":"works","description":'The size-guarded single-key channel for values mmkv.snapshot omitted. Returns {found:true, instanceId, key, value, valueType} \u2014 or, without throwing, {found:false, reason:"missing instanceId/key"} or {found:false, reason:"unknown instance"}. valueType is auto-detected as string|number|boolean|buffer. instanceId is the id the app registered, e.g. "mmkv.default" or "user-prefs" \u2014 get the exact ids from mmkv.snapshot.',"requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"mmkv.set","summary":"Write one key in a registered MMKV instance (string, number, or boolean).","params":{"type":"object","properties":{"instanceId":{"type":"string","description":"Registered MMKV instance id (see mmkv.snapshot)."},"key":{"type":"string","description":"The key to write."},"value":{"description":"Typed value \u2014 stored as string, number, or boolean exactly as passed.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["instanceId","key","value"],"additionalProperties":false},"effect":"write","release":"works","description":`Unlike AsyncStorage, MMKV is typed: pass a real number or boolean and it is stored as that type \u2014 do not stringify. Throws 'No MMKV instance registered as "<id>"' for an unknown instance and 'MMKV instance "<id>" is registered read-only' for one the app declared immutable, so a success means the write really landed. Returns nothing. MMKV writes are recorded in the event timeline but are NOT undoable via timeTravel (AsyncStorage only). TRAP: if this key is a live store's saved copy (the grounding marks these as "persists to <key>", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead \u2014 zustand.setState, redux.dispatch, jotai.setAtom.`,"requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"mmkv.remove","summary":"Delete one key from a registered MMKV instance.","params":{"type":"object","properties":{"instanceId":{"type":"string","description":"Registered MMKV instance id (see mmkv.snapshot)."},"key":{"type":"string","description":"The key to delete."}},"required":["instanceId","key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Calls remove() on react-native-mmkv v4 and falls back to delete() on older versions. Throws for an unknown instance id or one registered read-only; otherwise returns nothing, whether or not the key existed. Not recoverable through timeTravel \u2014 that covers AsyncStorage only.","requires":["registerMMKVInstance(...) called by the app (react-native-mmkv)"]},{"action":"secure.keys","summary":"List the registered Expo SecureStore keys (names and flags, no values).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns [{key, description, keychainService, requireAuthentication}]. SecureStore has no key-enumeration API, so only keys the app declared via registerSecureStoreKeys(...) are visible \u2014 an empty [] means nothing was registered, NOT that the keychain is empty. requireAuthentication:true marks a biometric-protected key whose value Buoy never reads.","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.snapshot","summary":"List every registered SecureStore key WITH its value in one round trip.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"The SecureStore counterpart to mmkv.snapshot, and the right read when you want values \u2014 one call instead of secure.keys plus N secure.get. Returns [{key, description, keychainService, requireAuthentication, value}]. value is null when unset, when the read failed, and always for biometric-protected keys (reading those would fire an auth prompt on the user's device, so they are skipped). Values over 16KB come back as {__buoyValueOnDevice:true}.","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.get","summary":"Read one registered SecureStore key's value.","params":{"type":"object","properties":{"key":{"type":"string","description":"A registered SecureStore key (see secure.keys)."}},"required":["key"],"additionalProperties":false},"effect":"read","release":"works","description":"Reads with the exact options (keychainService) the key was registered with, which is required or the read returns null. Resolves to null \u2014 never an error \u2014 in four different cases: the value is unset, the key is not registered, no SecureStore module was registered, or the key is biometric-protected (requireAuthentication, deliberately never read). Check secure.keys before concluding from a null that the app never stored anything. Prefer secure.snapshot for more than one key.","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.set","summary":"Write a registered SecureStore key (string value).","params":{"type":"object","properties":{"key":{"type":"string","description":"A registered, non-biometric SecureStore key."},"value":{"type":"string","description":"The string value to store. JSON-encode objects yourself."}},"required":["key","value"],"additionalProperties":false},"effect":"write","release":"works","description":`Goes through the registry so the value is written with the SAME options it was registered with \u2014 writing with a different keychainService silently creates a second entry the app cannot read. Throws rather than no-ops: 'No SecureStore module is registered', 'SecureStore key "<k>" is not registered', or '...is biometric-protected \u2014 DevTools never writes it'. Values are strings; JSON-encode objects yourself. SecureStore changes are NOT in the event timeline, so there is no timeTravel undo.`,"requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]},{"action":"secure.delete","summary":"Delete a registered SecureStore key's value from the keychain.","params":{"type":"object","properties":{"key":{"type":"string","description":"A registered SecureStore key (see secure.keys)."}},"required":["key"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Permanently deletes the keychain entry using the options the key was registered with. Beware the silent path: if the key is not registered or no SecureStore module was registered it resolves with NO error and NO deletion \u2014 a success here is not proof anything was deleted, so verify with secure.get/secure.snapshot afterwards. Deleting an auth token or session key logs the user out; not recoverable (SecureStore is not in the event timeline).","requires":["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]}],"unavailableWhen":'The app does not have @buoy-gg/storage installed alongside <FloatingDevTools/> \u2014 autoExternalSync only registers the "storage" adapter when that module resolves (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:320). Individual backends degrade instead of erroring: with no registerMMKVInstance() call mmkv.snapshot returns [], and with no registerSecureStoreKeys() call secure.keys/secure.snapshot return [].'},{"toolId":"highlight-updates","title":"Highlight Updates","summary":"Read and drive the live screen with describeScreen, tapElement, and waitFor. RN also exposes React render tracking; native Swift does not. Inspect the connected device\u2019s actions before calling a platform-specific operation. The touch-capture actions support Scenarios recording. Native capture is restricted to development builds and excludes Buoy controls and secure text inputs.","actions":[{"action":"describeScreen","summary":"List every meaningful/interactive element currently on screen, with normalized tap points \u2014 the read half of driving the app. The ONLY way to read what is on screen but in no store: text, values and names held in component state, which no other Buoy tool can see.","params":{"type":"object","properties":{"includeBuoy":{"type":"boolean","description":"Also list Buoy's own overlay (the dial, tool sheets, Ask Buoy's own chat). Default false \u2014 those are the tool, not the app."}},"additionalProperties":false},"effect":"read","release":"empty","description":"Walks the live React fiber tree across ALL renderers and measures each candidate. Returns {screen:{width,height}, count, elements[]} where each element has: nativeTag, name (owning component, not the host View), role, testID, label, text, interactive, control ('toggle'|'slider'|'text', omitted for a plain press), value (current value for toggle/slider/text), longPressable, tap:{x,y} and frame:{x,y,width,height} both normalized to 0-1. Sorted top-to-bottom then left-to-right. Prunes offscreen/inactive react-native-screens and hidden Offscreen subtrees, so it reflects the CURRENT screen only \u2014 re-run after every navigation, positions and tags move. No screenshot and no tracking needed; it does not require the highlight overlay to be enabled. Call this before tapElement to get an exact testID/nativeTag instead of guessing a fuzzy query. Buoy's OWN overlay is left out \u2014 the dial, tool sheets and your own chat sheet are the tool, not the app under test \u2014 and `hiddenBuoy` counts what was omitted; pass includeBuoy:true only when the task is about Buoy itself.","releaseNote":"packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:57 \u2014 getReactDevToolsHook() reads __REACT_DEVTOOLS_GLOBAL_HOOK__, which RN installs only under __DEV__; getAllFiberRoots() then returns [] and collectCandidates() short-circuits at line 539, so the result is a valid-looking {count:0, elements:[]} rather than an error.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"tapElement","summary":'Interact with one on-screen element by invoking its real handler in JS: press, long-press, toggle/slider, or type into a text input. This is how you make the app FETCH MORE \u2014 press its own "next", "load more" or a list row, waitFor, then read: the app fetches through its own code, so the data arrives in the shape its own screens render.',"params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"Exact nativeTag from describeScreen. Most precise; wins over testID/query."},"testID":{"type":"string","description":"Exact testID of the element."},"query":{"type":"string","description":"Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Sign in'. Least precise \u2014 verify with describeScreen first."},"value":{"description":"For a toggle/switch: true/false (omit to flip the current value). For a slider: the numeric value, clamped to minimumValue/maximumValue (omit to jump to the far end).","anyOf":[{"type":"number"},{"type":"boolean"}]},"text":{"type":"string","description":"For a text input: the string to set via onChangeText."},"longPress":{"type":"boolean","description":"Invoke onLongPress instead of onPress. Returns tapped:false if the element has no onLongPress."},"scrollIntoView":{"type":"boolean","description":"Scroll an ancestor ScrollView so the target is visible before acting. Default true \u2014 set false to avoid moving the user's screen."},"includeBuoy":{"type":"boolean","description":"Let a fuzzy `query` match Buoy's own overlay. Default false. An exact nativeTag/testID always may."}},"additionalProperties":false},"effect":"write","release":"empty","description":"WARNING: this fires the app's ACTUAL handler, so it can trigger irreversible flows (delete, purchase, logout, submit). Matching by fuzzy `query` can select the wrong element \u2014 prefer an exact nativeTag or testID from describeScreen, and confirm the target before firing anything consequential. Resolution order: the element's OWN onValueChange (toggle/slider) or onChangeText (text input) wins; otherwise the nearest onPress walking up the fiber chain (up to 25 levels). Returns {tapped, reason?, scrolled?, matched:{nativeTag,name,testID,label,text,via,value}, candidates?} \u2014 `via` is onPress|onLongPress|onValueChange|onChangeText. Trap: passing `text` to something that is not a text input silently runs its onPress instead, so check `matched.via` in the result. Elements driven only by react-native-gesture-handler GestureDetector have no JS handler and return tapped:false with a clear reason. If the target is off-screen it is scrolled into view first (this moves the user's screen). Provide exactly one of nativeTag / testID / query. A fuzzy query never matches Buoy's own overlay unless includeBuoy:true (your chat sheet echoes the words you search for; that echo used to be the best match). READ THE RESULT BEFORE TRUSTING THE TAP: `effect.commits` is how many React commits followed inside the settle window, and 0 means the handler ran but NOTHING re-rendered \u2014 the match was probably a screen still mounted underneath the current one (a stack keeps them), or an inert control \u2014 so treat it as not done and pick another target from describeScreen. `alsoMatched` lists elements that matched equally well; non-empty means the choice was a coin toss, so re-tap by nativeTag.","releaseNote":'packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:793 \u2014 collectCandidates() returns [] with no DevTools hook, so it returns {tapped:false, reason:"No React fiber roots / elements found on screen."}; it reports the failure honestly rather than claiming a tap.',"requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"waitFor","summary":"Block until an element is on screen (or gone), then report how long it took. Use it between navigating and tapping.","description":"Returns {ok, waitedMs, polls, matched?, reason?}. USE THIS AFTER ANY ACTION THAT STARTS A LOAD \u2014 `route-events.navigate` returns the moment the route is pushed, not when the screen has data, so tapping straight after it hits a loading skeleton and fails for a reason that looks like a bad selector. Matching is `tapElement`'s exactly (same fields, same ranking), so a wait can never resolve on an element the tap then cannot find. Presence means ON SCREEN: a candidate matching by name is measured before it counts, so a previous screen still mounted behind this one does not satisfy the wait. `gone:true` waits for absence instead \u2014 and returns immediately when nothing matched in the first place, which is a real answer, not a failure. Like tapElement, a fuzzy query ignores Buoy's own overlay unless includeBuoy:true; an exact nativeTag/testID always counts. Defaults: timeoutMs 5000 (capped at 30000), pollMs 150 (floored at 50, because this polls on the JS thread the app renders on). Provide exactly one of nativeTag / testID / query.","params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"Exact nativeTag from describeScreen. Most precise."},"testID":{"type":"string","description":"Exact testID of the element."},"query":{"type":"string","description":"Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Fire Grill'."},"gone":{"type":"boolean","description":"Wait for the element to DISAPPEAR instead of appear \u2014 a spinner, a skeleton, a modal. Default false."},"timeoutMs":{"type":"number","description":"Give up after this long. Default 5000, capped at 30000."},"pollMs":{"type":"number","description":"Gap between scans. Default 150, floored at 50."},"includeBuoy":{"type":"boolean","description":"Let a fuzzy `query` match Buoy's own overlay. Default false. An exact nativeTag/testID always may."}},"additionalProperties":false},"effect":"read","release":"empty","releaseNote":"Walks the same fiber tree describeScreen does, and React Native installs __REACT_DEVTOOLS_GLOBAL_HOOK__ only under `if (__DEV__)`, so outside a dev build nothing is ever found and every wait runs to its timeout.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"beginMeasurement","summary":"Open an invisible render-capture window (CommitProfiler) \u2014 pair with endMeasurement around the interaction you want to measure.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":"Starts a commit-level capture with detail:true. Backed by CommitProfiler, NOT the highlight overlay's RenderTracker, and that difference is the point: it walks COMPOSITE fibers, so it answers about the component you named (which of ITS props changed, by value or identity only, and which parent dragged it along) instead of about the native View inside it. Touches none of the user's state \u2014 nothing is enabled, nothing is cleared, nothing is drawn on screen; the user sees no change. Discards any previous unclaimed capture. Returns {ok:true} or {ok:false, reason} \u2014 always check `ok` before driving the interaction. Usage: beginMeasurement -> wait ~400ms to settle -> drive the UI with tapElement -> endMeasurement.","releaseNote":'packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts:458 \u2014 isSupported() returns false when !__DEV__, so this returns {ok:false, reason:"render capture needs a dev build with the React DevTools hook available"}. Honest self-report, unlike the toggle actions.',"requires":["@buoy-gg/highlight-updates at adapter version 3+ (older apps have no beginMeasurement)","dev build (__DEV__ === true)"]},{"action":"endMeasurement","summary":"Close the capture window and return the per-component render summary (cost in ms, cause, wasted renders).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":"Returns {summary: RenderCaptureSummary | null}. The summary carries totalCommits, totalRenders, totalRenderMs, wastedRenders (parent cascades + identity-only prop churn \u2014 the removable share), aggregate `causes`, and topComponents[] (capped) with {name, renders, totalMs, maxMs, avgMs, causes, changedProps, parentName}. Cause labels: mount (first render), hooks (own state changed), props (props changed by value), propsUnstable (props changed by IDENTITY only \u2014 a fresh function/object from the parent, fix upstream), parent (pure cascade, nothing of its own changed). Read topComponents sorted by totalMs, not by render count: a component rendering 40x for 0.4ms is noise; one rendering 4x for 38ms is the answer. summary is null when beginMeasurement was never called or the app reloaded mid-window.","releaseNote":"packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts \u2014 stopCapture() returns null when the window was never started, and beginMeasurement can never start one in release (isSupported() false at line 458), so this always yields {summary:null}.","requires":["@buoy-gg/highlight-updates at adapter version 3+","a prior successful beginMeasurement on the same device"]},{"action":"locateComponent","summary":"Resolve a component to a fresh on-screen rectangle (in points) plus pixel scale \u2014 used to crop a simulator screenshot to exactly that component.","params":{"type":"object","properties":{"query":{"type":"string","description":"Fuzzy match against testID / nativeID / accessibilityLabel / component name / view type, e.g. 'submit-button'. Exact field match wins, then prefix, then substring."},"nativeTag":{"type":"number","description":"Exact native tag \u2014 skips fuzzy matching and wins over query."},"scrollIntoView":{"type":"boolean","description":"Scroll an ancestor ScrollView to bring the component into view before measuring. Default true \u2014 this visibly moves the user's screen."},"margin":{"type":"number","description":"Gap in points to leave above the component when scrolling it in. Default 12."}},"additionalProperties":false},"effect":"write","release":"empty","description":"BEWARE the side effect: by default this SCROLLS the user's app so the target sits near the top of its ScrollView before measuring. Pass scrollIntoView:false for a pure read. Returns {matched, reason?, live?, scrolled?, inView?, rect:{x,y,width,height}, scale (PixelRatio, multiply points by it for pixels), screen:{width,height}, nativeTag, componentName, testID, candidates[]}. Two resolution paths: if the render tracker has data (highlights or silent tracking were on) it ranks tracked components by testID/nativeID/accessibilityLabel/componentName/viewType/nativeTag and re-measures the live node; if the tracker is empty it falls back to the same fiber walk as describeScreen, so it still works cold. `reason` is one of no-renders | no-match | no-measurement. When matched is false, read `candidates` and retry with an exact nativeTag.","releaseNote":'HighlightUpdatesController.ts:1744 \u2014 initialize() bails when !__DEV__ so the tracker is empty, and the describeScreen fallback finds no fiber roots; result is {matched:false, reason:"no-match", candidates:[]}.',"requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"getRenderDetail","summary":"Fetch one tracked component's full renderHistory and lastRenderCause (including hook-change values), which the synced snapshot omits.","params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"The component's native tag, from the synced renders[] list or describeScreen. Omitting it returns {found:false, reason:'missing nativeTag'}."}},"required":["nativeTag"],"additionalProperties":false},"effect":"read","release":"empty","description":"Snapshots strip the per-component history buffer (up to 20 events) and hookChanges to keep the ~5x/sec sync small; this is the on-demand fetch for a single row the user clicked. Returns {found:true, nativeTag, render} or {found:false, reason} where reason is 'missing nativeTag' or 'unknown nativeTag'. Keyed by nativeTag \u2014 get one from the snapshot's renders[] or from describeScreen. Only returns data for components the RenderTracker has actually seen, which means highlights (setEnabled/toggle) or setSilentTracking must have been on while the component rendered; a cold call on a fresh app returns found:false even though the component exists on screen.","releaseNote":'HighlightUpdatesController.ts:1819/1854 \u2014 enable()/disable() no-op when !__DEV__, so RenderTracker never records and every lookup returns {found:false, reason:"unknown nativeTag"}.',"requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)","render tracking was on while the component rendered"]},{"action":"setEnabled","summary":"Turn the user-visible render highlighting on or off explicitly (colored boxes flash on the device around every re-rendering component).","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true draws the highlight boxes on the device and starts tracking; false stops both."}},"required":["enabled"],"additionalProperties":false},"effect":"write","release":"noop","description":"Prefer this over `toggle` \u2014 it is idempotent, so the agent never has to know the current state. Enabling starts RenderTracker (populating the synced renders[] list and enabling getRenderDetail) AND draws colored boxes on the DEVICE screen: cyan for few renders through yellow for many, with a count badge. It also clears any leftover highlight suppression from a silent-tracking session. This is visible to whoever is holding the phone \u2014 say so before enabling. Returns undefined on success. Always send a params object: the handler reads params.enabled without a null guard, so calling with no params throws (surfaced as ok:false with a TypeError message).","releaseNote":"HighlightUpdatesController.ts:1819 (enable) and :1854 (disable) both `if (!__DEV__) return;` before doing anything. The wire still reports ok:true, so this action LIES in a release build \u2014 never tell a QA user highlighting is on without confirming a dev build.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"toggle","summary":"Flip render highlighting on/off \u2014 same visible effect as setEnabled but state-dependent.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"noop","description":"Calls enable() when off, disable() when on. Prefer setEnabled({enabled}) unless you genuinely want a flip, because this action's outcome depends on state you may not have read. Read the synced snapshot's `enabled` flag first if the distinction matters. Initializes the controller on first use. Draws colored boxes on the DEVICE screen \u2014 visible to the person holding the phone. Takes no params. Returns undefined.","releaseNote":"HighlightUpdatesController.ts:1954 \u2014 `if (!__DEV__) return;` at the top of toggle(). Reports ok:true and does nothing.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"setSilentTracking","summary":"Track and measure renders WITHOUT drawing any highlight boxes \u2014 invisible tracking for screenshot/locate flows.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true = track + measure with the overlay hidden; false = stop tracking and restore normal state. Omitted defaults to false (disable)."}},"additionalProperties":false},"effect":"write","release":"noop","description":"Turns the render tracker on (so renders[] populates, measurements sync, and getRenderDetail/locateComponent have data) while keeping the visual overlay hidden, so nothing appears on the user's screen. This is the right way to arm tracking when you only need data \u2014 use setEnabled only when the user actually wants to SEE the boxes. Idempotent and safe to call before every locateComponent. Disabling restores normal state (also disables tracking). Note: enabling any visual highlighting afterwards clears the suppression, so the boxes come back. Returns undefined. Always send a params object \u2014 the handler reads params.enabled without a null guard and throws if params is omitted; `{}` is treated as enabled:false.","releaseNote":"Reaches HighlightUpdatesController.setSilentTracking -> enable(), which returns early at HighlightUpdatesController.ts:1819 when !__DEV__. Reports ok:true; no tracking is armed and every later getRenderDetail/locateComponent comes back empty.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)"]},{"action":"toggleFreeze","summary":"Freeze the highlight boxes on screen so they stop fading, or unfreeze to resume normal fade-out.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"noop","description":"Freeze mode keeps existing highlight boxes on the device screen instead of letting them fade, so a human can read which components lit up during a burst. New renders are still captured. Unfreezing clears whatever boxes are currently drawn. State-dependent: read the snapshot's `frozen` flag before calling if you need a specific end state. Freeze is a view state on a LIVE overlay \u2014 disabling highlighting entirely also clears it. Takes no params. Returns undefined.","releaseNote":"toggleFreeze() itself has no gate, but both branches do: freeze() at HighlightUpdatesController.ts:2068 and unfreeze() at :2082 return early when !__DEV__. Reports ok:true and nothing changes.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)","highlighting already enabled (there are no boxes to freeze otherwise)"]},{"action":"setSpotlight","summary":"Draw a distinct spotlight highlight around one component on the device screen (or clear it with null).","params":{"type":"object","properties":{"nativeTag":{"description":"Native tag of the component to spotlight, or null to clear the current spotlight.","anyOf":[{"type":"number"},{"type":"null"}]}},"required":["nativeTag"],"additionalProperties":false},"effect":"write","release":"noop","description":"Used when someone is browsing a component's detail from the desktop dashboard and wants to see WHICH component that row is, physically on the phone. Draws on the DEVICE screen \u2014 visible to whoever is holding it. Pass nativeTag:null to clear the spotlight; always clear it when you're done, or a stale box stays on the user's screen. Requires the highlight overlay to be mounted (i.e. highlighting enabled) for anything to appear. Returns undefined. Always send a params object \u2014 the handler reads params.nativeTag without a null guard and throws if params is omitted entirely.","releaseNote":"setSpotlight has no __DEV__ gate of its own, but the overlay that renders the spotlight only exists once the tool is enabled, and enable() is gated at HighlightUpdatesController.ts:1819. It sets a variable, draws nothing, and reports ok:true.","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>","dev build (__DEV__ === true)","highlighting enabled so the overlay is mounted"]},{"action":"clearRenderCounts","summary":"Wipe ALL tracked render data and per-component counters \u2014 irreversible, and it destroys data someone may be collecting.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"noop","description":"Clears the nativeTag->count map and empties RenderTracker's entire renders store. There is no undo and no snapshot: any render history a human was watching accumulate, or that a colleague armed tracking to collect, is gone. Only call this when the user explicitly asks to reset counts, typically to get a clean baseline before measuring an interaction. Prefer beginMeasurement/endMeasurement for measuring \u2014 that opens its own window and never touches the user's overlay data. Takes no params. Returns undefined.","releaseNote":"packages/highlight-updates/src/highlight-updates/utils/HighlightUpdatesController.ts:1744 (initialize), :1819 (enable), :1905 (enableBackgroundTracking) all return early when __DEV__===false, and the only writers of nodeRenderCounts/RenderTracker are the DevTools-hook interceptors (ProfilerInterceptor.ts:107, CommitProfiler.ts:74). In a release build those stores are permanently empty, so clearRen","requires":["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>"]},{"action":"startTouchCapture","summary":"Start recording supported host interactions.","description":"Returns capture status and a reason if unavailable. RN uses its touch stream; Swift uses UIKit events and native-driver interactions in development builds. Read the status before driving the app. This does not save a scenario.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","releaseNote":'touchCapture.ts startTouchCapture() subscribes to RawEventEmitter and reports status "capturing", but each touch is named through resolveTouchTarget() in screenElements.ts:1484, which needs fiber roots from __REACT_DEVTOOLS_GLOBAL_HOOK__ (installed only under __DEV__). In a release build every target resolves to null, so nothing is recorded and readTouchCapture stays empty.'},{"action":"stopTouchCapture","summary":"Stop capturing new interactions and retain recorded data.","description":"Read retained records with readTouchCapture. Stopping does not save a scenario.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"},{"action":"clearTouchCapture","summary":"Clear retained interactions and reset the sequence number.","description":"Existing recording data is removed. Start subsequent reads with sinceSeq:0.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works"},{"action":"readTouchCapture","summary":"Read captured interactions after a sequence number.","description":"Returns {status, reason, records, seq}. Pass the last returned seq as sinceSeq on the next read. Coverage depends on platform; records do not prove every gesture was captured or replayable.","params":{"type":"object","properties":{"sinceSeq":{"type":"number","description":"Return records with a sequence number greater than this value. Default 0."}},"additionalProperties":false},"effect":"read","release":"works"}],"unavailableWhen":"RN requires @buoy-gg/highlight-updates registered with FloatingDevTools; React inspection and render tracking depend on a development build. Swift exposes native interaction and touch-capture actions under this ID, without React render tracking. Inspect the connected device\u2019s available actions. Swift touch capture refuses production builds."},{"toolId":"scenarios","title":"Scenarios","summary":'Named, parameterized app states ("out of stock at store 220", "expired token") stored as a list of steps that each call one other Buoy tool action; running one bends the app into that state deterministically and `deactivate` reverses what is reversible. Reach for it to put the app in a known state BEFORE driving a flow, and to check whether what is on screen is real or simulated \u2014 if `active` is non-null, the data the user is looking at is partly fake. Authoring flow: save (always lands as an inert DRAFT) \u2192 a human accepts it on the device (or acceptDraft) \u2192 preview \u2192 run \u2192 deactivate.',"actions":[{"action":"listScenarios","summary":"List every scenario on the device \u2014 code, device-saved, and inert drafts \u2014 plus which one is currently active.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":'Returns {code[], device[], drafts[], active, runState, hasLoaded}. Each entry has id, name, description, version, source, author, tags, vars, stepCount, expectedOutcome, authoredAgainst, usage.runs, plus `steps` (raw {tool,action,params} objects; any step whose params exceed 8KB is sent with params dropped and paramsOmitted:true) and `stepLines` [{label, durability}] \u2014 the same plain-English sentences the device UI shows. durability is one of durable | session | transient | one-shot. `active` non-null means the app is showing SIMULATED state: say so before reporting anything observed on the device. `drafts` are INERT \u2014 they cannot run until accepted. Identical payload to the tool\'s sync snapshot. HYDRATION TRAP: the store loads @react_buoy_scenarios_library lazily on first subscribe and this handler does not await it, so if hasLoaded is false, empty `device`/`drafts` means "not loaded yet", not "none saved" \u2014 read again once the Scenarios panel or a dashboard has subscribed.',"requires":["Scenarios tool installed in the app"]},{"action":"getScenario","summary":"Full record for one scenario id, including untruncated step params and undoSteps.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id, from listScenarios. Blank/whitespace counts as missing and throws."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Returns {scenario}. Looks in code scenarios first, then device-saved, then drafts (a code scenario wins an id clash). Use this when listScenarios truncated a step (paramsOmitted:true) or you need undoSteps/vars in full. THROWS `No scenario "<id>"` if the id is unknown \u2014 and can throw that on a cold device purely because the persisted library has not hydrated yet (see listScenarios).',"requires":["Scenarios tool installed in the app"]},{"action":"save","summary":"Write a new scenario onto the device \u2014 it ALWAYS lands in the draft inbox and cannot run until accepted.","params":{"type":"object","properties":{"scenario":{"type":"object","description":"The scenario to save. Always stored as a draft.","properties":{"id":{"type":"string","description":"Stable kebab-case id, e.g. 'out-of-stock-item'."},"name":{"type":"string","description":"Human name; may contain {{variable}} placeholders."},"description":{"type":"string"},"expectedOutcome":{"type":"string","description":"What SHOULD happen once applied \u2014 how a non-developer tells a bug from expected behavior without asking an engineer. Write it."},"version":{"type":"number","description":"Defaults to 1; bumped automatically when replacing an existing draft."},"author":{"type":"string","description":"Defaults to 'remote'."},"authoredAgainst":{"type":"string","description":"App version this was authored against, shown for staleness."},"tags":{"type":"array","items":{"type":"string"}},"vars":{"type":"array","description":"Declare a variable for every value a tester might change (store id, user, status code) instead of freezing it into steps. Referenced in step params as {{key}}.","items":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["string","number","boolean","enum"]},"options":{"type":"array","description":"enum only.","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"default":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["key","type"]}},"steps":{"type":"array","description":"At least one, executed in order.","items":{"type":"object","properties":{"id":{"type":"string"},"tool":{"type":"string","description":"Adapter tool id: network | storage | impersonate | route-events | query | time-machine, or 'scenario' for the engine-local 'wait' step. Never 'scenarios' \u2014 recursion is refused at pre-flight."},"action":{"type":"string","description":"Action on that tool, e.g. upsertOverrideRule, deleteOverrideRule, setOverridesEnabled, async.setItem, async.removeItem, async.multiSet, mmkv.set, startImpersonation, stopImpersonation, navigate, setQueryData, invalidate, restore. A reloadApp/reload step is only allowed as the LAST step."},"params":{"type":"object","description":"JSON params for that action; string leaves may contain {{variable}}. Max 64KB serialized per step.","additionalProperties":true},"label":{"type":"string","description":"Plain-English override for the humanized sentence."},"timeoutMs":{"type":"number","description":"Per-step budget; default 10000."}},"required":["tool","action"]}},"undoSteps":{"type":"array","description":"How to reverse effects with no automatic inverse (any storage write/remove). They run on deactivate and clear the unrestored-keys warning.","items":{"type":"object","properties":{"id":{"type":"string"},"tool":{"type":"string"},"action":{"type":"string"},"params":{"type":"object","additionalProperties":true},"label":{"type":"string"},"timeoutMs":{"type":"number"}},"required":["tool","action"]}},"folder":{"type":"string","description":'The flow this belongs to \u2014 "Checkout", "Login". Reuse a name from `folders` in listScenarios; a scenario nobody can find is a scenario nobody runs.'}},"required":["id","name","steps"]},"accept":{"type":"boolean","description":"IGNORED \u2014 the handler never reads it. The save always lands as a draft."}},"required":["scenario"],"additionalProperties":false},"effect":"write","release":"works","description":'Returns {ok:true, savedAsDraft:true, id, message} on success, or {ok:false, error} / {ok:false, errors:[...]} on rejection \u2014 it does not throw for validation. Rejects unless: `id` is a string, `name` is a string, `steps` is a non-empty array, every step has both `tool` and `action`, `vars` (if present) is an array, each step\'s params serialize under 64KB, and the whole scenario is under 256KB. Source is forced to "draft", author defaults to "remote", unsavedToRepo is set true; saving over an existing DRAFT id replaces it and bumps its version. Tell the user the draft is inert and someone must open Scenarios on the device and tap Accept to library (or call acceptDraft). The `accept` field is read into the params type but never used by the handler \u2014 passing it does nothing. Prefer a network `upsertOverrideRule` step over a `query.setQueryData` poke: the override survives refetch and restart, the poke dies on the next fetch. Supply `undoSteps` for anything that writes or removes storage, or deactivation will honestly report the key as unrestored.',"requires":["Scenarios tool installed in the app"]},{"action":"acceptDraft","summary":"Promote a reviewed draft into the runnable device library \u2014 this is the human-consent gate, so only do it when the user says to.","params":{"type":"object","properties":{"id":{"type":"string","description":"Draft id, from listScenarios `drafts`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Moves the draft out of the inbox with source:"device" and unsavedToRepo:true, making it runnable. Returns {ok:true, id}. THROWS `No draft "<id>"`. Drafts exist precisely so a scenario pushed from chat never becomes runnable on someone\'s phone without them seeing what it will do \u2014 accepting on their behalf skips that review. Ask first, or tell them to tap "Accept to library" on the device instead.',"requires":["Scenarios tool installed in the app"]},{"action":"discardDraft","summary":"Permanently delete a draft from the review inbox.","params":{"type":"object","properties":{"id":{"type":"string","description":"Draft id, from listScenarios `drafts`."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Removes the draft and rewrites the persisted library. Returns {ok:true, id} even when no draft with that id existed \u2014 a success here is not proof anything was deleted. There is no undo: the steps are gone unless you exported the JSON first.","requires":["Scenarios tool installed in the app"]},{"action":"preview","summary":"Dry-run a scenario: what it WOULD change, with variables resolved, without touching the app.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id, from listScenarios."},"params":{"type":"object","description":"Variable values, e.g. {storeId:'220', outOfStock:true}. Only string/number/boolean values are kept \u2014 objects and arrays are silently dropped. Declared defaults fill in the rest.","additionalProperties":true}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Returns {willDo:[{label,durability}], values, ok, errors, warnings, conflict, expectedOutcome}. `values` is declared defaults merged with the values you supplied (supplied wins), and the labels are rendered THROUGH those values, so no {{placeholder}} survives. `conflict` is non-null when the currently-active scenario touches an overlapping URL pattern / storage key / the impersonation session \u2014 running anyway SWAPS (the active one is deactivated first). ok:false lists every blocking reason at once: draft not yet accepted, a step whose tool is not installed in this app, an unknown action, an undeclared or unset {{variable}}, a reload that is not the last step. Always run this before running an unfamiliar scenario. Changes nothing.","requires":["Scenarios tool installed in the app"]},{"action":"run","summary":"Apply a scenario for real \u2014 installs network overrides, writes storage, starts impersonation, navigates \u2014 and resolve only once every step has landed.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id, from listScenarios. Must not be a draft."},"params":{"type":"object","description":"Variable values, e.g. {storeId:'220'}. Only string/number/boolean values are kept \u2014 objects and arrays are silently dropped. Declared defaults fill in the rest.","additionalProperties":true}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"throws","description":"Atomic pre-flight runs first: on failure it returns {ok:false, preflightErrors:[...], conflict} and NOTHING is applied. Otherwise returns a RunReport {ok, scenarioId, name, steps:[{label, ok, error, skipped, ms}], effects:[{kind,label,...}]}. Steps run in order and STOP at the first failure \u2014 later steps are marked skipped, and the steps before it already applied, leaving the device in a partial state (call deactivate). Exclusive activation: running a different scenario while one is active deactivates the active one first. Budgets are 10s per step and 60s overall. Effects vary in reversibility \u2014 a network override rule is removed on deactivate, but a storage write has NO automatic inverse and will be reported as unrestored. Drafts cannot run. After this, the device shows a SIMULATED banner: anything the user observes is partly fake until deactivate.","releaseNote":'packages/scenarios/src/store/scenariosStore.ts:98-104 (isRunnableInThisBuild) \u2014 in a release bundle checkBeforeRun injects "Scenarios cannot run in a production build.", so the adapter returns {ok:false, preflightErrors:[...]} and applies nothing; scenariosStore.run() at :346-348 throws the same message. Verified by packages/scenarios/src/__tests__/runGate.release.test.ts. Report the refusal \u2014 never claim the scenario was applied.',"requires":["Scenarios tool installed in the app","every tool a step targets must be installed on the device (network, storage, impersonate, route-events, query, time-machine)","a development build \u2014 release builds refuse"]},{"action":"deactivate","summary":"Reverse the active scenario and report honestly what could NOT be restored.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":"Returns {ok:true, report:{removedRules, stoppedImpersonation, clearedTransients, unrestoredStorageKeys[], undoStepsRan, errors[]}}. Deletes the override rules the run created, stops impersonation, and runs the author's undoSteps if any (which clears unrestoredStorageKeys). `unrestoredStorageKeys` names keys the app still holds scenario values for \u2014 surface those verbatim to the user, they are real leftover state. Safe when nothing is active: returns an all-zero report. Also the right call after a failed/partial run.","releaseNote":"Not itself __DEV__-gated, but run is (packages/scenarios/src/store/scenariosStore.ts:98-104), so a release build can never have an active scenario \u2014 expect an all-zero report rather than a real reversal.","requires":["Scenarios tool installed in the app"]},{"action":"delete","summary":"Permanently remove a device-saved scenario from the library.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id from listScenarios `device` \u2014 code scenarios and drafts are unaffected."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Deactivates it first if it happens to be the active scenario, then drops it from the device list and rewrites persistent storage. Returns {ok:true, id}. Only touches DEVICE-saved scenarios: an id that is a code scenario (registered via defineScenario in app source) or a draft still returns ok:true while deleting nothing \u2014 code scenarios come back from the app bundle, drafts need discardDraft. Not recoverable; call export first if the definition matters.","requires":["Scenarios tool installed in the app"]},{"action":"setFolder","summary":'File a saved scenario under a flow folder ("Checkout", "Login"), or clear its folder.',"params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id from listScenarios `device` or `drafts`."},"folder":{"type":"string","description":"Folder name. Omit or pass an empty string to un-file it (it then shows under Ungrouped). Trimmed, inner whitespace collapsed, capped at 32 characters."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":'Returns {ok:true, id, folder} where folder is the name it actually landed in, or null. A folder is just a string on the scenario \u2014 there is no folder record, so it exists exactly as long as something is filed in it and un-filing the last member makes it disappear. A name matching an existing folder case-insensitively snaps to that folder\'s spelling, so "checkout" joins "Checkout" instead of splitting it; read `folders` from listScenarios and reuse a name rather than inventing a near-duplicate. THROWS for a `code` scenario: those are filed by the `folder` field in defineScenario() in app source, so the QA menu looks the same on every device. Does not bump `version` \u2014 filing is organization, not an edit to what the scenario does.',"requires":["Scenarios tool installed in the app"]},{"action":"listFolders","summary":"Every folder currently in use, alphabetical.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns {folders:[...]} \u2014 the same derived, case-insensitively deduped list the device's folder bar renders, across code, device AND draft scenarios. listScenarios already includes this as `folders`; call this only when the folder names are all you need.","requires":["Scenarios tool installed in the app"]},{"action":"getActive","summary":"Cheap check of whether the app is currently showing simulated state.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":'Returns {active} \u2014 null, or {scenarioId, name, version, source, runAt, varValues, effects[{kind,label,...}], expiresAt}. Call this before trusting any data read off the device: if active is non-null, prices/inventory/user/session may be bent by the listed effects, and the answer to "is this a bug?" is probably "a scenario is running". Same hydration caveat as listScenarios \u2014 a cold read before the store has loaded @react_buoy_scenarios_active reports null.',"requires":["Scenarios tool installed in the app"]},{"action":"export","summary":"Get one scenario as pretty-printed JSON, to paste into the repo.","params":{"type":"object","properties":{"id":{"type":"string","description":"Scenario id from listScenarios (code, device, or draft)."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Returns {json} \u2014 JSON.stringify of the full Scenario (steps, undoSteps, vars, metadata) with 2-space indent. Use it to move a device-authored scenario into app source as a defineScenario(...) entry, or to back one up before delete/discardDraft. THROWS `No scenario "<id>"` for an unknown id, including when the library has not hydrated yet.',"requires":["Scenarios tool installed in the app"]}],"unavailableWhen":'The app does not install the Scenarios tool (no `createScenariosTool` / scenarios preset passed to `<FloatingDevTools />`), so "scenarios" is absent from the device\'s capability list. Driven through MCP or the desktop dashboard, the device must also be on a Pro license (`requireProDevice`).'},{"toolId":"perf-monitor","title":"Bench","summary":'Measures runtime performance on the device: live JS/UI FPS, CPU and memory sampled every 250ms, plus recorded "benchmark runs" saved to disk and an automation mode that navigates a screen with different query params and ranks the variants. Reach for it to answer "is this screen slow / which variant is faster / did my fix land", not to inspect data \u2014 it reads no app state. Two traps: live metrics are all zeros until `setEnabled {enabled:true}` arms sampling, and `startAutomation` swallows every validation error, so a bad config returns ok and simply never runs.',"actions":[{"action":"setEnabled","summary":"Arm/disarm live perf sampling (JS FPS, UI FPS, CPU, memory) on the device.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true starts silent sampling for the remote viewer; false stops it (unless the on-device HUD or a recording is holding it open)."}},"required":["enabled"],"additionalProperties":false},"effect":"write","release":"works","description":"Starts SILENT remote sampling: the device samples every 250ms and streams live.snapshot (current values + a 120-sample / 30s history ring) but its own on-device HUD stays hidden. This is the arming step for every live read \u2014 before it, live.snapshot is zeros with an empty history, and after setEnabled{false} the values freeze at their last tick. Always call with enabled:true before reading live metrics, and turn it back off when done (sampling costs a 250ms JS timer). Works with or without react-native-performance-toolkit; without the native module it falls back to a pure-JS sampler and cpuUsage reads 0."},{"action":"startRecording","summary":"Start a manual benchmark recording (auto-named, current route captured).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Begins accumulating perf samples into an in-memory run. The device auto-names it and tags the current route (when @buoy-gg/route-events is installed); the desktop/agent cannot pass a name here \u2014 naming happens at savePending. Self-arms sampling, so setEnabled is not required first. No-op if a recording is already active. Per-component render capture rides along only when @buoy-gg/highlight-updates is installed AND the build is a dev build \u2014 in a release build CommitProfiler.isSupported() returns false (packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts:458), so the saved report has FPS/CPU/memory but renders:null plus a diagnostic explaining why. Pair with stopRecording + savePending.","requires":["@buoy-gg/highlight-updates for render-commit data (dev builds only)"]},{"action":"stopRecording","summary":"Stop the active recording and hold it unsaved, awaiting a name.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Stops sampling for the run and parks the report in memory instead of persisting it. Returns { defaultName, sampleCount } so you can prompt for a name, or null when there was nothing to keep (no active recording, or zero samples because it was stopped almost immediately). The held run also shows up as live.pendingSave on the next snapshot. Nothing is on disk until savePending \u2014 a stopRecording followed by neither savePending nor discardPending leaves the run dangling and it is lost on reload."},{"action":"savePending","summary":"Persist the run held by stopRecording under a chosen name.","params":{"type":"object","properties":{"name":{"type":"string","description":"Name for the saved run. Empty or whitespace-only falls back to the device's auto-generated name."}},"additionalProperties":false},"effect":"write","release":"works","description":"Writes the held report to persistent storage (@react_buoy/perf-monitor/report/<id>) and appends it to the index at @react_buoy/perf-monitor/index, then clears live.pendingSave. An empty/whitespace name falls back to the auto-generated one. Silently does nothing if no run is held (stopRecording was never called, or it returned null). The saved id is what loadReport / deleteReport / the MCP compare_reports tool take."},{"action":"discardPending","summary":"Throw away the just-stopped recording without saving it.","params":{"type":"object","properties":{"label":{"type":"string"}},"additionalProperties":false},"effect":"destructive","release":"works","description":"Drops the report held by stopRecording and clears live.pendingSave. The samples are gone \u2014 they were never written to disk and cannot be recovered. Use when the user cancels the name prompt or the run was junk."},{"action":"mark","summary":"Drop a labelled timeline marker into the in-flight recording.","params":{"type":"object","properties":{"label":{"type":"string","description":'Human label for this moment, e.g. "tapped checkout". Optional; an unlabelled marker is just a timestamp.'}},"additionalProperties":false},"effect":"write","release":"works","description":'Appends { timestamp, label } to the active run so the timeline view can line a spike up with an action ("tapped submit", "list scrolled"). SILENT NO-OP when no recording is active \u2014 BenchmarkRecorder.mark returns immediately if !isRecording, so this returns ok even when nothing was recorded. Check live.isRecording first before telling a user a marker landed.'},{"action":"startAutomation","summary":"Run a multi-case benchmark batch: navigate, record, and save a run per variant.","params":{"type":"object","properties":{"config":{"type":"object","description":"Full batch config. Not merged with the device's saved settings \u2014 whatever you omit takes the runner's own fallback, so read getAutomationConfig first if you want the user's tuned profile.","properties":{"targetRoute":{"type":"string","description":'Pathname every case navigates to, e.g. "/perf-test". Required unless every case sets its own route. The screen must render its variant from the query params.'},"bounceRoute":{"type":"string","description":'Pathname visited between runs to force the target screen to remount. MUST differ from every case route or the whole batch is rejected silently. Typically "/".'},"cases":{"type":"array","description":"The variants to compare, in order; case #0 is the baseline column. At least one required.","items":{"type":"object","properties":{"id":{"type":"string","description":"Optional editor-stable id; the runner derives its own case id from batchId+index if omitted."},"name":{"type":"string","description":"Display name; becomes the saved run's name."},"params":{"type":"object","description":'Query params applied via router.replace, e.g. { "renderer": "v2", "count": "100" }. String values only.',"additionalProperties":true},"route":{"type":"string","description":"Per-case route override; defaults to targetRoute."}},"required":["name"]}},"perCaseDurationMs":{"type":"number","description":"Recording length per run. Device default 5000 (clamped 500-120000 when persisted)."},"settleMs":{"type":"number","description":"Idle gap after navigation lands before recording starts. Default 600."},"navTimeoutMs":{"type":"number","description":"Max wait for the route-change event after replace(). Default 5000."},"runsPerCase":{"type":"number","description":"Runs per case; the median becomes the canonical result. Default 3, range 1-10."},"coolDownMs":{"type":"number","description":"Idle between runs and cases so thermals recover. Device default 8000 \u2014 raising this is the fix when later cases score worse than earlier ones."},"discardWarmupRuns":{"type":"number","description":"Drop the first N runs of each case before taking the median. Default 0."},"discardWarmupCase":{"type":"boolean","description":"Insert one throwaway case at the front and drop it, so the batch's cold start does not poison whichever case runs first. Acts only on an explicit true."},"shuffleCases":{"type":"boolean","description":"Interleave and shuffle case order (deterministic per batchId) so thermal drift cannot systematically favour early cases."},"reloadBetweenCases":{"type":"boolean","description":"Fully reload the JS bundle between cases. Kills all in-memory app state and needs <AutomationResumer/> mounted; adds ~1-3s per case."},"reloadStrategy":{"type":"string","enum":["auto","dev-settings","expo-updates"],"description":"How to reload. auto = DevSettings.reload() in dev, Updates.reloadAsync() otherwise."},"postReloadSettleMs":{"type":"number","description":"Extra settle for the first case after a reload. Defaults to settleMs * 2."},"captureRenders":{"type":"boolean","description":"Capture per-component render counts/durations per run. Needs @buoy-gg/highlight-updates and a dev build; produces nothing in release."},"captureRenderDetail":{"type":"boolean","description":"Also capture changed prop keys and parent names. Heavier; default false."}},"required":["targetRoute","bounceRoute","cases"]}},"required":["config"],"additionalProperties":false},"effect":"destructive","release":"works","description":'Fire-and-forget. The DEVICE owns the loop: for each case it bounces to bounceRoute, navigates to targetRoute with that case\'s query params, settles, records for perCaseDurationMs, saves the run tagged with a shared batchId, cools down, and repeats runsPerCase times. Returns immediately with no result \u2014 poll live.automation.phase (idle/navigating/recording/reloading/done/cancelled), live.automationCompleted, or the growing index to follow it; a batch takes minutes. THE BIG TRAP: every failure is swallowed by the adapter (`void AutomationRunner.start(config).catch(() => {})`), so it returns ok and nothing happens when config is missing, cases is empty, expo-router is absent, targetRoute is empty, or any case route equals bounceRoute (validateConfig throws on that \u2014 no remount would happen). If live.automation.phase never leaves "idle", the config was rejected, not slow. reloadBetweenCases tears down the JS realm between cases and needs <AutomationResumer/> mounted at the router root; in a release build without expo-updates the reload throws and the batch quietly finishes in-process instead. captureRenders yields no render data in a release build (React DevTools hook is dev-only).',"requires":["expo-router (navigation) \u2014 without it this is a silent no-op","@buoy-gg/route-events for reliable navigation waits (otherwise it falls back to a fixed sleep)","<AutomationResumer/> mounted at the router root when reloadBetweenCases is true","a dev build (DevSettings) OR expo-updates installed when reloadBetweenCases is true \u2014 in a release build without expo-updates the reload silently fails and the batch continues in-process with leak risk","@buoy-gg/highlight-updates + a dev build for captureRenders data"]},{"action":"cancelAutomation","summary":"Request cancellation of the in-flight benchmark batch.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":`Asks the runner to stop after the current step and clears the persisted pending-batch state so a reload cannot resume it. No-op when no batch is running. Runs already saved remain on disk under the batch's batchId \u2014 delete them with deleteBatch if you want the batch gone. The status settles to phase "cancelled" and stays sticky in live.automationCompleted until acknowledgeAutomation.`},{"action":"acknowledgeAutomation","summary":"Reset a finished/cancelled batch to idle and clear the sticky completion flag.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":'The handshake that says "I have seen this batch finish": clears live.automationCompleted and puts live.automation back to phase "idle" so the same batch cannot re-trigger a navigation. Call it once after you have read the results, and on tool-open to discard a stale prior completion. Careful: it is global \u2014 clearing it also blinds any other connected dashboard that was waiting on that flag, which is why index-based completion detection (watching new batchIds appear) is more reliable than polling automationCompleted.'},{"action":"refreshIndex","summary":"Force a re-read of the saved-recordings index and push a fresh snapshot.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Re-reads @react_buoy/perf-monitor/index from disk and re-emits the snapshot. Returns nothing \u2014 read the result from snapshot.index (newest first: id, name, createdAt, route, durationMs, sampleCount, jsFpsAvg, uiFpsAvg, cpuAvg, memMaxMb, jank counts, plus batchId/batchIndex/caseId/runIndex/isMedianRun and renderCommits/renderWasted/topRenderers for batch runs). Call it on tool-open: if the device booted before storage was ready the cached index can be empty, and without this it stays empty until the next save or delete."},{"action":"getAutomationConfig","summary":"Read the device's saved benchmark profile (durations, runs, cooldown, toggles).","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Loads and returns the persisted AutomationConfig from @react_buoy/perf-monitor/automation \u2014 perCaseDurationMs, settleMs, navTimeoutMs, runsPerCase, coolDownMs, discardWarmupRuns/Case, shuffleCases, reloadBetweenCases, reloadStrategy, captureRenders, plus the user's last cases and targetRoute. Do this before startAutomation so a run inherits the user's tuned pacing instead of invented defaults, and ALWAYS before setAutomationConfig so you can merge rather than clobber. Note the snapshot's own automationConfig field omits `cases`; this action returns them."},{"action":"setAutomationConfig","summary":"Overwrite the device's persisted benchmark profile (FULL REPLACE, not a merge).","params":{"type":"object","properties":{"config":{"type":"object","description":"The COMPLETE config to persist \u2014 merge your changes onto the getAutomationConfig result, since omitted fields are reset to defaults (and omitted `cases` are erased).","properties":{"targetRoute":{"type":"string","description":"Saved target pathname. Omitting it blanks the user's saved route."},"bounceRoute":{"type":"string","description":'Saved bounce pathname. Defaults to "/" when omitted.'},"cases":{"type":"array","description":"The user's saved case list. OMITTING THIS DELETES IT.","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","description":'Blank names are stored as "Untitled".'},"params":{"type":"object","description":"Query params; non-string values are coerced to strings.","additionalProperties":true},"route":{"type":"string","description":'Per-case override; ignored unless it starts with "/".'}},"required":["name"]}},"perCaseDurationMs":{"type":"number","description":"Recording length per run (clamped 500-120000, default 5000)."},"settleMs":{"type":"number","description":"Idle after navigation before recording (0-30000, default 600)."},"navTimeoutMs":{"type":"number","description":"Navigation wait cap (500-60000, default 5000)."},"runsPerCase":{"type":"number","description":"Runs per case (1-10, default 3)."},"coolDownMs":{"type":"number","description":"Idle between runs/cases (0-30000, default 8000)."},"discardWarmupRuns":{"type":"number","description":"Warmup runs discarded per case (0-9, default 0)."},"discardWarmupCase":{"type":"boolean","description":"Prepend a throwaway case and drop it. Default true in the shipped profile."},"shuffleCases":{"type":"boolean","description":"Interleave + shuffle case order. Default true."},"reloadBetweenCases":{"type":"boolean","description":"Reload the JS bundle between cases. Default true in the shipped profile."},"reloadStrategy":{"type":"string","enum":["auto","dev-settings","expo-updates"],"description":'Anything else is coerced to "auto".'},"postReloadSettleMs":{"type":"number","description":"Extra settle after a reload (0-60000)."},"captureRenders":{"type":"boolean","description":"Per-component render capture. Default true; produces no data in release builds."},"captureRenderDetail":{"type":"boolean","description":"Changed prop keys + parent names. Default false."}}}},"additionalProperties":false},"effect":"destructive","release":"works","description":"Sanitizes and writes the whole config to @react_buoy/perf-monitor/automation, then returns the stored result. THIS IS A FULL REPLACE: sanitize() rebuilds every field, so a config passed without `cases` wipes the user's saved case matrix and one without `targetRoute` blanks it (packages/perf-monitor/src/perf-monitor/utils/automationSettings.ts sanitize + saveAutomationConfig). Read getAutomationConfig, spread your overrides onto it, and send the merged object. Numbers are clamped (perCaseDurationMs 500-120000, runsPerCase 1-10, coolDownMs 0-30000, settleMs 0-30000, navTimeoutMs 500-60000). Only for changes the user wants to STICK \u2014 for a one-off, pass overrides to startAutomation instead. Passing no config is a no-op that just returns the current one."},{"action":"loadReport","summary":"Fetch one full saved benchmark report (all samples + aggregate stats) by id.","params":{"type":"object","properties":{"id":{"type":"string","description":'Report id from snapshot.index, e.g. "bench-1779574310712-a1b2c3".'}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Reads @react_buoy/perf-monitor/report/<id> and returns the whole BenchmarkReport: metadata (name, route, batch fields, environmentSignals like thermalState/battery/network), every 250ms sample, markers, diagnostics, aggregate stats (avg/p95 FPS-CPU-memory, jank counts) and `renders` when render capture ran. Returns null for an unknown id. Payloads are large \u2014 use the index summary fields for lists and only load a report when you need the timeline or a head-to-head comparison."},{"action":"deleteReport","summary":"Permanently delete one saved benchmark run.","params":{"type":"object","properties":{"id":{"type":"string","description":"Report id from snapshot.index."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Removes @react_buoy/perf-monitor/report/<id> and drops its index entry, then re-emits the index. Irreversible \u2014 the samples are gone. Deleting one run of a multi-run case leaves the rest of the batch in place, which can skew a later re-ranking of that batchId; prefer deleteBatch for a whole batch."},{"action":"deleteBatch","summary":"Permanently delete every run belonging to one automation batch.","params":{"type":"object","properties":{"batchId":{"type":"string","description":`Batch id from an index entry's batchId or from live.automation/automationCompleted, e.g. "batch-1779574310712-ghww".`}},"required":["batchId"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Cascade-deletes all reports whose index entry carries this batchId and returns how many were removed (0 when the batchId matches nothing). Irreversible. This is the right cleanup after a botched or cancelled batch \u2014 it removes every run and failure placeholder in one serialized index mutation, instead of fanning out parallel deleteReport calls."},{"action":"clearAll","summary":"Delete EVERY saved benchmark recording on the device.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Wipes all @react_buoy/perf-monitor/report/* blobs and empties the index. Irreversible and total \u2014 every manual recording and every batch, including baselines someone may be comparing against. Only run on an explicit, unambiguous request to clear all recordings; deleteReport or deleteBatch cover every narrower case."}],"unavailableWhen":"The host app does not have @buoy-gg/perf-monitor installed \u2014 FloatingDevTools only registers this adapter when the package resolves (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:382). Automation actions additionally need expo-router; without it startAutomation is a silent no-op."},{"toolId":"assets","title":"Assets","summary":'Inventory of what the app SHIPS \u2014 every bundled asset (images, fonts, video, audio, data) with dimensions, @1x/@2x/@3x scale coverage, content hash, byte size, loaded-vs-never-loaded state, audit findings (duplicates, unused, WebP candidates, over-decode, heavy GIFs/fonts, scale gaps), and a diff vs a saved baseline. Reach for it for bundle-size questions ("what\'s making the app big", "did this branch add megabytes", "which images ship but are never used"); use the images tool instead for what the app actually RENDERS. Nothing here is __DEV__-gated (zero `__DEV__` in packages/assets/src), but the Metro dev server is what supplies the full-bundle graph and real byte sizes \u2014 in an embedded/release bundle the inventory shrinks to registry-only records with estimated sizes. Swift inventories loose resources in the main bundle and explicitly registered app resource bundles. Buoy resource bundles are excluded. Compiled Assets.car files are aggregate records, not per-image inventory. Native loaded status requires host calls to BuoyAssets.markLoaded; unobserved does not mean unused. Native scale and decoded-memory insight arrays are currently empty. Check scanStatus.coverage and warnings.',"actions":[{"action":"list","summary":"The whole asset inventory, largest-first, plus stats, scan status, audit insights and the baseline diff in one call.","params":{"type":"object","properties":{"limit":{"type":"number","description":"Max records to return, largest first. Default 50, clamped to 1..500."},"kind":{"type":"string","enum":["image","font","video","audio","data","other"],"description":"Only assets of this kind. Any other value throws 'kind must be one of: image, font, video, audio, data, other'."},"loadedOnly":{"type":"boolean","description":"true keeps ONLY assets loaded at runtime. There is no 'unusedOnly' param here \u2014 for shipped-but-never-loaded assets read insights.neverLoaded (ids) or fetch with a high limit and filter loaded===false."}},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":'Returns { stats, scanStatus, insights, diff, total, records }. `records` is sorted by size descending and sliced to `limit`; `total` is the count AFTER kind/loadedOnly filtering but BEFORE the slice. Each record: { id, key, registryId, name, type, kind, hash, location, width, height, scales, uri, loaded, sizeBytes, sizeSource }. `sizeSource` is "measured" (real bytes from the Metro dev server) or "estimate" (decoded RGBA memory, width*scale*height*scale*4) \u2014 never present them as the same number. `insights` carries id lists: duplicates (grouped by content hash, with wastedBytes), neverLoaded, webpCandidates (png/jpg >= 50KB), overDecode (decodes to >2 screenfuls), heavyGifs (>= 100KB), heavyFonts (>= 150KB), scaleGaps, scaleAnomalies. If `scanStatus.lastScanAt` is null or `graphCount` is null, run `rescan` first \u2014 the inventory is registry-only until the Metro graph is merged. If `scanStatus.measuring` is true, byte totals are partial; re-run shortly. Swift inventories loose resources in the main bundle and explicitly registered app resource bundles. Buoy resource bundles are excluded. Compiled Assets.car files are aggregate records, not per-image inventory. Native loaded status requires host calls to BuoyAssets.markLoaded; unobserved does not mean unused. Native scale and decoded-memory insight arrays are currently empty. Check scanStatus.coverage and warnings.',"requires":["@buoy-gg/assets imported in the app","Metro dev server (for full-bundle coverage, never-loaded detection and measured bytes)"]},{"action":"getDetail","summary":"Full metadata for one asset id: source dir, per-file graph paths, per-scale byte sizes, resolved URI, decoded-memory estimate.","params":{"type":"object","properties":{"id":{"type":"number","description":"Numeric record id from a `list` response. A non-number throws 'Missing numeric `id` param'; an unknown id throws 'Asset record N not found \u2014 re-run the list action for current ids.'"}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":'Everything `list` returns for that record plus origin ("registry" | "graph"), fileSystemLocation (the source directory on the dev machine \u2014 Metro-graph only), files (per-scale source file paths), sizesByScale ({ "1": bytes, "2": bytes, ... }), and estDecodedBytes. Use it to answer "where does this file live" and "which scale variant is the fat one". Ids come from `list` and are per-session: they are stable across a `rescan` (records are keyed by location/name.type) but NOT across `clearRecords`, which renumbers \u2014 re-run `list` before calling this if anything was cleared.',"requires":["@buoy-gg/assets imported in the app","a prior `list` call for a valid id"]},{"action":"rescan","summary":"Full refresh: re-walk the runtime registry, merge the Metro bundle graph, refresh Expo fonts, then kick off size measurement in the background.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Run this FIRST when the inventory looks empty, stale, or has no never-loaded data. Returns { ok: true, scanStatus } immediately; the measurement pass is fired with `void measureSizes()` and keeps running after the response, so scanStatus.measuring is usually true on return \u2014 call `list` or `getScanStatus` again a moment later for final byte totals. Merges into existing records (ids and any measured sizes are preserved); it never wipes. `ok:true` only means the scan ran \u2014 read scanStatus.graphError and graphCount to see whether the bundle graph actually loaded."},{"action":"measureSizes","summary":"Re-measure real byte sizes by fetching each scale variant from the Metro dev server; skips already-measured records.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"empty","description":'Returns { ok, measured, total } where ok is `measured > 0`. Fetches every scale variant (HEAD for Content-Length, GET-blob fallback) 4 at a time and sums them onto the record as sizeSource:"measured". Records it cannot fetch are marked sizeSource:"estimate". Safe to re-run \u2014 it only visits records that aren\'t measured yet. If a call is already in flight it returns the current counts immediately without starting a second pass.',"releaseNote":'packages/assets/src/capture/measure.ts:74-75 \u2014 variantURLs() needs an http(s) resolved URI or a live dev-server origin; in a release bundle assets resolve to file:// paths or Android resource ids, so it returns [] and measure.ts:118 marks every record "estimate". The result is { ok:false, measured:0, total:N }. Byte sizes are simply not obtainable from JS in a release build \u2014 say that instead of retrying.',"requires":["Metro dev server reachable from the device"]},{"action":"saveBaseline","summary":"Persist the current inventory as the before/after comparison point; OVERWRITES any existing baseline.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works","description":"Returns { ok: true, savedAt, assets }. Writes a slim {hash, sizeBytes, name, type} snapshot per asset to persistentStorage under '@react_buoy/assets/baseline' (FileSystem -> AsyncStorage -> memory). The workflow is: measure sizes, save baseline, make the change, then `list`/`getDiff` reports added / removed / grown (>=1KB) / netBytes. Two warnings worth surfacing before calling: (1) it silently replaces a previously saved baseline and the old one cannot be recovered \u2014 ask first if a comparison may already be in progress; (2) only sizeSource===\"measured\" bytes are stored, so saving before a measurement pass finishes produces a baseline whose later netBytes is null."},{"action":"clearBaseline","summary":"Delete the saved baseline permanently, so diffs stop being reported.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Returns { ok: true, message: \"Baseline cleared\" }. Removes '@react_buoy/assets/baseline' from persistent storage and nulls it in memory; after this `getDiff` returns null and `list().diff` is null. Irreversible \u2014 the saved snapshot is gone, and re-saving captures TODAY's inventory, not the one you deleted. Only call it when the user explicitly wants to stop comparing or start a fresh comparison."},{"action":"getDiff","summary":"Change vs the saved baseline: added ids, removed assets, grown assets, net bytes. Null when no baseline exists.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns { baselineSavedAt, added: number[], removed: [{key, name, type}], grown: [{id, deltaBytes}] sorted biggest-first, netBytes } or null if nothing was ever saved with saveBaseline. `grown` only counts increases of >=1KB (below that is codec noise) and only for assets measured both then and now. netBytes is null whenever no asset pair was comparable \u2014 that means 'sizes unknown', NOT 'no change'; `list` already embeds this same object as `diff`, so calling both is redundant."},{"action":"clearRecords","summary":"Wipe the entire in-memory inventory, including all measured byte sizes; a rescan rebuilds it.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":'Returns { ok: true, message: "Inventory cleared \u2014 rescan to rebuild" }. Empties records/byKey/byId and resets measuredCount and graphCount. Everything measured is lost and must be re-fetched from the dev server, and because the id counter is NOT reset, a following `rescan` assigns brand-new ids \u2014 any id from an earlier `list` is dead afterwards. Never a diagnostic step; only run it when the user asks for a clean slate. The saved baseline survives (use clearBaseline for that).'},{"action":"getScanStatus","summary":"Cheap health check for the capture: is the registry patched, did the Metro graph load, how many sizes are measured, is measurement still running.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns { registryAvailable, patched, registryCount, graphSupported, graphCount, graphError, lastScanAt, measuring, measuredCount, fontFamilies: string[], localAssetCount }. Use it to explain a thin or surprising inventory before drawing conclusions: registryAvailable:false means the RN asset registry could not be required at all; graphCount:null with a graphError means only runtime-registered assets are known (so 'never loaded' is unanswerable); measuring:true means byte totals are still landing. lastScanAt:null means nothing has scanned yet \u2014 call rescan. Swift inventories loose resources in the main bundle and explicitly registered app resource bundles. Buoy resource bundles are excluded. Compiled Assets.car files are aggregate records, not per-image inventory. Native loaded status requires host calls to BuoyAssets.markLoaded; unobserved does not mean unused. Native scale and decoded-memory insight arrays are currently empty. Check scanStatus.coverage and warnings."}],"unavailableWhen":"The app doesn't import @buoy-gg/assets (autoExternalSync only registers the adapter when the module resolves \u2014 packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:391), or the app is a release/production JS bundle that didn't opt in with `externalSync.enableInRelease` plus a real Pro license, in which case the device never connects to the broker at all."},{"toolId":"tv-remote","title":"TV Remote","summary":'Capture-only observer for Apple TV / Android TV apps: it reports the D-pad, select, menu, media and long-press events the app ACTUALLY received, which is how you tell "the app handled that press" apart from "something swallowed it". It cannot press anything \u2014 presses are injected host-side by Buoy Desktop (`adb shell input keyevent` / `idb ui key`), so never tell a user this tool moved focus. Reach for it to arm capture before a remote press happens, then poll getEventsSince for the echo. Inert on phones/tablets/web.',"actions":[{"action":"arm","summary":"Start capturing remote events into the 50-event ring buffer. Idempotent. Nothing is recorded until this runs.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"Attaches the native TVEventHandler listener and returns the fresh TvRemoteState: { supported, platform, captureArmed, listening, menuCaptureArmed, seq, recent[] }. CHECK `listening`, not `captureArmed`: on a non-TV build or an RN build without TVEventHandler, arm() still sets captureArmed:true while listening stays false and no event will ever arrive \u2014 reporting 'armed' there would make every replay step look swallowed. Buffer holds the last 50 events; key-DOWN (eventKeyAction 0) and continuous gestures (pan/swipeUp/swipeDown/swipeLeft/swipeRight) are dropped so one press = exactly one echoed event. Arming renders nothing on screen and costs one listener. This does NOT press any button.","requires":["@buoy-gg/tv-remote installed in the app","FloatingDevTools mounted (auto-discovery registers the adapter)","react-native-tvos build with Platform.isTV === true for listening to become true"]},{"action":"getEventsSince","summary":"Cursor-paged read of captured remote events newer than `seq`. The echo poll \u2014 use this, not the snapshot, to time whether a press landed.","params":{"type":"object","properties":{"seq":{"type":"number","description":"Cursor: return only events whose seq is strictly greater. Omit or 0 for the whole buffer; 9007199254740991 for the current cursor with no events."}},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":'Returns { seq, events: [{ seq, t, eventType, eventKeyAction }] }, where `events` are strictly newer than the passed cursor and `seq` is the store\'s current counter. eventType is the raw RN name: up | down | left | right | select | playPause | menu | longSelect | longLeft | \u2026 . Pure read \u2014 it never consumes or advances the buffer. Pattern: call with seq: 9007199254740991 (Number.MAX_SAFE_INTEGER) to grab the current cursor with zero events, have the press injected, then poll with that cursor. Omitted seq (or 0) returns everything buffered, max 50. TRAPS: a non-number (a string "5") silently falls back to 0 and returns EVERYTHING; a non-finite value (Infinity/NaN) also returns everything rather than nothing. Injected TEXT and Home never echo at all (they ride the platform keyboard/button path, not the TV event pipe) \u2014 absence of an event for those is expected, not a bug.',"requires":["arm() must have been called first \u2014 an unarmed store records nothing and returns an empty list"]},{"action":"setMenuCapture","summary":"tvOS only: route the Menu key to JS so it can be recorded, instead of letting it pop the nav stack. Changes how the app behaves.","params":{"type":"object","properties":{"on":{"type":"boolean","description":"true routes the Menu key to JS (Menu stops popping the nav stack). Anything else hands Menu back to the platform."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":'Calls TVEventControl.enableTVMenuKey/disableTVMenuKey and returns the fresh TvRemoteState. Pass { on: true } to capture Menu; ANY other value (omitted, "true", 1, null) disables it \u2014 the handler is a strict `params?.on === true`. WARN THE USER BEFORE ENABLING: with capture on, Menu no longer navigates back, so at the root of the app the remote\'s Back/Menu button stops working the way they expect until it is turned off. Hard no-op (returns ok, changes nothing) on Android TV, on non-TV builds, and on RN builds without TVEventControl \u2014 check `menuCaptureArmed` in the returned state to see whether it actually took. Always undone automatically by disarm().',"requires":['tvOS (Platform.OS === "ios") with Platform.isTV === true',"react-native-tvos TVEventControl present"]},{"action":"disarm","summary":"Stop capturing and hand the Menu key back to the platform. Any in-flight recording or replay stops echoing.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"Removes the native listener, turns menu capture off if it was on, and returns the fresh TvRemoteState. The 50-event buffer is NOT wiped \u2014 previously captured events stay readable via getEventsSince. Do not call this while someone is recording a macro or a replay is running on this device: an unarmed device echoes nothing, which reads as every press being swallowed. Idempotent and safe to call on a non-TV app."},{"action":"clear","summary":"Wipe the captured-event ring buffer. Irreversible \u2014 the recorded presses are gone.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"destructive","release":"works","description":"Empties the in-memory buffer and returns the fresh TvRemoteState (recent: []). The `seq` counter is NOT reset, so cursors held by an in-flight replay stay valid but their events vanish. This destroys the evidence a recording-from-the-real-remote session just collected \u2014 never call it to 'tidy up' while a macro is being recorded or a replay is polling for echoes; ask first. Capture state is untouched: still armed after clearing. No-ops when the buffer is already empty."}],"unavailableWhen":"The app is not a react-native-tvos TV build (`Platform.isTV !== true`): every action still returns ok, but the snapshot says `supported: false`, `listening` stays false, and no event is ever captured. Also absent if `@buoy-gg/tv-remote` isn't installed, or if FloatingDevTools isn't mounted (auto-discovery registers the adapter). In a release build the whole Buoy sync transport only connects when the app passes `externalSync={{ enableInRelease: true }}` AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts:38) \u2014 but if an action is reachable at all, its code path works in release."},{"toolId":"focus-inspector","title":"TV Focus Inspector","summary":`Debugs D-pad/remote focus on Android TV and tvOS: what holds focus right now, the observed focus-transition history, and detected dead ends, traps, invisible stops (TVFocusGuideView) and traversal coverage. Reach for it when a user says the remote won't move, focus is stuck inside one row, focus skips a button, or focus "disappeared". Unlike most timeline tools it needs NO arming \u2014 the focus/key observers attach at app launch, so history is already there when you first read it; but it is TV-ONLY (on a phone/tablet/web build snapshot.supported is false and nothing is ever observed), and in a RELEASE build the fiber half dies while the observation half survives (see per-action release notes).`,"actions":[{"action":"rescan","summary":"Re-walk the fiber tree and rebuild the on-screen focusable inventory (names, testIDs, frames, guide props).","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"read","release":"empty","description":"Returns {ok:true, focusables:N}. Refreshes snapshot.focusables / screen / scannedAt and the internal nativeTag->instance table that focusElement depends on. Call it after the app navigates to a new screen, and always before a focusElement. Inventory is capped at 400 nodes, sorted in reading order, frames normalized to [0,1]; off-window nodes are KEPT (a tile below a ScrollView fold is still a real D-pad target). Two gotchas: (1) it CLEARS the inventory and instance table before rebuilding, so a rescan fired while nothing is mounted leaves focusables:0 and breaks focusElement until you rescan again; (2) detected flags are judged against this scan's timestamp, so rescanning discards trap/invisible-stop evidence collected before it. Concurrent calls are coalesced \u2014 a rescan while one is in flight returns the previous scan.","releaseNote":"packages/focus-inspector/src/scan/focusableScanner.ts:109 reads global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which React Native installs ONLY under __DEV__ (node_modules/react-native/Libraries/Core/setUpReactDevTools.js:32). In a release build getAllFiberRoots() returns [], collectCandidates() returns [], and the action still answers {ok:true, focusables:0} \u2014 do not report that as an app with no focusable elements. Knock-on effects in release: snapshot.focusables is empty, current.node is null (only the raw tag is known), coverage.focusables is 0, and flags.traps / flags.invisibleStops are always empty because both need frames or a non-empty inventory.","requires":["Platform.isTV === true for the results to mean anything (the scan itself runs on a phone but nothing is ever observed there)","A __DEV__ build \u2014 a release build returns focusables:0"]},{"action":"focusElement","summary":"Move focus onto a specific element by native tag, then watch where the D-pad goes from there.","params":{"type":"object","properties":{"nativeTag":{"type":"number","description":"Native view tag of the element to focus, taken from snapshot.focusables[].nativeTag or snapshot.current.tag in the LATEST scan. Absent or non-number returns {ok:false, reason:'nativeTag is required.'}"}},"required":["nativeTag"],"additionalProperties":false},"effect":"write","release":"empty","description":"The tool's ONLY write into the host app \u2014 it calls the element's requestTVFocus(). Send {nativeTag: 166}, where the tag comes from snapshot.focusables[].nativeTag or snapshot.current.tag AND from the most recent rescan (the instance table is rebuilt on every scan, so a tag from an older scan is stale). Returns {ok:true} or {ok:false, reason} and the reason strings are exact and worth relaying verbatim: 'nativeTag is required.' (missing or non-number param), 'Not a TV build \u2014 there is no focus engine.' (Platform.isTV false), 'Unknown tag \u2014 rescan and try again.' (tag not in the current scan's instance table), or 'This element exposes no requestTVFocus().' (the host node is not a View \u2014 Text and Image never get one). Note the adapter hand-casts this as (params as {nativeTag?: number}), i.e. structurally optional on the wire, but the handler hard-rejects when it is absent.","releaseNote":"packages/focus-inspector/src/scan/focusableScanner.ts:550 looks the tag up in instancesByTag, which is populated ONLY by the DEV-gated fiber scan (focusableScanner.ts:454, reached via the __REACT_DEVTOOLS_GLOBAL_HOOK__ read at :109). In a release build that map is permanently empty, so every call returns {ok:false, reason:'Unknown tag \u2014 rescan and try again.'} no matter how many times you rescan. Do not loop on the rescan advice in the reason string \u2014 in release it can never succeed; say so and drive the app with the actual remote instead.","requires":["Platform.isTV === true","A __DEV__ build","A rescan must have run and included this tag"]},{"action":"setTracking","summary":"Pause or resume recording of focus transitions and D-pad probes, without detaching the native listeners.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true resumes recording, false pauses it. OMITTING THIS MEANS TRUE (resume) \u2014 always send it explicitly."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"Returns {tracking:boolean} \u2014 the value actually in effect. GOTCHA THAT WILL BITE: `enabled` defaults to TRUE. The handler is `setTracking(params?.enabled !== false)`, so calling setTracking with no params, or with anything other than exactly false, RESUMES recording. To pause you must explicitly send {enabled:false}. Pausing leaves the RawEventEmitter and TVEventHandler listeners attached and keeps the currently-focused element, so nothing is forgotten; it only stops new focus/blur/key events from being appended to the history. Use it when a person is driving the app by hand and does not want that traversal scored. The current value is also visible as snapshot.tracking."},{"action":"clearHistory","summary":"Permanently wipe the recorded focus history \u2014 transitions, D-pad probes, visited tags and counters.","params":{"type":"object","properties":{},"required":[],"additionalProperties":false},"effect":"destructive","release":"works","description":"Returns {ok:true}. Resets transitions, probes, visited, lostCount and the seq counters to zero, which also blanks the desktop timeline and zeroes every derived number (coverage.visited, stats.transitions, all dead-end/trap/invisible-stop flags, since they are computed from that stream). IRREVERSIBLE \u2014 there is no snapshot of the old history anywhere. It deliberately KEEPS the currently focused element as the only visited tag, so the Now card does not blank out. The legitimate use is starting a clean traversal run: clear, then have the QA user walk the screen with the remote, then read the flags. Do not call it just to tidy up \u2014 you are destroying the evidence the tool exists to collect, and focus history cannot be re-derived because focus can only be watched arriving, never queried."}],"unavailableWhen":'The app is not a TV build \u2014 `Platform.isTV !== true` means the focus and D-pad observers are never attached (focusInspectorSyncAdapter.ts:117), so `snapshot.supported` is false, `presence` stays "never-observed", transitions/current stay empty forever, and focusElement refuses. Also absent entirely if `@buoy-gg/focus-inspector` isn\'t installed, since @buoy-gg/core only registers the "focus-inspector" capability when that optional require resolves (autoExternalSync.tsx:397). There is deliberately NO on-device UI \u2014 a focusable overlay would insert itself into the host app\'s focus order and corrupt the measurement \u2014 so everything is read through this adapter.'},{"toolId":"images","title":"Images","summary":"Live registry of every image the app has loaded \u2014 RN core <Image> and expo-image \u2014 with cache verdict (memory/disk/network), load ms, decoded-vs-displayed pixel size plus oversize/wasted-KB math, error codes, and cross-record insights (duplicate URLs, retry storms, missing alt text, layout shifters). Also drives per-image and app-wide simulation: force error / force loading / blank / URL swap / offline / cold-start, plus locate-flash and cache clears. Reach for it whenever an image is broken, blank, blurry, slow, or suspected of memory bloat: image HTTP never passes through the JS network stack, so this is the ONLY visibility into image loading.","actions":[{"action":"list","summary":"List captured image loads, newest first, with stats, insights, capture status and active simulation modes.","params":{"type":"object","properties":{"limit":{"type":"number","description":"Max records to return, newest first. Defaults to 50; clamped to 1-200."},"status":{"type":"string","enum":["pending","loading","loaded","error"],"description":"Return only records in this state. 'error' gives the failure log. Any other string matches nothing and returns zero records."}},"required":[],"additionalProperties":false},"effect":"read","release":"works","description":"Returns { stats, captureStatus, globalModes, insights, total, records }. stats = {total, loading, loaded, errors, networkLoads, estDecodedBytes, estWastedBytes}. Each record carries id, lib ('rn'|'expo'), uri, kind ('network'|'asset'|'file'|'data'|'other'), status, mounted, cache verdict, ms, intrinsic px, layout dp, neededPx, oversizeFactor, decodedKB, wastedKB, error/errorCode, loadCount, overrideLabel, hasAltText, layoutShifts, ageMs. `data:` URIs and URIs over 2KB arrive truncated to a stub, never in full. insights flags duplicate URLs, retry storms (loadCount >= 5), iOS queue saturation (>4 RN images loading), missing alt text and layout shifters. The registry keeps the last 500 records and only holds images that mounted AFTER capture installed \u2014 an empty result means either nothing rendered yet or capture is not wired (call getCaptureStatus). Use the returned ids for every other action.","requires":["@buoy-gg/images installed in the app",'capture installed (import "@buoy-gg/images/register" as the first entry import, or <ImagesRoot/> mounted)']},{"action":"getDetail","summary":"Full wire detail for one image record, including the iOS error response headers that `list` omits.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`. Must be a number; a string throws."}},"required":["id"],"additionalProperties":false},"effect":"read","release":"works","description":"Same fields as a `list` record plus `errorHeaders` (iOS RN core only \u2014 the HTTP response headers captured from onError, the way to see a 403 body/auth header on a failing CDN image). Throws 'Missing numeric `id` param' if id is not a number, and throws 'Image record <id> not found (cleared or evicted)' when the id aged out of the 500-record buffer or was dropped by clearRecords \u2014 re-run `list` for current ids.","requires":["@buoy-gg/images installed in the app"]},{"action":"getCaptureStatus","summary":"Whether image capture actually installed, and whether the RN <Image> hook landed in time. Call this first when `list` looks empty.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works","description":"Returns { installed, rnDecoratorActive, rnDecoratorTooLate, expoPatched, expoAvailable }. rnDecoratorTooLate === true is the single most common cause of a missing/partial registry: RN latches its Image decorator at module evaluation, so `import \"@buoy-gg/images/register\"` must be the FIRST import of the app entry file. expo-image capture is timing-immune and unaffected. Never report 'the app loads no images' without checking this.","requires":["@buoy-gg/images installed in the app"]},{"action":"retry","summary":"Plain fresh load attempt for one mounted image \u2014 no cache bypass, nothing cleared.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"RN core: bumps the wrapper's key so the native view remounts and re-runs the load. expo-image: calls the live instance's reloadAsync(). The safe 'try loading it again' action \u2014 prefer it over hardReload unless you specifically need to prove a cache is stale. Returns { ok:false, message:'Instance unmounted \u2014 cannot reload' } when the image left the screen, or 'Live instance not reachable' for an expo record whose instance was not registered. Give it ~1.5s before re-reading the record.","requires":["@buoy-gg/images installed in the app","the image must still be mounted on screen"]},{"action":"flash","summary":"Draw a 3px red border on the on-screen image for 2.5s so a human can find it.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"Purely visual and self-reverting (the border clears itself after ~2.5s, no undo needed). Returns { ok:true, message:'Flashing for 2.5s' } unconditionally \u2014 including for an unmounted record, where nothing will actually be visible; check `mounted` on the record first. The right way to answer 'which image on screen is #42?'.","requires":["@buoy-gg/images installed in the app","the image must be mounted and on screen to be visible"]},{"action":"proveSavings","summary":"Re-encode the image at its displayed size as WebP ON DEVICE and return real byte savings plus a previewable file:// URI.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`. Best used on a record whose oversizeFactor is well above 1."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"Turns the estimated wastedKB into measured bytes: resizes to neededPx (layout dp x device pixel ratio, capped at the intrinsic width) and saves WebP at quality 0.8 via expo-image-manipulator, then stats the output. Original size is resolved in tiers: observed download bytes -> expo disk-cache file size -> an HTTP HEAD Content-Length (so it may issue one network request). Returns { ok, message, originalBytes, originalBytesSource ('download'|'cacheFile'|'head'), optimizedBytes, optimizedUri, optimizedDims, savedBytes, savedPct }. Feed optimizedUri into setOverride {kind:'url'} to A/B the optimized variant in place. Fails with ok:false when expo-image-manipulator is missing, when the record has no loadable URI (kind must be network/asset/file), or when the image has not laid out yet.","requires":["expo-image-manipulator installed","expo-file-system (to stat the output; without it optimizedBytes is unknown)","the image must have rendered at least once so its layout size is known"]},{"action":"setOverride","summary":"Simulate a failure state on ONE image: force error, force forever-loading, blank it, or swap in a different URL.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."},"kind":{"type":"string","enum":["error","hang","blank","url"],"description":"error = instant load failure; hang = loads forever; blank = no image rendered; url = replace the source with `uri`."},"uri":{"type":"string","description":"Replacement source URL. Required when kind is 'url' (throws without it); ignored otherwise."}},"required":["id","kind"],"additionalProperties":false},"effect":"write","release":"works","description":"kind:'error' points the source at a nonexistent file:// so native fires onError instantly (offline-safe). kind:'hang' points at a blackhole IP so the load never settles (permanent skeleton/spinner state). kind:'blank' renders expo-image with source=null; RN core has no safe empty source, so it blanks via opacity:0 (visual only \u2014 the decoded bitmap stays resident). kind:'url' requires `uri` and swaps the source in place. IMPORTANT: for error/hang/blank the action returns { ok:false, message:'Instance unmounted \u2014 overrides need the image on screen' } and does nothing when the image is not mounted; the 'url' path does NOT perform that check and reports ok:true even for an unmounted record. Unknown kinds throw. The override sticks until clearOverride (or massAction 'restore') \u2014 always tell the user how to undo it.","requires":["@buoy-gg/images installed in the app","the image must be mounted on screen for kinds error/hang/blank"]},{"action":"clearOverride","summary":"Remove the simulation override from one image and restore its original source.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"write","release":"works","description":"Deletes the per-record override and clears the record's overrideLabel, then re-renders just that image. Always returns { ok:true, message:'Override removed \u2014 original source restored' }, even for an id that had no override. This is the undo for setOverride.","requires":["@buoy-gg/images installed in the app"]},{"action":"setNetworkMode","summary":"App-wide image network simulation: 'offline' (every network image fails), 'cold' (every image bypasses caches), 'normal' to reset.","params":{"type":"object","properties":{"mode":{"type":"string","enum":["normal","offline","cold"],"description":"normal = simulation off; offline = every network image load fails instantly; cold = every image bypasses memory+disk caches."}},"required":["mode"],"additionalProperties":false},"effect":"write","release":"works","description":"'offline' swaps every NETWORK-kind source for a nonexistent file:// so it fails immediately on both libs \u2014 bundled/local assets keep loading, exactly like a real offline device showing shipped images. 'cold' injects RN source.cache:'reload' / expo cachePolicy:'none' so every load refetches: first-launch behavior WITHOUT clearing any cache. 'normal' resets. Any other value throws. Applies to every image in the app, persists until reset, and already-displayed images need a remount/navigation (or massAction 'reload') before the effect is visible. Returns { ok:true, modes:{ network, blank } }. list/getSnapshot surface the active mode in globalModes \u2014 say so out loud, since a stuck 'offline' looks exactly like a real app bug.","requires":["@buoy-gg/images installed in the app"]},{"action":"setBlankImages","summary":"Chrome-style 'disable images' app-wide: render every image with no source.","params":{"type":"object","properties":{"enabled":{"type":"boolean","description":"true blanks every image app-wide. Omitted / anything but true turns it off."}},"required":[],"additionalProperties":false},"effect":"write","release":"works","description":"expo-image gets source=null (its placeholder keeps showing); RN core gets opacity:0 because RN has no safe empty source (visual only \u2014 the bitmap is still decoded and resident, so this does NOT prove memory savings). Good for checking layout/alt-text without imagery. Returns { ok:true, modes:{ network, blank } }. NOTE the param cast is `enabled === true`: calling with no params, or with anything other than boolean true, turns the mode OFF \u2014 always pass `enabled` explicitly.","requires":["@buoy-gg/images installed in the app"]},{"action":"hardReload","summary":"Cache-busting reload of one image \u2014 for expo records this ALSO clears the app's entire expo-image memory cache.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"RN core: remounts with source.cache:'reload' injected (honored on both platforms) so the HTTP cache is bypassed. expo-image: deletes that URI's disk-cache file, then calls Image.clearMemoryCache() which wipes the WHOLE app's expo-image memory cache (no per-entry memory eviction exists), then reloads. Non-network sources (asset/file/data) have no HTTP cache and silently fall back to a plain `retry`. Returns { ok:false, message:'Instance unmounted \u2014 cannot reload' } when the image is off screen. Prefer `retry` unless the point is to prove a stale cache; re-read the record after ~1.5s to see the new cache verdict.","requires":["@buoy-gg/images installed in the app","the image must be mounted on screen","expo-image + expo-file-system for the disk-entry eviction half (expo records only)"]},{"action":"evictDisk","summary":"Delete this image's expo-image disk-cache file. Cache data only \u2014 irreversible, the image refetches next load.","params":{"type":"object","properties":{"id":{"type":"number","description":"Record id from `list`."}},"required":["id"],"additionalProperties":false},"effect":"destructive","release":"works","description":"Resolves the entry via expo-image's getCachePathAsync and deletes the file with expo-file-system. Returns { ok:true, message:'Disk cache entry deleted' } or { ok:false, message:'No disk entry found (expo-image + expo-file-system required)' } \u2014 that same ok:false covers 'neither package installed', 'not an expo-image record', and 'nothing was cached', so do not read it as a hard error. No effect at all on RN core <Image> records or on the memory cache.","requires":["expo-image installed","expo-file-system installed (legacy or main entry)","record must be an expo-image load with a cached disk entry"]},{"action":"massAction","summary":"Apply one action to EVERY mounted image at once: force error/loading/blank, hard-reload all, flash all, or restore all.","params":{"type":"object","properties":{"kind":{"type":"string","enum":["error","loading","blank","reload","flash","restore"],"description":"error/loading/blank = mass simulation override; reload = cache-busting hard reload of all mounted images; flash = red-border all; restore = clear every override."}},"required":["kind"],"additionalProperties":false},"effect":"destructive","release":"works","description":"kind 'error'|'loading'|'blank' set that override on every mounted record (note: per-record 'hang' is spelled 'loading' here) and return { ok: n>0, message:'... on N images' }. 'flash' red-borders everything the tool tracks for 2.5s. 'restore' clears every active override and is the undo for the three simulation kinds. 'reload' fans `hardReload` out across every mounted image \u2014 which for expo records means disk-entry evictions plus a full expo-image memory-cache clear, hence the destructive rating on this whole action. Unknown kinds throw. ok:false just means zero images were mounted. Whole-screen effect: confirm with the user before firing, and always report how to restore.","requires":["@buoy-gg/images installed in the app","images must be mounted on screen \u2014 a background screen yields ok:false with 0 affected"]},{"action":"clearRecords","summary":"Wipe the captured image registry (keeps only still-in-flight loads). Irreversible evidence loss.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"destructive","release":"works","description":"Drops every record that is unmounted or already settled (loaded/error); records that are still mounted and still loading survive so in-flight events do not orphan. Returns { ok:true, message:'Registry cleared' }. The captured history cannot be recovered \u2014 take a `list` first if the user might need it. Legitimate use: clear, then have the user re-do the broken step, so the registry contains only the repro.","requires":["@buoy-gg/images installed in the app"]},{"action":"clearExpoCaches","summary":"Clear expo-image's memory and/or disk caches app-wide. Cache data only, but irreversible.","params":{"type":"object","properties":{"memory":{"type":"boolean","description":"Clear the memory cache. Defaults to true; only an explicit false skips it."},"disk":{"type":"boolean","description":"Clear the disk cache. Defaults to true; only an explicit false skips it."}},"required":[],"additionalProperties":false},"effect":"destructive","release":"works","description":"Calls expo-image's Image.clearMemoryCache() and Image.clearDiskCache(). Both default to true \u2014 the cast is `p.memory !== false`, so omitting params clears BOTH; pass false explicitly to skip one. Returns { ok:true, message:'Cleared expo-image memory + disk cache' } or { ok:false, message:'expo-image not installed' }. Affects the whole app, not one image, and does nothing for RN core <Image> (whose caches are native/Fresco/NSURLCache and unreachable from JS). Use it to make the next loads genuinely cold; `setNetworkMode {mode:'cold'}` simulates the same thing without deleting anything.","requires":["expo-image installed (otherwise returns ok:false and does nothing)"]}],"unavailableWhen":'@buoy-gg/images is not installed in the app, or capture never installed (no `import "@buoy-gg/images/register"`, no <ImagesRoot/>, and the Images tool UI never opened). RN core <Image> specifically goes uncaptured when that register import is not the FIRST import of the entry file \u2014 expo-image is captured regardless; check getCaptureStatus.rnDecoratorTooLate before concluding "no images". Separately, in a release build the sync transport itself is off unless the app passes externalSync={{enableInRelease:true}} with a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts), in which case no action reaches the device at all.'},{"toolId":"ask-buoy","title":"Ask Buoy","summary":'Ask Buoy\'s OWN session \u2014 the changes it has made in this conversation, and the undo for them. Call listChanges when the user asks "what did you change?"; call undoAll when they say "undo that" / "put it back" / "clean up". Undo restores exactly what the ledger captured at write time: override rules Ask Buoy created are deleted, impersonation is stopped, storage keys are restored to their pre-write values, and a query cache edit (setQueryData) is put back to the data read just before the write \u2014 unless the app has refetched it since, in which case the server\'s data is already back and undo says so. Changes listed with reversible:false (state writes, wipes, one-shot actions) cannot be automatically reversed \u2014 say so honestly. NEVER improvise a reverse-write with a guessed shape instead of calling undoAll; a hand-rolled "undo" that writes invented data is worse than telling the user a change is permanent. Its retrieve action re-reads any earlier tool result in FULL: every result over 24,000 characters is cut with a `[truncated \u2014 \u2026; ref ev_N]` marker and every result compressed out of memory leaves an `[earlier result \u2014 \u2026; ref ev_N]` marker \u2014 call retrieve with that ref (and a path or pattern) instead of asking the user for a narrower slice or calling the tool again for a value you already had.',"actions":[{"action":"listChanges","summary":'List what Ask Buoy has changed in this conversation and whether each change can be undone. Use it to answer "what did you change?" before offering undo.',"params":{"type":"object","properties":{},"additionalProperties":false,"description":"No parameters."},"effect":"read","release":"works","description":'Returns `{changes:[{toolId, action, kind, label, reversible}], returned}` \u2014 outstanding (not yet undone) changes only, oldest first. `reversible:true` means undoAll can restore that change exactly. `kind` "transient" is a one-shot action (a navigation, a tap) that changed no persistent state.'},{"action":"undoAll","summary":"Undo every reversible change Ask Buoy made this conversation \u2014 deletes override rules it created, stops impersonation, restores storage keys to their prior values. The safe direction: use it whenever the user asks to undo or clean up.","params":{"type":"object","properties":{},"additionalProperties":false,"description":"No parameters."},"effect":"write","release":"works","description":"Returns `{ok, reverted, failed:[{label, error}], skippedTransients, permanent}`. Report the numbers honestly: `failed` entries were attempted and could not be restored (tell the user which, using the labels); `permanent` is how many changes were never undoable (state writes, refetches) and are still applied \u2014 say so, they are not failures; `skippedTransients` are one-shot actions that never needed undoing. This undoes ALL reversible changes from this conversation, newest first \u2014 there is no per-change undo action, so if the user wants to keep one change, say so instead of calling this."},{"action":"retrieve","summary":'Re-read part of an earlier tool result by its ref (ev_N from a [truncated \u2026] or [earlier result \u2026] marker). With only `ref` it returns the result\'s SHAPE (top-level keys with types and sizes); add `path` to get one value ("stats.0.base_stat", "moves.3.move.name"), `slice` for a window of an array or string, or `pattern` for a literal case-insensitive substring search with `contextLines` of surrounding text around each match. The kept copy is exactly what you were shown when it arrived \u2014 for the app\'s CURRENT value call the original tool again.',"params":{"type":"object","properties":{"ref":{"type":"string","description":'The ref from the marker, e.g. "ev_7".'},"path":{"type":"string","description":'Dotted path into the JSON result; arrays index by number: "stats.0.base_stat", "data.items.2.name".'},"slice":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2,"description":"[start, end) window of items when the value at path is an array, or of characters when it is a string."},"pattern":{"type":"string","description":"Literal, case-insensitive text to find. Returns up to 20 matching lines with context. Not a regex."},"contextLines":{"type":"number","description":"Lines shown around each pattern match. Default 2, max 10."}},"required":["ref"],"additionalProperties":false,"description":"ref is required; add exactly what you need \u2014 a bare ref shows the shape, then path/slice/pattern read a part."},"effect":"read","release":"works","servedBy":"engine","description":"Returns `{ref, from, capturedAt, totalChars, \u2026}` plus `shape` (no selector), `value` (path), `value`+`sliced`+`of` (slice), or `matches:[{line,text}]`+`totalMatches` (pattern). `{ok:false, error}` when the ref is unknown or was evicted to make room (the store keeps the most recent ~4 MB) \u2014 then call the original tool again. Results are capped at 24,000 characters like any other; narrow with path or pattern rather than asking for the whole thing."},{"action":"openProcedure","summary":"Open one of this app's developer-written procedures by `id` (the ids are listed in the system prompt under PROCEDURES, each with a one-line summary). Returns the full playbook: preconditions, the stores and keys involved, the steps in order, and what done looks like. Call it FIRST when a request matches a procedure's summary, then follow it with the ordinary tools.","params":{"type":"object","properties":{"id":{"type":"string","description":'The procedure id from the PROCEDURES list, e.g. "expire-subscription".'}},"required":["id"],"additionalProperties":false,"description":"Just the id."},"effect":"read","release":"works","servedBy":"engine","description":"Returns `{id, title, version?, requires?, body, note}`; `{ok:false, error}` naming the available ids when the id is unknown or the app has none. A procedure grants nothing: each step still runs through the same catalog, policy, approval card and undo as any other call."}],"unavailableWhen":"Only present while an Ask Buoy session is running \u2014 which is exactly when this catalog is in use, so in practice always available to you."},{"toolId":"push-notifications","title":"Push Notifications","summary":"Inspect notification receipt, responses, presentation decisions, background-task results and tokens from an explicitly configured Expo capture adapter. Use test IDs to correlate stages. Simulator sending lives in the desktop host and is not a device action. Real-provider sending is not implemented.","unavailableWhen":"Capture requires @buoy-gg/notifications and early installExpoNotificationCapture setup with the app SDK. Release capture needs an explicit enableInRelease option; release desktop sync separately requires its own opt-in and Pro. Background evidence requires the app task wrapper. Native delivery validation covers Expo 56 on iOS.","actions":[{"action":"getSnapshot","summary":"Read captured notification evidence and tokens.","description":"Returns the bounded journal, session, provider capabilities, permissions and token observations. A missing callback does not prove that no OS alert appeared.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"getCapabilities","summary":"Check notification capture setup.","description":"Returns provider/version, app ID, platform, supported operations and durable-storage state. No device tokens are registered by this action.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"getEvent","summary":"Read one captured event by id.","description":"Returns an event by its Buoy record ID, or null after eviction. Native notification ID and test ID are separate fields.","effect":"read","release":"works","params":{"type":"object","properties":{"id":{"type":"string","description":"Buoy event record id from getSnapshot.events."}},"additionalProperties":false,"required":["id"]}},{"action":"getPermissions","summary":"Refresh notification permission settings.","description":"Reads the existing OS settings through Expo. Does not display a permission prompt.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"listPresented","summary":"Inspect notifications currently in the system tray.","description":"Reads a current snapshot through Expo. This is not a historical record and does not dismiss notifications.","effect":"read","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"refreshToken","summary":'Register a token with type "device" or "expo". Expo registration requires a configured projectId.',"description":"May register with APNs/FCM or contact Expo Push Service. Expo tokens require projectId in app setup. Never invoke merely to open the inspector.","effect":"write","release":"works","params":{"type":"object","properties":{"type":{"type":"string","enum":["device","expo"],"description":"device returns APNs on iOS or FCM on Android; expo requests an Expo token."}},"additionalProperties":false,"required":["type"]}},{"action":"setCaptureSession","summary":"Set runId and optional durationMs to arm capture, or runId:null to stop.","description":"Arm while the app is connected, before backgrounding or stopping it. Await durable:true before relying on cold-launch recovery. Sessions last at most one hour.","effect":"write","release":"works","params":{"type":"object","properties":{"runId":{"description":"A test-run label of 1 to 128 characters, or null to stop capture.","anyOf":[{"type":"string"},{"type":"null"}]},"durationMs":{"type":"number","description":"Capture duration in milliseconds. Default 1800000."}},"additionalProperties":false,"required":["runId"]}},{"action":"clearCapturedEvents","summary":"Clear Buoy notification history.","description":"Deletes retained captured events. Leaves device tokens, OS notifications and the capture session unchanged.","effect":"destructive","release":"works","params":{"type":"object","properties":{},"additionalProperties":false}},{"action":"scheduleLocal","summary":"Schedule a local notification with title, body, optional data and a delay in seconds.","description":"This tests local notification behavior, not APNs or FCM delivery. The app must be connected before scheduling. Uses the app SDK and notification settings.","effect":"write","release":"works","params":{"type":"object","properties":{"title":{"type":"string","description":"Displayed title."},"body":{"type":"string","description":"Displayed message."},"data":{"type":"object","description":"Custom notification data. Include __buoyTestId to correlate with a test run."},"seconds":{"type":"number","description":"Delay before local delivery."}},"additionalProperties":false,"required":["title","body","seconds"]}}]},{"toolId":"image-overlay","title":"Image Overlay","summary":"Control the Swift design-image overlay. Uses the same state as the on-device controls. Read getSnapshot after loadImage to check loading and error. Available on Swift iOS only; check device capabilities.","actions":[{"action":"getSnapshot","summary":"Read overlay loading, image presence, placement, selected target and settings.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works"},{"action":"listTargets","summary":"Scan visible app image-overlay targets and return their ids, labels and frames.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"read","release":"works"},{"action":"selectTarget","summary":"Attach the overlay to the visible target with this `id`.","params":{"type":"object","properties":{"id":{"type":"string","description":"Target id returned by listTargets."}},"additionalProperties":false,"required":["id"]},"effect":"write","release":"works"},{"action":"loadImage","summary":"Start loading a design image from `url`; returns {scheduled:true}. Check getSnapshot for completion or error.","params":{"type":"object","properties":{"url":{"type":"string","description":"HTTP(S) image URL."}},"additionalProperties":false,"required":["url"]},"effect":"write","release":"works"},{"action":"setSettings","summary":"Update overlay settings: `visible`, `locked`, `flipped`, `flippedY`, `showOutline`, `autoTrack`, `opacity`, `scale`, `offsetX`, `offsetY`. Invalid batches fail before any settings change.","params":{"type":"object","properties":{"visible":{"type":"boolean","description":"Show the overlay."},"locked":{"type":"boolean","description":"Lock direct manipulation."},"flipped":{"type":"boolean","description":"Flip horizontally."},"flippedY":{"type":"boolean","description":"Flip vertically."},"showOutline":{"type":"boolean","description":"Show the target outline."},"autoTrack":{"type":"boolean","description":"Follow the selected target."},"opacity":{"type":"number","description":"Opacity from 0 to 1."},"scale":{"type":"number","description":"Positive scale factor."},"offsetX":{"type":"number","description":"Horizontal offset in points."},"offsetY":{"type":"number","description":"Vertical offset in points."}},"additionalProperties":false},"effect":"write","release":"works"},{"action":"fitToScreen","summary":"Fit the image to the screen width while preserving its aspect ratio.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"},{"action":"resetSettings","summary":"Reset opacity, flips, scale and offsets.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"},{"action":"remove","summary":"Remove the image and target; cancel pending image loading.","params":{"type":"object","properties":{},"additionalProperties":false},"effect":"write","release":"works"}],"unavailableWhen":"The native Swift image-overlay adapter is not registered. React Native does not expose these actions."}];const CATALOG_TOOL_COUNT=exports.CATALOG_TOOL_COUNT=30;const CATALOG_ACTION_COUNT=exports.CATALOG_ACTION_COUNT=269;
|