@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.
@@ -2,12 +2,12 @@
2
2
  {
3
3
  "toolId": "env",
4
4
  "title": "Env",
5
- "summary": "Env is a read-only snapshot tool with exactly one action, getSnapshot. The payload is `{ env: Record<string,string>, requiredEnvVars: (string | {key, expectedValue, description?} | {key, expectedType, description?})[] }` where `expectedType` is one of string|number|boolean|array|object|url. Reach for it to answer \"what API URL / feature flag / environment is this build pointed at\" and \"which required env vars are missing, empty, or the wrong value/type\". Values are baked into the JS bundle at build time and the adapter's `subscribe` is a no-op, so the snapshot is static for the life of the app — there is no way to set, change, or reload an env var from here, and only EXPO_PUBLIC_-style vars exist in the RN runtime at all (secrets are not present).",
5
+ "summary": "Env is a read-only snapshot tool with exactly one action, getSnapshot. The payload is `{ env: Record<string,string>, requiredEnvVars: (string | {key, expectedValue, description?} | {key, expectedType, description?})[] }` where `expectedType` is one of string|number|boolean|array|object|url. Reach for it to answer \"what API URL / feature flag / environment is this build pointed at\" and \"which required env vars are missing, empty, or the wrong value/type\". Values are baked into the JS bundle at build time and the adapter's `subscribe` is a no-op, so the snapshot is static for the life of the app \u2014 there is no way to set, change, or reload an env var from here, and only EXPO_PUBLIC_-style vars exist in the RN runtime at all (secrets are not present).",
6
6
  "actions": [
7
7
  {
8
8
  "action": "getSnapshot",
9
9
  "summary": "Read this build's environment: every EXPO_PUBLIC_-style variable it was compiled with, plus the app's required-env-var checks.",
10
- "description": "Returns `{ env: Record<string,string>, requiredEnvVars: [...] }`. This is the ONLY read env has — it exposes no other action. Values are baked into the JS bundle at build time and never change at runtime, so one read is good for the life of the app, and real secrets are not present (only EXPO_PUBLIC_-style vars exist in the RN runtime at all).",
10
+ "description": "Returns `{ env: Record<string,string>, requiredEnvVars: [...] }`. This is the ONLY read env has \u2014 it exposes no other action. Values are baked into the JS bundle at build time and never change at runtime, so one read is good for the life of the app, and real secrets are not present (only EXPO_PUBLIC_-style vars exist in the RN runtime at all).",
11
11
  "params": {
12
12
  "type": "object",
13
13
  "properties": {},
@@ -17,17 +17,17 @@
17
17
  "release": "works"
18
18
  }
19
19
  ],
20
- "unavailableWhen": "The whole tool is absent unless @buoy-gg/env resolves at runtime (autoExternalSync only registers `map.env` when the optional require succeeds) — and, separately, no env snapshot reaches the agent at all in a release bundle: the device only dials the broker when `__DEV__` is true, or when the app opts in with `enableInRelease: true` plus a valid Pro license."
20
+ "unavailableWhen": "The whole tool is absent unless @buoy-gg/env resolves at runtime (autoExternalSync only registers `map.env` when the optional require succeeds) \u2014 and, separately, no env snapshot reaches the agent at all in a release bundle: the device only dials the broker when `__DEV__` is true, or when the app opts in with `enableInRelease: true` plus a valid Pro license."
21
21
  },
22
22
  {
23
23
  "toolId": "console",
24
24
  "title": "Console",
25
- "summary": "Reach for this when the app crashed, redboxed, or logged something you need to see: the device's captured console.log/info/warn/error/debug/trace/dir/table/assert/group output plus uncaught JS errors tagged [FATAL]/[UNCAUGHT]/[RENDER ERROR] with component stacks. Read it with getSnapshot (a compact time·level·message tail, filterable by level/pattern and capped by limit). The only other action is clearEntries, and it is destructive: it wipes the crash evidence. Capture is a 1000-entry in-memory ring buffer that starts when Buoy mounts, so anything logged before that is absent, and it resets on every JS reload unless the user turned on \"Preserve log\" (@react_buoy_console_preserve).",
25
+ "summary": "Reach for this when the app crashed, redboxed, or logged something you need to see: the device's captured console.log/info/warn/error/debug/trace/dir/table/assert/group output plus uncaught JS errors tagged [FATAL]/[UNCAUGHT]/[RENDER ERROR] with component stacks. Read it with getSnapshot (a compact time\u00b7level\u00b7message tail, filterable by level/pattern and capped by limit). The only other action is clearEntries, and it is destructive: it wipes the crash evidence. Capture is a 1000-entry in-memory ring buffer that starts when Buoy mounts, so anything logged before that is absent, and it resets on every JS reload unless the user turned on \"Preserve log\" (@react_buoy_console_preserve).",
26
26
  "actions": [
27
27
  {
28
28
  "action": "getSnapshot",
29
- "summary": "Read the app's captured console output — log/info/warn/error plus uncaught JS errors tagged [FATAL]. Start here when the app crashed, redboxed, or logged something.",
30
- "description": "Returns `{ totalCaptured, shown, entries: [{at, level, message}] }`, newest last. Narrow with `level` (severity threshold), `pattern` (substring) and `limit` — the raw buffer holds up to 1000 entries at roughly 1KB each, so an unfiltered read is the most expensive thing you can ask for. Only the pre-rendered message is returned; full args and stacks stay on the device. Capture is an in-memory ring buffer that starts when Buoy mounts and resets on every JS reload unless the user turned on \"Preserve log\", so anything logged before that is genuinely absent — say \"nothing was captured\", never \"nothing happened\".",
29
+ "summary": "Read the app's captured console output \u2014 log/info/warn/error plus uncaught JS errors tagged [FATAL]. Start here when the app crashed, redboxed, or logged something.",
30
+ "description": "Returns `{ totalCaptured, shown, entries: [{at, level, message}] }`, newest last. Narrow with `level` (severity threshold), `pattern` (substring) and `limit` \u2014 the raw buffer holds up to 1000 entries at roughly 1KB each, so an unfiltered read is the most expensive thing you can ask for. Only the pre-rendered message is returned; full args and stacks stay on the device. Capture is an in-memory ring buffer that starts when Buoy mounts and resets on every JS reload unless the user turned on \"Preserve log\", so anything logged before that is genuinely absent \u2014 say \"nothing was captured\", never \"nothing happened\".",
31
31
  "params": {
32
32
  "type": "object",
33
33
  "properties": {
@@ -68,25 +68,25 @@
68
68
  },
69
69
  "effect": "destructive",
70
70
  "release": "works",
71
- "description": "Takes no params — the handler ignores anything passed. Calls consoleLogStore.clearEntries(), which (1) fires the onClear listeners so a desktop dashboard in mirror mode forwards the clear down to the device, (2) removes the persisted key `@react_buoy_console_buffer` from storage, and (3) empties the in-memory 1000-entry ring buffer and notifies subscribers. Always returns {cleared:true}, even if the buffer was already empty — a true result is NOT evidence anything existed. There is no undo and no re-capture: a crash entry ([FATAL]/[UNCAUGHT]/[RENDER ERROR], the app's last words before it died) is gone for good, and a dead app will never re-log it. Read with get_console / get_snapshot('console') BEFORE clearing. Legitimate use is narrow: zeroing the log right before reproducing a bug so the next read contains only that repro.",
71
+ "description": "Takes no params \u2014 the handler ignores anything passed. Calls consoleLogStore.clearEntries(), which (1) fires the onClear listeners so a desktop dashboard in mirror mode forwards the clear down to the device, (2) removes the persisted key `@react_buoy_console_buffer` from storage, and (3) empties the in-memory 1000-entry ring buffer and notifies subscribers. Always returns {cleared:true}, even if the buffer was already empty \u2014 a true result is NOT evidence anything existed. There is no undo and no re-capture: a crash entry ([FATAL]/[UNCAUGHT]/[RENDER ERROR], the app's last words before it died) is gone for good, and a dead app will never re-log it. Read with get_console / get_snapshot('console') BEFORE clearing. Legitimate use is narrow: zeroing the log right before reproducing a bug so the next read contains only that repro.",
72
72
  "requires": [
73
73
  "@buoy-gg/console installed in the app",
74
- "<FloatingDevTools /> rendered — it auto-mounts ConsoleRoot (capture) and registers consoleSyncAdapter as the \"console\" capability",
74
+ "<FloatingDevTools /> rendered \u2014 it auto-mounts ConsoleRoot (capture) and registers consoleSyncAdapter as the \"console\" capability",
75
75
  "external-sync connection to the broker (:42831)",
76
- "in a release build capture starts only when ConsoleRoot mounts — the import-time install is __DEV__-gated, so pre-mount/boot console output is never captured"
76
+ "in a release build capture starts only when ConsoleRoot mounts \u2014 the import-time install is __DEV__-gated, so pre-mount/boot console output is never captured"
77
77
  ]
78
78
  }
79
79
  ],
80
- "unavailableWhen": "The app doesn't depend on @buoy-gg/console, or FloatingDevTools never renders — in a release build it bails out for free users (only a Pro license renders it), so neither the console capability nor its snapshot is announced at all."
80
+ "unavailableWhen": "The app doesn't depend on @buoy-gg/console, or FloatingDevTools never renders \u2014 in a release build it bails out for free users (only a Pro license renders it), so neither the console capability nor its snapshot is announced at all."
81
81
  },
82
82
  {
83
83
  "toolId": "sentry",
84
84
  "title": "Sentry",
85
- "summary": "What the app is SENDING to Sentry — errors, transactions, logs, sessions — captured at the SDK's beforeEnvelope tee on their way out of the device. Reach for it when the user asks whether an error/crash was reported to Sentry, what Sentry traffic the app produces, or why the Sentry bill is big (transaction items carry spanCount — spans are Sentry's tracing billing unit). Read with getSnapshot; clearEnvelopes wipes the captured list on the device only (copies already sent to Sentry are unaffected). The snapshot's `status` matters: \"sdk-not-found\" or \"no-client\" means the app has no live Sentry client, so say that plainly — an empty list is NOT evidence that nothing errored. For crash stack traces themselves, the console tool's [FATAL] entries are usually the better read.",
85
+ "summary": "What the app is SENDING to Sentry \u2014 errors, transactions, logs, sessions \u2014 captured at the SDK's beforeEnvelope tee on their way out of the device. Reach for it when the user asks whether an error/crash was reported to Sentry, what Sentry traffic the app produces, or why the Sentry bill is big (transaction items carry spanCount \u2014 spans are Sentry's tracing billing unit). Read with getSnapshot; clearEnvelopes wipes the captured list on the device only (copies already sent to Sentry are unaffected). The snapshot's `status` matters: \"sdk-not-found\" or \"no-client\" means the app has no live Sentry client, so say that plainly \u2014 an empty list is NOT evidence that nothing errored. For crash stack traces themselves, the console tool's [FATAL] entries are usually the better read.",
86
86
  "actions": [
87
87
  {
88
88
  "action": "getSnapshot",
89
- "summary": "Read the envelopes the app has sent to Sentry — newest first, summaries only. Start here for \"was that error reported?\" and \"what is the app sending to Sentry?\".",
89
+ "summary": "Read the envelopes the app has sent to Sentry \u2014 newest first, summaries only. Start here for \"was that error reported?\" and \"what is the app sending to Sentry?\".",
90
90
  "params": {
91
91
  "type": "object",
92
92
  "properties": {
@@ -107,7 +107,7 @@
107
107
  },
108
108
  "effect": "read",
109
109
  "release": "works",
110
- "description": "Returns `{status, totalCaptured, shown, envelopes:[{id, at, origin, eventId, totalBytes, items:[{type, summary, spanCount, bytes}]}]}`, newest first. Item payloads stay on the device — the summary line is the one-line human read (error headline, transaction name, log count). `status` is \"attached\" when capture is live; \"searching\" right after launch; \"sdk-not-found\"/\"no-client\" when the app has no Sentry client, in which case nothing can ever appear here. Capture starts when Buoy mounts, so envelopes sent before that are absent — \"nothing was captured\", never \"nothing was sent\"."
110
+ "description": "Returns `{status, totalCaptured, shown, envelopes:[{id, at, origin, eventId, totalBytes, items:[{type, summary, spanCount, bytes}]}]}`, newest first. Item payloads stay on the device \u2014 the summary line is the one-line human read (error headline, transaction name, log count). `status` is \"attached\" when capture is live; \"searching\" right after launch; \"sdk-not-found\"/\"no-client\" when the app has no Sentry client, in which case nothing can ever appear here. Capture starts when Buoy mounts, so envelopes sent before that are absent \u2014 \"nothing was captured\", never \"nothing was sent\"."
111
111
  },
112
112
  {
113
113
  "action": "clearEnvelopes",
@@ -128,11 +128,11 @@
128
128
  {
129
129
  "toolId": "jotai",
130
130
  "title": "Jotai",
131
- "summary": "Reads and writes the app's Jotai atoms: list registered atoms with change counts and writability, fetch one atom's current value, fetch the real prev/next values behind one recorded change, wipe the change timeline, and set a writable atom. Reach for it when on-screen data disagrees with the API or a value looks stale/wrong and the app uses Jotai. Only atoms the app explicitly passed to watchAtoms()/watchDefaultStoreAtoms() exist here — coverage is opt-in, so an empty list means nothing was registered, not that the app has no state. For the HISTORY of atom changes use get_events with sources:['jotai']; this tool's snapshot deliberately ships value-free markers and you fetch values on demand.",
131
+ "summary": "Reads and writes the app's Jotai atoms: list registered atoms with change counts and writability, fetch one atom's current value, fetch the real prev/next values behind one recorded change, wipe the change timeline, and set a writable atom. Reach for it when on-screen data disagrees with the API or a value looks stale/wrong and the app uses Jotai. Only atoms the app explicitly passed to watchAtoms()/watchDefaultStoreAtoms() exist here \u2014 coverage is opt-in, so an empty list means nothing was registered, not that the app has no state. For the HISTORY of atom changes use get_events with sources:['jotai']; this tool's snapshot deliberately ships value-free markers and you fetch values on demand.",
132
132
  "actions": [
133
133
  {
134
134
  "action": "listAtoms",
135
- "summary": "Compact reader for a remote driver: the registered atoms with light metadata. Each atom's value stays on the device unless `includeValues` is set, and `limit` caps how many are returned. `writable` says which can be set via setAtom. Each atom also carries `shape` — the item type of any list it holds — which is the cheap way to learn what an addition must look like.",
135
+ "summary": "Compact reader for a remote driver: the registered atoms with light metadata. Each atom's value stays on the device unless `includeValues` is set, and `limit` caps how many are returned. `writable` says which can be set via setAtom. Each atom also carries `shape` \u2014 the item type of any list it holds \u2014 which is the cheap way to learn what an addition must look like.",
136
136
  "params": {
137
137
  "type": "object",
138
138
  "properties": {
@@ -149,7 +149,7 @@
149
149
  },
150
150
  "effect": "read",
151
151
  "release": "works",
152
- "description": "Compact reader for a remote driver: the registered atoms with light metadata. Each atom's value stays on the device unless `includeValues` is set, and `limit` caps how many are returned. `writable` says which can be set via setAtom. Each atom also carries `shape` — the item type of any list it holds — which is the cheap way to learn what an addition must look like.",
152
+ "description": "Compact reader for a remote driver: the registered atoms with light metadata. Each atom's value stays on the device unless `includeValues` is set, and `limit` caps how many are returned. `writable` says which can be set via setAtom. Each atom also carries `shape` \u2014 the item type of any list it holds \u2014 which is the cheap way to learn what an addition must look like.",
153
153
  "requires": [
154
154
  "@buoy-gg/jotai installed in the app",
155
155
  "watchAtoms(store, atoms) or watchDefaultStoreAtoms(atoms) called at app startup"
@@ -157,13 +157,13 @@
157
157
  },
158
158
  {
159
159
  "action": "getAtomValue",
160
- "summary": "Fetch one atom's current value on demand, by label. Send `path` to get back ONE value instead of the whole atom (path:\"lines[id=seed-1].qty\") — do that whenever the value is big, because results are cut off at 24,000 characters. Also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. Read it before writing and match it exactly.",
160
+ "summary": "Fetch one atom's current value on demand, by label. Send `path` to get back ONE value instead of the whole atom (path:\"lines[id=seed-1].qty\") \u2014 do that whenever the value is big, because results are cut off at 24,000 characters. Also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. Read it before writing and match it exactly.",
161
161
  "params": {
162
162
  "type": "object",
163
163
  "properties": {
164
164
  "label": {
165
165
  "type": "string",
166
- "description": "Atom label exactly as registered — the object key in watchAtoms(store, { countAtom }), e.g. \"countAtom\". Get exact labels from listAtoms."
166
+ "description": "Atom label exactly as registered \u2014 the object key in watchAtoms(store, { countAtom }), e.g. \"countAtom\". Get exact labels from listAtoms."
167
167
  },
168
168
  "path": {
169
169
  "type": "string",
@@ -177,9 +177,9 @@
177
177
  },
178
178
  "effect": "read",
179
179
  "release": "works",
180
- "description": "Fetch one atom's current value on demand, by label. Send `path` to get back ONE value instead of the whole atom (path:\"lines[id=seed-1].qty\") — do that whenever the value is big, because results are cut off at 24,000 characters. Also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. Read it before writing and match it exactly.",
180
+ "description": "Fetch one atom's current value on demand, by label. Send `path` to get back ONE value instead of the whole atom (path:\"lines[id=seed-1].qty\") \u2014 do that whenever the value is big, because results are cut off at 24,000 characters. Also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. Read it before writing and match it exactly.",
181
181
  "requires": [
182
- "The label must already be registered via watchAtoms — call listAtoms first for exact labels"
182
+ "The label must already be registered via watchAtoms \u2014 call listAtoms first for exact labels"
183
183
  ]
184
184
  },
185
185
  {
@@ -190,7 +190,7 @@
190
190
  "properties": {
191
191
  "id": {
192
192
  "type": "string",
193
- "description": "Change id from a jotai snapshot row or get_events sources:['jotai'], shaped \"<epochMs>-<counter>\" e.g. \"1755102003123-42\". Omitting it does not throw — it returns {found:false, reason:'missing id'}."
193
+ "description": "Change id from a jotai snapshot row or get_events sources:['jotai'], shaped \"<epochMs>-<counter>\" e.g. \"1755102003123-42\". Omitting it does not throw \u2014 it returns {found:false, reason:'missing id'}."
194
194
  }
195
195
  },
196
196
  "required": [
@@ -200,14 +200,14 @@
200
200
  },
201
201
  "effect": "read",
202
202
  "release": "works",
203
- "description": "Returns { found:true, id, prevValue, nextValue } or { found:false, reason:'missing id' | 'unknown id' }. The streamed change timeline carries {__buoyValueOnDevice:true} in place of both values (up to 200 changes x 2 values per snapshot would blow the wire budget), so this is the only way to see what a change actually contained. `id` comes from a jotai snapshot row or a get_events sources:['jotai'] row and has the shape \"<epochMs>-<counter>\" (e.g. \"1755102003123-42\"). Only the newest 200 changes are retained — older ids return found:false, and clearEvents drops them all. Values over 8MB are replaced with {__buoyTruncated:true}.",
203
+ "description": "Returns { found:true, id, prevValue, nextValue } or { found:false, reason:'missing id' | 'unknown id' }. The streamed change timeline carries {__buoyValueOnDevice:true} in place of both values (up to 200 changes x 2 values per snapshot would blow the wire budget), so this is the only way to see what a change actually contained. `id` comes from a jotai snapshot row or a get_events sources:['jotai'] row and has the shape \"<epochMs>-<counter>\" (e.g. \"1755102003123-42\"). Only the newest 200 changes are retained \u2014 older ids return found:false, and clearEvents drops them all. Values over 8MB are replaced with {__buoyTruncated:true}.",
204
204
  "requires": [
205
205
  "A change id from the jotai snapshot or get_events sources:['jotai']"
206
206
  ]
207
207
  },
208
208
  {
209
209
  "action": "setAtom",
210
- "summary": "Write to a writable Jotai atom, named by its `label` — this mutates the running app's state. Three forms: `value` alone REPLACES; `value` with `merge:true` merges a patch under the typed-edit rule (change existing fields to the same type; a list may grow or shrink but every item must have the fields the others have); `path`+`value` sets ONE value with no nesting to get wrong (path:\"lines[id=seed-1].qty\"). To change one item of a list, address it by its own id — {\"lines\":{\"seed-1\":{\"qty\":5}}} changes one, {\"lines\":{\"seed-2\":null}} removes one, a new id adds one; items you don't name are untouched. A plain array replaces the whole list. A refused merge names the field and, when the problem was depth, hands back the corrected patch — fix it that way rather than reaching for `force`, which writes raw and can crash the screen.",
210
+ "summary": "Write to a writable Jotai atom, named by its `label` \u2014 this mutates the running app's state. Three forms: `value` alone REPLACES; `value` with `merge:true` merges a patch under the typed-edit rule (change existing fields to the same type; a list may grow or shrink but every item must have the fields the others have); `path`+`value` sets ONE value with no nesting to get wrong (path:\"lines[id=seed-1].qty\"). To change one item of a list, address it by its own id \u2014 {\"lines\":{\"seed-1\":{\"qty\":5}}} changes one, {\"lines\":{\"seed-2\":null}} removes one, a new id adds one; items you don't name are untouched. A plain array replaces the whole list. A refused merge names the field and, when the problem was depth, hands back the corrected patch \u2014 fix it that way rather than reaching for `force`, which writes raw and can crash the screen.",
211
211
  "params": {
212
212
  "type": "object",
213
213
  "properties": {
@@ -216,7 +216,7 @@
216
216
  "description": "Atom label from listAtoms; must be writable:true. The wire param is `label` (the MCP tool calls it `atom`)."
217
217
  },
218
218
  "value": {
219
- "description": "The new value — any JSON (number, string, boolean, object, array, null). Passed straight to store.set(atom, value); it REPLACES the value, no merging."
219
+ "description": "The new value \u2014 any JSON (number, string, boolean, object, array, null). Passed straight to store.set(atom, value); it REPLACES the value, no merging."
220
220
  },
221
221
  "merge": {
222
222
  "type": "boolean",
@@ -224,7 +224,7 @@
224
224
  },
225
225
  "path": {
226
226
  "type": "string",
227
- "description": "Set ONE value inside the atom, named by its path in the CURRENT value — read it first and copy the path from `shape`. Always a merge. A list step is written [field=value] or [0] and resolves to that item's own id."
227
+ "description": "Set ONE value inside the atom, named by its path in the CURRENT value \u2014 read it first and copy the path from `shape`. Always a merge. A list step is written [field=value] or [0] and resolves to that item's own id."
228
228
  },
229
229
  "force": {
230
230
  "type": "boolean",
@@ -239,7 +239,7 @@
239
239
  },
240
240
  "effect": "write",
241
241
  "release": "works",
242
- "description": "Write to a writable Jotai atom, named by its `label` — this mutates the running app's state. Three forms: `value` alone REPLACES; `value` with `merge:true` merges a patch under the typed-edit rule (change existing fields to the same type; a list may grow or shrink but every item must have the fields the others have); `path`+`value` sets ONE value with no nesting to get wrong (path:\"lines[id=seed-1].qty\"). To change one item of a list, address it by its own id — {\"lines\":{\"seed-1\":{\"qty\":5}}} changes one, {\"lines\":{\"seed-2\":null}} removes one, a new id adds one; items you don't name are untouched. A plain array replaces the whole list. A refused merge names the field and, when the problem was depth, hands back the corrected patch — fix it that way rather than reaching for `force`, which writes raw and can crash the screen.",
242
+ "description": "Write to a writable Jotai atom, named by its `label` \u2014 this mutates the running app's state. Three forms: `value` alone REPLACES; `value` with `merge:true` merges a patch under the typed-edit rule (change existing fields to the same type; a list may grow or shrink but every item must have the fields the others have); `path`+`value` sets ONE value with no nesting to get wrong (path:\"lines[id=seed-1].qty\"). To change one item of a list, address it by its own id \u2014 {\"lines\":{\"seed-1\":{\"qty\":5}}} changes one, {\"lines\":{\"seed-2\":null}} removes one, a new id adds one; items you don't name are untouched. A plain array replaces the whole list. A refused merge names the field and, when the problem was depth, hands back the corrected patch \u2014 fix it that way rather than reaching for `force`, which writes raw and can crash the screen.",
243
243
  "requires": [
244
244
  "listAtoms reports writable:true for this label (atom has a write fn AND the store exposes set())"
245
245
  ]
@@ -254,20 +254,20 @@
254
254
  },
255
255
  "effect": "destructive",
256
256
  "release": "works",
257
- "description": "Empties the 200-entry change ring buffer and sets changeCount to 0 on every registered atom, then notifies listeners. Irreversible — the discarded prev/next values are gone, so getChangeDetail on any prior id will return found:false and get_events sources:['jotai'] will show nothing until new writes land. Atom REGISTRATION and current values are untouched; only history is destroyed. Use it deliberately to get a clean baseline before reproducing a bug, not as housekeeping."
257
+ "description": "Empties the 200-entry change ring buffer and sets changeCount to 0 on every registered atom, then notifies listeners. Irreversible \u2014 the discarded prev/next values are gone, so getChangeDetail on any prior id will return found:false and get_events sources:['jotai'] will show nothing until new writes land. Atom REGISTRATION and current values are untouched; only history is destroyed. Use it deliberately to get a clean baseline before reproducing a bug, not as housekeeping."
258
258
  }
259
259
  ],
260
- "unavailableWhen": "The app never calls watchAtoms(store, atoms) or watchDefaultStoreAtoms(atoms) — the registry is then empty and every action succeeds but returns nothing (listAtoms → total 0, getAtomValue → found:false \"unknown label\"). Also inert against a store in remote-mirror mode (jotaiStateStore.disableCapture(), used by the desktop dashboard's own copy)."
260
+ "unavailableWhen": "The app never calls watchAtoms(store, atoms) or watchDefaultStoreAtoms(atoms) \u2014 the registry is then empty and every action succeeds but returns nothing (listAtoms \u2192 total 0, getAtomValue \u2192 found:false \"unknown label\"). Also inert against a store in remote-mirror mode (jotaiStateStore.disableCapture(), used by the desktop dashboard's own copy)."
261
261
  },
262
262
  {
263
263
  "toolId": "route-events",
264
264
  "title": "Routes",
265
- "summary": "Reads and drives app navigation: the snapshot carries recorded route-change events, the expo-router sitemap (paths are TEMPLATES like /pokemon/[id]), and the live navigation stack (top-most last); the actions navigate the device to a path and manipulate that stack. Reach for it to answer \"what screen am I on / what routes exist\" and to move a QA user to a screen before exercising another tool. Unlike several Buoy tools, nothing here is __DEV__-gated — all six actions really run in a release build.",
265
+ "summary": "Reads and drives app navigation: the snapshot carries recorded route-change events, the expo-router sitemap (paths are TEMPLATES like /pokemon/[id]), and the live navigation stack (top-most last); the actions navigate the device to a path and manipulate that stack. Reach for it to answer \"what screen am I on / what routes exist\" and to move a QA user to a screen before exercising another tool. Unlike several Buoy tools, nothing here is __DEV__-gated \u2014 all six actions really run in a release build.",
266
266
  "actions": [
267
267
  {
268
268
  "action": "getSnapshot",
269
269
  "summary": "Read where the app is: the current screen, the live navigation stack, every route the app declares, and recent navigations. Answers \"what screen am I on\" and \"what routes exist\".",
270
- "description": "Returns `{ currentRoute, stack, routes, sitemapSource, recentNavigations }`. `routes` are expo-router TEMPLATES like /pokemon/[id] — resolve dynamic segments yourself before passing a path to `navigate`, which takes a concrete path only. `stack` and `recentNavigations` stay empty unless the app mounts <RouteTracker />; `routes` does not depend on it.",
270
+ "description": "Returns `{ currentRoute, stack, routes, sitemapSource, recentNavigations }`. `routes` are expo-router TEMPLATES like /pokemon/[id] \u2014 resolve dynamic segments yourself before passing a path to `navigate`, which takes a concrete path only. `stack` and `recentNavigations` stay empty unless the app mounts <RouteTracker />; `routes` does not depend on it.",
271
271
  "params": {
272
272
  "type": "object",
273
273
  "properties": {
@@ -283,7 +283,7 @@
283
283
  },
284
284
  {
285
285
  "action": "getCurrentRoute",
286
- "summary": "Just the current route — {path, params, at, source} — without the sitemap or history. The cheap way to confirm a navigation landed.",
286
+ "summary": "Just the current route \u2014 {path, params, at, source} \u2014 without the sitemap or history. The cheap way to confirm a navigation landed.",
287
287
  "params": {
288
288
  "type": "object",
289
289
  "properties": {},
@@ -292,17 +292,17 @@
292
292
  },
293
293
  "effect": "read",
294
294
  "release": "works",
295
- "description": "Returns `{path, params, at, source}` where `source` is \"event\" (the newest navigation event won) or \"stack\" (a freshly launched app that has not navigated yet — the focused stack item answered). Returns `{path:null}` when the app has no <RouteTracker/> and no live navigation stack: a null path means \"unknown\", never \"/\". Prefer this over the full getSnapshot when all you need is where the app is right now."
295
+ "description": "Returns `{path, params, at, source}` where `source` is \"event\" (the newest navigation event won) or \"stack\" (a freshly launched app that has not navigated yet \u2014 the focused stack item answered). Returns `{path:null}` when the app has no <RouteTracker/> and no live navigation stack: a null path means \"unknown\", never \"/\". Prefer this over the full getSnapshot when all you need is where the app is right now."
296
296
  },
297
297
  {
298
298
  "action": "navigate",
299
- "summary": "Navigate the device to a concrete path (pushes by default; replace:true swaps the current screen). ALSO HOW YOU LOAD DATA THE APP HAS NOT FETCHED YET: go to the screen that fetches it, waitFor something on it, then read. Moving the user's screen to go and look is not a change to the app — you do not need to ask, and you do not have to navigate back.",
299
+ "summary": "Navigate the device to a concrete path (pushes by default; replace:true swaps the current screen). ALSO HOW YOU LOAD DATA THE APP HAS NOT FETCHED YET: go to the screen that fetches it, waitFor something on it, then read. Moving the user's screen to go and look is not a change to the app \u2014 you do not need to ask, and you do not have to navigate back.",
300
300
  "params": {
301
301
  "type": "object",
302
302
  "properties": {
303
303
  "path": {
304
304
  "type": "string",
305
- "description": "Concrete route path, e.g. '/settings' or '/pokemon/25'. Dynamic segments must already be resolved — '/pokemon/[id]' navigates to a literal '[id]' screen or nowhere."
305
+ "description": "Concrete route path, e.g. '/settings' or '/pokemon/25'. Dynamic segments must already be resolved \u2014 '/pokemon/[id]' navigates to a literal '[id]' screen or nowhere."
306
306
  },
307
307
  "replace": {
308
308
  "type": "boolean",
@@ -316,7 +316,7 @@
316
316
  },
317
317
  "effect": "write",
318
318
  "release": "works",
319
- "description": "Calls expo-router's router.navigate(path), or router.replace(path) when replace:true. On bare React Navigation (no expo-router) it falls back to navigating by SCREEN NAME through the captured container ref — pass the screen's name ('Settings' or '/Settings'; replace is ignored there), and nested navigators are resolved automatically. The fallback needs <RouteTracker/> mounted inside the NavigationContainer. Pass a CONCRETE path — sitemap entries are templates ('/pokemon/[id]'), so resolve dynamic segments to real values ('/pokemon/25') first; a query string is allowed ('/pokemon/25?tab=stats'). Push is the default so navigate-then-back flows keep working; pass replace:true when you mean 'leave this screen' (e.g. resetting to '/'), otherwise repeated 'go to home' stacks another '/' on top. Throws 'navigate requires a path param' when path is missing/empty, and 'expo-router is not available on this device' on React-Navigation-only apps. Returns { navigated: path, replaced: boolean }. The return only proves the router call was made, not that the screen rendered — re-read the snapshot's stack to confirm, and use highlight-updates.waitFor before reading anything the new screen has to FETCH. This is the main tool for filling a gap in what you can read: the query cache, the request list and the events timeline only ever hold what the app has ALREADY done, so when the data you were asked about was never loaded, the answer is to go to the screen that loads it rather than to report the gap.",
319
+ "description": "Calls expo-router's router.navigate(path), or router.replace(path) when replace:true. On bare React Navigation (no expo-router) it falls back to navigating by SCREEN NAME through the captured container ref \u2014 pass the screen's name ('Settings' or '/Settings'; replace is ignored there), and nested navigators are resolved automatically. The fallback needs <RouteTracker/> mounted inside the NavigationContainer. Pass a CONCRETE path \u2014 sitemap entries are templates ('/pokemon/[id]'), so resolve dynamic segments to real values ('/pokemon/25') first; a query string is allowed ('/pokemon/25?tab=stats'). Push is the default so navigate-then-back flows keep working; pass replace:true when you mean 'leave this screen' (e.g. resetting to '/'), otherwise repeated 'go to home' stacks another '/' on top. Throws 'navigate requires a path param' when path is missing/empty, and 'expo-router is not available on this device' on React-Navigation-only apps. Returns { navigated: path, replaced: boolean }. The return only proves the router call was made, not that the screen rendered \u2014 re-read the snapshot's stack to confirm, and use highlight-updates.waitFor before reading anything the new screen has to FETCH. This is the main tool for filling a gap in what you can read: the query cache, the request list and the events timeline only ever hold what the app has ALREADY done, so when the data you were asked about was never loaded, the answer is to go to the screen that loads it rather than to report the gap.",
320
320
  "requires": [
321
321
  "expo-router installed and initialized in the app",
322
322
  "<FloatingDevTools> mounted (adapter registered)"
@@ -332,20 +332,20 @@
332
332
  },
333
333
  "effect": "write",
334
334
  "release": "works",
335
- "description": "Delegates to the live navigation actions captured by <RouteTracker />: expo-router's router.back(), or containerRef.goBack() on React Navigation. IMPORTANT: it silently does nothing when the stack is already at its root (depth <= 1) yet still returns { wentBack: true } — never report 'went back' from the return value alone; re-read the snapshot's stack (or get_routes) and compare the focused pathname. Throws 'navigation stack is not available' when <RouteTracker /> is not mounted.",
335
+ "description": "Delegates to the live navigation actions captured by <RouteTracker />: expo-router's router.back(), or containerRef.goBack() on React Navigation. IMPORTANT: it silently does nothing when the stack is already at its root (depth <= 1) yet still returns { wentBack: true } \u2014 never report 'went back' from the return value alone; re-read the snapshot's stack (or get_routes) and compare the focused pathname. Throws 'navigation stack is not available' when <RouteTracker /> is not mounted.",
336
336
  "requires": [
337
337
  "<RouteTracker /> mounted inside the navigation tree"
338
338
  ]
339
339
  },
340
340
  {
341
341
  "action": "stackNavigateToIndex",
342
- "summary": "Jump to the screen at a 0-based index of the current navigation stack — note this PUSHES on expo-router, it does not pop.",
342
+ "summary": "Jump to the screen at a 0-based index of the current navigation stack \u2014 note this PUSHES on expo-router, it does not pop.",
343
343
  "params": {
344
344
  "type": "object",
345
345
  "properties": {
346
346
  "index": {
347
347
  "type": "number",
348
- "description": "0-based index into the synced `stack` array (top-most last; 0 = root). Must be a number — strings throw."
348
+ "description": "0-based index into the synced `stack` array (top-most last; 0 = root). Must be a number \u2014 strings throw."
349
349
  }
350
350
  },
351
351
  "required": [
@@ -355,7 +355,7 @@
355
355
  },
356
356
  "effect": "write",
357
357
  "release": "works",
358
- "description": "Reads stack[index] from the live stack and navigates to its pathname. On expo-router this is router.navigate(pathname), which PUSHES that path — the stack gets deeper, it does not rewind (use stackPopToIndex to actually rewind). On React Navigation it dispatches a nested CommonActions.navigate built by walking the root state. Index is 0-based into the same `stack` array the snapshot sends (top-most last), so index 0 is the root. Out-of-range indexes (index < 0 or >= stack.length) are silently ignored while the action still returns { navigatedToIndex: index } — verify by re-reading the stack. Throws 'stackNavigateToIndex requires a numeric index' for a missing/non-number index, and 'navigation stack is not available' without <RouteTracker />.",
358
+ "description": "Reads stack[index] from the live stack and navigates to its pathname. On expo-router this is router.navigate(pathname), which PUSHES that path \u2014 the stack gets deeper, it does not rewind (use stackPopToIndex to actually rewind). On React Navigation it dispatches a nested CommonActions.navigate built by walking the root state. Index is 0-based into the same `stack` array the snapshot sends (top-most last), so index 0 is the root. Out-of-range indexes (index < 0 or >= stack.length) are silently ignored while the action still returns { navigatedToIndex: index } \u2014 verify by re-reading the stack. Throws 'stackNavigateToIndex requires a numeric index' for a missing/non-number index, and 'navigation stack is not available' without <RouteTracker />.",
359
359
  "requires": [
360
360
  "<RouteTracker /> mounted inside the navigation tree"
361
361
  ]
@@ -378,7 +378,7 @@
378
378
  },
379
379
  "effect": "destructive",
380
380
  "release": "works",
381
- "description": "Pops (stack.length - 1 - index) screens: expo-router calls router.back() that many times in a loop; React Navigation dispatches StackActions.pop(count). Index is 0-based into the synced `stack` array (top-most last). Everything above `index` is destroyed along with its in-memory screen state — unsaved form input on those screens is gone and cannot be restored. No-ops silently when index is already at or above the top (popCount <= 0) or out of range, while still returning { poppedToIndex: index }; confirm by re-reading the stack. Throws 'stackPopToIndex requires a numeric index' or 'navigation stack is not available'.",
381
+ "description": "Pops (stack.length - 1 - index) screens: expo-router calls router.back() that many times in a loop; React Navigation dispatches StackActions.pop(count). Index is 0-based into the synced `stack` array (top-most last). Everything above `index` is destroyed along with its in-memory screen state \u2014 unsaved form input on those screens is gone and cannot be restored. No-ops silently when index is already at or above the top (popCount <= 0) or out of range, while still returning { poppedToIndex: index }; confirm by re-reading the stack. Throws 'stackPopToIndex requires a numeric index' or 'navigation stack is not available'.",
382
382
  "requires": [
383
383
  "<RouteTracker /> mounted inside the navigation tree"
384
384
  ]
@@ -393,7 +393,7 @@
393
393
  },
394
394
  "effect": "destructive",
395
395
  "release": "works",
396
- "description": "Delegates to the captured actions: on expo-router it is popToIndex(0) (a loop of router.back() calls); on React Navigation it dispatches StackActions.popToTop(). Discards every screen above the root along with its in-memory state — call this only when the user asked to reset, not to 'tidy up' mid-flow, because an in-progress form or checkout is lost. Silently does nothing when already at root (depth <= 1) yet still returns { poppedToTop: true }; verify with a fresh stack read. Throws 'navigation stack is not available' without <RouteTracker />.",
396
+ "description": "Delegates to the captured actions: on expo-router it is popToIndex(0) (a loop of router.back() calls); on React Navigation it dispatches StackActions.popToTop(). Discards every screen above the root along with its in-memory state \u2014 call this only when the user asked to reset, not to 'tidy up' mid-flow, because an in-progress form or checkout is lost. Silently does nothing when already at root (depth <= 1) yet still returns { poppedToTop: true }; verify with a fresh stack read. Throws 'navigation stack is not available' without <RouteTracker />.",
397
397
  "requires": [
398
398
  "<RouteTracker /> mounted inside the navigation tree"
399
399
  ]
@@ -408,19 +408,19 @@
408
408
  },
409
409
  "effect": "destructive",
410
410
  "release": "works",
411
- "description": "Calls routeEventStore.clearEvents(), emptying the in-memory RouteChangeEvent ring buffer (pathname, params, segments, timestamp, previousPathname, timeSincePrevious) that feeds the snapshot's `events` array and get_events(sources:['route']). Irreversible — the history is memory-only and not persisted anywhere. Useful as a 'start clean' marker before driving a reproduction. Does NOT touch the navigation stack, the sitemap, or the current screen. Takes no params and returns undefined.",
411
+ "description": "Calls routeEventStore.clearEvents(), emptying the in-memory RouteChangeEvent ring buffer (pathname, params, segments, timestamp, previousPathname, timeSincePrevious) that feeds the snapshot's `events` array and get_events(sources:['route']). Irreversible \u2014 the history is memory-only and not persisted anywhere. Useful as a 'start clean' marker before driving a reproduction. Does NOT touch the navigation stack, the sitemap, or the current screen. Takes no params and returns undefined.",
412
412
  "requires": [
413
413
  "<RouteTracker /> mounted inside the navigation tree (for events to exist at all)"
414
414
  ],
415
415
  "armsCapture": true
416
416
  }
417
417
  ],
418
- "unavailableWhen": "The app has no `<RouteTracker />` mounted inside its navigation tree: the four `stack*` actions then throw \"navigation stack is not available\", and the synced `events`/`stack` arrays stay empty. `navigate` additionally needs expo-router — in a bare React Navigation (RN CLI) app `getSafeRouter()` returns null and it throws \"expo-router is not available on this device\", though the `stack*` actions still work there via the React Navigation container ref."
418
+ "unavailableWhen": "The app has no `<RouteTracker />` mounted inside its navigation tree: the four `stack*` actions then throw \"navigation stack is not available\", and the synced `events`/`stack` arrays stay empty. `navigate` additionally needs expo-router \u2014 in a bare React Navigation (RN CLI) app `getSafeRouter()` returns null and it throws \"expo-router is not available on this device\", though the `stack*` actions still work there via the React Navigation container ref."
419
419
  },
420
420
  {
421
421
  "toolId": "debug-borders",
422
422
  "title": "Debug Borders",
423
- "summary": "Remote control for the on-device layout debugger: draws colored outlines (and, on Pro, tappable labels) around every native view in the running app. It is a pure remote control — there is no dashboard mirror, so the ONLY readable state is the snapshot field `mode` (\"off\" | \"borders\" | \"labels\"). Reach for it when a QA/support user asks \"why is this misaligned / what component is this / what's the testID of that button\", not for reading data. Both actions are visual-only and in-memory: the mode resets to \"off\" on reload, and in a release bundle they flip the flag while nothing is ever drawn on screen.",
423
+ "summary": "Remote control for the on-device layout debugger: draws colored outlines (and, on Pro, tappable labels) around every native view in the running app. It is a pure remote control \u2014 there is no dashboard mirror, so the ONLY readable state is the snapshot field `mode` (\"off\" | \"borders\" | \"labels\"). Reach for it when a QA/support user asks \"why is this misaligned / what component is this / what's the testID of that button\", not for reading data. Both actions are visual-only and in-memory: the mode resets to \"off\" on reload, and in a release bundle they flip the flag while nothing is ever drawn on screen.",
424
424
  "actions": [
425
425
  {
426
426
  "action": "cycleMode",
@@ -432,8 +432,8 @@
432
432
  },
433
433
  "effect": "write",
434
434
  "release": "noop",
435
- "description": "Calls DebugBordersManager.cycle(). The cycle list is license-dependent: Pro gets [off, borders, labels], free gets [off, borders] only — so on a free device this is a plain on/off toggle and will NEVER reach labels no matter how many times it is called. If the current mode isn't in the available list (e.g. the device was in labels and the license lapsed) it resets to \"off\" instead of advancing. Returns undefined, so the wire result is ok:true with no data — read the new mode from the tool snapshot, never assume it. Prefer setMode when you know the mode you want; use cycleMode only for a literal \"toggle it\" request. Once enabled, the overlay draws its first pass ~500ms later and re-measures every 2s, and it hides itself entirely while any Buoy modal or the dial is open, so a screenshot taken with the Buoy UI open shows no borders.",
436
- "releaseNote": "packages/debug-borders/src/debug-borders/utils/fiberTreeTraversal.js:30 — the overlay enumerates views through global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which React Native installs only when __DEV__ is true. In a release bundle getFiberRoots() returns [], the overlay bails on `instances.length === 0`, and zero borders are drawn even though the mode changed and the snapshot reports \"borders\". Do not tell the user borders are on screen in a release build.",
435
+ "description": "Calls DebugBordersManager.cycle(). The cycle list is license-dependent: Pro gets [off, borders, labels], free gets [off, borders] only \u2014 so on a free device this is a plain on/off toggle and will NEVER reach labels no matter how many times it is called. If the current mode isn't in the available list (e.g. the device was in labels and the license lapsed) it resets to \"off\" instead of advancing. Returns undefined, so the wire result is ok:true with no data \u2014 read the new mode from the tool snapshot, never assume it. Prefer setMode when you know the mode you want; use cycleMode only for a literal \"toggle it\" request. Once enabled, the overlay draws its first pass ~500ms later and re-measures every 2s, and it hides itself entirely while any Buoy modal or the dial is open, so a screenshot taken with the Buoy UI open shows no borders.",
436
+ "releaseNote": "packages/debug-borders/src/debug-borders/utils/fiberTreeTraversal.js:30 \u2014 the overlay enumerates views through global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which React Native installs only when __DEV__ is true. In a release bundle getFiberRoots() returns [], the overlay bails on `instances.length === 0`, and zero borders are drawn even though the mode changed and the snapshot reports \"borders\". Do not tell the user borders are on screen in a release build.",
437
437
  "requires": [
438
438
  "@buoy-gg/debug-borders installed in the app",
439
439
  "<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root",
@@ -463,8 +463,8 @@
463
463
  },
464
464
  "effect": "write",
465
465
  "release": "noop",
466
- "description": "Calls DebugBordersManager.setMode(params.mode). `mode` is REQUIRED — the handler hand-casts `(params as {mode}).mode` with no optional chaining and no validation, so omitting params entirely throws (ok:false, \"Cannot read property 'mode' of undefined\"), while an object with a missing or misspelled mode is written verbatim into global state: the snapshot then reports that junk value and, because the overlay only checks `mode !== \"off\"`, borders keep drawing in an unnamed mode. Always pass one of the three literals. \"borders\" outlines every native view, colored by tree depth. \"labels\" (Pro) outlines only views that have a testID or accessibilityLabel and puts a tappable colored chip above each one; tapping a chip opens a device-side sheet with testID / nativeID / component name / x,y,w,h / accessibility props / styles. Those chips sit at zIndex 9000 and DO swallow taps aimed at the app underneath, so set the mode back to \"off\" before driving the UI with taps. Pro gate: setMode(\"labels\") without a license logs \"[DebugBorders] Labels mode requires React Buoy Pro\" and returns false, but the adapter discards that boolean — the action still resolves ok:true with the mode unchanged. Confirm the result in the snapshot before reporting success. Mode is a module-level variable, not persisted: a reload or app restart returns it to \"off\".",
467
- "releaseNote": "Same path as cycleMode: packages/debug-borders/src/debug-borders/utils/fiberTreeTraversal.js:30 depends on global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which exists only under __DEV__, so no rectangles are ever measured in a release bundle. Additionally, in a release build FloatingDevTools returns null unless a real Pro license is present (FloatingDevTools.tsx:708) and the headless branch (FloatingDevTools.tsx:752+) never mounts the overlay at all — three independent reasons nothing appears, while the action still reports ok:true.",
466
+ "description": "Calls DebugBordersManager.setMode(params.mode). `mode` is REQUIRED \u2014 the handler hand-casts `(params as {mode}).mode` with no optional chaining and no validation, so omitting params entirely throws (ok:false, \"Cannot read property 'mode' of undefined\"), while an object with a missing or misspelled mode is written verbatim into global state: the snapshot then reports that junk value and, because the overlay only checks `mode !== \"off\"`, borders keep drawing in an unnamed mode. Always pass one of the three literals. \"borders\" outlines every native view, colored by tree depth. \"labels\" (Pro) outlines only views that have a testID or accessibilityLabel and puts a tappable colored chip above each one; tapping a chip opens a device-side sheet with testID / nativeID / component name / x,y,w,h / accessibility props / styles. Those chips sit at zIndex 9000 and DO swallow taps aimed at the app underneath, so set the mode back to \"off\" before driving the UI with taps. Pro gate: setMode(\"labels\") without a license logs \"[DebugBorders] Labels mode requires React Buoy Pro\" and returns false, but the adapter discards that boolean \u2014 the action still resolves ok:true with the mode unchanged. Confirm the result in the snapshot before reporting success. Mode is a module-level variable, not persisted: a reload or app restart returns it to \"off\".",
467
+ "releaseNote": "Same path as cycleMode: packages/debug-borders/src/debug-borders/utils/fiberTreeTraversal.js:30 depends on global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which exists only under __DEV__, so no rectangles are ever measured in a release bundle. Additionally, in a release build FloatingDevTools returns null unless a real Pro license is present (FloatingDevTools.tsx:708) and the headless branch (FloatingDevTools.tsx:752+) never mounts the overlay at all \u2014 three independent reasons nothing appears, while the action still reports ok:true.",
468
468
  "requires": [
469
469
  "@buoy-gg/debug-borders installed in the app",
470
470
  "<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root",
@@ -477,7 +477,7 @@
477
477
  {
478
478
  "toolId": "zustand",
479
479
  "title": "Zustand",
480
- "summary": "Reads and writes the app's live Zustand stores: list registered stores with their top-level keys or full state, fetch one store's current state, fetch the real before/after trees for one recorded state change, and setState a store for time-travel/reset. Reach for it when on-screen data disagrees with the API, or when a QA user needs the app put into a specific state. The change TIMELINE (history over time) is better read via get_events sources:['zustand']; this tool is for CURRENT state plus on-demand detail. Everything here works in release builds — but setState defaults to replace:true, which wipes the store's action functions.",
480
+ "summary": "Reads and writes the app's live Zustand stores: list registered stores with their top-level keys or full state, fetch one store's current state, fetch the real before/after trees for one recorded state change, and setState a store for time-travel/reset. Reach for it when on-screen data disagrees with the API, or when a QA user needs the app put into a specific state. The change TIMELINE (history over time) is better read via get_events sources:['zustand']; this tool is for CURRENT state plus on-demand detail. Everything here works in release builds \u2014 but setState defaults to replace:true, which wipes the store's action functions.",
481
481
  "actions": [
482
482
  {
483
483
  "action": "listStores",
@@ -487,7 +487,7 @@
487
487
  "properties": {
488
488
  "includeValues": {
489
489
  "type": "boolean",
490
- "description": "Include each store's full currentState object (HEAVY — whole state trees). Default false, which returns only top-level `keys` so the shape is visible cheaply."
490
+ "description": "Include each store's full currentState object (HEAVY \u2014 whole state trees). Default false, which returns only top-level `keys` so the shape is visible cheaply."
491
491
  },
492
492
  "limit": {
493
493
  "type": "number",
@@ -498,15 +498,15 @@
498
498
  },
499
499
  "effect": "read",
500
500
  "release": "works",
501
- "description": "The entry point — call this first to learn valid storeName values for getStoreState/setState. Returns {stores:[{name, changes, isPersisted, persistName?, keys?|currentState?}], total, returned, includedValues}. Compact by default: `keys` is Object.keys() of the state (undefined when the state isn't a plain object). includeValues:true swaps `keys` for the full `currentState` object and can be very large — the state objects here are NOT wire-budget-capped the way the streaming snapshot is. `changes` is that store's recorded state-change count, reset to 0 by clearEvents. Names come from the app: the object keys passed to watchStores({counterStore: useCounterStore}) or the `name` option of buoyDevTools(). An empty stores array means the app never instrumented its stores, not that it has none. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not — read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields.",
501
+ "description": "The entry point \u2014 call this first to learn valid storeName values for getStoreState/setState. Returns {stores:[{name, changes, isPersisted, persistName?, keys?|currentState?}], total, returned, includedValues}. Compact by default: `keys` is Object.keys() of the state (undefined when the state isn't a plain object). includeValues:true swaps `keys` for the full `currentState` object and can be very large \u2014 the state objects here are NOT wire-budget-capped the way the streaming snapshot is. `changes` is that store's recorded state-change count, reset to 0 by clearEvents. Names come from the app: the object keys passed to watchStores({counterStore: useCounterStore}) or the `name` option of buoyDevTools(). An empty stores array means the app never instrumented its stores, not that it has none. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not \u2014 read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields.",
502
502
  "requires": [
503
503
  "@buoy-gg/zustand installed and the zustand tool registered with FloatingDevTools",
504
- "the app calls watchStores({...}) or wraps stores with buoyDevTools() — otherwise the registry is empty"
504
+ "the app calls watchStores({...}) or wraps stores with buoyDevTools() \u2014 otherwise the registry is empty"
505
505
  ]
506
506
  },
507
507
  {
508
508
  "action": "getStoreState",
509
- "summary": "Fetch one store's full current state object on demand, by store name. Send `path` to get back ONE value instead of the whole store (path:\"lines[lineId=seed-1].qty\") — do that whenever the store is big, because results are cut off at 24,000 characters.",
509
+ "summary": "Fetch one store's full current state object on demand, by store name. Send `path` to get back ONE value instead of the whole store (path:\"lines[lineId=seed-1].qty\") \u2014 do that whenever the store is big, because results are cut off at 24,000 characters.",
510
510
  "params": {
511
511
  "type": "object",
512
512
  "properties": {
@@ -516,7 +516,7 @@
516
516
  },
517
517
  "path": {
518
518
  "type": "string",
519
- "description": "Return only the value at this path instead of the whole payload. Use it when you only need one field, and ALWAYS when the payload is big: results are cut off at 24,000 characters, and a field you never saw is a field you will guess the shape of. The path must match the CURRENT data — read `shape` first rather than assuming a wrapper. On a bad path it answers with the field names that do exist, and with the path that would have worked if that field lives somewhere else."
519
+ "description": "Return only the value at this path instead of the whole payload. Use it when you only need one field, and ALWAYS when the payload is big: results are cut off at 24,000 characters, and a field you never saw is a field you will guess the shape of. The path must match the CURRENT data \u2014 read `shape` first rather than assuming a wrapper. On a bad path it answers with the field names that do exist, and with the path that would have worked if that field lives somewhere else."
520
520
  }
521
521
  },
522
522
  "required": [
@@ -526,9 +526,9 @@
526
526
  },
527
527
  "effect": "read",
528
528
  "release": "works",
529
- "description": "Use when listStores' compact `keys` view isn't enough, or when the streaming snapshot showed the {__buoyStateOnDevice:true} marker (states over 16KB are withheld from the per-snapshot wire). Returns {found:true, storeName, currentState} — or {found:false, reason:\"missing storeName\"} / {found:false, reason:\"unknown storeName\"}, which is a plain answer, not an error. currentState is read live via store.api.getState(); if that throws it comes back undefined. Over the 8MB detail cap it returns {__buoyTruncated:true, note} instead, or the STATE_ON_DEVICE marker when the state isn't JSON-serializable. Note that action functions living in state (increment, reset, …) do not survive the JSON wire — what you read back is the data half of the store only. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not — read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields. Send `path` to get back ONE value instead of the whole store (path:\"lines[lineId=seed-1].qty\") — do that whenever the store is big, because results are cut off at 24,000 characters.",
529
+ "description": "Use when listStores' compact `keys` view isn't enough, or when the streaming snapshot showed the {__buoyStateOnDevice:true} marker (states over 16KB are withheld from the per-snapshot wire). Returns {found:true, storeName, currentState} \u2014 or {found:false, reason:\"missing storeName\"} / {found:false, reason:\"unknown storeName\"}, which is a plain answer, not an error. currentState is read live via store.api.getState(); if that throws it comes back undefined. Over the 8MB detail cap it returns {__buoyTruncated:true, note} instead, or the STATE_ON_DEVICE marker when the state isn't JSON-serializable. Note that action functions living in state (increment, reset, \u2026) do not survive the JSON wire \u2014 what you read back is the data half of the store only. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not \u2014 read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields. Send `path` to get back ONE value instead of the whole store (path:\"lines[lineId=seed-1].qty\") \u2014 do that whenever the store is big, because results are cut off at 24,000 characters.",
530
530
  "requires": [
531
- "the store must already be registered — get the exact name from listStores"
531
+ "the store must already be registered \u2014 get the exact name from listStores"
532
532
  ]
533
533
  },
534
534
  {
@@ -549,14 +549,14 @@
549
549
  },
550
550
  "effect": "read",
551
551
  "release": "works",
552
- "description": "The streaming change log deliberately carries no state trees — every change's prevState/nextState arrive as the {__buoyStateOnDevice:true} marker, and an oversized `partial` as {__buoyPayloadOnDevice:true}. This is the explicit channel that fetches the real trees for one change so you can diff before/after. `id` comes from a change row (format `<epochMs>-<counter>`, e.g. \"1761580000123-42\"). Returns {found:true, id, prevState, nextState, partial} or {found:false, reason:\"missing id\"|\"unknown id\"}. Each value over 8MB is replaced by {__buoyTruncated:true, note}. Only the newest 200 changes are retained (ring buffer), and clearEvents empties it — an id that scrolled off returns \"unknown id\". `partial` is undefined for stores instrumented with watchStores (subscribe-only mode can't see the setState argument); only the buoyDevTools middleware records partial and duration.",
552
+ "description": "The streaming change log deliberately carries no state trees \u2014 every change's prevState/nextState arrive as the {__buoyStateOnDevice:true} marker, and an oversized `partial` as {__buoyPayloadOnDevice:true}. This is the explicit channel that fetches the real trees for one change so you can diff before/after. `id` comes from a change row (format `<epochMs>-<counter>`, e.g. \"1761580000123-42\"). Returns {found:true, id, prevState, nextState, partial} or {found:false, reason:\"missing id\"|\"unknown id\"}. Each value over 8MB is replaced by {__buoyTruncated:true, note}. Only the newest 200 changes are retained (ring buffer), and clearEvents empties it \u2014 an id that scrolled off returns \"unknown id\". `partial` is undefined for stores instrumented with watchStores (subscribe-only mode can't see the setState argument); only the buoyDevTools middleware records partial and duration.",
553
553
  "requires": [
554
554
  "a change id from the zustand change log (the tool snapshot, or get_events sources:['zustand'])"
555
555
  ]
556
556
  },
557
557
  {
558
558
  "action": "setState",
559
- "summary": "Change a live zustand store. Ask Buoy MERGES by default (replace:false): it merges the fields you send, including inside nested objects (so {\"member\":{\"crowns\":5}} changes crowns and keeps member's other fields), and KEEPS the store's action functions (setQty, removeLine, …) and untouched keys. Never send replace:true unless you mean to reset the whole store — replacing drops the functions (they can't cross the wire), and the app's buttons that call them then crash. To change a list in the store, ADDRESS ITEMS BY THEIR OWN id instead of resending the list: {\"lines\":{\"seed-1\":{\"qty\":5}}} changes one field of one item, {\"lines\":{\"seed-2\":null}} removes that item, and a key that isn't in the list yet appends a new item (send all its fields). Items you don't name are untouched, so this is the only safe form when you haven't seen the whole list — a capped read means you CANNOT resend it without deleting what you weren't shown. Sending a plain ARRAY still works and still replaces the whole list, which is how you deliberately empty it. On a merge it's a TYPED EDIT: you can only change a field that already exists, to the same type, and any list item you add must match the shape of the ones already there — a wrong type, an unknown field, or a malformed new item is refused with the exact path. force:true bypasses. To change a single value, `path` + `value` is the safest form (path:\"lines[lineId=seed-1].qty\", value:5): it is always a merge and there is no nesting to get wrong.",
559
+ "summary": "Change a live zustand store. Ask Buoy MERGES by default (replace:false): it merges the fields you send, including inside nested objects (so {\"member\":{\"crowns\":5}} changes crowns and keeps member's other fields), and KEEPS the store's action functions (setQty, removeLine, \u2026) and untouched keys. Never send replace:true unless you mean to reset the whole store \u2014 replacing drops the functions (they can't cross the wire), and the app's buttons that call them then crash. To change a list in the store, ADDRESS ITEMS BY THEIR OWN id instead of resending the list: {\"lines\":{\"seed-1\":{\"qty\":5}}} changes one field of one item, {\"lines\":{\"seed-2\":null}} removes that item, and a key that isn't in the list yet appends a new item (send all its fields). Items you don't name are untouched, so this is the only safe form when you haven't seen the whole list \u2014 a capped read means you CANNOT resend it without deleting what you weren't shown. Sending a plain ARRAY still works and still replaces the whole list, which is how you deliberately empty it. On a merge it's a TYPED EDIT: you can only change a field that already exists, to the same type, and any list item you add must match the shape of the ones already there \u2014 a wrong type, an unknown field, or a malformed new item is refused with the exact path. force:true bypasses. To change a single value, `path` + `value` is the safest form (path:\"lines[lineId=seed-1].qty\", value:5): it is always a merge and there is no nesting to get wrong.",
560
560
  "params": {
561
561
  "type": "object",
562
562
  "properties": {
@@ -566,21 +566,21 @@
566
566
  },
567
567
  "state": {
568
568
  "type": "object",
569
- "description": "The state to write. With replace true (the default) this becomes the store's ENTIRE state and every absent key — including action functions — is removed. With replace false it is merged as a partial, so pass only the fields you intend to change (e.g. {\"count\": 5}).",
569
+ "description": "The state to write. With replace true (the default) this becomes the store's ENTIRE state and every absent key \u2014 including action functions \u2014 is removed. With replace false it is merged as a partial, so pass only the fields you intend to change (e.g. {\"count\": 5}).",
570
570
  "additionalProperties": true
571
571
  },
572
572
  "replace": {
573
573
  "type": "boolean",
574
- "description": "true replaces the whole state — including the store's action functions, which breaks every button wired to them until the app reloads. Buoy sends false for you unless you explicitly pass true, so a partial merge is the safe path: pass only the fields you intend to change (e.g. {\"count\": 5}). The ADAPTER's own default is true; this is a deliberately safer default for agent calls.",
574
+ "description": "true replaces the whole state \u2014 including the store's action functions, which breaks every button wired to them until the app reloads. Buoy sends false for you unless you explicitly pass true, so a partial merge is the safe path: pass only the fields you intend to change (e.g. {\"count\": 5}). The ADAPTER's own default is true; this is a deliberately safer default for agent calls.",
575
575
  "default": false
576
576
  },
577
577
  "force": {
578
578
  "type": "boolean",
579
- "description": "Bypass the typed-edit safety on a merge and write raw. Default false — a violating merge is refused."
579
+ "description": "Bypass the typed-edit safety on a merge and write raw. Default false \u2014 a violating merge is refused."
580
580
  },
581
581
  "path": {
582
582
  "type": "string",
583
- "description": "Set ONE value, named by its full path in the CURRENT data: path:\"<the real path>\", value:<new value>. READ THE DATA FIRST AND COPY THE PATH FROM IT — `shape` on getStoreState/listStores prints it. A path is only safer than a hand-nested `data` if it matches the payload you are actually looking at; if the field is at the top level the path is just \"name\", and inventing a wrapper that is not there is refused. A list step is written [field=value] or [0] and resolves to that item's own id, so it still means the same row if the list changed. Always a merge, and always through the same typed-edit guard as `data`. Requires `value`; send `data` OR `path`+`value`, not both."
583
+ "description": "Set ONE value, named by its full path in the CURRENT data: path:\"<the real path>\", value:<new value>. READ THE DATA FIRST AND COPY THE PATH FROM IT \u2014 `shape` on getStoreState/listStores prints it. A path is only safer than a hand-nested `data` if it matches the payload you are actually looking at; if the field is at the top level the path is just \"name\", and inventing a wrapper that is not there is refused. A list step is written [field=value] or [0] and resolves to that item's own id, so it still means the same row if the list changed. Always a merge, and always through the same typed-edit guard as `data`. Requires `value`; send `data` OR `path`+`value`, not both."
584
584
  },
585
585
  "value": {
586
586
  "description": "The value `path` is set to. Required whenever `path` is sent. null is a real value (only allowed if the field is already nullable), not a delete."
@@ -593,16 +593,16 @@
593
593
  },
594
594
  "effect": "destructive",
595
595
  "release": "works",
596
- "description": "Powers time-travel / reset / 'put the app in this state' from a dashboard. Calls the store's real setState(state, replace ?? true). THE DEFAULT IS DESTRUCTIVE: `replace` defaults to TRUE, so any key missing from `state` is deleted — and Zustand stores conventionally keep their action functions in state (increment, login, addToCart). Functions cannot cross the sync wire, so a JSON `state` object can never carry them back; a default-replace leaves every `useStore(s => s.increment)` call site reading undefined and the app broken until reload. Buoy's own Time Machine restore avoids this by re-grafting the live store's functions onto the snapshot before replacing (packages/zustand/src/zustand/utils/snapshotProvider.ts:8-12) — this raw action does NOT do that. For a QA-facing tweak always pass replace:false to merge just the fields you're changing. Returns {ok:true}, or {ok:false, error:\"Missing storeName.\"} / {ok:false, error:'No store named \"X\".'}. The write also lands in the change log as a normal recorded change. A write that adds the first item to an EMPTY list comes back ok with `unchecked`: the list had no items, so nothing knew its item shape and yours was not verified. Treat that as a warning to check the screen, not as a pass. To change a single value, `path` + `value` is the safest form (path:\"lines[lineId=seed-1].qty\", value:5): it is always a merge and there is no nesting to get wrong.",
596
+ "description": "Powers time-travel / reset / 'put the app in this state' from a dashboard. Calls the store's real setState(state, replace ?? true). THE DEFAULT IS DESTRUCTIVE: `replace` defaults to TRUE, so any key missing from `state` is deleted \u2014 and Zustand stores conventionally keep their action functions in state (increment, login, addToCart). Functions cannot cross the sync wire, so a JSON `state` object can never carry them back; a default-replace leaves every `useStore(s => s.increment)` call site reading undefined and the app broken until reload. Buoy's own Time Machine restore avoids this by re-grafting the live store's functions onto the snapshot before replacing (packages/zustand/src/zustand/utils/snapshotProvider.ts:8-12) \u2014 this raw action does NOT do that. For a QA-facing tweak always pass replace:false to merge just the fields you're changing. Returns {ok:true}, or {ok:false, error:\"Missing storeName.\"} / {ok:false, error:'No store named \"X\".'}. The write also lands in the change log as a normal recorded change. A write that adds the first item to an EMPTY list comes back ok with `unchecked`: the list had no items, so nothing knew its item shape and yours was not verified. Treat that as a warning to check the screen, not as a pass. To change a single value, `path` + `value` is the safest form (path:\"lines[lineId=seed-1].qty\", value:5): it is always a merge and there is no nesting to get wrong.",
597
597
  "requires": [
598
- "the store must be registered — get the exact name from listStores",
598
+ "the store must be registered \u2014 get the exact name from listStores",
599
599
  "the store's registered setState handle: watchStores registers the raw setState, buoyDevTools registers its instrumented set (both apply the write)"
600
600
  ]
601
601
  },
602
602
  {
603
603
  "action": "rehydrate",
604
604
  "summary": "Re-read a persisted store's saved value from storage and merge it into the live store. Use it after anything wrote that storage key directly.",
605
- "description": "Returns {ok, storeName, persistName}. THIS IS HOW A STORAGE EDIT TAKES EFFECT. A persisted store reads its key once at startup and then holds the state in memory, so writing the key with storage.async.setItem / mmkv.set changes the disk and nothing else — the screen does not move, and the next time the store saves it writes its own copy back over the edit. Rehydrating closes that loop. It merges the saved value OVER current state, so the store's action functions survive, and it does not rewrite storage unless a version migration ran. Two limits, both inherent to how persist works: a field the store's `partialize` excludes is not in the saved value and cannot be applied, and because the merge is shallow, DELETING a key from the saved JSON does not remove it from the live store. Prefer setState for an ordinary change; this is for when the storage key is what changed.",
605
+ "description": "Returns {ok, storeName, persistName}. THIS IS HOW A STORAGE EDIT TAKES EFFECT. A persisted store reads its key once at startup and then holds the state in memory, so writing the key with storage.async.setItem / mmkv.set changes the disk and nothing else \u2014 the screen does not move, and the next time the store saves it writes its own copy back over the edit. Rehydrating closes that loop. It merges the saved value OVER current state, so the store's action functions survive, and it does not rewrite storage unless a version migration ran. Two limits, both inherent to how persist works: a field the store's `partialize` excludes is not in the saved value and cannot be applied, and because the merge is shallow, DELETING a key from the saved JSON does not remove it from the live store. Prefer setState for an ordinary change; this is for when the storage key is what changed.",
606
606
  "params": {
607
607
  "type": "object",
608
608
  "properties": {
@@ -632,35 +632,35 @@
632
632
  },
633
633
  "effect": "destructive",
634
634
  "release": "works",
635
- "description": "Empties the in-memory change log for ALL stores (not one store) and sets each registered store's stateChangeCount to 0, then notifies listeners. Irreversible — the discarded changes are not persisted anywhere, and any change id you were holding becomes \"unknown id\" for getChangeDetail. Does NOT touch app state: the stores keep their current values, only the history is destroyed. Useful to get a clean baseline before reproducing a bug. Returns nothing (undefined) on success. Capture continues afterwards without needing a re-subscribe."
635
+ "description": "Empties the in-memory change log for ALL stores (not one store) and sets each registered store's stateChangeCount to 0, then notifies listeners. Irreversible \u2014 the discarded changes are not persisted anywhere, and any change id you were holding becomes \"unknown id\" for getChangeDetail. Does NOT touch app state: the stores keep their current values, only the history is destroyed. Useful to get a clean baseline before reproducing a bug. Returns nothing (undefined) on success. Capture continues afterwards without needing a re-subscribe."
636
636
  }
637
637
  ],
638
- "unavailableWhen": "The app doesn't depend on @buoy-gg/zustand or the zustand tool isn't registered with FloatingDevTools — the adapter is never mapped in (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:335) and the tool id \"zustand\" is absent from the device's action inventory. Installed but never instrumented (no watchStores() / buoyDevTools() call) is different: every action still answers, but listStores returns zero stores and the change log is empty. On a desktop mirror the store runs with capture suppressed (zustandStateStore.disableCapture()), so it only reflects what the device sent."
638
+ "unavailableWhen": "The app doesn't depend on @buoy-gg/zustand or the zustand tool isn't registered with FloatingDevTools \u2014 the adapter is never mapped in (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:335) and the tool id \"zustand\" is absent from the device's action inventory. Installed but never instrumented (no watchStores() / buoyDevTools() call) is different: every action still answers, but listStores returns zero stores and the change log is empty. On a desktop mirror the store runs with capture suppressed (zustandStateStore.disableCapture()), so it only reflects what the device sent."
639
639
  },
640
640
  {
641
641
  "toolId": "redux",
642
642
  "title": "Redux",
643
- "summary": "Reads and drives the app's LIVE Redux store, plus the captured action log. Use getState for what is in the store right now (Redux is action-based, so current state is NOT in the action-log snapshot — that history comes from get_events sources:['redux']), dispatch to push a plain action into the running app, getActionDetail to pull one action's real prevState/nextState trees (snapshots ship markers, not trees), and clearEvents to wipe the recorded log. All four need a Redux store bound to Buoy; when none is, getState/dispatch answer available:false with a specific reason instead of failing generically.",
643
+ "summary": "Reads and drives the app's LIVE Redux store, plus the captured action log. Use getState for what is in the store right now (Redux is action-based, so current state is NOT in the action-log snapshot \u2014 that history comes from get_events sources:['redux']), dispatch to push a plain action into the running app, getActionDetail to pull one action's real prevState/nextState trees (snapshots ship markers, not trees), and clearEvents to wipe the recorded log. All four need a Redux store bound to Buoy; when none is, getState/dispatch answer available:false with a specific reason instead of failing generically.",
644
644
  "actions": [
645
645
  {
646
646
  "action": "getState",
647
- "summary": "Read the app's CURRENT Redux state — top-level slice names by default, full state tree with includeValues:true.",
647
+ "summary": "Read the app's CURRENT Redux state \u2014 top-level slice names by default, full state tree with includeValues:true.",
648
648
  "params": {
649
649
  "type": "object",
650
650
  "properties": {
651
651
  "includeValues": {
652
652
  "type": "boolean",
653
- "description": "Include the full current state tree (HEAVY — the whole store is serialized over the wire). Default false: slice names only."
653
+ "description": "Include the full current state tree (HEAVY \u2014 the whole store is serialized over the wire). Default false: slice names only."
654
654
  }
655
655
  },
656
656
  "additionalProperties": false
657
657
  },
658
658
  "effect": "read",
659
659
  "release": "works",
660
- "description": "Compact by DEFAULT and token-cheap: returns {available:true, slices:string[], capture:'full'|'top-level-only', mechanism:'enhancer'|'middleware'|'patch'}. Pass includeValues:true to add `state` — the entire store tree, which can be megabytes on a real app. Use for 'what's in my redux store / current auth state'. When no store is bound it returns {available:false, slices:[], reason:'no-react-redux'|'no-provider'|'not-instrumented'} — report that specific reason: no-react-redux means the app lacks react-redux, no-provider means <FloatingDevTools /> is not inside <Provider store={store}>, not-instrumented usually means an older @buoy-gg/core or the app should call registerReduxStore(store). If `capture` is 'top-level-only', warn the user that thunk-internal and RTK Query actions are NOT in the action log (the fix is `import '@buoy-gg/redux';` first in the app entry, or adding buoyReduxMiddleware) — this does not affect the state values you just read, which are always live and correct.",
660
+ "description": "Compact by DEFAULT and token-cheap: returns {available:true, slices:string[], capture:'full'|'top-level-only', mechanism:'enhancer'|'middleware'|'patch'}. Pass includeValues:true to add `state` \u2014 the entire store tree, which can be megabytes on a real app. Use for 'what's in my redux store / current auth state'. When no store is bound it returns {available:false, slices:[], reason:'no-react-redux'|'no-provider'|'not-instrumented'} \u2014 report that specific reason: no-react-redux means the app lacks react-redux, no-provider means <FloatingDevTools /> is not inside <Provider store={store}>, not-instrumented usually means an older @buoy-gg/core or the app should call registerReduxStore(store). If `capture` is 'top-level-only', warn the user that thunk-internal and RTK Query actions are NOT in the action log (the fix is `import '@buoy-gg/redux';` first in the app entry, or adding buoyReduxMiddleware) \u2014 this does not affect the state values you just read, which are always live and correct.",
661
661
  "requires": [
662
662
  "react-redux installed in the app",
663
- "a Redux <Provider> above <FloatingDevTools /> — or an explicit registerReduxStore(store) from @buoy-gg/redux"
663
+ "a Redux <Provider> above <FloatingDevTools /> \u2014 or an explicit registerReduxStore(store) from @buoy-gg/redux"
664
664
  ]
665
665
  },
666
666
  {
@@ -681,14 +681,14 @@
681
681
  },
682
682
  "effect": "read",
683
683
  "release": "works",
684
- "description": "The per-snapshot action stream deliberately carries markers ({__buoyStateOnDevice:true}, {__buoyPayloadOnDevice:true}) instead of state trees — shipping them froze and OOM-killed large apps — so this is the ONLY way to see an action's before/after state. `id` comes from a redux action row and is formatted \"<epochMillis>-<counter>\" (e.g. \"1724612345678-42\"). Returns {found:true, id, prevState, nextState, payload, action, meta, error}; any single field over 8MB is replaced by {__buoyTruncated:true, note} rather than failing the whole call. Two negative shapes to relay verbatim: {found:false, reason:'unknown id'|'missing id'} (id not in the current log, or omitted), and {found:true, evicted:true, reason:...} — raw trees are retained for the 25 MOST RECENT actions only, so an older action keeps its diff metadata but its trees are gone forever. Do not retry an evicted action; reproduce the behavior again and read the fresh entry.",
684
+ "description": "The per-snapshot action stream deliberately carries markers ({__buoyStateOnDevice:true}, {__buoyPayloadOnDevice:true}) instead of state trees \u2014 shipping them froze and OOM-killed large apps \u2014 so this is the ONLY way to see an action's before/after state. `id` comes from a redux action row and is formatted \"<epochMillis>-<counter>\" (e.g. \"1724612345678-42\"). Returns {found:true, id, prevState, nextState, payload, action, meta, error}; any single field over 8MB is replaced by {__buoyTruncated:true, note} rather than failing the whole call. Two negative shapes to relay verbatim: {found:false, reason:'unknown id'|'missing id'} (id not in the current log, or omitted), and {found:true, evicted:true, reason:...} \u2014 raw trees are retained for the 25 MOST RECENT actions only, so an older action keeps its diff metadata but its trees are gone forever. Do not retry an evicted action; reproduce the behavior again and read the fresh entry.",
685
685
  "requires": [
686
686
  "an instrumented store that has already recorded the action (log holds the 200 most recent actions; raw state trees only the 25 most recent)"
687
687
  ]
688
688
  },
689
689
  {
690
690
  "action": "dispatch",
691
- "summary": "Dispatch a plain action object into the app's live Redux store — really changes app state.",
691
+ "summary": "Dispatch a plain action object into the app's live Redux store \u2014 really changes app state.",
692
692
  "params": {
693
693
  "type": "object",
694
694
  "properties": {
@@ -716,10 +716,10 @@
716
716
  },
717
717
  "effect": "destructive",
718
718
  "release": "works",
719
- "description": "Sends the given plain action straight to store.dispatch, e.g. {\"action\":{\"type\":\"counter/increment\",\"payload\":1}} or {\"action\":{\"type\":\"auth/logout\"}}. (The MCP redux_dispatch tool takes flat {type, payload} and wraps it into this shape for you.) `action` is REQUIRED and must be a plain object with a string `type` — Redux itself throws on a missing/undefined type, and thunk functions cannot be sent over the wire. Returns {dispatched:true, type} on success, or {dispatched:false, available:false, reason:'no-react-redux'|'no-provider'|'not-instrumented'} when no store is bound — never claim a dispatch landed unless dispatched is true. Treat as destructive: this mutates the real app the user is looking at, and a type like auth/logout, cart/clear or a rehydrate action is irreversible from here — the adapter exposes no time travel (jumpToState is NOT a sync action). Confirm the exact action type with the user before dispatching anything that resets, clears, or logs out.",
719
+ "description": "Sends the given plain action straight to store.dispatch, e.g. {\"action\":{\"type\":\"counter/increment\",\"payload\":1}} or {\"action\":{\"type\":\"auth/logout\"}}. (The MCP redux_dispatch tool takes flat {type, payload} and wraps it into this shape for you.) `action` is REQUIRED and must be a plain object with a string `type` \u2014 Redux itself throws on a missing/undefined type, and thunk functions cannot be sent over the wire. Returns {dispatched:true, type} on success, or {dispatched:false, available:false, reason:'no-react-redux'|'no-provider'|'not-instrumented'} when no store is bound \u2014 never claim a dispatch landed unless dispatched is true. Treat as destructive: this mutates the real app the user is looking at, and a type like auth/logout, cart/clear or a rehydrate action is irreversible from here \u2014 the adapter exposes no time travel (jumpToState is NOT a sync action). Confirm the exact action type with the user before dispatching anything that resets, clears, or logs out.",
720
720
  "requires": [
721
721
  "react-redux installed in the app",
722
- "a Redux <Provider> above <FloatingDevTools /> — or an explicit registerReduxStore(store)",
722
+ "a Redux <Provider> above <FloatingDevTools /> \u2014 or an explicit registerReduxStore(store)",
723
723
  "the app must actually handle the action type; an unknown type dispatches successfully and changes nothing"
724
724
  ]
725
725
  },
@@ -733,15 +733,15 @@
733
733
  },
734
734
  "effect": "destructive",
735
735
  "release": "works",
736
- "description": "Empties reduxActionStore — every captured action, its payload and its retained prevState/nextState trees are gone, and any pending getActionDetail id becomes 'unknown id'. Does NOT touch the app's actual Redux state: the store keeps whatever it currently holds, only the recording is cleared. Returns nothing (undefined) — success is the absence of an error. Useful to get a clean baseline right before reproducing a bug; never call it before you have read anything the user might still need, since there is no export or restore."
736
+ "description": "Empties reduxActionStore \u2014 every captured action, its payload and its retained prevState/nextState trees are gone, and any pending getActionDetail id becomes 'unknown id'. Does NOT touch the app's actual Redux state: the store keeps whatever it currently holds, only the recording is cleared. Returns nothing (undefined) \u2014 success is the absence of an error. Useful to get a clean baseline right before reproducing a bug; never call it before you have read anything the user might still need, since there is no export or restore."
737
737
  }
738
738
  ],
739
- "unavailableWhen": "No Redux store is bound to Buoy — react-redux not installed (\"no-react-redux\"), no <Provider> above <FloatingDevTools /> (\"no-provider\"), or nothing instrumented the store yet (\"not-instrumented\"). getState/dispatch then return available:false with that reason and dispatched:false; the log actions still respond but the log stays empty. Separately, in a release build (__DEV__ === false) the whole sync channel only exists if the app opted in with externalSync.enableInRelease AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts:49-60) — otherwise no action on this tool is reachable at all."
739
+ "unavailableWhen": "No Redux store is bound to Buoy \u2014 react-redux not installed (\"no-react-redux\"), no <Provider> above <FloatingDevTools /> (\"no-provider\"), or nothing instrumented the store yet (\"not-instrumented\"). getState/dispatch then return available:false with that reason and dispatched:false; the log actions still respond but the log stays empty. Separately, in a release build (__DEV__ === false) the whole sync channel only exists if the app opted in with externalSync.enableInRelease AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts:49-60) \u2014 otherwise no action on this tool is reachable at all."
740
740
  },
741
741
  {
742
742
  "toolId": "impersonate",
743
743
  "title": "Impersonate",
744
- "summary": "Become another user inside the running app without logging out: it injects an impersonation header (default `x-impersonate-user-id: <user.id>`) into every outgoing globalThis.fetch and XMLHttpRequest, so the backend returns that user's data. Reach for it to reproduce a specific customer's bug (\"show me what account 8812 sees\"), then stop/pause to return to the real login. Actions only mutate state — they all resolve to void, so read the tool snapshot (isActive / isPaused / currentUser / history) to confirm anything took effect. Note that switching or stopping also CLEARS app caches per dataNukeSettings (react-query + redux on by default), so it is not a passive read-only view.",
744
+ "summary": "Become another user inside the running app without logging out: it injects an impersonation header (default `x-impersonate-user-id: <user.id>`) into every outgoing globalThis.fetch and XMLHttpRequest, so the backend returns that user's data. Reach for it to reproduce a specific customer's bug (\"show me what account 8812 sees\"), then stop/pause to return to the real login. Actions only mutate state \u2014 they all resolve to void, so read the tool snapshot (isActive / isPaused / currentUser / history) to confirm anything took effect. Note that switching or stopping also CLEARS app caches per dataNukeSettings (react-query + redux on by default), so it is not a passive read-only view.",
745
745
  "actions": [
746
746
  {
747
747
  "action": "searchUsers",
@@ -751,7 +751,7 @@
751
751
  "properties": {
752
752
  "query": {
753
753
  "type": "string",
754
- "description": "Search text handed verbatim to the app's onSearchUsers — usually an email, name, or user id. Coerced via String(); omitting it sends an empty string, which most apps treat as 'list everything'."
754
+ "description": "Search text handed verbatim to the app's onSearchUsers \u2014 usually an email, name, or user id. Coerced via String(); omitting it sends an empty string, which most apps treat as 'list everything'."
755
755
  }
756
756
  },
757
757
  "required": [],
@@ -759,20 +759,20 @@
759
759
  },
760
760
  "effect": "read",
761
761
  "release": "works",
762
- "description": "Proxies straight to the host app's onSearchUsers(query) callback — a real request to the company's own admin/user API, so results and latency are entirely the app's. Returns an array of User objects: { id, displayName?, email?, avatarUrl?, metadata? }. Wire-shrinking is applied before it reaches you: any avatarUrl that is a data: URI or longer than 2048 chars is replaced by a stub string like \"data:image/png;base64,[3145728 chars]\", and a metadata object over 16KB is replaced by { role, __buoyOmitted: \"user-metadata\" } — the device keeps the real values. ALWAYS call this before startImpersonation instead of hand-constructing a user; the app's real user id is what the backend checks. Throws \"No onSearchUsers configured\" if the app never passed onSearchUsers to createImpersonateTool().",
762
+ "description": "Proxies straight to the host app's onSearchUsers(query) callback \u2014 a real request to the company's own admin/user API, so results and latency are entirely the app's. Returns an array of User objects: { id, displayName?, email?, avatarUrl?, metadata? }. Wire-shrinking is applied before it reaches you: any avatarUrl that is a data: URI or longer than 2048 chars is replaced by a stub string like \"data:image/png;base64,[3145728 chars]\", and a metadata object over 16KB is replaced by { role, __buoyOmitted: \"user-metadata\" } \u2014 the device keeps the real values. ALWAYS call this before startImpersonation instead of hand-constructing a user; the app's real user id is what the backend checks. Throws \"No onSearchUsers configured\" if the app never passed onSearchUsers to createImpersonateTool().",
763
763
  "requires": [
764
764
  "createImpersonateTool({ onSearchUsers }) called by the host app"
765
765
  ]
766
766
  },
767
767
  {
768
768
  "action": "startImpersonation",
769
- "summary": "Begin impersonating a user — every subsequent fetch/XHR carries the impersonation header, and app caches are wiped per dataNukeSettings.",
769
+ "summary": "Begin impersonating a user \u2014 every subsequent fetch/XHR carries the impersonation header, and app caches are wiped per dataNukeSettings.",
770
770
  "params": {
771
771
  "type": "object",
772
772
  "properties": {
773
773
  "user": {
774
774
  "type": "object",
775
- "description": "The full User object to impersonate. Pass one returned by searchUsers; a bare object with only an id also works. Required — a missing user throws a TypeError on the device.",
775
+ "description": "The full User object to impersonate. Pass one returned by searchUsers; a bare object with only an id also works. Required \u2014 a missing user throws a TypeError on the device.",
776
776
  "properties": {
777
777
  "id": {
778
778
  "type": "string",
@@ -805,7 +805,7 @@
805
805
  },
806
806
  "effect": "destructive",
807
807
  "release": "works",
808
- "description": "Sets isActive=true and currentUser=user, points the fetch/XHR interceptor at user.id, prepends the user to history (deduped by id, capped at 10, persisted to @buoy/impersonate/state), THEN runs the data nuke and persists. The nuke clears react-query and resets redux by default (dataNukeSettings.reactQuery/redux default true) and can also wipe AsyncStorage and MMKV when those settings were turned on — that part is irreversible. Calling this while already impersonating switches users (that is the 'quick switch' path). Two ways it can look successful but change nothing on screen: (1) the nuke callbacks are only registered once the Impersonate panel has been opened at least once in this app session, so caches may keep the previous user's data and the UI won't refresh — tell the user to open the Impersonate tool once, or reload the app; (2) the header is injected only into globalThis.fetch and XMLHttpRequest.prototype, so a client that bypasses both (e.g. Expo's native expo/fetch) sends no header. Metro/dev URLs (localhost:8081, /symbolicate, /logs, .hot-update., __metro) are always excluded. Resolves to void — read the snapshot's isActive/currentUser to confirm."
808
+ "description": "Sets isActive=true and currentUser=user, points the fetch/XHR interceptor at user.id, prepends the user to history (deduped by id, capped at 10, persisted to @buoy/impersonate/state), THEN runs the data nuke and persists. The nuke clears react-query and resets redux by default (dataNukeSettings.reactQuery/redux default true) and can also wipe AsyncStorage and MMKV when those settings were turned on \u2014 that part is irreversible. Calling this while already impersonating switches users (that is the 'quick switch' path). Two ways it can look successful but change nothing on screen: (1) the nuke callbacks are only registered once the Impersonate panel has been opened at least once in this app session, so caches may keep the previous user's data and the UI won't refresh \u2014 tell the user to open the Impersonate tool once, or reload the app; (2) the header is injected only into globalThis.fetch and XMLHttpRequest.prototype, so a client that bypasses both (e.g. Expo's native expo/fetch) sends no header. Metro/dev URLs (localhost:8081, /symbolicate, /logs, .hot-update., __metro) are always excluded. Resolves to void \u2014 read the snapshot's isActive/currentUser to confirm."
809
809
  },
810
810
  {
811
811
  "action": "stopImpersonation",
@@ -817,7 +817,7 @@
817
817
  },
818
818
  "effect": "destructive",
819
819
  "release": "works",
820
- "description": "Clears isActive, isPaused and currentUser, stops header injection, then runs the same data nuke as startImpersonation (react-query + redux by default; AsyncStorage/MMKV if enabled) and persists. Use this to return the device to its real identity — it is the correct 'undo' after any impersonation session. History is untouched. Safe to call when not impersonating (state is already clear), but note the nuke still fires. Resolves to void."
820
+ "description": "Clears isActive, isPaused and currentUser, stops header injection, then runs the same data nuke as startImpersonation (react-query + redux by default; AsyncStorage/MMKV if enabled) and persists. Use this to return the device to its real identity \u2014 it is the correct 'undo' after any impersonation session. History is untouched. Safe to call when not impersonating (state is already clear), but note the nuke still fires. Resolves to void."
821
821
  },
822
822
  {
823
823
  "action": "pauseImpersonation",
@@ -829,7 +829,7 @@
829
829
  },
830
830
  "effect": "write",
831
831
  "release": "works",
832
- "description": "Sets isPaused=true and passes a null userId to the interceptor, so requests go out as the real account again while currentUser is remembered. No cache nuke runs, which is exactly why it is the safer A/B toggle: pause, check the screen as yourself, resume. IMPORTANT — it silently returns and does nothing if isActive is false or isPaused is already true, and it still resolves successfully, so verify isPaused in the snapshot rather than assuming. Because no cache is cleared, already-fetched data on screen will not change until something refetches."
832
+ "description": "Sets isPaused=true and passes a null userId to the interceptor, so requests go out as the real account again while currentUser is remembered. No cache nuke runs, which is exactly why it is the safer A/B toggle: pause, check the screen as yourself, resume. IMPORTANT \u2014 it silently returns and does nothing if isActive is false or isPaused is already true, and it still resolves successfully, so verify isPaused in the snapshot rather than assuming. Because no cache is cleared, already-fetched data on screen will not change until something refetches."
833
833
  },
834
834
  {
835
835
  "action": "resumeImpersonation",
@@ -841,7 +841,7 @@
841
841
  },
842
842
  "effect": "destructive",
843
843
  "release": "works",
844
- "description": "Sets isPaused=false and re-points the interceptor at currentUser.id. No cache nuke runs. Silently does nothing (while still reporting success) when isActive is false or isPaused is already false — check isPaused in the snapshot to confirm. Stale on-screen data from the paused window persists until a refetch."
844
+ "description": "Sets isPaused=false and re-points the interceptor at currentUser.id. No cache nuke runs. Silently does nothing (while still reporting success) when isActive is false or isPaused is already false \u2014 check isPaused in the snapshot to confirm. Stale on-screen data from the paused window persists until a refetch."
845
845
  },
846
846
  {
847
847
  "action": "updateSettings",
@@ -851,7 +851,7 @@
851
851
  "properties": {
852
852
  "settings": {
853
853
  "type": "object",
854
- "description": "Required wrapper. Partial patch — omitted keys keep their current value.",
854
+ "description": "Required wrapper. Partial patch \u2014 omitted keys keep their current value.",
855
855
  "properties": {
856
856
  "headerKey": {
857
857
  "type": "string",
@@ -866,7 +866,7 @@
866
866
  },
867
867
  "showBanner": {
868
868
  "type": "boolean",
869
- "description": "Show the floating on-device banner while impersonating. Default true — leave it on so a QA user can see they are not themselves."
869
+ "description": "Show the floating on-device banner while impersonating. Default true \u2014 leave it on so a QA user can see they are not themselves."
870
870
  },
871
871
  "dataNukeSettings": {
872
872
  "type": "object",
@@ -902,7 +902,7 @@
902
902
  },
903
903
  "effect": "write",
904
904
  "release": "works",
905
- "description": "Shallow-merges the given settings into state and persists them to @buoy/impersonate/state. Only the keys you send change. headerKey is the HTTP header name used for injection (default x-impersonate-user-id) — change it only if the backend expects a different one, since a wrong key means the backend silently ignores impersonation. ignorePatterns are REGEX SOURCE STRINGS (compiled with new RegExp) for URLs that must never receive the header; an invalid pattern throws on the device. dataNukeSettings is itself merged key-by-key. DANGER: setting dataNukeSettings.asyncStorage or .mmkv to true arms a full app-storage wipe that fires on the NEXT startImpersonation/stopImpersonation — both default to false for that reason, so do not enable them without the user explicitly asking. Changing headerKey or ignorePatterns takes effect on the very next request."
905
+ "description": "Shallow-merges the given settings into state and persists them to @buoy/impersonate/state. Only the keys you send change. headerKey is the HTTP header name used for injection (default x-impersonate-user-id) \u2014 change it only if the backend expects a different one, since a wrong key means the backend silently ignores impersonation. ignorePatterns are REGEX SOURCE STRINGS (compiled with new RegExp) for URLs that must never receive the header; an invalid pattern throws on the device. dataNukeSettings is itself merged key-by-key. DANGER: setting dataNukeSettings.asyncStorage or .mmkv to true arms a full app-storage wipe that fires on the NEXT startImpersonation/stopImpersonation \u2014 both default to false for that reason, so do not enable them without the user explicitly asking. Changing headerKey or ignorePatterns takes effect on the very next request."
906
906
  },
907
907
  {
908
908
  "action": "removeFromHistory",
@@ -922,7 +922,7 @@
922
922
  },
923
923
  "effect": "destructive",
924
924
  "release": "works",
925
- "description": "Filters the persisted history down to entries whose user.id !== userId, then writes @buoy/impersonate/state. Permanent — there is no undo and the entry can only come back by impersonating that user again. Does not stop an active impersonation of that same user; call stopImpersonation for that. A userId that matches nothing is a silent no-op that still reports success, so compare history length in the snapshot before and after."
925
+ "description": "Filters the persisted history down to entries whose user.id !== userId, then writes @buoy/impersonate/state. Permanent \u2014 there is no undo and the entry can only come back by impersonating that user again. Does not stop an active impersonation of that same user; call stopImpersonation for that. A userId that matches nothing is a silent no-op that still reports success, so compare history length in the snapshot before and after."
926
926
  },
927
927
  {
928
928
  "action": "clearHistory",
@@ -934,25 +934,25 @@
934
934
  },
935
935
  "effect": "destructive",
936
936
  "release": "works",
937
- "description": "Empties history (max 10 entries) and persists the empty list to @buoy/impersonate/state. Permanent and unrecoverable — every quick-switch shortcut the user built up is gone. Does not stop an active impersonation and does not touch settings or app caches. Only call when the user explicitly asks to clear the list."
937
+ "description": "Empties history (max 10 entries) and persists the empty list to @buoy/impersonate/state. Permanent and unrecoverable \u2014 every quick-switch shortcut the user built up is gone. Does not stop an active impersonation and does not touch settings or app caches. Only call when the user explicitly asks to clear the list."
938
938
  }
939
939
  ],
940
- "unavailableWhen": "The app doesn't depend on @buoy-gg/impersonate (the adapter is absent from the device's tool list). Note the adapter self-registers whenever the package merely resolves, even if the app never called createImpersonateTool() — in that state every action still works except searchUsers, which throws \"No onSearchUsers configured — pass it to createImpersonateTool()\"."
940
+ "unavailableWhen": "The app doesn't depend on @buoy-gg/impersonate (the adapter is absent from the device's tool list). Note the adapter self-registers whenever the package merely resolves, even if the app never called createImpersonateTool() \u2014 in that state every action still works except searchUsers, which throws \"No onSearchUsers configured \u2014 pass it to createImpersonateTool()\"."
941
941
  },
942
942
  {
943
943
  "toolId": "query",
944
944
  "title": "React Query",
945
- "summary": "THE way to change what a server-backed screen shows. Most screens that render API data render this cache, so \"edit what I'm looking at\" on such a screen means setQueryData on the query that is mounted (observers > 0), not a store and not the API. Also reads and mutates the rest of the app's live TanStack Query (React Query) cache on the device: list every query with status/staleness/observers/error, pull one query's real cached data, and then refetch / invalidate / reset / remove / overwrite it, simulate a query error or a perpetual loading state, clear the whole query or mutation cache, and flip TanStack's onlineManager to fake offline. Reach for it when data on screen is stale, wrong, or missing and you need to know whether the CACHE or the API is at fault (network.getSnapshot answers the API half), and for the QA moves — force the error view (triggerError), the loading view (triggerLoading), a specific payload (setQueryData). A cache edit lasts until the next successful refetch; when the change must survive a refetch or a reload, put a network override on the request instead and invalidate. Everything here is the current cache — for the history of query updates over time use the events tool with sources:['react-query'].",
945
+ "summary": "THE way to change what a server-backed screen shows. Most screens that render API data render this cache, so \"edit what I'm looking at\" on such a screen means setQueryData on the query that is mounted (observers > 0), not a store and not the API. Also reads and mutates the rest of the app's live TanStack Query (React Query) cache on the device: list every query with status/staleness/observers/error, pull one query's real cached data, and then refetch / invalidate / reset / remove / overwrite it, simulate a query error or a perpetual loading state, clear the whole query or mutation cache, and flip TanStack's onlineManager to fake offline. Reach for it when data on screen is stale, wrong, or missing and you need to know whether the CACHE or the API is at fault (network.getSnapshot answers the API half), and for the QA moves \u2014 force the error view (triggerError), the loading view (triggerLoading), a specific payload (setQueryData). A cache edit lasts until the next successful refetch; when the change must survive a refetch or a reload, put a network override on the request instead and invalidate. Everything here is the current cache \u2014 for the history of query updates over time use the events tool with sources:['react-query'].",
946
946
  "actions": [
947
947
  {
948
948
  "action": "listQueries",
949
- "summary": "List every query in the cache — hash, key, status, staleness, observer count, last-updated, error message. The token-cheap cache reader; start here. Send `staleOnly:true` to see only stale queries. THIS IS A CACHE, NOT AN INVENTORY: it holds only what this app has already fetched in this session, so a short list means the user has not visited those screens yet, never that the data does not exist.",
949
+ "summary": "List every query in the cache \u2014 hash, key, status, staleness, observer count, last-updated, error message. The token-cheap cache reader; start here. Send `staleOnly:true` to see only stale queries. THIS IS A CACHE, NOT AN INVENTORY: it holds only what this app has already fetched in this session, so a short list means the user has not visited those screens yet, never that the data does not exist.",
950
950
  "params": {
951
951
  "type": "object",
952
952
  "properties": {
953
953
  "includeData": {
954
954
  "type": "boolean",
955
- "description": "Include each query's full cached data payload verbatim and UNCAPPED. Default false. Heavy — one cached list can be megabytes."
955
+ "description": "Include each query's full cached data payload verbatim and UNCAPPED. Default false. Heavy \u2014 one cached list can be megabytes."
956
956
  },
957
957
  "staleOnly": {
958
958
  "type": "boolean",
@@ -960,14 +960,14 @@
960
960
  },
961
961
  "limit": {
962
962
  "type": "number",
963
- "description": "Cap to the N most-recently-updated queries. NO default in the adapter — omit and every query is returned. Values <= 0 are ignored. 25 is a sane value."
963
+ "description": "Cap to the N most-recently-updated queries. NO default in the adapter \u2014 omit and every query is returned. Values <= 0 are ignored. 25 is a sane value."
964
964
  }
965
965
  },
966
966
  "additionalProperties": false
967
967
  },
968
968
  "effect": "read",
969
969
  "release": "works",
970
- "description": "Projects each live query to light fields: queryHash, queryKey, status (one of fresh/stale/fetching/error/inactive/paused/disabled, from getQueryStatusLabel), fetchStatus, isStale, observers, updatedAt (state.dataUpdatedAt), and error.message when present. Returns {queries, total, returned, includedData}. Sorted most-recently-updated first. Safe to call repeatedly — unlike the full dehydrated snapshot, the heavy cached data stays on the device by default. TWO TRAPS: (1) the adapter has NO default limit — omit it and you get every query in the cache; (2) includeData:true returns q.state.data RAW and UNCAPPED (no 16KB wire marker, no 8MB cap like getQueryData), so it can blow the wire/token budget on a big cache — prefer getQueryData for one query's payload. The queryHash of each row is the handle every other action takes. What is NOT here has not been fetched yet — the cache fills as the user visits screens — so treat a missing key as a screen to go to (route-events.navigate, then highlight-updates.waitFor, then read again), not as an absence to report. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not — read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields. Send `staleOnly:true` to see only stale queries.",
970
+ "description": "Projects each live query to light fields: queryHash, queryKey, status (one of fresh/stale/fetching/error/inactive/paused/disabled, from getQueryStatusLabel), fetchStatus, isStale, observers, updatedAt (state.dataUpdatedAt), and error.message when present. Returns {queries, total, returned, includedData}. Sorted most-recently-updated first. Safe to call repeatedly \u2014 unlike the full dehydrated snapshot, the heavy cached data stays on the device by default. TWO TRAPS: (1) the adapter has NO default limit \u2014 omit it and you get every query in the cache; (2) includeData:true returns q.state.data RAW and UNCAPPED (no 16KB wire marker, no 8MB cap like getQueryData), so it can blow the wire/token budget on a big cache \u2014 prefer getQueryData for one query's payload. The queryHash of each row is the handle every other action takes. What is NOT here has not been fetched yet \u2014 the cache fills as the user visits screens \u2014 so treat a missing key as a screen to go to (route-events.navigate, then highlight-updates.waitFor, then read again), not as an absence to report. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not \u2014 read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields. Send `staleOnly:true` to see only stale queries.",
971
971
  "requires": [
972
972
  "QueryClientProvider above <FloatingDevTools/>",
973
973
  "@tanstack/react-query v5"
@@ -975,17 +975,17 @@
975
975
  },
976
976
  {
977
977
  "action": "getQueryData",
978
- "summary": "Get ONE query's real cached data by queryHash — the size-guarded channel for a payload the snapshot replaced with a marker. Send `path` to get back ONE value instead of the whole payload (path:\"item.name\", path:\"results[name=pikachu].url\") — do that whenever the payload is big, because results are cut off at 24,000 characters.",
978
+ "summary": "Get ONE query's real cached data by queryHash \u2014 the size-guarded channel for a payload the snapshot replaced with a marker. Send `path` to get back ONE value instead of the whole payload (path:\"item.name\", path:\"results[name=pikachu].url\") \u2014 do that whenever the payload is big, because results are cut off at 24,000 characters.",
979
979
  "params": {
980
980
  "type": "object",
981
981
  "properties": {
982
982
  "queryHash": {
983
983
  "type": "string",
984
- "description": "The target query's hash from listQueries — TanStack's default hash is the JSON-stringified key, e.g. '[\"todos\",{\"page\":1}]'. Tolerated if omitted (returns found:false) but then the call does nothing useful."
984
+ "description": "The target query's hash from listQueries \u2014 TanStack's default hash is the JSON-stringified key, e.g. '[\"todos\",{\"page\":1}]'. Tolerated if omitted (returns found:false) but then the call does nothing useful."
985
985
  },
986
986
  "path": {
987
987
  "type": "string",
988
- "description": "Return only the value at this path instead of the whole payload. Use it when you only need one field, and ALWAYS when the payload is big: results are cut off at 24,000 characters, and a field you never saw is a field you will guess the shape of. The path must match the CURRENT data — read `shape` first rather than assuming a wrapper. On a bad path it answers with the field names that do exist, and with the path that would have worked if that field lives somewhere else."
988
+ "description": "Return only the value at this path instead of the whole payload. Use it when you only need one field, and ALWAYS when the payload is big: results are cut off at 24,000 characters, and a field you never saw is a field you will guess the shape of. The path must match the CURRENT data \u2014 read `shape` first rather than assuming a wrapper. On a bad path it answers with the field names that do exist, and with the path that would have worked if that field lives somewhere else."
989
989
  }
990
990
  },
991
991
  "required": [
@@ -995,7 +995,7 @@
995
995
  },
996
996
  "effect": "read",
997
997
  "release": "works",
998
- "description": "Returns {found:true, queryHash, data} where data is query.state.data capped at 8MB (over that you get {__buoyTruncated:true}; non-JSON-serializable values such as circular refs or bigint come back as {__buoyUnserializable:true}). Use this when a snapshot or detail pane shows the {__buoyDataOnDevice:true} marker — streamed snapshots strip anything over 16KB. Never throws: an unknown or missing hash returns {found:false, reason:'unknown queryHash'|'missing queryHash'}. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not — read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields. Send `path` to get back ONE value instead of the whole payload (path:\"item.name\", path:\"results[name=pikachu].url\") — do that whenever the payload is big, because results are cut off at 24,000 characters.",
998
+ "description": "Returns {found:true, queryHash, data} where data is query.state.data capped at 8MB (over that you get {__buoyTruncated:true}; non-JSON-serializable values such as circular refs or bigint come back as {__buoyUnserializable:true}). Use this when a snapshot or detail pane shows the {__buoyDataOnDevice:true} marker \u2014 streamed snapshots strip anything over 16KB. Never throws: an unknown or missing hash returns {found:false, reason:'unknown queryHash'|'missing queryHash'}. Every read here also returns `shape`: a one-line sketch of the value's type plus, for each list in it, what its items look like. It is tiny and does not grow with the data, so it survives the 24,000-character cut when the data itself does not \u2014 read it before writing, and match it exactly. A list whose `item` is missing is EMPTY, which means nothing in the running app knows what belongs in it; anything you add there cannot be checked and will be accepted as-is, so say so rather than inventing fields. Send `path` to get back ONE value instead of the whole payload (path:\"item.name\", path:\"results[name=pikachu].url\") \u2014 do that whenever the payload is big, because results are cut off at 24,000 characters.",
999
999
  "requires": [
1000
1000
  "QueryClientProvider above <FloatingDevTools/>"
1001
1001
  ]
@@ -1008,7 +1008,7 @@
1008
1008
  "properties": {
1009
1009
  "queryHash": {
1010
1010
  "type": "string",
1011
- "description": "Target query's hash from listQueries, e.g. '[\"todos\",{\"page\":1}]'. Required — an unknown hash throws."
1011
+ "description": "Target query's hash from listQueries, e.g. '[\"todos\",{\"page\":1}]'. Required \u2014 an unknown hash throws."
1012
1012
  }
1013
1013
  },
1014
1014
  "required": [
@@ -1018,7 +1018,7 @@
1018
1018
  },
1019
1019
  "effect": "write",
1020
1020
  "release": "works",
1021
- "description": "Calls query.fetch() on the single query with that hash and SWALLOWS the rejection — it resolves ok even when the fetch fails, because the resulting error state syncs anyway. So never report 'refetch succeeded' from the return value: call listQueries afterwards and read that row's status/error. If the query has no queryFn (e.g. it was created by setQueryData) the fetch fails and the query lands in error status. THROWS 'Query with hash \"X\" not found' for an unknown hash.",
1021
+ "description": "Calls query.fetch() on the single query with that hash and SWALLOWS the rejection \u2014 it resolves ok even when the fetch fails, because the resulting error state syncs anyway. So never report 'refetch succeeded' from the return value: call listQueries afterwards and read that row's status/error. If the query has no queryFn (e.g. it was created by setQueryData) the fetch fails and the query lands in error status. THROWS 'Query with hash \"X\" not found' for an unknown hash.",
1022
1022
  "requires": [
1023
1023
  "QueryClientProvider above <FloatingDevTools/>",
1024
1024
  "the query must have a queryFn to actually fetch"
@@ -1026,7 +1026,7 @@
1026
1026
  },
1027
1027
  {
1028
1028
  "action": "invalidate",
1029
- "summary": "Mark a query stale and refetch it if it has active observers — the normal 'this data is out of date' fix. Takes the query's `queryHash`.",
1029
+ "summary": "Mark a query stale and refetch it if it has active observers \u2014 the normal 'this data is out of date' fix. Takes the query's `queryHash`.",
1030
1030
  "params": {
1031
1031
  "type": "object",
1032
1032
  "properties": {
@@ -1049,7 +1049,7 @@
1049
1049
  },
1050
1050
  {
1051
1051
  "action": "reset",
1052
- "summary": "Reset a query to its initial state — DISCARDS its cached data, then refetches if it is active. Takes the query's `queryHash`.",
1052
+ "summary": "Reset a query to its initial state \u2014 DISCARDS its cached data, then refetches if it is active. Takes the query's `queryHash`.",
1053
1053
  "params": {
1054
1054
  "type": "object",
1055
1055
  "properties": {
@@ -1065,20 +1065,20 @@
1065
1065
  },
1066
1066
  "effect": "destructive",
1067
1067
  "release": "works",
1068
- "description": "queryClient.resetQueries() with the Query as filter, so the same NON-EXACT prefix match as invalidate applies (resetting '[\\\"todos\\\"]' also resets deeper todos keys). Unlike invalidate this throws the cached value away and reverts to initialData/pending — screens bound to it will flash their loading state. There is no undo: the data only comes back if the query has a queryFn and an active observer to refetch it. THROWS for an unknown hash. Takes the query's `queryHash`.",
1068
+ "description": "queryClient.resetQueries() with the Query as filter, so the same NON-EXACT prefix match as invalidate applies (resetting '[\\\"todos\\\"]' also resets deeper todos keys). Unlike invalidate this throws the cached value away and reverts to initialData/pending \u2014 screens bound to it will flash their loading state. There is no undo: the data only comes back if the query has a queryFn and an active observer to refetch it. THROWS for an unknown hash. Takes the query's `queryHash`.",
1069
1069
  "requires": [
1070
1070
  "QueryClientProvider above <FloatingDevTools/>"
1071
1071
  ]
1072
1072
  },
1073
1073
  {
1074
1074
  "action": "remove",
1075
- "summary": "Delete a query from the cache entirely — the entry, its data, and its state are gone. Takes the query's `queryHash`.",
1075
+ "summary": "Delete a query from the cache entirely \u2014 the entry, its data, and its state are gone. Takes the query's `queryHash`.",
1076
1076
  "params": {
1077
1077
  "type": "object",
1078
1078
  "properties": {
1079
1079
  "queryHash": {
1080
1080
  "type": "string",
1081
- "description": "Target query's hash from listQueries. Prefix-matches — it can delete more than the one row you picked."
1081
+ "description": "Target query's hash from listQueries. Prefix-matches \u2014 it can delete more than the one row you picked."
1082
1082
  }
1083
1083
  },
1084
1084
  "required": [
@@ -1088,39 +1088,39 @@
1088
1088
  },
1089
1089
  "effect": "destructive",
1090
1090
  "release": "works",
1091
- "description": "queryClient.removeQueries() with the Query as filter — again a NON-EXACT prefix match, so removing '[\\\"todos\\\"]' removes every key that extends it. Harsher than reset: the cache entry itself disappears rather than reverting to pending. Irreversible; a mounted component will create a brand-new entry and fetch from scratch on its next render. THROWS for an unknown hash. Returns nothing. Takes the query's `queryHash`.",
1091
+ "description": "queryClient.removeQueries() with the Query as filter \u2014 again a NON-EXACT prefix match, so removing '[\\\"todos\\\"]' removes every key that extends it. Harsher than reset: the cache entry itself disappears rather than reverting to pending. Irreversible; a mounted component will create a brand-new entry and fetch from scratch on its next render. THROWS for an unknown hash. Returns nothing. Takes the query's `queryHash`.",
1092
1092
  "requires": [
1093
1093
  "QueryClientProvider above <FloatingDevTools/>"
1094
1094
  ]
1095
1095
  },
1096
1096
  {
1097
1097
  "action": "setQueryData",
1098
- "summary": "Change a query's cached data — takes the queryKey ARRAY, not the hash. The instant, on-screen edit for anything a mounted query renders. To change some fields send merge:true and ONLY those fields (deep-merged into the cached value); never paste a whole payload back — large getQueryData results are truncated, and a replace that drops fields the screen renders is refused. Reverts on the next successful refetch — say so; use a network override when it must not. To change a single field, `path` + `value` is the safest form (path:\"item.name\", value:\"test123\") — always a merge, and no nesting to get wrong. Read the data first either way: the path has to match the shape that is actually there.",
1098
+ "summary": "Change a query's cached data \u2014 takes the queryKey ARRAY, not the hash. The instant, on-screen edit for anything a mounted query renders. To change some fields send merge:true and ONLY those fields (deep-merged into the cached value); never paste a whole payload back \u2014 large getQueryData results are truncated, and a replace that drops fields the screen renders is refused. Reverts on the next successful refetch \u2014 say so; use a network override when it must not. To change a single field, `path` + `value` is the safest form (path:\"item.name\", value:\"test123\") \u2014 always a merge, and no nesting to get wrong. Read the data first either way: the path has to match the shape that is actually there.",
1099
1099
  "params": {
1100
1100
  "type": "object",
1101
1101
  "properties": {
1102
1102
  "queryKey": {
1103
1103
  "type": "array",
1104
- "description": "The query's key ARRAY exactly as listQueries returns it, e.g. [\"todos\",{\"page\":1}] — NOT the queryHash string. An unknown key creates a new cache entry."
1104
+ "description": "The query's key ARRAY exactly as listQueries returns it, e.g. [\"todos\",{\"page\":1}] \u2014 NOT the queryHash string. An unknown key creates a new cache entry."
1105
1105
  },
1106
1106
  "data": {
1107
- "description": "With merge:true: only the fields to change, nested to match the current shape. Without: the complete new value. If you are changing a single field, use `path`+`value` instead — it cannot be nested wrongly."
1107
+ "description": "With merge:true: only the fields to change, nested to match the current shape. Without: the complete new value. If you are changing a single field, use `path`+`value` instead \u2014 it cannot be nested wrongly."
1108
1108
  },
1109
1109
  "queryHash": {
1110
1110
  "type": "string",
1111
- "description": "Declared by the adapter's param type but never read by the handler — passing it has no effect."
1111
+ "description": "Declared by the adapter's param type but never read by the handler \u2014 passing it has no effect."
1112
1112
  },
1113
1113
  "merge": {
1114
1114
  "type": "boolean",
1115
- "description": "Deep-merge `data` into the cached value as a TYPED LEAF EDIT — like the React Query devtools editor. Change the value of a field that already exists, to the SAME type: no new object fields, no type changes, no null-ing a rendered list/object. A LIST may gain or lose items, but every item you send must have the same fields as the items already in it — to change one item, resend the WHOLE list with just that item changed. Refused with the exact field on a violation. Default false."
1115
+ "description": "Deep-merge `data` into the cached value as a TYPED LEAF EDIT \u2014 like the React Query devtools editor. Change the value of a field that already exists, to the SAME type: no new object fields, no type changes, no null-ing a rendered list/object. A LIST may gain or lose items, but every item you send must have the same fields as the items already in it \u2014 to change one item, resend the WHOLE list with just that item changed. Refused with the exact field on a violation. Default false."
1116
1116
  },
1117
1117
  "force": {
1118
1118
  "type": "boolean",
1119
- "description": "Bypass the typed-edit safety and write raw — can add/remove fields, change types, resize lists. This is what crashes screens; use ONLY when you deliberately mean to replace the whole shape. Default false."
1119
+ "description": "Bypass the typed-edit safety and write raw \u2014 can add/remove fields, change types, resize lists. This is what crashes screens; use ONLY when you deliberately mean to replace the whole shape. Default false."
1120
1120
  },
1121
1121
  "path": {
1122
1122
  "type": "string",
1123
- "description": "Set ONE value, named by its full path in the CURRENT data: path:\"<the real path>\", value:<new value>. READ THE DATA FIRST AND COPY THE PATH FROM IT — `shape` on getQueryData/listQueries prints it. A path is only safer than a hand-nested `data` if it matches the payload you are actually looking at; if the field is at the top level the path is just \"name\", and inventing a wrapper that is not there is refused. A list step is written [field=value] or [0] and resolves to that item's own id, so it still means the same row if the list changed. Always a merge, and always through the same typed-edit guard as `data`. Requires `value`; send `data` OR `path`+`value`, not both."
1123
+ "description": "Set ONE value, named by its full path in the CURRENT data: path:\"<the real path>\", value:<new value>. READ THE DATA FIRST AND COPY THE PATH FROM IT \u2014 `shape` on getQueryData/listQueries prints it. A path is only safer than a hand-nested `data` if it matches the payload you are actually looking at; if the field is at the top level the path is just \"name\", and inventing a wrapper that is not there is refused. A list step is written [field=value] or [0] and resolves to that item's own id, so it still means the same row if the list changed. Always a merge, and always through the same typed-edit guard as `data`. Requires `value`; send `data` OR `path`+`value`, not both."
1124
1124
  },
1125
1125
  "value": {
1126
1126
  "description": "The value `path` is set to. Required whenever `path` is sent. null is a real value (only allowed if the field is already nullable), not a delete."
@@ -1133,7 +1133,7 @@
1133
1133
  },
1134
1134
  "effect": "write",
1135
1135
  "release": "works",
1136
- "description": "queryClient.setQueryData(queryKey, value, {updatedAt: Date.now()}). With merge:true the value written is the CURRENT cached data deep-merged with `data` (plain objects merge key by key, arrays and primitives are replaced), so `{name:'test123'}` changes one field of a 30KB payload. TO CHANGE ONE ITEM IN A LIST, address it by its own id rather than resending the array: if rows carry an id, `{results:{pikachu:{name:'test123'}}}` edits that row and leaves every other row untouched. This is the ONLY correct form when the read was capped and you did not see every row — a plain array REPLACES the list, so a one-item array deletes the rest. Without merge it REPLACES — and if the cached value is an object whose top-level keys `data` lacks, the call is refused with {ok:false, error, missingKeys} unless force:true, because a screen that renders the dropped fields crashes. Returns {ok:true, mode:'merge'|'replace'}. Gotchas: (1) it keys off queryKey, the actual array from a listQueries row (e.g. ['todos',{page:1}]) — the declared param type also mentions queryHash but the handler IGNORES it; (2) if that key is not in the cache, setQueryData CREATES a new entry with no queryFn, which then errors if anything refetches it. The injected value is overwritten by the next successful refetch. When a merge is refused for writing at the wrong depth, the refusal carries `suggestedData`: the SAME edit rebuilt at the right depth. Send that back as `data` rather than re-deriving it. To change a single field, `path` + `value` is the safest form (path:\"item.name\", value:\"test123\") — always a merge, and no nesting to get wrong. Read the data first either way: the path has to match the shape that is actually there.",
1136
+ "description": "queryClient.setQueryData(queryKey, value, {updatedAt: Date.now()}). With merge:true the value written is the CURRENT cached data deep-merged with `data` (plain objects merge key by key, arrays and primitives are replaced), so `{name:'test123'}` changes one field of a 30KB payload. TO CHANGE ONE ITEM IN A LIST, address it by its own id rather than resending the array: if rows carry an id, `{results:{pikachu:{name:'test123'}}}` edits that row and leaves every other row untouched. This is the ONLY correct form when the read was capped and you did not see every row \u2014 a plain array REPLACES the list, so a one-item array deletes the rest. Without merge it REPLACES \u2014 and if the cached value is an object whose top-level keys `data` lacks, the call is refused with {ok:false, error, missingKeys} unless force:true, because a screen that renders the dropped fields crashes. Returns {ok:true, mode:'merge'|'replace'}. Gotchas: (1) it keys off queryKey, the actual array from a listQueries row (e.g. ['todos',{page:1}]) \u2014 the declared param type also mentions queryHash but the handler IGNORES it; (2) if that key is not in the cache, setQueryData CREATES a new entry with no queryFn, which then errors if anything refetches it. The injected value is overwritten by the next successful refetch. When a merge is refused for writing at the wrong depth, the refusal carries `suggestedData`: the SAME edit rebuilt at the right depth. Send that back as `data` rather than re-deriving it. To change a single field, `path` + `value` is the safest form (path:\"item.name\", value:\"test123\") \u2014 always a merge, and no nesting to get wrong. Read the data first either way: the path has to match the shape that is actually there.",
1137
1137
  "requires": [
1138
1138
  "QueryClientProvider above <FloatingDevTools/>"
1139
1139
  ]
@@ -1156,14 +1156,14 @@
1156
1156
  },
1157
1157
  "effect": "write",
1158
1158
  "release": "works",
1159
- "description": "Sets the query's state to {status:'error', error: new Error('Unknown error from devtools')} and stashes the real options in fetchMeta.__previousQueryOptions. The app's error boundary / error view for that screen should appear. Nothing is fetched and the network is untouched — this is a pure cache-state simulation. Always undo it with restoreError when you're done, or that screen stays broken for the person holding the device. THROWS for an unknown hash.",
1159
+ "description": "Sets the query's state to {status:'error', error: new Error('Unknown error from devtools')} and stashes the real options in fetchMeta.__previousQueryOptions. The app's error boundary / error view for that screen should appear. Nothing is fetched and the network is untouched \u2014 this is a pure cache-state simulation. Always undo it with restoreError when you're done, or that screen stays broken for the person holding the device. THROWS for an unknown hash.",
1160
1160
  "requires": [
1161
1161
  "QueryClientProvider above <FloatingDevTools/>"
1162
1162
  ]
1163
1163
  },
1164
1164
  {
1165
1165
  "action": "restoreError",
1166
- "summary": "Undo triggerError — clears the fake error. Implemented as resetQueries, so it also discards cached data.",
1166
+ "summary": "Undo triggerError \u2014 clears the fake error. Implemented as resetQueries, so it also discards cached data.",
1167
1167
  "params": {
1168
1168
  "type": "object",
1169
1169
  "properties": {
@@ -1179,7 +1179,7 @@
1179
1179
  },
1180
1180
  "effect": "destructive",
1181
1181
  "release": "works",
1182
- "description": "The undo for triggerError, but the handler is literally queryClient.resetQueries(query) — identical to the reset action. That means it clears the query's cached data and reverts it to pending as well as clearing the fake error, and it prefix-matches on queryKey. Active queries refetch and recover; an inactive query is left empty until something observes it. THROWS for an unknown hash.",
1182
+ "description": "The undo for triggerError, but the handler is literally queryClient.resetQueries(query) \u2014 identical to the reset action. That means it clears the query's cached data and reverts it to pending as well as clearing the fake error, and it prefix-matches on queryKey. Active queries refetch and recover; an inactive query is left empty until something observes it. THROWS for an unknown hash.",
1183
1183
  "requires": [
1184
1184
  "QueryClientProvider above <FloatingDevTools/>"
1185
1185
  ]
@@ -1202,14 +1202,14 @@
1202
1202
  },
1203
1203
  "effect": "destructive",
1204
1204
  "release": "works",
1205
- "description": "Clears state.data, sets status 'pending', and starts a fetch whose queryFn is a promise that NEVER resolves (with gcTime:-1), stashing the real options in fetchMeta.__previousQueryOptions. The app's skeleton/spinner/suspense fallback for that screen stays up FOREVER until you call restoreLoading — nothing times out and a reload of the app is the only other escape. Tell the user this is a simulation, and always pair it with restoreLoading. THROWS for an unknown hash.",
1205
+ "description": "Clears state.data, sets status 'pending', and starts a fetch whose queryFn is a promise that NEVER resolves (with gcTime:-1), stashing the real options in fetchMeta.__previousQueryOptions. The app's skeleton/spinner/suspense fallback for that screen stays up FOREVER until you call restoreLoading \u2014 nothing times out and a reload of the app is the only other escape. Tell the user this is a simulation, and always pair it with restoreLoading. THROWS for an unknown hash.",
1206
1206
  "requires": [
1207
1207
  "QueryClientProvider above <FloatingDevTools/>"
1208
1208
  ]
1209
1209
  },
1210
1210
  {
1211
1211
  "action": "restoreLoading",
1212
- "summary": "Undo triggerLoading — cancel the never-resolving fetch and refetch with the query's real options.",
1212
+ "summary": "Undo triggerLoading \u2014 cancel the never-resolving fetch and refetch with the query's real options.",
1213
1213
  "params": {
1214
1214
  "type": "object",
1215
1215
  "properties": {
@@ -1232,7 +1232,7 @@
1232
1232
  },
1233
1233
  {
1234
1234
  "action": "clearQueryCache",
1235
- "summary": "Wipe the ENTIRE query cache — every query on the device, not just one.",
1235
+ "summary": "Wipe the ENTIRE query cache \u2014 every query on the device, not just one.",
1236
1236
  "params": {
1237
1237
  "type": "object",
1238
1238
  "properties": {},
@@ -1240,14 +1240,14 @@
1240
1240
  },
1241
1241
  "effect": "destructive",
1242
1242
  "release": "works",
1243
- "description": "queryClient.getQueryCache().clear(). Nukes all cached data app-wide and is irreversible; mounted screens will refetch from scratch and briefly show loading or empty states. Only reach for this when the user explicitly asks to clear the cache or to reproduce a cold-start — for one bad query use remove or invalidate instead. Takes no parameters.",
1243
+ "description": "queryClient.getQueryCache().clear(). Nukes all cached data app-wide and is irreversible; mounted screens will refetch from scratch and briefly show loading or empty states. Only reach for this when the user explicitly asks to clear the cache or to reproduce a cold-start \u2014 for one bad query use remove or invalidate instead. Takes no parameters.",
1244
1244
  "requires": [
1245
1245
  "QueryClientProvider above <FloatingDevTools/>"
1246
1246
  ]
1247
1247
  },
1248
1248
  {
1249
1249
  "action": "clearMutationCache",
1250
- "summary": "Wipe the entire mutation cache — all recorded mutations and their states.",
1250
+ "summary": "Wipe the entire mutation cache \u2014 all recorded mutations and their states.",
1251
1251
  "params": {
1252
1252
  "type": "object",
1253
1253
  "properties": {},
@@ -1255,7 +1255,7 @@
1255
1255
  },
1256
1256
  "effect": "destructive",
1257
1257
  "release": "works",
1258
- "description": "queryClient.getMutationCache().clear(). Drops the record of every mutation (pending, success, error) so the mutations list goes empty; it does NOT cancel work already in flight on the server. Irreversible — the mutation history you were reading disappears. Takes no parameters.",
1258
+ "description": "queryClient.getMutationCache().clear(). Drops the record of every mutation (pending, success, error) so the mutations list goes empty; it does NOT cancel work already in flight on the server. Irreversible \u2014 the mutation history you were reading disappears. Takes no parameters.",
1259
1259
  "requires": [
1260
1260
  "QueryClientProvider above <FloatingDevTools/>"
1261
1261
  ]
@@ -1278,18 +1278,18 @@
1278
1278
  },
1279
1279
  "effect": "write",
1280
1280
  "release": "works",
1281
- "description": "onlineManager.setOnline(online). With false, React Query treats the device as offline: fetches go to fetchStatus 'paused' instead of running, and mutations queue — perfect for testing offline UI. It does NOT touch the real network stack, so plain fetch/axios calls outside React Query still go through. BLAST RADIUS IS THE WHOLE APP: leave it false and every query looks hung, so always restore it with online:true when you're done and say so to the user. Persistence is unreliable — the value only gets saved when the on-device React Query panel is open, and a reload restores whatever the WiFi toggle last saved.",
1281
+ "description": "onlineManager.setOnline(online). With false, React Query treats the device as offline: fetches go to fetchStatus 'paused' instead of running, and mutations queue \u2014 perfect for testing offline UI. It does NOT touch the real network stack, so plain fetch/axios calls outside React Query still go through. BLAST RADIUS IS THE WHOLE APP: leave it false and every query looks hung, so always restore it with online:true when you're done and say so to the user. Persistence is unreliable \u2014 the value only gets saved when the on-device React Query panel is open, and a reload restores whatever the WiFi toggle last saved.",
1282
1282
  "requires": [
1283
1283
  "QueryClientProvider above <FloatingDevTools/>"
1284
1284
  ]
1285
1285
  }
1286
1286
  ],
1287
- "unavailableWhen": "There is no QueryClientProvider above <FloatingDevTools/> — the adapter factory returns null and the \"query\" tool is never registered (older apps advertise it under the legacy id \"react-query\"). Separately, in a RELEASE JS bundle (__DEV__ === false) the whole external-sync socket only mounts when the app passed externalSync.enableInRelease AND holds a real Pro license, so no query action is reachable at all in a normal shipped build — that gate is on the transport (FloatingDevTools.tsx / externalSyncGate), not on these handlers."
1287
+ "unavailableWhen": "There is no QueryClientProvider above <FloatingDevTools/> \u2014 the adapter factory returns null and the \"query\" tool is never registered (older apps advertise it under the legacy id \"react-query\"). Separately, in a RELEASE JS bundle (__DEV__ === false) the whole external-sync socket only mounts when the app passed externalSync.enableInRelease AND holds a real Pro license, so no query action is reachable at all in a normal shipped build \u2014 that gate is on the transport (FloatingDevTools.tsx / externalSyncGate), not on these handlers."
1288
1288
  },
1289
1289
  {
1290
1290
  "toolId": "events",
1291
1291
  "title": "Events",
1292
- "summary": "The cross-tool activity timeline: one chronological ring buffer (max 200, newest-first) that aggregates events from every other installed Buoy tool — network requests, redux/zustand/jotai state changes, react-query query/mutation updates, AsyncStorage/MMKV writes, route navigations, and component renders. Reach for it first for any \"what just happened in the app?\" question, before drilling into a single-tool reader. It exposes 3 actions: exportEvents (formatted read), setEnabledSources (choose what the device records), clearEvents (wipe the timeline). CRITICAL: it only records while something is watching — a cold exportEvents on a freshly connected session usually returns 0 events because nothing has ever armed capture, which is NOT the same as \"the app did nothing\". Swift captures network, storage-async, storage-mmkv, and route. React state and render sources are unavailable. Native export accepts format, includeEventData, includeSource, includeStatus, includeTitle, includeSubtitle, includeSummaryHeader, filterMode, filterSources, dataSizeThreshold, and timestampFormat; other settings are rejected. Native detail inherits source snapshot payload limits.",
1292
+ "summary": "The cross-tool activity timeline: one chronological ring buffer (max 200, newest-first) that aggregates events from every other installed Buoy tool \u2014 network requests, redux/zustand/jotai state changes, react-query query/mutation updates, AsyncStorage/MMKV writes, route navigations, and component renders. Reach for it first for any \"what just happened in the app?\" question, before drilling into a single-tool reader. It exposes 3 actions: exportEvents (formatted read), setEnabledSources (choose what the device records), clearEvents (wipe the timeline). CRITICAL: it only records while something is watching \u2014 a cold exportEvents on a freshly connected session usually returns 0 events because nothing has ever armed capture, which is NOT the same as \"the app did nothing\". Swift captures network, storage-async, storage-mmkv, and route. React state and render sources are unavailable. Native export accepts format, includeEventData, includeSource, includeStatus, includeTitle, includeSubtitle, includeSummaryHeader, filterMode, filterSources, dataSizeThreshold, and timestampFormat; other settings are rejected. Native detail inherits source snapshot payload limits.",
1293
1293
  "actions": [
1294
1294
  {
1295
1295
  "action": "exportEvents",
@@ -1331,7 +1331,7 @@
1331
1331
  "render"
1332
1332
  ]
1333
1333
  },
1334
- "description": "Only these sources reach the output. Empty/omitted = all. GRANULAR values only: no bare 'storage' or friendly aliases. Applied AFTER limit — pair with a large limit."
1334
+ "description": "Only these sources reach the output. Empty/omitted = all. GRANULAR values only: no bare 'storage' or friendly aliases. Applied AFTER limit \u2014 pair with a large limit."
1335
1335
  },
1336
1336
  "filterMode": {
1337
1337
  "type": "string",
@@ -1441,17 +1441,17 @@
1441
1441
  },
1442
1442
  "effect": "read",
1443
1443
  "release": "works",
1444
- "description": "Runs the device's own \"Copy Settings\" formatter over the unified store and returns {output: string, returned: number, totalAvailable: number, includedData: boolean, format: string}. The store is a 200-event ring, newest-first. Defaults are compact: includeEventData=false, so heavy raw payloads (network bodies, redux state trees, query data) stay on the device — pass settings.includeEventData=true only when you actually need them.\n\nTHREE traps a caller must know:\n(1) COLD READS ARE EMPTY. Sources are only subscribed while a consumer holds a watch on toolId \"events\" (the adapter's subscribe() calls ensureRemoteSourcesDefault) or the on-device Events modal is open. A bare call_action does NOT arm capture — the MCP SyncClient only watches on getSnapshot, never on callAction (packages/mcp/src/tools/packs/events.ts:80). If totalAvailable is 0, say \"nothing was being recorded\", not \"nothing happened\": arm it first (read the events snapshot, or call setEnabledSources), reproduce, then export.\n(2) limit IS APPLIED BEFORE FILTERING. The adapter does all.slice(0, limit) and only then does generateExport filter by settings.filterSources / filterMode (packages/events/src/sync/eventsSyncAdapter.ts:378-381 + eventExportFormatter.ts:616). Filtering to one source with limit:25 can produce an EMPTY output while `returned` still reports 25 — `returned` counts events handed to the formatter, not events in the output. When filtering by source, pass a large limit (or omit it, which uses the whole buffer).\n(3) filterSources takes GRANULAR EventSource values, not friendly names. \"storage\" matches nothing — use [\"storage-async\",\"storage-mmkv\"]; react-query is three values ([\"react-query\",\"react-query-query\",\"react-query-mutation\"]). The MCP get_events wrapper expands those aliases for you; a direct call_action does not.\n\nAn unrecognized preset name silently falls back to DEFAULT_COPY_SETTINGS instead of erroring. settings is shallow-merged OVER the preset. Swift captures network, storage-async, storage-mmkv, and route. React state and render sources are unavailable. Native export accepts format, includeEventData, includeSource, includeStatus, includeTitle, includeSubtitle, includeSummaryHeader, filterMode, filterSources, dataSizeThreshold, and timestampFormat; other settings are rejected. Native detail inherits source snapshot payload limits.",
1444
+ "description": "Runs the device's own \"Copy Settings\" formatter over the unified store and returns {output: string, returned: number, totalAvailable: number, includedData: boolean, format: string}. The store is a 200-event ring, newest-first. Defaults are compact: includeEventData=false, so heavy raw payloads (network bodies, redux state trees, query data) stay on the device \u2014 pass settings.includeEventData=true only when you actually need them.\n\nTHREE traps a caller must know:\n(1) COLD READS ARE EMPTY. Sources are only subscribed while a consumer holds a watch on toolId \"events\" (the adapter's subscribe() calls ensureRemoteSourcesDefault) or the on-device Events modal is open. A bare call_action does NOT arm capture \u2014 the MCP SyncClient only watches on getSnapshot, never on callAction (packages/mcp/src/tools/packs/events.ts:80). If totalAvailable is 0, say \"nothing was being recorded\", not \"nothing happened\": arm it first (read the events snapshot, or call setEnabledSources), reproduce, then export.\n(2) limit IS APPLIED BEFORE FILTERING. The adapter does all.slice(0, limit) and only then does generateExport filter by settings.filterSources / filterMode (packages/events/src/sync/eventsSyncAdapter.ts:378-381 + eventExportFormatter.ts:616). Filtering to one source with limit:25 can produce an EMPTY output while `returned` still reports 25 \u2014 `returned` counts events handed to the formatter, not events in the output. When filtering by source, pass a large limit (or omit it, which uses the whole buffer).\n(3) filterSources takes GRANULAR EventSource values, not friendly names. \"storage\" matches nothing \u2014 use [\"storage-async\",\"storage-mmkv\"]; react-query is three values ([\"react-query\",\"react-query-query\",\"react-query-mutation\"]). The MCP get_events wrapper expands those aliases for you; a direct call_action does not.\n\nAn unrecognized preset name silently falls back to DEFAULT_COPY_SETTINGS instead of erroring. settings is shallow-merged OVER the preset. Swift captures network, storage-async, storage-mmkv, and route. React state and render sources are unavailable. Native export accepts format, includeEventData, includeSource, includeStatus, includeTitle, includeSubtitle, includeSummaryHeader, filterMode, filterSources, dataSizeThreshold, and timestampFormat; other settings are rejected. Native detail inherits source snapshot payload limits.",
1445
1445
  "requires": [
1446
1446
  "@buoy-gg/events installed and auto-discovered by FloatingDevTools",
1447
- "a live watch on toolId \"events\" (an events snapshot read, a desktop Events panel, or a prior setEnabledSources call) before the window you want recorded — otherwise the buffer is empty",
1447
+ "a live watch on toolId \"events\" (an events snapshot read, a desktop Events panel, or a prior setEnabledSources call) before the window you want recorded \u2014 otherwise the buffer is empty",
1448
1448
  "the source packages you want in the timeline (@buoy-gg/network, /redux, /storage, /react-query, /route-events, /zustand, /jotai); the /highlight-updates 'render' source produces NOTHING in a release build"
1449
1449
  ],
1450
1450
  "armsCapture": true
1451
1451
  },
1452
1452
  {
1453
1453
  "action": "setEnabledSources",
1454
- "summary": "Arm/narrow which event sources the device records for this dashboard — the way to START capture before reproducing a bug.",
1454
+ "summary": "Arm/narrow which event sources the device records for this dashboard \u2014 the way to START capture before reproducing a bug.",
1455
1455
  "params": {
1456
1456
  "type": "object",
1457
1457
  "properties": {
@@ -1473,14 +1473,14 @@
1473
1473
  "render"
1474
1474
  ]
1475
1475
  },
1476
- "description": "Sources this dashboard wants the device to capture. REQUIRED in practice: omitting it (or passing []) unsubscribes everything and stops recording. Use granular values — 'storage' alone is not valid; react-query needs its three variants. 'render' is high-frequency and floods the 200-event buffer (and does nothing in a release build)."
1476
+ "description": "Sources this dashboard wants the device to capture. REQUIRED in practice: omitting it (or passing []) unsubscribes everything and stops recording. Use granular values \u2014 'storage' alone is not valid; react-query needs its three variants. 'render' is high-frequency and floods the 200-event buffer (and does nothing in a release build)."
1477
1477
  }
1478
1478
  },
1479
1479
  "additionalProperties": false
1480
1480
  },
1481
1481
  "effect": "write",
1482
1482
  "release": "works",
1483
- "description": "Declares this remote consumer's wanted source set. Subscriptions are ref-counted in unifiedEventStore, so narrowing here never tears down what the on-device Events modal is also using. Returns {enabledSources: <exactly what you passed>} — a pure echo, NOT a confirmation that the source exists or actually subscribed: an uninstalled or misspelled source silently subscribes nothing and still comes back in the echo. Verify by exporting and seeing events appear.\n\nDANGER: sources is `?? []`. Calling it with no params (or sources: []) unsubscribes EVERY source this consumer had requested and the device stops recording (packages/events/src/sync/eventsSyncAdapter.ts:357-362; the adapter test at __tests__/eventsSyncAdapter.test.ts:351 pins the {enabledSources: []} echo). Never call it bare to 'reset' — pass the full list you want.\n\nPass 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 — prefer an explicit list.\n\nRELEASE 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 — the echo still lists it. Every other source (network, storage, redux, zustand, jotai, route, react-query) records normally in release.",
1483
+ "description": "Declares this remote consumer's wanted source set. Subscriptions are ref-counted in unifiedEventStore, so narrowing here never tears down what the on-device Events modal is also using. Returns {enabledSources: <exactly what you passed>} \u2014 a pure echo, NOT a confirmation that the source exists or actually subscribed: an uninstalled or misspelled source silently subscribes nothing and still comes back in the echo. Verify by exporting and seeing events appear.\n\nDANGER: sources is `?? []`. Calling it with no params (or sources: []) unsubscribes EVERY source this consumer had requested and the device stops recording (packages/events/src/sync/eventsSyncAdapter.ts:357-362; the adapter test at __tests__/eventsSyncAdapter.test.ts:351 pins the {enabledSources: []} echo). Never call it bare to 'reset' \u2014 pass the full list you want.\n\nPass 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.\n\nRELEASE 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.",
1484
1484
  "requires": [
1485
1485
  "@buoy-gg/events installed and auto-discovered by FloatingDevTools",
1486
1486
  "the package behind each requested source installed in the app (otherwise the request is silently a no-op)"
@@ -1497,23 +1497,23 @@
1497
1497
  },
1498
1498
  "effect": "destructive",
1499
1499
  "release": "works",
1500
- "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 — 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.",
1500
+ "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.",
1501
1501
  "requires": [
1502
1502
  "@buoy-gg/events installed and auto-discovered by FloatingDevTools"
1503
1503
  ]
1504
1504
  }
1505
1505
  ],
1506
- "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) — 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)."
1506
+ "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)."
1507
1507
  },
1508
1508
  {
1509
1509
  "toolId": "network",
1510
1510
  "title": "Network",
1511
- "summary": "Read and act on the app's captured HTTP traffic — 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 — 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 — 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).",
1511
+ "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).",
1512
1512
  "actions": [
1513
1513
  {
1514
1514
  "action": "getSnapshot",
1515
- "summary": "List the app's captured HTTP requests — method, url, status, duration, error. The read half of the network tool; there is no action that lists requests.",
1516
- "description": "Returns `{ totalCaptured, shown, requests: [{id, method, url, status, durationMs, error}] }`, newest last. Bodies are NOT included — 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.",
1515
+ "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.",
1516
+ "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.",
1517
1517
  "params": {
1518
1518
  "type": "object",
1519
1519
  "properties": {
@@ -1537,7 +1537,7 @@
1537
1537
  },
1538
1538
  {
1539
1539
  "action": "getCaptureStatus",
1540
- "summary": "Is the device actually recording right now, and why not — call this before reporting an empty request list.",
1540
+ "summary": "Is the device actually recording right now, and why not \u2014 call this before reporting an empty request list.",
1541
1541
  "params": {
1542
1542
  "type": "object",
1543
1543
  "properties": {},
@@ -1545,10 +1545,10 @@
1545
1545
  },
1546
1546
  "effect": "read",
1547
1547
  "release": "works",
1548
- "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 — 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.",
1548
+ "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.",
1549
1549
  "requires": [
1550
1550
  "@buoy-gg/network mounted in the app",
1551
- "in a release build there is NO boot capture — packages/network/src/preset.tsx:52 gates startBootCapture on __DEV__, so requests fired before the first watch are unrecoverable"
1551
+ "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"
1552
1552
  ]
1553
1553
  },
1554
1554
  {
@@ -1569,9 +1569,9 @@
1569
1569
  },
1570
1570
  "effect": "read",
1571
1571
  "release": "works",
1572
- "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 — 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.",
1572
+ "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.",
1573
1573
  "requires": [
1574
- "the event must still exist — live capture list (500-request cap) or the saved store"
1574
+ "the event must still exist \u2014 live capture list (500-request cap) or the saved store"
1575
1575
  ],
1576
1576
  "armsCapture": true
1577
1577
  },
@@ -1587,7 +1587,7 @@
1587
1587
  },
1588
1588
  "pinned": {
1589
1589
  "type": "boolean",
1590
- "description": "true pins. false OR OMITTED unpins — always send this explicitly."
1590
+ "description": "true pins. false OR OMITTED unpins \u2014 always send this explicitly."
1591
1591
  }
1592
1592
  },
1593
1593
  "required": [
@@ -1597,7 +1597,7 @@
1597
1597
  },
1598
1598
  "effect": "write",
1599
1599
  "release": "works",
1600
- "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 — 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\"} — pin-cap is the 25-pin ceiling (lower on some license tiers); clear some first.",
1600
+ "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.",
1601
1601
  "requires": [
1602
1602
  "the event must still exist in the live or saved store"
1603
1603
  ],
@@ -1615,7 +1615,7 @@
1615
1615
  },
1616
1616
  "saved": {
1617
1617
  "type": "boolean",
1618
- "description": "true saves. false OR OMITTED unsaves — always send this explicitly."
1618
+ "description": "true saves. false OR OMITTED unsaves \u2014 always send this explicitly."
1619
1619
  }
1620
1620
  },
1621
1621
  "required": [
@@ -1625,7 +1625,7 @@
1625
1625
  },
1626
1626
  "effect": "write",
1627
1627
  "release": "works",
1628
- "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\"} — saved-cap is the 50-record ceiling (lower on some license tiers).",
1628
+ "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).",
1629
1629
  "requires": [
1630
1630
  "the event must still exist in the live or saved store"
1631
1631
  ],
@@ -1649,7 +1649,7 @@
1649
1649
  },
1650
1650
  "effect": "destructive",
1651
1651
  "release": "works",
1652
- "description": "Takes the record `key` from the snapshot's `saved` list — 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."
1652
+ "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."
1653
1653
  },
1654
1654
  {
1655
1655
  "action": "clearSavedRequests",
@@ -1661,7 +1661,7 @@
1661
1661
  },
1662
1662
  "effect": "destructive",
1663
1663
  "release": "works",
1664
- "description": "Clears the 'saved'/favorites flag across the persisted store. Records that carry only the saved flag are deleted for good — 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 — do not call it to tidy up."
1664
+ "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."
1665
1665
  },
1666
1666
  {
1667
1667
  "action": "clearPinnedRequests",
@@ -1673,7 +1673,7 @@
1673
1673
  },
1674
1674
  "effect": "destructive",
1675
1675
  "release": "works",
1676
- "description": "Clears the pin flag across the persisted store. Pin-only records are deleted permanently, with no undo — 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."
1676
+ "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."
1677
1677
  },
1678
1678
  {
1679
1679
  "action": "clearEvents",
@@ -1685,7 +1685,7 @@
1685
1685
  },
1686
1686
  "effect": "destructive",
1687
1687
  "release": "works",
1688
- "description": "Empties the in-memory event list and its pending-request map. Irreversible — 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)."
1688
+ "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)."
1689
1689
  },
1690
1690
  {
1691
1691
  "action": "listOverrideRules",
@@ -1697,7 +1697,7 @@
1697
1697
  },
1698
1698
  "effect": "read",
1699
1699
  "release": "works",
1700
- "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 — 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) — that is the explanation for 'it matched but nothing happened'."
1700
+ "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'."
1701
1701
  },
1702
1702
  {
1703
1703
  "action": "getOverrideRuleBody",
@@ -1717,34 +1717,34 @@
1717
1717
  },
1718
1718
  "effect": "read",
1719
1719
  "release": "works",
1720
- "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 — 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."
1720
+ "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."
1721
1721
  },
1722
1722
  {
1723
1723
  "action": "debugOverrides",
1724
- "summary": "Why a rule is or isn't firing — and the ONLY action that reveals whether overrides can work in this build at all.",
1724
+ "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.",
1725
1725
  "params": {
1726
1726
  "type": "object",
1727
1727
  "properties": {
1728
1728
  "url": {
1729
1729
  "type": "string",
1730
- "description": "Concrete URL to test the rules against, query string included. Defaults to 'https://example.com/', which matches nothing useful — always pass the real URL you expect to be overridden."
1730
+ "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."
1731
1731
  }
1732
1732
  },
1733
1733
  "additionalProperties": false
1734
1734
  },
1735
1735
  "effect": "read",
1736
1736
  "release": "works",
1737
- "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 — a pattern must match the WHOLE url. Caution: unlike getCaptureStatus this probes the listener unguarded, so it throws in a runtime with no XMLHttpRequest."
1737
+ "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."
1738
1738
  },
1739
1739
  {
1740
1740
  "action": "upsertOverrideRule",
1741
- "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) — 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 — 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.",
1741
+ "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.",
1742
1742
  "params": {
1743
1743
  "type": "object",
1744
1744
  "properties": {
1745
1745
  "fromRequestId": {
1746
1746
  "type": "string",
1747
- "description": "Build the rule from a captured request id — seeds urlPattern (query string replaced with `*`), method, status and the REAL response body. Anything you send explicitly wins over the prefill."
1747
+ "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."
1748
1748
  },
1749
1749
  "rule": {
1750
1750
  "type": "object",
@@ -1788,14 +1788,14 @@
1788
1788
  },
1789
1789
  "statusText": {
1790
1790
  "type": "string",
1791
- "description": "kind 'respond': cosmetic only — React Native's XMLHttpRequest has no statusText, so the app never sees it."
1791
+ "description": "kind 'respond': cosmetic only \u2014 React Native's XMLHttpRequest has no statusText, so the app never sees it."
1792
1792
  },
1793
1793
  "headers": {
1794
1794
  "type": "object",
1795
1795
  "description": "kind 'respond': response headers, string values."
1796
1796
  },
1797
1797
  "body": {
1798
- "description": "kind 'respond': the COMPLETE response body. A string is used as-is; an object or array is JSON.stringify'd for you — do not pre-stringify. To change a few fields of the real response use bodyPatch instead — 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."
1798
+ "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."
1799
1799
  },
1800
1800
  "failKind": {
1801
1801
  "type": "string",
@@ -1810,7 +1810,7 @@
1810
1810
  },
1811
1811
  "times": {
1812
1812
  "type": "number",
1813
- "description": "Auto-disable after N matches. **Set times:1 whenever the ask is about the NEXT call** — \"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."
1813
+ "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."
1814
1814
  },
1815
1815
  "alternate": {
1816
1816
  "type": "boolean",
@@ -1822,11 +1822,11 @@
1822
1822
  },
1823
1823
  "bodyPatch": {
1824
1824
  "type": "object",
1825
- "description": "Fields to change in the real captured response (needs fromRequestId, or the id of an existing rule) as a TYPED LEAF EDIT — 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."
1825
+ "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."
1826
1826
  },
1827
1827
  "force": {
1828
1828
  "type": "boolean",
1829
- "description": "Bypass the typed-edit safety on bodyPatch and write the patch raw. Default false — a violating patch is refused."
1829
+ "description": "Bypass the typed-edit safety on bodyPatch and write the patch raw. Default false \u2014 a violating patch is refused."
1830
1830
  },
1831
1831
  "bodyPath": {
1832
1832
  "type": "string",
@@ -1843,12 +1843,12 @@
1843
1843
  },
1844
1844
  "effect": "write",
1845
1845
  "release": "noop",
1846
- "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) — 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 — 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.",
1847
- "releaseNote": "packages/network/src/network/overrides/engine.ts:74 — 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 → engine.devFlag before telling anyone the override took effect.",
1846
+ "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.",
1847
+ "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.",
1848
1848
  "requires": [
1849
1849
  "a __DEV__ build for the rule to actually apply",
1850
1850
  "something holding the interceptor open (an enabled rule pins it itself)",
1851
- "Buoy Pro only to keep more than 1 rule — and only in the on-device UI; this action is not license-gated"
1851
+ "Buoy Pro only to keep more than 1 rule \u2014 and only in the on-device UI; this action is not license-gated"
1852
1852
  ]
1853
1853
  },
1854
1854
  {
@@ -1863,7 +1863,7 @@
1863
1863
  },
1864
1864
  "enabled": {
1865
1865
  "type": "boolean",
1866
- "description": "true arms this rule. false OR OMITTED disables it — always send this explicitly."
1866
+ "description": "true arms this rule. false OR OMITTED disables it \u2014 always send this explicitly."
1867
1867
  }
1868
1868
  },
1869
1869
  "required": [
@@ -1873,8 +1873,8 @@
1873
1873
  },
1874
1874
  "effect": "write",
1875
1875
  "release": "noop",
1876
- "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 — 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.",
1877
- "releaseNote": "packages/network/src/network/overrides/engine.ts:74 — 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.",
1876
+ "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.",
1877
+ "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.",
1878
1878
  "requires": [
1879
1879
  "a __DEV__ build for the rule to actually apply"
1880
1880
  ],
@@ -1885,13 +1885,13 @@
1885
1885
  },
1886
1886
  {
1887
1887
  "action": "setOverridesEnabled",
1888
- "summary": "Master switch for ALL override rules — off silences every rule without deleting any.",
1888
+ "summary": "Master switch for ALL override rules \u2014 off silences every rule without deleting any.",
1889
1889
  "params": {
1890
1890
  "type": "object",
1891
1891
  "properties": {
1892
1892
  "enabled": {
1893
1893
  "type": "boolean",
1894
- "description": "true arms all enabled rules. false OR OMITTED silences every rule (rules are kept) — always send this explicitly."
1894
+ "description": "true arms all enabled rules. false OR OMITTED silences every rule (rules are kept) \u2014 always send this explicitly."
1895
1895
  }
1896
1896
  },
1897
1897
  "required": [],
@@ -1899,8 +1899,8 @@
1899
1899
  },
1900
1900
  "effect": "write",
1901
1901
  "release": "noop",
1902
- "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>} — 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.",
1903
- "releaseNote": "packages/network/src/network/overrides/engine.ts:74 — 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.",
1902
+ "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.",
1903
+ "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.",
1904
1904
  "requires": [
1905
1905
  "a __DEV__ build for rules to actually apply"
1906
1906
  ],
@@ -1911,7 +1911,7 @@
1911
1911
  },
1912
1912
  {
1913
1913
  "action": "deleteOverrideRule",
1914
- "summary": "Delete one override rule by id — the undo for upsertOverrideRule.",
1914
+ "summary": "Delete one override rule by id \u2014 the undo for upsertOverrideRule.",
1915
1915
  "params": {
1916
1916
  "type": "object",
1917
1917
  "properties": {
@@ -1927,7 +1927,7 @@
1927
1927
  },
1928
1928
  "effect": "destructive",
1929
1929
  "release": "works",
1930
- "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 — 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."
1930
+ "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."
1931
1931
  },
1932
1932
  {
1933
1933
  "action": "clearOverrideRules",
@@ -1939,7 +1939,7 @@
1939
1939
  },
1940
1940
  "effect": "destructive",
1941
1941
  "release": "works",
1942
- "description": "Wipes the whole persisted rule list at once — 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}."
1942
+ "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}."
1943
1943
  },
1944
1944
  {
1945
1945
  "action": "getNetworkConditions",
@@ -1979,12 +1979,12 @@
1979
1979
  "releaseNote": "Release builds refuse active conditions. Normal can still clear the profile."
1980
1980
  }
1981
1981
  ],
1982
- "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 — there getCaptureStatus reports interceptorInstalled:null and debugOverrides throws). The override actions are additionally Pro-gated by the MCP/dashboard layer (requireProDevice)."
1982
+ "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)."
1983
1983
  },
1984
1984
  {
1985
1985
  "toolId": "js-top",
1986
1986
  "title": "JS TOP (JS-thread task manager)",
1987
- "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 — everything else is control (setEnabled/pause/resume/clear) or drill-down (getOriginDetail). NOT for \"components re-render too often\" — this measures TASKS, not renders.",
1987
+ "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.",
1988
1988
  "actions": [
1989
1989
  {
1990
1990
  "action": "sample",
@@ -2006,7 +2006,7 @@
2006
2006
  },
2007
2007
  "effect": "write",
2008
2008
  "release": "works",
2009
- "description": "Self-arming one-shot measurement — 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 — 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.",
2009
+ "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.",
2010
2010
  "requires": [
2011
2011
  "@buoy-gg/js-top installed and imported in the app",
2012
2012
  "device connected to the Buoy broker (external sync)"
@@ -2020,7 +2020,7 @@
2020
2020
  "properties": {
2021
2021
  "key": {
2022
2022
  "type": "string",
2023
- "description": "Origin key from a snapshot row, e.g. \"interval|pollFeed\", \"raf|anonymous\", or the literal \"unattributed\" system row. Required — a missing key throws."
2023
+ "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."
2024
2024
  }
2025
2025
  },
2026
2026
  "required": [
@@ -2030,23 +2030,23 @@
2030
2030
  },
2031
2031
  "effect": "read",
2032
2032
  "release": "works",
2033
- "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) — 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.",
2033
+ "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.",
2034
2034
  "requires": [
2035
2035
  "@buoy-gg/js-top installed and imported in the app",
2036
- "the engine must have been recording — call `sample` (or setEnabled true) first",
2037
- "a __DEV__ build with Metro reachable for symbolicated labels/file/line — in a release build origins come back as raw unsymbolicated frames"
2036
+ "the engine must have been recording \u2014 call `sample` (or setEnabled true) first",
2037
+ "a __DEV__ build with Metro reachable for symbolicated labels/file/line \u2014 in a release build origins come back as raw unsymbolicated frames"
2038
2038
  ],
2039
2039
  "armsCapture": true
2040
2040
  },
2041
2041
  {
2042
2042
  "action": "setEnabled",
2043
- "summary": "Turn silent remote sampling on/off — runs the accounting engine with no visible change on the device.",
2043
+ "summary": "Turn silent remote sampling on/off \u2014 runs the accounting engine with no visible change on the device.",
2044
2044
  "params": {
2045
2045
  "type": "object",
2046
2046
  "properties": {
2047
2047
  "enabled": {
2048
2048
  "type": "boolean",
2049
- "description": "true = start silent remote sampling (engine runs, device UI unchanged); false = stop it. Required — the handler reads params.enabled with no guard, so omitting params throws."
2049
+ "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."
2050
2050
  }
2051
2051
  },
2052
2052
  "required": [
@@ -2056,7 +2056,7 @@
2056
2056
  },
2057
2057
  "effect": "write",
2058
2058
  "release": "works",
2059
- "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 — 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.",
2059
+ "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.",
2060
2060
  "requires": [
2061
2061
  "@buoy-gg/js-top installed and imported in the app"
2062
2062
  ],
@@ -2072,7 +2072,7 @@
2072
2072
  },
2073
2073
  "effect": "write",
2074
2074
  "release": "works",
2075
- "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` — `sample` does not auto-resume.",
2075
+ "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.",
2076
2076
  "requires": [
2077
2077
  "@buoy-gg/js-top installed and imported in the app"
2078
2078
  ]
@@ -2087,14 +2087,14 @@
2087
2087
  },
2088
2088
  "effect": "write",
2089
2089
  "release": "works",
2090
- "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 — the pause gap is not counted as busy time.",
2090
+ "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.",
2091
2091
  "requires": [
2092
2092
  "@buoy-gg/js-top installed and imported in the app"
2093
2093
  ]
2094
2094
  },
2095
2095
  {
2096
2096
  "action": "clear",
2097
- "summary": "Wipe the whole task table, busy history and freeze history — starts a fresh measurement window.",
2097
+ "summary": "Wipe the whole task table, busy history and freeze history \u2014 starts a fresh measurement window.",
2098
2098
  "params": {
2099
2099
  "type": "object",
2100
2100
  "properties": {},
@@ -2102,22 +2102,22 @@
2102
2102
  },
2103
2103
  "effect": "destructive",
2104
2104
  "release": "works",
2105
- "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 — 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.",
2105
+ "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.",
2106
2106
  "requires": [
2107
2107
  "@buoy-gg/js-top installed and imported in the app"
2108
2108
  ]
2109
2109
  }
2110
2110
  ],
2111
- "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 — 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."
2111
+ "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."
2112
2112
  },
2113
2113
  {
2114
2114
  "toolId": "app",
2115
2115
  "title": "App",
2116
- "summary": "Device-level pseudo-tool from @buoy-gg/core itself (packages/devtools-floating-menu/src/floatingMenu/sync/appSyncAdapter.ts) — 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}}` — read `reload.available` before promising a user a reload will work.",
2116
+ "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.",
2117
2117
  "actions": [
2118
2118
  {
2119
2119
  "action": "ping",
2120
- "summary": "Liveness probe: returns {ok:true, startedAt, devServerOrigin}. `startedAt` identifies WHICH JS realm answered — the only reliable way to confirm a reload really happened.",
2120
+ "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.",
2121
2121
  "params": {
2122
2122
  "type": "object",
2123
2123
  "properties": {},
@@ -2126,7 +2126,7 @@
2126
2126
  },
2127
2127
  "effect": "read",
2128
2128
  "release": "works",
2129
- "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 — capture it while the app is healthy, because after a crash it is the only remaining way to recover the app.",
2129
+ "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.",
2130
2130
  "requires": [
2131
2131
  "@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)",
2132
2132
  "external-sync socket connected to the broker on :42831"
@@ -2134,7 +2134,7 @@
2134
2134
  },
2135
2135
  {
2136
2136
  "action": "reloadApp",
2137
- "summary": "Restart the app's JS bundle (DevSettings.reload() in dev, expo-updates otherwise). ALL in-memory state is destroyed — read anything you need first.",
2137
+ "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.",
2138
2138
  "params": {
2139
2139
  "type": "object",
2140
2140
  "properties": {
@@ -2157,21 +2157,21 @@
2157
2157
  },
2158
2158
  "effect": "destructive",
2159
2159
  "release": "throws",
2160
- "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 — 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 — 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.",
2161
- "releaseNote": "packages/shared/src/utils/reloadApp.ts:80 — `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 — never a fake success. Explicit strategy \"dev-settings\" throws in release regardless.",
2160
+ "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.",
2161
+ "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.",
2162
2162
  "requires": [
2163
2163
  "@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)",
2164
2164
  "A dev build (for DevSettings.reload) OR the host app has `expo-updates` installed",
2165
- "Snapshot `reload.available === true` — check before promising a reload"
2165
+ "Snapshot `reload.available === true` \u2014 check before promising a reload"
2166
2166
  ]
2167
2167
  }
2168
2168
  ],
2169
- "unavailableWhen": "The app's bundle is a release build (`__DEV__ === false`) that did not opt into `externalSync.enableInRelease` AND hold a real Pro license — 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\")."
2169
+ "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\")."
2170
2170
  },
2171
2171
  {
2172
2172
  "toolId": "time-machine",
2173
2173
  "title": "Time Machine",
2174
- "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 — `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.",
2174
+ "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.",
2175
2175
  "actions": [
2176
2176
  {
2177
2177
  "action": "list",
@@ -2183,7 +2183,7 @@
2183
2183
  },
2184
2184
  "effect": "read",
2185
2185
  "release": "works",
2186
- "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[] } } } — 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 — 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 — 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.",
2186
+ "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.",
2187
2187
  "requires": [
2188
2188
  "@buoy-gg/time-machine registered in FloatingDevTools"
2189
2189
  ]
@@ -2196,7 +2196,7 @@
2196
2196
  "properties": {
2197
2197
  "name": {
2198
2198
  "type": "string",
2199
- "description": "Label for the restore point. Omit or pass \"\" to auto-name it \"<current pathname> · HH:MM\"."
2199
+ "description": "Label for the restore point. Omit or pass \"\" to auto-name it \"<current pathname> \u00b7 HH:MM\"."
2200
2200
  },
2201
2201
  "sourceIds": {
2202
2202
  "type": "array",
@@ -2210,7 +2210,7 @@
2210
2210
  },
2211
2211
  "effect": "write",
2212
2212
  "release": "works",
2213
- "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` — 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> · HH:MM\". Does NOT modify app state. Do this before driving a flow you want to re-run.",
2213
+ "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> \u00b7 HH:MM\". Does NOT modify app state. Do this before driving a flow you want to re-run.",
2214
2214
  "requires": [
2215
2215
  "at least one state source registered (storage/redux/zustand/jotai/react-query package installed and instrumented)",
2216
2216
  "expo-router for the route stamp (optional)"
@@ -2226,7 +2226,7 @@
2226
2226
  },
2227
2227
  "effect": "write",
2228
2228
  "release": "works",
2229
- "description": "Takes no reading of current state — 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.",
2229
+ "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.",
2230
2230
  "requires": [
2231
2231
  "@buoy-gg/time-machine registered in FloatingDevTools"
2232
2232
  ]
@@ -2241,7 +2241,7 @@
2241
2241
  },
2242
2242
  "effect": "destructive",
2243
2243
  "release": "works",
2244
- "description": "Calls clear() on every registered provider (app AsyncStorage/MMKV/SecureStore keys, redux, zustand, jotai, react-query cache — 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 — take a `capture` first if the state matters. CHECK `willReload` in the result before telling anyone the app restarted (see releaseNote).",
2244
+ "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).",
2245
2245
  "requires": [
2246
2246
  "expo-updates installed for the reload leg to work in a release build"
2247
2247
  ]
@@ -2269,11 +2269,11 @@
2269
2269
  "items": {
2270
2270
  "type": "string"
2271
2271
  },
2272
- "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 — and disables — the snapshot's saved exclusions and saved scope."
2272
+ "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."
2273
2273
  },
2274
2274
  "restoreRoute": {
2275
2275
  "type": "boolean",
2276
- "description": "Also navigate back to the route the snapshot was captured on. OMITTING THIS IS NOT false — 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)."
2276
+ "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)."
2277
2277
  }
2278
2278
  },
2279
2279
  "required": [
@@ -2283,7 +2283,7 @@
2283
2283
  },
2284
2284
  "effect": "destructive",
2285
2285
  "release": "works",
2286
- "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 → redux → zustand → jotai → 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 — 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 — 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.",
2286
+ "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.",
2287
2287
  "requires": [
2288
2288
  "a source's provider must report canRestore:true or that source is skipped with a reason",
2289
2289
  "expo-router for the route leg",
@@ -2292,7 +2292,7 @@
2292
2292
  },
2293
2293
  {
2294
2294
  "action": "preview",
2295
- "summary": "Compute the exact blast radius of restoring a snapshot — per-item added/removed/changed/wont-apply verdicts — WITHOUT changing anything.",
2295
+ "summary": "Compute the exact blast radius of restoring a snapshot \u2014 per-item added/removed/changed/wont-apply verdicts \u2014 WITHOUT changing anything.",
2296
2296
  "params": {
2297
2297
  "type": "object",
2298
2298
  "properties": {
@@ -2302,7 +2302,7 @@
2302
2302
  },
2303
2303
  "compareTo": {
2304
2304
  "type": "string",
2305
- "description": "Another snapshot id to diff against instead of the app's live current state. Omit to compare against live state — which is what a restore would actually overwrite."
2305
+ "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."
2306
2306
  }
2307
2307
  },
2308
2308
  "required": [
@@ -2312,7 +2312,7 @@
2312
2312
  },
2313
2313
  "effect": "read",
2314
2314
  "release": "works",
2315
- "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) — 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.",
2315
+ "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.",
2316
2316
  "requires": [
2317
2317
  "@buoy-gg/time-machine registered in FloatingDevTools"
2318
2318
  ]
@@ -2329,7 +2329,7 @@
2329
2329
  },
2330
2330
  "sourceId": {
2331
2331
  "type": "string",
2332
- "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` — required."
2332
+ "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."
2333
2333
  }
2334
2334
  },
2335
2335
  "required": [
@@ -2340,7 +2340,7 @@
2340
2340
  },
2341
2341
  "effect": "read",
2342
2342
  "release": "works",
2343
- "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 — `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.",
2343
+ "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.",
2344
2344
  "requires": [
2345
2345
  "@buoy-gg/time-machine registered in FloatingDevTools"
2346
2346
  ]
@@ -2363,7 +2363,7 @@
2363
2363
  },
2364
2364
  "effect": "destructive",
2365
2365
  "release": "works",
2366
- "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 — 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.",
2366
+ "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.",
2367
2367
  "requires": [
2368
2368
  "@buoy-gg/time-machine registered in FloatingDevTools"
2369
2369
  ]
@@ -2391,7 +2391,7 @@
2391
2391
  },
2392
2392
  "effect": "write",
2393
2393
  "release": "works",
2394
- "description": "Requires BOTH `id` and a non-empty `name` (throws \"rename requires `id` and `name`\"). Metadata only — the captured payload is untouched. Returns { renamed:true, id, name }.",
2394
+ "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 }.",
2395
2395
  "requires": [
2396
2396
  "@buoy-gg/time-machine registered in FloatingDevTools"
2397
2397
  ]
@@ -2447,14 +2447,14 @@
2447
2447
  },
2448
2448
  "effect": "write",
2449
2449
  "release": "works",
2450
- "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 — nothing in the app changes now; the effect lands on the next restore.",
2450
+ "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.",
2451
2451
  "requires": [
2452
2452
  "@buoy-gg/time-machine registered in FloatingDevTools"
2453
2453
  ]
2454
2454
  },
2455
2455
  {
2456
2456
  "action": "setScope",
2457
- "summary": "Persist a targeted-restore scope — the ONLY items this restore point will ever touch.",
2457
+ "summary": "Persist a targeted-restore scope \u2014 the ONLY items this restore point will ever touch.",
2458
2458
  "params": {
2459
2459
  "type": "object",
2460
2460
  "properties": {
@@ -2470,7 +2470,7 @@
2470
2470
  "items": {
2471
2471
  "type": "string"
2472
2472
  },
2473
- "description": "Compound \"sourceId::itemKey\" strings from `preview` — the only items a restore may touch. null or an empty array clears the scope (full restore)."
2473
+ "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)."
2474
2474
  }
2475
2475
  },
2476
2476
  "required": [
@@ -2497,7 +2497,7 @@
2497
2497
  },
2498
2498
  "restoreRoute": {
2499
2499
  "type": "boolean",
2500
- "description": "true to navigate back to the captured route on restore, false to skip it. Only an explicit false turns it off — omitting it turns it ON."
2500
+ "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."
2501
2501
  }
2502
2502
  },
2503
2503
  "required": [
@@ -2507,13 +2507,13 @@
2507
2507
  },
2508
2508
  "effect": "write",
2509
2509
  "release": "works",
2510
- "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 — or sending anything other than exactly false — turns it ON. Returns { id, restoreRoute }. Only meaningful for snapshots that captured a route (see `route` on the snapshot and `route.available` from `list`).",
2510
+ "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`).",
2511
2511
  "requires": [
2512
2512
  "expo-router on the device for the flag to have any effect"
2513
2513
  ]
2514
2514
  }
2515
2515
  ],
2516
- "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 — 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."
2516
+ "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."
2517
2517
  },
2518
2518
  {
2519
2519
  "toolId": "clock",
@@ -2764,10 +2764,850 @@
2764
2764
  }
2765
2765
  ]
2766
2766
  },
2767
+ {
2768
+ "toolId": "lifecycle",
2769
+ "title": "Lifecycle",
2770
+ "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.",
2771
+ "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.",
2772
+ "actions": [
2773
+ {
2774
+ "action": "getState",
2775
+ "summary": "Read what is simulated, the app's listener counts per signal, the last report and the relaunch check.",
2776
+ "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.",
2777
+ "effect": "read",
2778
+ "release": "works",
2779
+ "params": {
2780
+ "type": "object",
2781
+ "properties": {},
2782
+ "additionalProperties": false
2783
+ }
2784
+ },
2785
+ {
2786
+ "action": "background",
2787
+ "summary": "Send the app to the background (iOS: inactive then background; Android: blur then background), optionally for a `duration`.",
2788
+ "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.",
2789
+ "effect": "write",
2790
+ "release": "works",
2791
+ "undo": {
2792
+ "action": "returnToApp",
2793
+ "note": "Brings the app back to active. reset also ends it."
2794
+ },
2795
+ "params": {
2796
+ "type": "object",
2797
+ "properties": {
2798
+ "duration": {
2799
+ "type": [
2800
+ "string",
2801
+ "number"
2802
+ ],
2803
+ "description": "Return by itself after this long: \"5s\", \"5m\", \"2h\", or ms. Omit to stay away until returnToApp."
2804
+ },
2805
+ "skipWait": {
2806
+ "type": "boolean",
2807
+ "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."
2808
+ }
2809
+ },
2810
+ "additionalProperties": false
2811
+ }
2812
+ },
2813
+ {
2814
+ "action": "interrupt",
2815
+ "summary": "Make the app inactive, like the app switcher, Control Center or an incoming call (Android: window blur).",
2816
+ "description": "iOS emits 'inactive'; Android takes window focus away without changing AppState. Same `duration` and `skipWait` as background. Returns the new state.",
2817
+ "effect": "write",
2818
+ "release": "works",
2819
+ "undo": {
2820
+ "action": "returnToApp",
2821
+ "note": "Brings the app back to active. reset also ends it."
2822
+ },
2823
+ "params": {
2824
+ "type": "object",
2825
+ "properties": {
2826
+ "duration": {
2827
+ "type": [
2828
+ "string",
2829
+ "number"
2830
+ ],
2831
+ "description": "Return by itself after this long: \"5s\", \"5m\", \"2h\", or ms. Omit to stay away until returnToApp."
2832
+ },
2833
+ "skipWait": {
2834
+ "type": "boolean",
2835
+ "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."
2836
+ }
2837
+ },
2838
+ "additionalProperties": false
2839
+ }
2840
+ },
2841
+ {
2842
+ "action": "returnToApp",
2843
+ "summary": "Bring the app back to active after background or interrupt.",
2844
+ "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.",
2845
+ "effect": "write",
2846
+ "release": "works",
2847
+ "params": {
2848
+ "type": "object",
2849
+ "properties": {},
2850
+ "additionalProperties": false
2851
+ }
2852
+ },
2853
+ {
2854
+ "action": "memoryWarning",
2855
+ "summary": "Send a memory warning to the app's AppState 'memoryWarning' listeners.",
2856
+ "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.",
2857
+ "effect": "write",
2858
+ "release": "works",
2859
+ "params": {
2860
+ "type": "object",
2861
+ "properties": {},
2862
+ "additionalProperties": false
2863
+ }
2864
+ },
2865
+ {
2866
+ "action": "openUrl",
2867
+ "summary": "Deliver a deep link to the running app, as the OS would (Linking 'url').",
2868
+ "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).",
2869
+ "effect": "write",
2870
+ "release": "works",
2871
+ "params": {
2872
+ "type": "object",
2873
+ "properties": {
2874
+ "url": {
2875
+ "type": "string",
2876
+ "description": "The link with its scheme, e.g. \"myapp://orders/42\"."
2877
+ }
2878
+ },
2879
+ "additionalProperties": false,
2880
+ "required": [
2881
+ "url"
2882
+ ]
2883
+ }
2884
+ },
2885
+ {
2886
+ "action": "pressBack",
2887
+ "summary": "Press Android's hardware back button without ever leaving the app; reports whether a screen handled it.",
2888
+ "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`.",
2889
+ "effect": "write",
2890
+ "release": "works",
2891
+ "params": {
2892
+ "type": "object",
2893
+ "properties": {},
2894
+ "additionalProperties": false
2895
+ }
2896
+ },
2897
+ {
2898
+ "action": "setColorScheme",
2899
+ "summary": "Switch the app to `scheme` light or dark as if the system setting changed, or back to the system value.",
2900
+ "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.",
2901
+ "effect": "write",
2902
+ "release": "works",
2903
+ "undo": {
2904
+ "action": "reset",
2905
+ "note": "reset ends every simulation and puts the real values back."
2906
+ },
2907
+ "params": {
2908
+ "type": "object",
2909
+ "properties": {
2910
+ "scheme": {
2911
+ "type": "string",
2912
+ "enum": [
2913
+ "light",
2914
+ "dark",
2915
+ "system"
2916
+ ],
2917
+ "description": "The scheme to switch to. 'system' ends the simulation and puts the real value back."
2918
+ },
2919
+ "mode": {
2920
+ "type": "string",
2921
+ "enum": [
2922
+ "js",
2923
+ "native"
2924
+ ],
2925
+ "description": "'js' (default) or 'native'."
2926
+ }
2927
+ },
2928
+ "additionalProperties": false,
2929
+ "required": [
2930
+ "scheme"
2931
+ ]
2932
+ }
2933
+ },
2934
+ {
2935
+ "action": "setPower",
2936
+ "summary": "Set battery `level`, charging `state` and `lowPowerMode` for expo-battery and react-native-device-info listeners.",
2937
+ "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.",
2938
+ "effect": "write",
2939
+ "release": "works",
2940
+ "undo": {
2941
+ "action": "resetPower",
2942
+ "note": "resetPower restores the real getters and re-emits the real values."
2943
+ },
2944
+ "params": {
2945
+ "type": "object",
2946
+ "properties": {
2947
+ "level": {
2948
+ "type": "number",
2949
+ "description": "Battery level 0-1 (a value above 1 is read as a percentage)."
2950
+ },
2951
+ "lowPowerMode": {
2952
+ "type": "boolean",
2953
+ "description": "Low power mode on or off."
2954
+ },
2955
+ "state": {
2956
+ "type": "string",
2957
+ "enum": [
2958
+ "unplugged",
2959
+ "charging",
2960
+ "full",
2961
+ "unknown"
2962
+ ],
2963
+ "description": "Charging state."
2964
+ }
2965
+ },
2966
+ "additionalProperties": false
2967
+ }
2968
+ },
2969
+ {
2970
+ "action": "resetPower",
2971
+ "summary": "Put the real battery values back.",
2972
+ "description": "Restores wrapped getters and re-emits the real values. Returns the new state.",
2973
+ "effect": "write",
2974
+ "release": "works",
2975
+ "params": {
2976
+ "type": "object",
2977
+ "properties": {},
2978
+ "additionalProperties": false
2979
+ }
2980
+ },
2981
+ {
2982
+ "action": "relaunch",
2983
+ "summary": "Restart the app's JS and check whether it reopens the screen it was on.",
2984
+ "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.",
2985
+ "effect": "destructive",
2986
+ "release": "works",
2987
+ "params": {
2988
+ "type": "object",
2989
+ "properties": {},
2990
+ "additionalProperties": false
2991
+ }
2992
+ },
2993
+ {
2994
+ "action": "runRecipe",
2995
+ "summary": "Run the interruption named by `id`: phone-call, quick-switch, away-31m, overnight, low-memory, dying-battery or relaunch.",
2996
+ "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.",
2997
+ "effect": "write",
2998
+ "release": "works",
2999
+ "undo": {
3000
+ "action": "reset",
3001
+ "note": "reset ends every simulation and puts the real values back."
3002
+ },
3003
+ "params": {
3004
+ "type": "object",
3005
+ "properties": {
3006
+ "id": {
3007
+ "type": "string",
3008
+ "description": "Recipe id, e.g. \"away-31m\"."
3009
+ }
3010
+ },
3011
+ "additionalProperties": false,
3012
+ "required": [
3013
+ "id"
3014
+ ]
3015
+ }
3016
+ },
3017
+ {
3018
+ "action": "clearReport",
3019
+ "summary": "Stop and clear the current report.",
3020
+ "description": "Returns the new state.",
3021
+ "effect": "write",
3022
+ "release": "works",
3023
+ "params": {
3024
+ "type": "object",
3025
+ "properties": {},
3026
+ "additionalProperties": false
3027
+ }
3028
+ },
3029
+ {
3030
+ "action": "reset",
3031
+ "summary": "End every simulation: return to the app, restore battery and color scheme.",
3032
+ "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.",
3033
+ "effect": "write",
3034
+ "release": "works",
3035
+ "params": {
3036
+ "type": "object",
3037
+ "properties": {},
3038
+ "additionalProperties": false
3039
+ }
3040
+ }
3041
+ ]
3042
+ },
3043
+ {
3044
+ "toolId": "location",
3045
+ "title": "Location",
3046
+ "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.",
3047
+ "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.",
3048
+ "actions": [
3049
+ {
3050
+ "action": "getState",
3051
+ "summary": "Read where the app thinks it is, its live location watches, its geofence regions and recent location events.",
3052
+ "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.",
3053
+ "effect": "read",
3054
+ "release": "works",
3055
+ "params": {
3056
+ "type": "object",
3057
+ "properties": {},
3058
+ "additionalProperties": false
3059
+ }
3060
+ },
3061
+ {
3062
+ "action": "setLocation",
3063
+ "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.",
3064
+ "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.",
3065
+ "effect": "write",
3066
+ "release": "works",
3067
+ "params": {
3068
+ "type": "object",
3069
+ "properties": {
3070
+ "latitude": {
3071
+ "type": "number",
3072
+ "description": "Latitude, -90 to 90."
3073
+ },
3074
+ "longitude": {
3075
+ "type": "number",
3076
+ "description": "Longitude, -180 to 180."
3077
+ },
3078
+ "label": {
3079
+ "type": "string",
3080
+ "description": "Name to show for the pinned place."
3081
+ },
3082
+ "query": {
3083
+ "type": "string",
3084
+ "description": "\"37.3349, -122.0090\" or a Google/Apple Maps link."
3085
+ },
3086
+ "place": {
3087
+ "type": "string",
3088
+ "description": "A place name from getState places or builtInPlaces."
3089
+ }
3090
+ },
3091
+ "additionalProperties": false
3092
+ }
3093
+ },
3094
+ {
3095
+ "action": "startRoute",
3096
+ "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.",
3097
+ "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.",
3098
+ "effect": "write",
3099
+ "release": "works",
3100
+ "params": {
3101
+ "type": "object",
3102
+ "properties": {
3103
+ "points": {
3104
+ "type": "array",
3105
+ "items": {
3106
+ "type": "object",
3107
+ "properties": {
3108
+ "latitude": {
3109
+ "type": "number"
3110
+ },
3111
+ "longitude": {
3112
+ "type": "number"
3113
+ }
3114
+ },
3115
+ "required": [
3116
+ "latitude",
3117
+ "longitude"
3118
+ ],
3119
+ "additionalProperties": false
3120
+ },
3121
+ "minItems": 2,
3122
+ "description": "Two or more points to travel through."
3123
+ },
3124
+ "route": {
3125
+ "type": "string",
3126
+ "description": "A preset id (walk, run, drive, highway, tunnel, loop) or an app route id from getState routes."
3127
+ },
3128
+ "to": {
3129
+ "type": "string",
3130
+ "description": "Travel in a straight line to this place name or \"lat, lon\"."
3131
+ },
3132
+ "speed": {
3133
+ "type": "number",
3134
+ "description": "Meters per second: 1.4 walking, 4 cycling, 13.4 city driving, 30 highway."
3135
+ },
3136
+ "heading": {
3137
+ "type": "number",
3138
+ "description": "For a preset: direction to head, degrees from north. Default 0 (north)."
3139
+ },
3140
+ "loop": {
3141
+ "type": "boolean",
3142
+ "description": "With points: start over after the last point."
3143
+ }
3144
+ },
3145
+ "additionalProperties": false
3146
+ }
3147
+ },
3148
+ {
3149
+ "action": "pauseRoute",
3150
+ "summary": "Stop moving along the route; the app keeps the current position.",
3151
+ "description": "Updates stop changing until resumeRoute. No-op when no route is playing.",
3152
+ "effect": "write",
3153
+ "release": "works",
3154
+ "params": {
3155
+ "type": "object",
3156
+ "properties": {},
3157
+ "additionalProperties": false
3158
+ }
3159
+ },
3160
+ {
3161
+ "action": "resumeRoute",
3162
+ "summary": "Continue a paused route, or play a finished one again from the start.",
3163
+ "description": "No-op when no route is loaded.",
3164
+ "effect": "write",
3165
+ "release": "works",
3166
+ "params": {
3167
+ "type": "object",
3168
+ "properties": {},
3169
+ "additionalProperties": false
3170
+ }
3171
+ },
3172
+ {
3173
+ "action": "stopRoute",
3174
+ "summary": "End the route and keep the app pinned where it got to.",
3175
+ "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.",
3176
+ "effect": "write",
3177
+ "release": "works",
3178
+ "params": {
3179
+ "type": "object",
3180
+ "properties": {},
3181
+ "additionalProperties": false
3182
+ }
3183
+ },
3184
+ {
3185
+ "action": "seekRoute",
3186
+ "summary": "Jump to a point along the route by `meters` from the start or `fraction` (0 to 1).",
3187
+ "description": "Keeps playing or paused as it was. Throws when no route is loaded.",
3188
+ "effect": "write",
3189
+ "release": "works",
3190
+ "params": {
3191
+ "type": "object",
3192
+ "properties": {
3193
+ "meters": {
3194
+ "type": "number",
3195
+ "description": "Meters from the route's start."
3196
+ },
3197
+ "fraction": {
3198
+ "type": "number",
3199
+ "description": "0 = start, 1 = end."
3200
+ }
3201
+ },
3202
+ "additionalProperties": false
3203
+ }
3204
+ },
3205
+ {
3206
+ "action": "setSpeed",
3207
+ "summary": "Change the route's speed to `speed` meters per second without losing progress.",
3208
+ "description": "Throws when no route is loaded or speed is not positive.",
3209
+ "effect": "write",
3210
+ "release": "works",
3211
+ "params": {
3212
+ "type": "object",
3213
+ "properties": {
3214
+ "speed": {
3215
+ "type": "number",
3216
+ "description": "Meters per second."
3217
+ }
3218
+ },
3219
+ "additionalProperties": false,
3220
+ "required": [
3221
+ "speed"
3222
+ ]
3223
+ }
3224
+ },
3225
+ {
3226
+ "action": "enterRegion",
3227
+ "summary": "Move just inside the geofence region `identifier`; the app's geofencing task runs with an enter event.",
3228
+ "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.",
3229
+ "effect": "write",
3230
+ "release": "works",
3231
+ "params": {
3232
+ "type": "object",
3233
+ "properties": {
3234
+ "identifier": {
3235
+ "type": "string",
3236
+ "description": "The region identifier."
3237
+ },
3238
+ "task": {
3239
+ "type": "string",
3240
+ "description": "The geofencing task name, when identifiers repeat."
3241
+ }
3242
+ },
3243
+ "additionalProperties": false,
3244
+ "required": [
3245
+ "identifier"
3246
+ ]
3247
+ }
3248
+ },
3249
+ {
3250
+ "action": "exitRegion",
3251
+ "summary": "Move just outside the geofence region `identifier`; the app's geofencing task runs with an exit event.",
3252
+ "description": "Pins the position past the region's edge on the side of the current position. Same rules as enterRegion.",
3253
+ "effect": "write",
3254
+ "release": "works",
3255
+ "params": {
3256
+ "type": "object",
3257
+ "properties": {
3258
+ "identifier": {
3259
+ "type": "string",
3260
+ "description": "The region identifier."
3261
+ },
3262
+ "task": {
3263
+ "type": "string",
3264
+ "description": "The geofencing task name, when identifiers repeat."
3265
+ }
3266
+ },
3267
+ "additionalProperties": false,
3268
+ "required": [
3269
+ "identifier"
3270
+ ]
3271
+ }
3272
+ },
3273
+ {
3274
+ "action": "setConditions",
3275
+ "summary": "Set the signal quality in one step: `conditions` good, weak, no-signal or off.",
3276
+ "description": "good: \u00b15 m fixes, no drift. weak: \u00b165 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.",
3277
+ "effect": "write",
3278
+ "release": "works",
3279
+ "params": {
3280
+ "type": "object",
3281
+ "properties": {
3282
+ "conditions": {
3283
+ "type": "string",
3284
+ "enum": [
3285
+ "good",
3286
+ "weak",
3287
+ "no-signal",
3288
+ "off"
3289
+ ],
3290
+ "description": "Which signal conditions the app should see."
3291
+ }
3292
+ },
3293
+ "additionalProperties": false,
3294
+ "required": [
3295
+ "conditions"
3296
+ ]
3297
+ }
3298
+ },
3299
+ {
3300
+ "action": "setSignal",
3301
+ "summary": "Turn the GPS signal off (`on` false) or back on.",
3302
+ "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.",
3303
+ "effect": "write",
3304
+ "release": "works",
3305
+ "params": {
3306
+ "type": "object",
3307
+ "properties": {
3308
+ "on": {
3309
+ "type": "boolean",
3310
+ "description": "true = signal, false = no signal."
3311
+ }
3312
+ },
3313
+ "additionalProperties": false,
3314
+ "required": [
3315
+ "on"
3316
+ ]
3317
+ }
3318
+ },
3319
+ {
3320
+ "action": "setServices",
3321
+ "summary": "Report location services as disabled (`on` false) or enabled.",
3322
+ "description": "Off: hasServicesEnabledAsync returns false, provider status reports locationServicesEnabled false, requests fail with a services-disabled error, and live watches get one error event.",
3323
+ "effect": "write",
3324
+ "release": "works",
3325
+ "params": {
3326
+ "type": "object",
3327
+ "properties": {
3328
+ "on": {
3329
+ "type": "boolean",
3330
+ "description": "true = enabled, false = disabled."
3331
+ }
3332
+ },
3333
+ "additionalProperties": false,
3334
+ "required": [
3335
+ "on"
3336
+ ]
3337
+ }
3338
+ },
3339
+ {
3340
+ "action": "tune",
3341
+ "summary": "Set the reported `accuracy`, GPS `jitter` (drift), `altitude`, pinned `heading`, or update `interval`.",
3342
+ "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).",
3343
+ "effect": "write",
3344
+ "release": "works",
3345
+ "params": {
3346
+ "type": "object",
3347
+ "properties": {
3348
+ "accuracy": {
3349
+ "type": "number",
3350
+ "description": "Reported accuracy radius in meters."
3351
+ },
3352
+ "jitter": {
3353
+ "type": "number",
3354
+ "description": "Drift in meters; 0 turns it off."
3355
+ },
3356
+ "altitude": {
3357
+ "type": "number",
3358
+ "description": "Meters above sea level."
3359
+ },
3360
+ "heading": {
3361
+ "type": "number",
3362
+ "description": "Heading reported while pinned, degrees from north."
3363
+ },
3364
+ "interval": {
3365
+ "type": "number",
3366
+ "description": "Milliseconds between updates, 100 to 60000."
3367
+ }
3368
+ },
3369
+ "additionalProperties": false
3370
+ }
3371
+ },
3372
+ {
3373
+ "action": "useRealLocation",
3374
+ "summary": "Go back to the device's real position but keep the signal and services switches.",
3375
+ "description": "Use reset to end every override at once.",
3376
+ "effect": "write",
3377
+ "release": "works",
3378
+ "params": {
3379
+ "type": "object",
3380
+ "properties": {},
3381
+ "additionalProperties": false
3382
+ }
3383
+ },
3384
+ {
3385
+ "action": "reset",
3386
+ "summary": "End every location override; the app gets the device's real location again.",
3387
+ "description": "Watches and geofence or background tasks the app started under the override are started natively with the app's own options.",
3388
+ "effect": "write",
3389
+ "release": "works",
3390
+ "params": {
3391
+ "type": "object",
3392
+ "properties": {},
3393
+ "additionalProperties": false
3394
+ }
3395
+ },
3396
+ {
3397
+ "action": "updateSettings",
3398
+ "summary": "Set `persist` (keep the override across reloads) and `showChip` (floating status while overridden).",
3399
+ "description": "Both default to true.",
3400
+ "effect": "write",
3401
+ "release": "works",
3402
+ "params": {
3403
+ "type": "object",
3404
+ "properties": {
3405
+ "persist": {
3406
+ "type": "boolean",
3407
+ "description": "Keep the override across reloads."
3408
+ },
3409
+ "showChip": {
3410
+ "type": "boolean",
3411
+ "description": "Show the floating location chip while overridden."
3412
+ }
3413
+ },
3414
+ "additionalProperties": false
3415
+ }
3416
+ },
3417
+ {
3418
+ "action": "clearLog",
3419
+ "summary": "Clear the recent location events shown in getState log.",
3420
+ "description": "Does not change the override.",
3421
+ "effect": "write",
3422
+ "release": "works",
3423
+ "params": {
3424
+ "type": "object",
3425
+ "properties": {},
3426
+ "additionalProperties": false
3427
+ }
3428
+ }
3429
+ ]
3430
+ },
3431
+ {
3432
+ "toolId": "permissions",
3433
+ "title": "Permissions",
3434
+ "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? }] }.",
3435
+ "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.",
3436
+ "actions": [
3437
+ {
3438
+ "action": "getState",
3439
+ "summary": "Read each permission the app can reach: the override, what the OS says, which libraries asked, and the recent permission calls.",
3440
+ "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.",
3441
+ "effect": "read",
3442
+ "release": "works",
3443
+ "params": {
3444
+ "type": "object",
3445
+ "properties": {},
3446
+ "additionalProperties": false
3447
+ }
3448
+ },
3449
+ {
3450
+ "action": "setOverride",
3451
+ "summary": "Make `permission` read as `state` for the app, without changing the OS.",
3452
+ "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.",
3453
+ "effect": "write",
3454
+ "release": "works",
3455
+ "undo": {
3456
+ "action": "clearOverride",
3457
+ "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."
3458
+ },
3459
+ "params": {
3460
+ "type": "object",
3461
+ "properties": {
3462
+ "permission": {
3463
+ "type": "string",
3464
+ "description": "Permission id: location, locationBackground, camera, microphone, notifications, photos, contacts, calendar, reminders (iOS), tracking (iOS), motion, bluetooth, or android:<android.permission.NAME>."
3465
+ },
3466
+ "state": {
3467
+ "type": "string",
3468
+ "enum": [
3469
+ "undetermined",
3470
+ "granted",
3471
+ "limited",
3472
+ "denied",
3473
+ "blocked",
3474
+ "restricted"
3475
+ ],
3476
+ "description": "What the app sees for this permission."
3477
+ }
3478
+ },
3479
+ "required": [
3480
+ "permission",
3481
+ "state"
3482
+ ],
3483
+ "additionalProperties": false
3484
+ }
3485
+ },
3486
+ {
3487
+ "action": "clearOverride",
3488
+ "summary": "Stop overriding `permission`; the app sees the OS answer again.",
3489
+ "description": "Returns the new state.",
3490
+ "effect": "write",
3491
+ "release": "works",
3492
+ "params": {
3493
+ "type": "object",
3494
+ "properties": {
3495
+ "permission": {
3496
+ "type": "string",
3497
+ "description": "Permission id: location, locationBackground, camera, microphone, notifications, photos, contacts, calendar, reminders (iOS), tracking (iOS), motion, bluetooth, or android:<android.permission.NAME>."
3498
+ }
3499
+ },
3500
+ "required": [
3501
+ "permission"
3502
+ ],
3503
+ "additionalProperties": false
3504
+ }
3505
+ },
3506
+ {
3507
+ "action": "resetAll",
3508
+ "summary": "Stop every override.",
3509
+ "description": "Every permission reads the OS answer again. Returns the new state.",
3510
+ "effect": "write",
3511
+ "release": "works",
3512
+ "params": {
3513
+ "type": "object",
3514
+ "properties": {},
3515
+ "additionalProperties": false
3516
+ }
3517
+ },
3518
+ {
3519
+ "action": "requestReal",
3520
+ "summary": "Show the real system prompt for `permission`, ignoring the override, so the OS can grant it.",
3521
+ "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`.",
3522
+ "effect": "write",
3523
+ "release": "works",
3524
+ "params": {
3525
+ "type": "object",
3526
+ "properties": {
3527
+ "permission": {
3528
+ "type": "string",
3529
+ "description": "Permission id: location, locationBackground, camera, microphone, notifications, photos, contacts, calendar, reminders (iOS), tracking (iOS), motion, bluetooth, or android:<android.permission.NAME>."
3530
+ }
3531
+ },
3532
+ "required": [
3533
+ "permission"
3534
+ ],
3535
+ "additionalProperties": false
3536
+ }
3537
+ },
3538
+ {
3539
+ "action": "updateSettings",
3540
+ "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.",
3541
+ "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.",
3542
+ "effect": "write",
3543
+ "release": "works",
3544
+ "params": {
3545
+ "type": "object",
3546
+ "properties": {
3547
+ "requestAnswer": {
3548
+ "type": "string",
3549
+ "enum": [
3550
+ "ask",
3551
+ "allow",
3552
+ "limited",
3553
+ "deny"
3554
+ ],
3555
+ "description": "How a request from 'not asked' (or Android 'denied') is answered while overridden."
3556
+ },
3557
+ "notifyApp": {
3558
+ "type": "boolean",
3559
+ "description": "Send background \u2192 active after each change, like a return from Settings."
3560
+ },
3561
+ "interceptSettings": {
3562
+ "type": "boolean",
3563
+ "description": "Show Buoy's stand-in when the app opens its Settings page while overridden."
3564
+ },
3565
+ "enforce": {
3566
+ "type": "boolean",
3567
+ "description": "Fail calls that need a refused permission with the native error, and coarsen approximate positions."
3568
+ },
3569
+ "persist": {
3570
+ "type": "boolean",
3571
+ "description": "Keep overrides across reloads."
3572
+ },
3573
+ "showChip": {
3574
+ "type": "boolean",
3575
+ "description": "Show the floating chip while any override is on."
3576
+ }
3577
+ },
3578
+ "additionalProperties": false
3579
+ }
3580
+ },
3581
+ {
3582
+ "action": "simulateReturnFromSettings",
3583
+ "summary": "Send the app background \u2192 active, as a real return from Settings does.",
3584
+ "description": "Screens that re-check permissions when the app comes to the foreground run their check. Returns the state.",
3585
+ "effect": "write",
3586
+ "release": "works",
3587
+ "params": {
3588
+ "type": "object",
3589
+ "properties": {},
3590
+ "additionalProperties": false
3591
+ }
3592
+ },
3593
+ {
3594
+ "action": "clearLog",
3595
+ "summary": "Clear the recent permission calls and their counts.",
3596
+ "description": "Returns the state.",
3597
+ "effect": "write",
3598
+ "release": "works",
3599
+ "params": {
3600
+ "type": "object",
3601
+ "properties": {},
3602
+ "additionalProperties": false
3603
+ }
3604
+ }
3605
+ ]
3606
+ },
2767
3607
  {
2768
3608
  "toolId": "storage",
2769
3609
  "title": "Storage",
2770
- "summary": "Read and write the app's persisted state across all three backends — AsyncStorage, every registered MMKV instance, and registered Expo SecureStore keys — 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 — every action really runs in a release build.",
3610
+ "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.",
2771
3611
  "actions": [
2772
3612
  {
2773
3613
  "action": "getRequiredKeys",
@@ -2807,7 +3647,7 @@
2807
3647
  "items": {
2808
3648
  "type": "string"
2809
3649
  },
2810
- "description": "Keys to read, e.g. [\"session\",\"user.prefs\"]. Required — the handler reads params.keys with no guard and throws if params is missing."
3650
+ "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."
2811
3651
  }
2812
3652
  },
2813
3653
  "required": [
@@ -2817,7 +3657,7 @@
2817
3657
  },
2818
3658
  "effect": "read",
2819
3659
  "release": "works",
2820
- "description": "Values are always strings (or null when unset) — 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`.",
3660
+ "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`.",
2821
3661
  "requires": [
2822
3662
  "@react-native-async-storage/async-storage installed in the app"
2823
3663
  ]
@@ -2840,7 +3680,7 @@
2840
3680
  },
2841
3681
  "effect": "read",
2842
3682
  "release": "works",
2843
- "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 — so a null here does not prove the app never wrote it if the key is Buoy-prefixed.",
3683
+ "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.",
2844
3684
  "requires": [
2845
3685
  "@react-native-async-storage/async-storage installed in the app"
2846
3686
  ]
@@ -2868,7 +3708,7 @@
2868
3708
  },
2869
3709
  "effect": "write",
2870
3710
  "release": "works",
2871
- "description": "Values are strings only — 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 — zustand.setState, redux.dispatch, jotai.setAtom.",
3711
+ "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.",
2872
3712
  "requires": [
2873
3713
  "@react-native-async-storage/async-storage installed in the app"
2874
3714
  ]
@@ -2891,7 +3731,7 @@
2891
3731
  },
2892
3732
  "effect": "destructive",
2893
3733
  "release": "works",
2894
- "description": "Permanently removes the key from the device. No confirmation and no result payload — it resolves to undefined whether or not the key existed. Read the value first if you might need it back.",
3734
+ "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.",
2895
3735
  "requires": [
2896
3736
  "@react-native-async-storage/async-storage installed in the app"
2897
3737
  ]
@@ -2917,7 +3757,7 @@
2917
3757
  },
2918
3758
  "effect": "destructive",
2919
3759
  "release": "works",
2920
- "description": "Batch form of async.removeItem (translated to removeMany on async-storage v3). Deletes exactly the keys you name — 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.",
3760
+ "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.",
2921
3761
  "requires": [
2922
3762
  "@react-native-async-storage/async-storage installed in the app",
2923
3763
  "for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"
@@ -2949,7 +3789,7 @@
2949
3789
  },
2950
3790
  "effect": "write",
2951
3791
  "release": "works",
2952
- "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 — zustand.setState, redux.dispatch, jotai.setAtom.",
3792
+ "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.",
2953
3793
  "requires": [
2954
3794
  "@react-native-async-storage/async-storage installed in the app",
2955
3795
  "for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"
@@ -2965,10 +3805,10 @@
2965
3805
  },
2966
3806
  "effect": "destructive",
2967
3807
  "release": "works",
2968
- "description": "Raw AsyncStorage.clear() — 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.",
3808
+ "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.",
2969
3809
  "requires": [
2970
3810
  "@react-native-async-storage/async-storage installed in the app",
2971
- "for undoAction timeTravel.undo: storage capture must be held open (a storage events read/watch) AT THE MOMENT OF THE WRITE — otherwise no event with prevPairs is recorded and the undo is impossible"
3811
+ "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"
2972
3812
  ]
2973
3813
  },
2974
3814
  {
@@ -2984,7 +3824,7 @@
2984
3824
  "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.",
2985
3825
  "requires": [
2986
3826
  "@react-native-async-storage/async-storage installed in the app",
2987
- "for undoAction timeTravel.undo: storage capture must be held open at the moment of the call — the wipe goes through removeMany, so with no subscriber there is no multiRemove event and the wipe is unrecoverable"
3827
+ "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"
2988
3828
  ]
2989
3829
  },
2990
3830
  {
@@ -3005,7 +3845,7 @@
3005
3845
  },
3006
3846
  "effect": "read",
3007
3847
  "release": "works",
3008
- "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 — 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\".",
3848
+ "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\".",
3009
3849
  "armsCapture": true
3010
3850
  },
3011
3851
  {
@@ -3018,7 +3858,7 @@
3018
3858
  },
3019
3859
  "effect": "destructive",
3020
3860
  "release": "works",
3021
- "description": "Empties the 500-event ring buffer. No device storage is written or deleted — 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 — it only refills with new writes. Use async.getAllKeys to see current keys instead.",
3861
+ "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.",
3022
3862
  "armsCapture": true
3023
3863
  },
3024
3864
  {
@@ -3039,7 +3879,7 @@
3039
3879
  },
3040
3880
  "effect": "destructive",
3041
3881
  "release": "works",
3042
- "description": "Addresses a single event by id and restores its prevValue/prevPairs (removing the key when it did not exist before). AsyncStorage events only — 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.",
3882
+ "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.",
3043
3883
  "armsCapture": true
3044
3884
  },
3045
3885
  {
@@ -3068,7 +3908,7 @@
3068
3908
  },
3069
3909
  "effect": "destructive",
3070
3910
  "release": "works",
3071
- "description": "Replays the captured AsyncStorage timeline up to and including `id`, then reconciles the keys that timeline governs — 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 — 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}.",
3911
+ "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}.",
3072
3912
  "armsCapture": true
3073
3913
  },
3074
3914
  {
@@ -3081,7 +3921,7 @@
3081
3921
  },
3082
3922
  "effect": "read",
3083
3923
  "release": "works",
3084
- "description": "Returns [{id, encrypted, readOnly, entries:[{key, value, valueType}]}] where valueType is string|number|boolean|buffer. One round trip for all instances — 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.",
3924
+ "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.",
3085
3925
  "requires": [
3086
3926
  "registerMMKVInstance(...) called by the app (react-native-mmkv)"
3087
3927
  ]
@@ -3109,7 +3949,7 @@
3109
3949
  },
3110
3950
  "effect": "read",
3111
3951
  "release": "works",
3112
- "description": "The size-guarded single-key channel for values mmkv.snapshot omitted. Returns {found:true, instanceId, key, value, valueType} — 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\" — get the exact ids from mmkv.snapshot.",
3952
+ "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.",
3113
3953
  "requires": [
3114
3954
  "registerMMKVInstance(...) called by the app (react-native-mmkv)"
3115
3955
  ]
@@ -3134,7 +3974,7 @@
3134
3974
  "number",
3135
3975
  "boolean"
3136
3976
  ],
3137
- "description": "Typed value — stored as string, number, or boolean exactly as passed."
3977
+ "description": "Typed value \u2014 stored as string, number, or boolean exactly as passed."
3138
3978
  }
3139
3979
  },
3140
3980
  "required": [
@@ -3146,7 +3986,7 @@
3146
3986
  },
3147
3987
  "effect": "write",
3148
3988
  "release": "works",
3149
- "description": "Unlike AsyncStorage, MMKV is typed: pass a real number or boolean and it is stored as that type — 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 — zustand.setState, redux.dispatch, jotai.setAtom.",
3989
+ "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.",
3150
3990
  "requires": [
3151
3991
  "registerMMKVInstance(...) called by the app (react-native-mmkv)"
3152
3992
  ]
@@ -3174,7 +4014,7 @@
3174
4014
  },
3175
4015
  "effect": "destructive",
3176
4016
  "release": "works",
3177
- "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 — that covers AsyncStorage only.",
4017
+ "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.",
3178
4018
  "requires": [
3179
4019
  "registerMMKVInstance(...) called by the app (react-native-mmkv)"
3180
4020
  ]
@@ -3189,7 +4029,7 @@
3189
4029
  },
3190
4030
  "effect": "read",
3191
4031
  "release": "works",
3192
- "description": "Returns [{key, description, keychainService, requireAuthentication}]. SecureStore has no key-enumeration API, so only keys the app declared via registerSecureStoreKeys(...) are visible — an empty [] means nothing was registered, NOT that the keychain is empty. requireAuthentication:true marks a biometric-protected key whose value Buoy never reads.",
4032
+ "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.",
3193
4033
  "requires": [
3194
4034
  "registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"
3195
4035
  ]
@@ -3204,7 +4044,7 @@
3204
4044
  },
3205
4045
  "effect": "read",
3206
4046
  "release": "works",
3207
- "description": "The SecureStore counterpart to mmkv.snapshot, and the right read when you want values — 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}.",
4047
+ "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}.",
3208
4048
  "requires": [
3209
4049
  "registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"
3210
4050
  ]
@@ -3227,7 +4067,7 @@
3227
4067
  },
3228
4068
  "effect": "read",
3229
4069
  "release": "works",
3230
- "description": "Reads with the exact options (keychainService) the key was registered with, which is required or the read returns null. Resolves to null — never an error — 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.",
4070
+ "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.",
3231
4071
  "requires": [
3232
4072
  "registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"
3233
4073
  ]
@@ -3255,7 +4095,7 @@
3255
4095
  },
3256
4096
  "effect": "write",
3257
4097
  "release": "works",
3258
- "description": "Goes through the registry so the value is written with the SAME options it was registered with — 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 — 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.",
4098
+ "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.",
3259
4099
  "requires": [
3260
4100
  "registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"
3261
4101
  ]
@@ -3278,36 +4118,36 @@
3278
4118
  },
3279
4119
  "effect": "destructive",
3280
4120
  "release": "works",
3281
- "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 — 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).",
4121
+ "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).",
3282
4122
  "requires": [
3283
4123
  "registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"
3284
4124
  ]
3285
4125
  }
3286
4126
  ],
3287
- "unavailableWhen": "The app does not have @buoy-gg/storage installed alongside <FloatingDevTools/> — 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 []."
4127
+ "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 []."
3288
4128
  },
3289
4129
  {
3290
4130
  "toolId": "highlight-updates",
3291
4131
  "title": "Highlight Updates",
3292
- "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’s 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.",
4132
+ "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.",
3293
4133
  "actions": [
3294
4134
  {
3295
4135
  "action": "describeScreen",
3296
- "summary": "List every meaningful/interactive element currently on screen, with normalized tap points — 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.",
4136
+ "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.",
3297
4137
  "params": {
3298
4138
  "type": "object",
3299
4139
  "properties": {
3300
4140
  "includeBuoy": {
3301
4141
  "type": "boolean",
3302
- "description": "Also list Buoy's own overlay (the dial, tool sheets, Ask Buoy's own chat). Default false — those are the tool, not the app."
4142
+ "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."
3303
4143
  }
3304
4144
  },
3305
4145
  "additionalProperties": false
3306
4146
  },
3307
4147
  "effect": "read",
3308
4148
  "release": "empty",
3309
- "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 — 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 — the dial, tool sheets and your own chat sheet are the tool, not the app under test — and `hiddenBuoy` counts what was omitted; pass includeBuoy:true only when the task is about Buoy itself.",
3310
- "releaseNote": "packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:57 — 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.",
4149
+ "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.",
4150
+ "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.",
3311
4151
  "requires": [
3312
4152
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
3313
4153
  "dev build (__DEV__ === true)"
@@ -3315,7 +4155,7 @@
3315
4155
  },
3316
4156
  {
3317
4157
  "action": "tapElement",
3318
- "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 — 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.",
4158
+ "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.",
3319
4159
  "params": {
3320
4160
  "type": "object",
3321
4161
  "properties": {
@@ -3329,7 +4169,7 @@
3329
4169
  },
3330
4170
  "query": {
3331
4171
  "type": "string",
3332
- "description": "Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Sign in'. Least precise — verify with describeScreen first."
4172
+ "description": "Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Sign in'. Least precise \u2014 verify with describeScreen first."
3333
4173
  },
3334
4174
  "value": {
3335
4175
  "type": [
@@ -3348,7 +4188,7 @@
3348
4188
  },
3349
4189
  "scrollIntoView": {
3350
4190
  "type": "boolean",
3351
- "description": "Scroll an ancestor ScrollView so the target is visible before acting. Default true — set false to avoid moving the user's screen."
4191
+ "description": "Scroll an ancestor ScrollView so the target is visible before acting. Default true \u2014 set false to avoid moving the user's screen."
3352
4192
  },
3353
4193
  "includeBuoy": {
3354
4194
  "type": "boolean",
@@ -3359,8 +4199,8 @@
3359
4199
  },
3360
4200
  "effect": "write",
3361
4201
  "release": "empty",
3362
- "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 — 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?} — `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 — the match was probably a screen still mounted underneath the current one (a stack keeps them), or an inert control — 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.",
3363
- "releaseNote": "packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:793 — 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.",
4202
+ "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.",
4203
+ "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.",
3364
4204
  "requires": [
3365
4205
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
3366
4206
  "dev build (__DEV__ === true)"
@@ -3369,7 +4209,7 @@
3369
4209
  {
3370
4210
  "action": "waitFor",
3371
4211
  "summary": "Block until an element is on screen (or gone), then report how long it took. Use it between navigating and tapping.",
3372
- "description": "Returns {ok, waitedMs, polls, matched?, reason?}. USE THIS AFTER ANY ACTION THAT STARTS A LOAD — `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 — 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.",
4212
+ "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.",
3373
4213
  "params": {
3374
4214
  "type": "object",
3375
4215
  "properties": {
@@ -3387,7 +4227,7 @@
3387
4227
  },
3388
4228
  "gone": {
3389
4229
  "type": "boolean",
3390
- "description": "Wait for the element to DISAPPEAR instead of appear — a spinner, a skeleton, a modal. Default false."
4230
+ "description": "Wait for the element to DISAPPEAR instead of appear \u2014 a spinner, a skeleton, a modal. Default false."
3391
4231
  },
3392
4232
  "timeoutMs": {
3393
4233
  "type": "number",
@@ -3414,7 +4254,7 @@
3414
4254
  },
3415
4255
  {
3416
4256
  "action": "beginMeasurement",
3417
- "summary": "Open an invisible render-capture window (CommitProfiler) — pair with endMeasurement around the interaction you want to measure.",
4257
+ "summary": "Open an invisible render-capture window (CommitProfiler) \u2014 pair with endMeasurement around the interaction you want to measure.",
3418
4258
  "params": {
3419
4259
  "type": "object",
3420
4260
  "properties": {},
@@ -3422,8 +4262,8 @@
3422
4262
  },
3423
4263
  "effect": "write",
3424
4264
  "release": "empty",
3425
- "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 — 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} — always check `ok` before driving the interaction. Usage: beginMeasurement -> wait ~400ms to settle -> drive the UI with tapElement -> endMeasurement.",
3426
- "releaseNote": "packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts:458 — 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.",
4265
+ "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.",
4266
+ "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.",
3427
4267
  "requires": [
3428
4268
  "@buoy-gg/highlight-updates at adapter version 3+ (older apps have no beginMeasurement)",
3429
4269
  "dev build (__DEV__ === true)"
@@ -3439,8 +4279,8 @@
3439
4279
  },
3440
4280
  "effect": "write",
3441
4281
  "release": "empty",
3442
- "description": "Returns {summary: RenderCaptureSummary | null}. The summary carries totalCommits, totalRenders, totalRenderMs, wastedRenders (parent cascades + identity-only prop churn — 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 — 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.",
3443
- "releaseNote": "packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts — 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}.",
4282
+ "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.",
4283
+ "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}.",
3444
4284
  "requires": [
3445
4285
  "@buoy-gg/highlight-updates at adapter version 3+",
3446
4286
  "a prior successful beginMeasurement on the same device"
@@ -3448,7 +4288,7 @@
3448
4288
  },
3449
4289
  {
3450
4290
  "action": "locateComponent",
3451
- "summary": "Resolve a component to a fresh on-screen rectangle (in points) plus pixel scale — used to crop a simulator screenshot to exactly that component.",
4291
+ "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.",
3452
4292
  "params": {
3453
4293
  "type": "object",
3454
4294
  "properties": {
@@ -3458,11 +4298,11 @@
3458
4298
  },
3459
4299
  "nativeTag": {
3460
4300
  "type": "number",
3461
- "description": "Exact native tag — skips fuzzy matching and wins over query."
4301
+ "description": "Exact native tag \u2014 skips fuzzy matching and wins over query."
3462
4302
  },
3463
4303
  "scrollIntoView": {
3464
4304
  "type": "boolean",
3465
- "description": "Scroll an ancestor ScrollView to bring the component into view before measuring. Default true — this visibly moves the user's screen."
4305
+ "description": "Scroll an ancestor ScrollView to bring the component into view before measuring. Default true \u2014 this visibly moves the user's screen."
3466
4306
  },
3467
4307
  "margin": {
3468
4308
  "type": "number",
@@ -3474,7 +4314,7 @@
3474
4314
  "effect": "write",
3475
4315
  "release": "empty",
3476
4316
  "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.",
3477
- "releaseNote": "HighlightUpdatesController.ts:1744 — 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:[]}.",
4317
+ "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:[]}.",
3478
4318
  "requires": [
3479
4319
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
3480
4320
  "dev build (__DEV__ === true)"
@@ -3498,8 +4338,8 @@
3498
4338
  },
3499
4339
  "effect": "read",
3500
4340
  "release": "empty",
3501
- "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 — 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.",
3502
- "releaseNote": "HighlightUpdatesController.ts:1819/1854 — enable()/disable() no-op when !__DEV__, so RenderTracker never records and every lookup returns {found:false, reason:\"unknown nativeTag\"}.",
4341
+ "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.",
4342
+ "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\"}.",
3503
4343
  "requires": [
3504
4344
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
3505
4345
  "dev build (__DEV__ === true)",
@@ -3524,8 +4364,8 @@
3524
4364
  },
3525
4365
  "effect": "write",
3526
4366
  "release": "noop",
3527
- "description": "Prefer this over `toggle` — 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 — 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).",
3528
- "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 — never tell a QA user highlighting is on without confirming a dev build.",
4367
+ "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).",
4368
+ "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.",
3529
4369
  "requires": [
3530
4370
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
3531
4371
  "dev build (__DEV__ === true)"
@@ -3533,7 +4373,7 @@
3533
4373
  },
3534
4374
  {
3535
4375
  "action": "toggle",
3536
- "summary": "Flip render highlighting on/off — same visible effect as setEnabled but state-dependent.",
4376
+ "summary": "Flip render highlighting on/off \u2014 same visible effect as setEnabled but state-dependent.",
3537
4377
  "params": {
3538
4378
  "type": "object",
3539
4379
  "properties": {},
@@ -3541,8 +4381,8 @@
3541
4381
  },
3542
4382
  "effect": "write",
3543
4383
  "release": "noop",
3544
- "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 — visible to the person holding the phone. Takes no params. Returns undefined.",
3545
- "releaseNote": "HighlightUpdatesController.ts:1954 — `if (!__DEV__) return;` at the top of toggle(). Reports ok:true and does nothing.",
4384
+ "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.",
4385
+ "releaseNote": "HighlightUpdatesController.ts:1954 \u2014 `if (!__DEV__) return;` at the top of toggle(). Reports ok:true and does nothing.",
3546
4386
  "requires": [
3547
4387
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
3548
4388
  "dev build (__DEV__ === true)"
@@ -3550,7 +4390,7 @@
3550
4390
  },
3551
4391
  {
3552
4392
  "action": "setSilentTracking",
3553
- "summary": "Track and measure renders WITHOUT drawing any highlight boxes — invisible tracking for screenshot/locate flows.",
4393
+ "summary": "Track and measure renders WITHOUT drawing any highlight boxes \u2014 invisible tracking for screenshot/locate flows.",
3554
4394
  "params": {
3555
4395
  "type": "object",
3556
4396
  "properties": {
@@ -3563,7 +4403,7 @@
3563
4403
  },
3564
4404
  "effect": "write",
3565
4405
  "release": "noop",
3566
- "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 — 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 — the handler reads params.enabled without a null guard and throws if params is omitted; `{}` is treated as enabled:false.",
4406
+ "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.",
3567
4407
  "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.",
3568
4408
  "requires": [
3569
4409
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
@@ -3580,7 +4420,7 @@
3580
4420
  },
3581
4421
  "effect": "write",
3582
4422
  "release": "noop",
3583
- "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 — disabling highlighting entirely also clears it. Takes no params. Returns undefined.",
4423
+ "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.",
3584
4424
  "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.",
3585
4425
  "requires": [
3586
4426
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
@@ -3609,7 +4449,7 @@
3609
4449
  },
3610
4450
  "effect": "write",
3611
4451
  "release": "noop",
3612
- "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 — 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 — the handler reads params.nativeTag without a null guard and throws if params is omitted entirely.",
4452
+ "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.",
3613
4453
  "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.",
3614
4454
  "requires": [
3615
4455
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>",
@@ -3619,7 +4459,7 @@
3619
4459
  },
3620
4460
  {
3621
4461
  "action": "clearRenderCounts",
3622
- "summary": "Wipe ALL tracked render data and per-component counters — irreversible, and it destroys data someone may be collecting.",
4462
+ "summary": "Wipe ALL tracked render data and per-component counters \u2014 irreversible, and it destroys data someone may be collecting.",
3623
4463
  "params": {
3624
4464
  "type": "object",
3625
4465
  "properties": {},
@@ -3627,7 +4467,7 @@
3627
4467
  },
3628
4468
  "effect": "destructive",
3629
4469
  "release": "noop",
3630
- "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 — that opens its own window and never touches the user's overlay data. Takes no params. Returns undefined.",
4470
+ "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.",
3631
4471
  "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",
3632
4472
  "requires": [
3633
4473
  "@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>"
@@ -3643,7 +4483,8 @@
3643
4483
  "additionalProperties": false
3644
4484
  },
3645
4485
  "effect": "write",
3646
- "release": "empty"
4486
+ "release": "empty",
4487
+ "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."
3647
4488
  },
3648
4489
  {
3649
4490
  "action": "stopTouchCapture",
@@ -3687,16 +4528,16 @@
3687
4528
  "release": "works"
3688
4529
  }
3689
4530
  ],
3690
- "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’s available actions. Swift touch capture refuses production builds."
4531
+ "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."
3691
4532
  },
3692
4533
  {
3693
4534
  "toolId": "scenarios",
3694
4535
  "title": "Scenarios",
3695
- "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 — if `active` is non-null, the data the user is looking at is partly fake. Authoring flow: save (always lands as an inert DRAFT) → a human accepts it on the device (or acceptDraft) → preview → run → deactivate.",
4536
+ "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.",
3696
4537
  "actions": [
3697
4538
  {
3698
4539
  "action": "listScenarios",
3699
- "summary": "List every scenario on the device — code, device-saved, and inert drafts — plus which one is currently active.",
4540
+ "summary": "List every scenario on the device \u2014 code, device-saved, and inert drafts \u2014 plus which one is currently active.",
3700
4541
  "params": {
3701
4542
  "type": "object",
3702
4543
  "properties": {},
@@ -3704,7 +4545,7 @@
3704
4545
  },
3705
4546
  "effect": "read",
3706
4547
  "release": "works",
3707
- "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}] — 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 — 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\" — read again once the Scenarios panel or a dashboard has subscribed.",
4548
+ "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.",
3708
4549
  "requires": [
3709
4550
  "Scenarios tool installed in the app"
3710
4551
  ]
@@ -3727,14 +4568,14 @@
3727
4568
  },
3728
4569
  "effect": "read",
3729
4570
  "release": "works",
3730
- "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 — and can throw that on a cold device purely because the persisted library has not hydrated yet (see listScenarios).",
4571
+ "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).",
3731
4572
  "requires": [
3732
4573
  "Scenarios tool installed in the app"
3733
4574
  ]
3734
4575
  },
3735
4576
  {
3736
4577
  "action": "save",
3737
- "summary": "Write a new scenario onto the device — it ALWAYS lands in the draft inbox and cannot run until accepted.",
4578
+ "summary": "Write a new scenario onto the device \u2014 it ALWAYS lands in the draft inbox and cannot run until accepted.",
3738
4579
  "params": {
3739
4580
  "type": "object",
3740
4581
  "properties": {
@@ -3755,7 +4596,7 @@
3755
4596
  },
3756
4597
  "expectedOutcome": {
3757
4598
  "type": "string",
3758
- "description": "What SHOULD happen once applied — how a non-developer tells a bug from expected behavior without asking an engineer. Write it."
4599
+ "description": "What SHOULD happen once applied \u2014 how a non-developer tells a bug from expected behavior without asking an engineer. Write it."
3759
4600
  },
3760
4601
  "version": {
3761
4602
  "type": "number",
@@ -3832,7 +4673,7 @@
3832
4673
  },
3833
4674
  "tool": {
3834
4675
  "type": "string",
3835
- "description": "Adapter tool id: network | storage | impersonate | route-events | query | time-machine, or 'scenario' for the engine-local 'wait' step. Never 'scenarios' — recursion is refused at pre-flight."
4676
+ "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."
3836
4677
  },
3837
4678
  "action": {
3838
4679
  "type": "string",
@@ -3892,7 +4733,7 @@
3892
4733
  },
3893
4734
  "folder": {
3894
4735
  "type": "string",
3895
- "description": "The flow this belongs to — \"Checkout\", \"Login\". Reuse a name from `folders` in listScenarios; a scenario nobody can find is a scenario nobody runs."
4736
+ "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."
3896
4737
  }
3897
4738
  },
3898
4739
  "required": [
@@ -3903,7 +4744,7 @@
3903
4744
  },
3904
4745
  "accept": {
3905
4746
  "type": "boolean",
3906
- "description": "IGNORED — the handler never reads it. The save always lands as a draft."
4747
+ "description": "IGNORED \u2014 the handler never reads it. The save always lands as a draft."
3907
4748
  }
3908
4749
  },
3909
4750
  "required": [
@@ -3913,14 +4754,14 @@
3913
4754
  },
3914
4755
  "effect": "write",
3915
4756
  "release": "works",
3916
- "description": "Returns {ok:true, savedAsDraft:true, id, message} on success, or {ok:false, error} / {ok:false, errors:[...]} on rejection — 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 — 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.",
4757
+ "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.",
3917
4758
  "requires": [
3918
4759
  "Scenarios tool installed in the app"
3919
4760
  ]
3920
4761
  },
3921
4762
  {
3922
4763
  "action": "acceptDraft",
3923
- "summary": "Promote a reviewed draft into the runnable device library — this is the human-consent gate, so only do it when the user says to.",
4764
+ "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.",
3924
4765
  "params": {
3925
4766
  "type": "object",
3926
4767
  "properties": {
@@ -3936,7 +4777,7 @@
3936
4777
  },
3937
4778
  "effect": "write",
3938
4779
  "release": "works",
3939
- "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 — accepting on their behalf skips that review. Ask first, or tell them to tap \"Accept to library\" on the device instead.",
4780
+ "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.",
3940
4781
  "requires": [
3941
4782
  "Scenarios tool installed in the app"
3942
4783
  ]
@@ -3959,7 +4800,7 @@
3959
4800
  },
3960
4801
  "effect": "destructive",
3961
4802
  "release": "works",
3962
- "description": "Removes the draft and rewrites the persisted library. Returns {ok:true, id} even when no draft with that id existed — a success here is not proof anything was deleted. There is no undo: the steps are gone unless you exported the JSON first.",
4803
+ "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.",
3963
4804
  "requires": [
3964
4805
  "Scenarios tool installed in the app"
3965
4806
  ]
@@ -3976,7 +4817,7 @@
3976
4817
  },
3977
4818
  "params": {
3978
4819
  "type": "object",
3979
- "description": "Variable values, e.g. {storeId:'220', outOfStock:true}. Only string/number/boolean values are kept — objects and arrays are silently dropped. Declared defaults fill in the rest.",
4820
+ "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.",
3980
4821
  "additionalProperties": {
3981
4822
  "type": [
3982
4823
  "string",
@@ -3993,14 +4834,14 @@
3993
4834
  },
3994
4835
  "effect": "read",
3995
4836
  "release": "works",
3996
- "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 — 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.",
4837
+ "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.",
3997
4838
  "requires": [
3998
4839
  "Scenarios tool installed in the app"
3999
4840
  ]
4000
4841
  },
4001
4842
  {
4002
4843
  "action": "run",
4003
- "summary": "Apply a scenario for real — installs network overrides, writes storage, starts impersonation, navigates — and resolve only once every step has landed.",
4844
+ "summary": "Apply a scenario for real \u2014 installs network overrides, writes storage, starts impersonation, navigates \u2014 and resolve only once every step has landed.",
4004
4845
  "params": {
4005
4846
  "type": "object",
4006
4847
  "properties": {
@@ -4010,7 +4851,7 @@
4010
4851
  },
4011
4852
  "params": {
4012
4853
  "type": "object",
4013
- "description": "Variable values, e.g. {storeId:'220'}. Only string/number/boolean values are kept — objects and arrays are silently dropped. Declared defaults fill in the rest.",
4854
+ "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.",
4014
4855
  "additionalProperties": {
4015
4856
  "type": [
4016
4857
  "string",
@@ -4027,12 +4868,12 @@
4027
4868
  },
4028
4869
  "effect": "destructive",
4029
4870
  "release": "throws",
4030
- "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 — 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 — 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.",
4031
- "releaseNote": "packages/scenarios/src/store/scenariosStore.ts:98-104 (isRunnableInThisBuild) — 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 — never claim the scenario was applied.",
4871
+ "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.",
4872
+ "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.",
4032
4873
  "requires": [
4033
4874
  "Scenarios tool installed in the app",
4034
4875
  "every tool a step targets must be installed on the device (network, storage, impersonate, route-events, query, time-machine)",
4035
- "a development build — release builds refuse"
4876
+ "a development build \u2014 release builds refuse"
4036
4877
  ]
4037
4878
  },
4038
4879
  {
@@ -4045,8 +4886,8 @@
4045
4886
  },
4046
4887
  "effect": "write",
4047
4888
  "release": "empty",
4048
- "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 — 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.",
4049
- "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 — expect an all-zero report rather than a real reversal.",
4889
+ "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.",
4890
+ "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.",
4050
4891
  "requires": [
4051
4892
  "Scenarios tool installed in the app"
4052
4893
  ]
@@ -4059,7 +4900,7 @@
4059
4900
  "properties": {
4060
4901
  "id": {
4061
4902
  "type": "string",
4062
- "description": "Scenario id from listScenarios `device` — code scenarios and drafts are unaffected."
4903
+ "description": "Scenario id from listScenarios `device` \u2014 code scenarios and drafts are unaffected."
4063
4904
  }
4064
4905
  },
4065
4906
  "required": [
@@ -4069,7 +4910,7 @@
4069
4910
  },
4070
4911
  "effect": "destructive",
4071
4912
  "release": "works",
4072
- "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 — code scenarios come back from the app bundle, drafts need discardDraft. Not recoverable; call export first if the definition matters.",
4913
+ "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.",
4073
4914
  "requires": [
4074
4915
  "Scenarios tool installed in the app"
4075
4916
  ]
@@ -4096,7 +4937,7 @@
4096
4937
  },
4097
4938
  "effect": "write",
4098
4939
  "release": "works",
4099
- "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 — 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` — filing is organization, not an edit to what the scenario does.",
4940
+ "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.",
4100
4941
  "requires": [
4101
4942
  "Scenarios tool installed in the app"
4102
4943
  ]
@@ -4111,7 +4952,7 @@
4111
4952
  },
4112
4953
  "effect": "read",
4113
4954
  "release": "works",
4114
- "description": "Returns {folders:[...]} — 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.",
4955
+ "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.",
4115
4956
  "requires": [
4116
4957
  "Scenarios tool installed in the app"
4117
4958
  ]
@@ -4126,7 +4967,7 @@
4126
4967
  },
4127
4968
  "effect": "read",
4128
4969
  "release": "works",
4129
- "description": "Returns {active} — 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 — a cold read before the store has loaded @react_buoy_scenarios_active reports null.",
4970
+ "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.",
4130
4971
  "requires": [
4131
4972
  "Scenarios tool installed in the app"
4132
4973
  ]
@@ -4149,7 +4990,7 @@
4149
4990
  },
4150
4991
  "effect": "read",
4151
4992
  "release": "works",
4152
- "description": "Returns {json} — 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.",
4993
+ "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.",
4153
4994
  "requires": [
4154
4995
  "Scenarios tool installed in the app"
4155
4996
  ]
@@ -4160,7 +5001,7 @@
4160
5001
  {
4161
5002
  "toolId": "perf-monitor",
4162
5003
  "title": "Bench",
4163
- "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 — 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.",
5004
+ "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.",
4164
5005
  "actions": [
4165
5006
  {
4166
5007
  "action": "setEnabled",
@@ -4180,7 +5021,7 @@
4180
5021
  },
4181
5022
  "effect": "write",
4182
5023
  "release": "works",
4183
- "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 — 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."
5024
+ "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."
4184
5025
  },
4185
5026
  {
4186
5027
  "action": "startRecording",
@@ -4192,7 +5033,7 @@
4192
5033
  },
4193
5034
  "effect": "write",
4194
5035
  "release": "works",
4195
- "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 — 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 — 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.",
5036
+ "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.",
4196
5037
  "requires": [
4197
5038
  "@buoy-gg/highlight-updates for render-commit data (dev builds only)"
4198
5039
  ]
@@ -4207,7 +5048,7 @@
4207
5048
  },
4208
5049
  "effect": "write",
4209
5050
  "release": "works",
4210
- "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 — a stopRecording followed by neither savePending nor discardPending leaves the run dangling and it is lost on reload."
5051
+ "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."
4211
5052
  },
4212
5053
  {
4213
5054
  "action": "savePending",
@@ -4240,7 +5081,7 @@
4240
5081
  },
4241
5082
  "effect": "destructive",
4242
5083
  "release": "works",
4243
- "description": "Drops the report held by stopRecording and clears live.pendingSave. The samples are gone — they were never written to disk and cannot be recovered. Use when the user cancels the name prompt or the run was junk."
5084
+ "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."
4244
5085
  },
4245
5086
  {
4246
5087
  "action": "mark",
@@ -4257,7 +5098,7 @@
4257
5098
  },
4258
5099
  "effect": "write",
4259
5100
  "release": "works",
4260
- "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 — 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."
5101
+ "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."
4261
5102
  },
4262
5103
  {
4263
5104
  "action": "startAutomation",
@@ -4267,7 +5108,7 @@
4267
5108
  "properties": {
4268
5109
  "config": {
4269
5110
  "type": "object",
4270
- "description": "Full batch config. Not merged with the device's saved settings — whatever you omit takes the runner's own fallback, so read getAutomationConfig first if you want the user's tuned profile.",
5111
+ "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.",
4271
5112
  "properties": {
4272
5113
  "targetRoute": {
4273
5114
  "type": "string",
@@ -4326,7 +5167,7 @@
4326
5167
  },
4327
5168
  "coolDownMs": {
4328
5169
  "type": "number",
4329
- "description": "Idle between runs and cases so thermals recover. Device default 8000 — raising this is the fix when later cases score worse than earlier ones."
5170
+ "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."
4330
5171
  },
4331
5172
  "discardWarmupRuns": {
4332
5173
  "type": "number",
@@ -4380,12 +5221,12 @@
4380
5221
  },
4381
5222
  "effect": "destructive",
4382
5223
  "release": "works",
4383
- "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 — 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 — 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).",
5224
+ "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).",
4384
5225
  "requires": [
4385
- "expo-router (navigation) — without it this is a silent no-op",
5226
+ "expo-router (navigation) \u2014 without it this is a silent no-op",
4386
5227
  "@buoy-gg/route-events for reliable navigation waits (otherwise it falls back to a fixed sleep)",
4387
5228
  "<AutomationResumer/> mounted at the router root when reloadBetweenCases is true",
4388
- "a dev build (DevSettings) OR expo-updates installed when reloadBetweenCases is true — in a release build without expo-updates the reload silently fails and the batch continues in-process with leak risk",
5229
+ "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",
4389
5230
  "@buoy-gg/highlight-updates + a dev build for captureRenders data"
4390
5231
  ]
4391
5232
  },
@@ -4399,7 +5240,7 @@
4399
5240
  },
4400
5241
  "effect": "write",
4401
5242
  "release": "works",
4402
- "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 — delete them with deleteBatch if you want the batch gone. The status settles to phase \"cancelled\" and stays sticky in live.automationCompleted until acknowledgeAutomation."
5243
+ "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."
4403
5244
  },
4404
5245
  {
4405
5246
  "action": "acknowledgeAutomation",
@@ -4411,7 +5252,7 @@
4411
5252
  },
4412
5253
  "effect": "write",
4413
5254
  "release": "works",
4414
- "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 — 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."
5255
+ "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."
4415
5256
  },
4416
5257
  {
4417
5258
  "action": "refreshIndex",
@@ -4423,7 +5264,7 @@
4423
5264
  },
4424
5265
  "effect": "read",
4425
5266
  "release": "works",
4426
- "description": "Re-reads @react_buoy/perf-monitor/index from disk and re-emits the snapshot. Returns nothing — 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."
5267
+ "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."
4427
5268
  },
4428
5269
  {
4429
5270
  "action": "getAutomationConfig",
@@ -4435,7 +5276,7 @@
4435
5276
  },
4436
5277
  "effect": "read",
4437
5278
  "release": "works",
4438
- "description": "Loads and returns the persisted AutomationConfig from @react_buoy/perf-monitor/automation — 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."
5279
+ "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."
4439
5280
  },
4440
5281
  {
4441
5282
  "action": "setAutomationConfig",
@@ -4445,7 +5286,7 @@
4445
5286
  "properties": {
4446
5287
  "config": {
4447
5288
  "type": "object",
4448
- "description": "The COMPLETE config to persist — merge your changes onto the getAutomationConfig result, since omitted fields are reset to defaults (and omitted `cases` are erased).",
5289
+ "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).",
4449
5290
  "properties": {
4450
5291
  "targetRoute": {
4451
5292
  "type": "string",
@@ -4549,7 +5390,7 @@
4549
5390
  },
4550
5391
  "effect": "destructive",
4551
5392
  "release": "works",
4552
- "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 — for a one-off, pass overrides to startAutomation instead. Passing no config is a no-op that just returns the current one."
5393
+ "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."
4553
5394
  },
4554
5395
  {
4555
5396
  "action": "loadReport",
@@ -4569,7 +5410,7 @@
4569
5410
  },
4570
5411
  "effect": "read",
4571
5412
  "release": "works",
4572
- "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 — use the index summary fields for lists and only load a report when you need the timeline or a head-to-head comparison."
5413
+ "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."
4573
5414
  },
4574
5415
  {
4575
5416
  "action": "deleteReport",
@@ -4589,7 +5430,7 @@
4589
5430
  },
4590
5431
  "effect": "destructive",
4591
5432
  "release": "works",
4592
- "description": "Removes @react_buoy/perf-monitor/report/<id> and drops its index entry, then re-emits the index. Irreversible — 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."
5433
+ "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."
4593
5434
  },
4594
5435
  {
4595
5436
  "action": "deleteBatch",
@@ -4609,7 +5450,7 @@
4609
5450
  },
4610
5451
  "effect": "destructive",
4611
5452
  "release": "works",
4612
- "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 — it removes every run and failure placeholder in one serialized index mutation, instead of fanning out parallel deleteReport calls."
5453
+ "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."
4613
5454
  },
4614
5455
  {
4615
5456
  "action": "clearAll",
@@ -4621,15 +5462,15 @@
4621
5462
  },
4622
5463
  "effect": "destructive",
4623
5464
  "release": "works",
4624
- "description": "Wipes all @react_buoy/perf-monitor/report/* blobs and empties the index. Irreversible and total — 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."
5465
+ "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."
4625
5466
  }
4626
5467
  ],
4627
- "unavailableWhen": "The host app does not have @buoy-gg/perf-monitor installed — 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."
5468
+ "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."
4628
5469
  },
4629
5470
  {
4630
5471
  "toolId": "assets",
4631
5472
  "title": "Assets",
4632
- "summary": "Inventory of what the app SHIPS — 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 — 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.",
5473
+ "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.",
4633
5474
  "actions": [
4634
5475
  {
4635
5476
  "action": "list",
@@ -4655,7 +5496,7 @@
4655
5496
  },
4656
5497
  "loadedOnly": {
4657
5498
  "type": "boolean",
4658
- "description": "true keeps ONLY assets loaded at runtime. There is no 'unusedOnly' param here — for shipped-but-never-loaded assets read insights.neverLoaded (ids) or fetch with a high limit and filter loaded===false."
5499
+ "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."
4659
5500
  }
4660
5501
  },
4661
5502
  "required": [],
@@ -4663,7 +5504,7 @@
4663
5504
  },
4664
5505
  "effect": "read",
4665
5506
  "release": "works",
4666
- "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) — 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 — 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.",
5507
+ "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.",
4667
5508
  "requires": [
4668
5509
  "@buoy-gg/assets imported in the app",
4669
5510
  "Metro dev server (for full-bundle coverage, never-loaded detection and measured bytes)"
@@ -4677,7 +5518,7 @@
4677
5518
  "properties": {
4678
5519
  "id": {
4679
5520
  "type": "number",
4680
- "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 — re-run the list action for current ids.'"
5521
+ "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.'"
4681
5522
  }
4682
5523
  },
4683
5524
  "required": [
@@ -4687,7 +5528,7 @@
4687
5528
  },
4688
5529
  "effect": "read",
4689
5530
  "release": "works",
4690
- "description": "Everything `list` returns for that record plus origin (\"registry\" | \"graph\"), fileSystemLocation (the source directory on the dev machine — 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 — re-run `list` before calling this if anything was cleared.",
5531
+ "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.",
4691
5532
  "requires": [
4692
5533
  "@buoy-gg/assets imported in the app",
4693
5534
  "a prior `list` call for a valid id"
@@ -4703,7 +5544,7 @@
4703
5544
  },
4704
5545
  "effect": "write",
4705
5546
  "release": "works",
4706
- "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 — 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 — read scanStatus.graphError and graphCount to see whether the bundle graph actually loaded."
5547
+ "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."
4707
5548
  },
4708
5549
  {
4709
5550
  "action": "measureSizes",
@@ -4715,8 +5556,8 @@
4715
5556
  },
4716
5557
  "effect": "write",
4717
5558
  "release": "empty",
4718
- "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 — 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.",
4719
- "releaseNote": "packages/assets/src/capture/measure.ts:74-75 — 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 — say that instead of retrying.",
5559
+ "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.",
5560
+ "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.",
4720
5561
  "requires": [
4721
5562
  "Metro dev server reachable from the device"
4722
5563
  ]
@@ -4731,7 +5572,7 @@
4731
5572
  },
4732
5573
  "effect": "write",
4733
5574
  "release": "works",
4734
- "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 — 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."
5575
+ "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."
4735
5576
  },
4736
5577
  {
4737
5578
  "action": "clearBaseline",
@@ -4743,7 +5584,7 @@
4743
5584
  },
4744
5585
  "effect": "destructive",
4745
5586
  "release": "works",
4746
- "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 — 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."
5587
+ "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."
4747
5588
  },
4748
5589
  {
4749
5590
  "action": "getDiff",
@@ -4755,7 +5596,7 @@
4755
5596
  },
4756
5597
  "effect": "read",
4757
5598
  "release": "works",
4758
- "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 — that means 'sizes unknown', NOT 'no change'; `list` already embeds this same object as `diff`, so calling both is redundant."
5599
+ "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."
4759
5600
  },
4760
5601
  {
4761
5602
  "action": "clearRecords",
@@ -4767,7 +5608,7 @@
4767
5608
  },
4768
5609
  "effect": "destructive",
4769
5610
  "release": "works",
4770
- "description": "Returns { ok: true, message: \"Inventory cleared — 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 — 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)."
5611
+ "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)."
4771
5612
  },
4772
5613
  {
4773
5614
  "action": "getScanStatus",
@@ -4779,15 +5620,15 @@
4779
5620
  },
4780
5621
  "effect": "read",
4781
5622
  "release": "works",
4782
- "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 — 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."
5623
+ "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."
4783
5624
  }
4784
5625
  ],
4785
- "unavailableWhen": "The app doesn't import @buoy-gg/assets (autoExternalSync only registers the adapter when the module resolves — 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."
5626
+ "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."
4786
5627
  },
4787
5628
  {
4788
5629
  "toolId": "tv-remote",
4789
5630
  "title": "TV Remote",
4790
- "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 — 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.",
5631
+ "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.",
4791
5632
  "actions": [
4792
5633
  {
4793
5634
  "action": "arm",
@@ -4800,7 +5641,7 @@
4800
5641
  },
4801
5642
  "effect": "write",
4802
5643
  "release": "works",
4803
- "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 — 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.",
5644
+ "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.",
4804
5645
  "requires": [
4805
5646
  "@buoy-gg/tv-remote installed in the app",
4806
5647
  "FloatingDevTools mounted (auto-discovery registers the adapter)",
@@ -4809,7 +5650,7 @@
4809
5650
  },
4810
5651
  {
4811
5652
  "action": "getEventsSince",
4812
- "summary": "Cursor-paged read of captured remote events newer than `seq`. The echo poll — use this, not the snapshot, to time whether a press landed.",
5653
+ "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.",
4813
5654
  "params": {
4814
5655
  "type": "object",
4815
5656
  "properties": {
@@ -4823,9 +5664,9 @@
4823
5664
  },
4824
5665
  "effect": "read",
4825
5666
  "release": "works",
4826
- "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 | … . Pure read — 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) — absence of an event for those is expected, not a bug.",
5667
+ "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.",
4827
5668
  "requires": [
4828
- "arm() must have been called first — an unarmed store records nothing and returns an empty list"
5669
+ "arm() must have been called first \u2014 an unarmed store records nothing and returns an empty list"
4829
5670
  ]
4830
5671
  },
4831
5672
  {
@@ -4844,7 +5685,7 @@
4844
5685
  },
4845
5686
  "effect": "write",
4846
5687
  "release": "works",
4847
- "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 — 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 — check `menuCaptureArmed` in the returned state to see whether it actually took. Always undone automatically by disarm().",
5688
+ "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().",
4848
5689
  "requires": [
4849
5690
  "tvOS (Platform.OS === \"ios\") with Platform.isTV === true",
4850
5691
  "react-native-tvos TVEventControl present"
@@ -4861,11 +5702,11 @@
4861
5702
  },
4862
5703
  "effect": "write",
4863
5704
  "release": "works",
4864
- "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 — 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."
5705
+ "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."
4865
5706
  },
4866
5707
  {
4867
5708
  "action": "clear",
4868
- "summary": "Wipe the captured-event ring buffer. Irreversible — the recorded presses are gone.",
5709
+ "summary": "Wipe the captured-event ring buffer. Irreversible \u2014 the recorded presses are gone.",
4869
5710
  "params": {
4870
5711
  "type": "object",
4871
5712
  "properties": {},
@@ -4874,15 +5715,15 @@
4874
5715
  },
4875
5716
  "effect": "destructive",
4876
5717
  "release": "works",
4877
- "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 — 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."
5718
+ "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."
4878
5719
  }
4879
5720
  ],
4880
- "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) — but if an action is reachable at all, its code path works in release."
5721
+ "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."
4881
5722
  },
4882
5723
  {
4883
5724
  "toolId": "focus-inspector",
4884
5725
  "title": "TV Focus Inspector",
4885
- "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 — 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).",
5726
+ "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).",
4886
5727
  "actions": [
4887
5728
  {
4888
5729
  "action": "rescan",
@@ -4895,11 +5736,11 @@
4895
5736
  },
4896
5737
  "effect": "read",
4897
5738
  "release": "empty",
4898
- "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 — a rescan while one is in flight returns the previous scan.",
4899
- "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} — 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.",
5739
+ "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.",
5740
+ "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.",
4900
5741
  "requires": [
4901
5742
  "Platform.isTV === true for the results to mean anything (the scan itself runs on a phone but nothing is ever observed there)",
4902
- "A __DEV__ build — a release build returns focusables:0"
5743
+ "A __DEV__ build \u2014 a release build returns focusables:0"
4903
5744
  ]
4904
5745
  },
4905
5746
  {
@@ -4920,8 +5761,8 @@
4920
5761
  },
4921
5762
  "effect": "write",
4922
5763
  "release": "empty",
4923
- "description": "The tool's ONLY write into the host app — 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 — there is no focus engine.' (Platform.isTV false), 'Unknown tag — 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 — 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.",
4924
- "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 — rescan and try again.'} no matter how many times you rescan. Do not loop on the rescan advice in the reason string — in release it can never succeed; say so and drive the app with the actual remote instead.",
5764
+ "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.",
5765
+ "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.",
4925
5766
  "requires": [
4926
5767
  "Platform.isTV === true",
4927
5768
  "A __DEV__ build",
@@ -4936,7 +5777,7 @@
4936
5777
  "properties": {
4937
5778
  "enabled": {
4938
5779
  "type": "boolean",
4939
- "description": "true resumes recording, false pauses it. OMITTING THIS MEANS TRUE (resume) — always send it explicitly."
5780
+ "description": "true resumes recording, false pauses it. OMITTING THIS MEANS TRUE (resume) \u2014 always send it explicitly."
4940
5781
  }
4941
5782
  },
4942
5783
  "required": [],
@@ -4944,11 +5785,11 @@
4944
5785
  },
4945
5786
  "effect": "write",
4946
5787
  "release": "works",
4947
- "description": "Returns {tracking:boolean} — 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."
5788
+ "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."
4948
5789
  },
4949
5790
  {
4950
5791
  "action": "clearHistory",
4951
- "summary": "Permanently wipe the recorded focus history — transitions, D-pad probes, visited tags and counters.",
5792
+ "summary": "Permanently wipe the recorded focus history \u2014 transitions, D-pad probes, visited tags and counters.",
4952
5793
  "params": {
4953
5794
  "type": "object",
4954
5795
  "properties": {},
@@ -4957,15 +5798,15 @@
4957
5798
  },
4958
5799
  "effect": "destructive",
4959
5800
  "release": "works",
4960
- "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 — 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 — 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."
5801
+ "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."
4961
5802
  }
4962
5803
  ],
4963
- "unavailableWhen": "The app is not a TV build — `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 — a focusable overlay would insert itself into the host app's focus order and corrupt the measurement — so everything is read through this adapter."
5804
+ "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."
4964
5805
  },
4965
5806
  {
4966
5807
  "toolId": "images",
4967
5808
  "title": "Images",
4968
- "summary": "Live registry of every image the app has loaded — RN core <Image> and expo-image — 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.",
5809
+ "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.",
4969
5810
  "actions": [
4970
5811
  {
4971
5812
  "action": "list",
@@ -4993,7 +5834,7 @@
4993
5834
  },
4994
5835
  "effect": "read",
4995
5836
  "release": "works",
4996
- "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 — an empty result means either nothing rendered yet or capture is not wired (call getCaptureStatus). Use the returned ids for every other action.",
5837
+ "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.",
4997
5838
  "requires": [
4998
5839
  "@buoy-gg/images installed in the app",
4999
5840
  "capture installed (import \"@buoy-gg/images/register\" as the first entry import, or <ImagesRoot/> mounted)"
@@ -5017,7 +5858,7 @@
5017
5858
  },
5018
5859
  "effect": "read",
5019
5860
  "release": "works",
5020
- "description": "Same fields as a `list` record plus `errorHeaders` (iOS RN core only — 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 — re-run `list` for current ids.",
5861
+ "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.",
5021
5862
  "requires": [
5022
5863
  "@buoy-gg/images installed in the app"
5023
5864
  ]
@@ -5039,7 +5880,7 @@
5039
5880
  },
5040
5881
  {
5041
5882
  "action": "retry",
5042
- "summary": "Plain fresh load attempt for one mounted image — no cache bypass, nothing cleared.",
5883
+ "summary": "Plain fresh load attempt for one mounted image \u2014 no cache bypass, nothing cleared.",
5043
5884
  "params": {
5044
5885
  "type": "object",
5045
5886
  "properties": {
@@ -5055,7 +5896,7 @@
5055
5896
  },
5056
5897
  "effect": "write",
5057
5898
  "release": "works",
5058
- "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 — prefer it over hardReload unless you specifically need to prove a cache is stale. Returns { ok:false, message:'Instance unmounted — 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.",
5899
+ "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.",
5059
5900
  "requires": [
5060
5901
  "@buoy-gg/images installed in the app",
5061
5902
  "the image must still be mounted on screen"
@@ -5079,7 +5920,7 @@
5079
5920
  },
5080
5921
  "effect": "write",
5081
5922
  "release": "works",
5082
- "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 — 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?'.",
5923
+ "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?'.",
5083
5924
  "requires": [
5084
5925
  "@buoy-gg/images installed in the app",
5085
5926
  "the image must be mounted and on screen to be visible"
@@ -5143,7 +5984,7 @@
5143
5984
  },
5144
5985
  "effect": "write",
5145
5986
  "release": "works",
5146
- "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 — 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 — 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') — always tell the user how to undo it.",
5987
+ "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.",
5147
5988
  "requires": [
5148
5989
  "@buoy-gg/images installed in the app",
5149
5990
  "the image must be mounted on screen for kinds error/hang/blank"
@@ -5167,7 +6008,7 @@
5167
6008
  },
5168
6009
  "effect": "write",
5169
6010
  "release": "works",
5170
- "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 — original source restored' }, even for an id that had no override. This is the undo for setOverride.",
6011
+ "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.",
5171
6012
  "requires": [
5172
6013
  "@buoy-gg/images installed in the app"
5173
6014
  ]
@@ -5195,7 +6036,7 @@
5195
6036
  },
5196
6037
  "effect": "write",
5197
6038
  "release": "works",
5198
- "description": "'offline' swaps every NETWORK-kind source for a nonexistent file:// so it fails immediately on both libs — 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 — say so out loud, since a stuck 'offline' looks exactly like a real app bug.",
6039
+ "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.",
5199
6040
  "requires": [
5200
6041
  "@buoy-gg/images installed in the app"
5201
6042
  ]
@@ -5216,14 +6057,14 @@
5216
6057
  },
5217
6058
  "effect": "write",
5218
6059
  "release": "works",
5219
- "description": "expo-image gets source=null (its placeholder keeps showing); RN core gets opacity:0 because RN has no safe empty source (visual only — 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 — always pass `enabled` explicitly.",
6060
+ "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.",
5220
6061
  "requires": [
5221
6062
  "@buoy-gg/images installed in the app"
5222
6063
  ]
5223
6064
  },
5224
6065
  {
5225
6066
  "action": "hardReload",
5226
- "summary": "Cache-busting reload of one image — for expo records this ALSO clears the app's entire expo-image memory cache.",
6067
+ "summary": "Cache-busting reload of one image \u2014 for expo records this ALSO clears the app's entire expo-image memory cache.",
5227
6068
  "params": {
5228
6069
  "type": "object",
5229
6070
  "properties": {
@@ -5239,7 +6080,7 @@
5239
6080
  },
5240
6081
  "effect": "destructive",
5241
6082
  "release": "works",
5242
- "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 — 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.",
6083
+ "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.",
5243
6084
  "requires": [
5244
6085
  "@buoy-gg/images installed in the app",
5245
6086
  "the image must be mounted on screen",
@@ -5248,7 +6089,7 @@
5248
6089
  },
5249
6090
  {
5250
6091
  "action": "evictDisk",
5251
- "summary": "Delete this image's expo-image disk-cache file. Cache data only — irreversible, the image refetches next load.",
6092
+ "summary": "Delete this image's expo-image disk-cache file. Cache data only \u2014 irreversible, the image refetches next load.",
5252
6093
  "params": {
5253
6094
  "type": "object",
5254
6095
  "properties": {
@@ -5264,7 +6105,7 @@
5264
6105
  },
5265
6106
  "effect": "destructive",
5266
6107
  "release": "works",
5267
- "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)' } — 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.",
6108
+ "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.",
5268
6109
  "requires": [
5269
6110
  "expo-image installed",
5270
6111
  "expo-file-system installed (legacy or main entry)",
@@ -5297,10 +6138,10 @@
5297
6138
  },
5298
6139
  "effect": "destructive",
5299
6140
  "release": "works",
5300
- "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 — 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.",
6141
+ "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.",
5301
6142
  "requires": [
5302
6143
  "@buoy-gg/images installed in the app",
5303
- "images must be mounted on screen — a background screen yields ok:false with 0 affected"
6144
+ "images must be mounted on screen \u2014 a background screen yields ok:false with 0 affected"
5304
6145
  ]
5305
6146
  },
5306
6147
  {
@@ -5313,7 +6154,7 @@
5313
6154
  },
5314
6155
  "effect": "destructive",
5315
6156
  "release": "works",
5316
- "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 — 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.",
6157
+ "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.",
5317
6158
  "requires": [
5318
6159
  "@buoy-gg/images installed in the app"
5319
6160
  ]
@@ -5338,18 +6179,18 @@
5338
6179
  },
5339
6180
  "effect": "destructive",
5340
6181
  "release": "works",
5341
- "description": "Calls expo-image's Image.clearMemoryCache() and Image.clearDiskCache(). Both default to true — 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.",
6182
+ "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.",
5342
6183
  "requires": [
5343
6184
  "expo-image installed (otherwise returns ok:false and does nothing)"
5344
6185
  ]
5345
6186
  }
5346
6187
  ],
5347
- "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 — 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."
6188
+ "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."
5348
6189
  },
5349
6190
  {
5350
6191
  "toolId": "ask-buoy",
5351
6192
  "title": "Ask Buoy",
5352
- "summary": "Ask Buoy's OWN session — 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 — 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 — 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 — …; ref ev_N]` marker and every result compressed out of memory leaves an `[earlier result — …; ref ev_N]` marker — 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.",
6193
+ "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.",
5353
6194
  "actions": [
5354
6195
  {
5355
6196
  "action": "listChanges",
@@ -5362,11 +6203,11 @@
5362
6203
  },
5363
6204
  "effect": "read",
5364
6205
  "release": "works",
5365
- "description": "Returns `{changes:[{toolId, action, kind, label, reversible}], returned}` — 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."
6206
+ "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."
5366
6207
  },
5367
6208
  {
5368
6209
  "action": "undoAll",
5369
- "summary": "Undo every reversible change Ask Buoy made this conversation — 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.",
6210
+ "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.",
5370
6211
  "params": {
5371
6212
  "type": "object",
5372
6213
  "properties": {},
@@ -5375,11 +6216,11 @@
5375
6216
  },
5376
6217
  "effect": "write",
5377
6218
  "release": "works",
5378
- "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 — 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 — there is no per-change undo action, so if the user wants to keep one change, say so instead of calling this."
6219
+ "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."
5379
6220
  },
5380
6221
  {
5381
6222
  "action": "retrieve",
5382
- "summary": "Re-read part of an earlier tool result by its ref (ev_N from a [truncated …] or [earlier result …] 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 — for the app's CURRENT value call the original tool again.",
6223
+ "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.",
5383
6224
  "params": {
5384
6225
  "type": "object",
5385
6226
  "properties": {
@@ -5413,12 +6254,12 @@
5413
6254
  "ref"
5414
6255
  ],
5415
6256
  "additionalProperties": false,
5416
- "description": "ref is required; add exactly what you need — a bare ref shows the shape, then path/slice/pattern read a part."
6257
+ "description": "ref is required; add exactly what you need \u2014 a bare ref shows the shape, then path/slice/pattern read a part."
5417
6258
  },
5418
6259
  "effect": "read",
5419
6260
  "release": "works",
5420
6261
  "servedBy": "engine",
5421
- "description": "Returns `{ref, from, capturedAt, totalChars, …}` 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) — 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."
6262
+ "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."
5422
6263
  },
5423
6264
  {
5424
6265
  "action": "openProcedure",
@@ -5443,7 +6284,7 @@
5443
6284
  "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."
5444
6285
  }
5445
6286
  ],
5446
- "unavailableWhen": "Only present while an Ask Buoy session is running — which is exactly when this catalog is in use, so in practice always available to you."
6287
+ "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."
5447
6288
  },
5448
6289
  {
5449
6290
  "toolId": "push-notifications",
@@ -5651,7 +6492,7 @@
5651
6492
  },
5652
6493
  {
5653
6494
  "action": "selectTarget",
5654
- "summary": "Attach the overlay to a visible target.",
6495
+ "summary": "Attach the overlay to the visible target with this `id`.",
5655
6496
  "params": {
5656
6497
  "type": "object",
5657
6498
  "properties": {
@@ -5670,7 +6511,7 @@
5670
6511
  },
5671
6512
  {
5672
6513
  "action": "loadImage",
5673
- "summary": "Start loading a design image; returns {scheduled:true}. Check getSnapshot for completion or error.",
6514
+ "summary": "Start loading a design image from `url`; returns {scheduled:true}. Check getSnapshot for completion or error.",
5674
6515
  "params": {
5675
6516
  "type": "object",
5676
6517
  "properties": {
@@ -5689,7 +6530,7 @@
5689
6530
  },
5690
6531
  {
5691
6532
  "action": "setSettings",
5692
- "summary": "Update overlay settings. Invalid batches fail before any settings change.",
6533
+ "summary": "Update overlay settings: `visible`, `locked`, `flipped`, `flippedY`, `showOutline`, `autoTrack`, `opacity`, `scale`, `offsetX`, `offsetY`. Invalid batches fail before any settings change.",
5693
6534
  "params": {
5694
6535
  "type": "object",
5695
6536
  "properties": {