@buoy-gg/agent-core 7.0.55 → 7.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/web/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- "use client";typeof window<"u"&&typeof globalThis.global>"u"&&(globalThis.global=globalThis);var He=[{toolId:"env",title:"Env",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).',actions:[{action:"getSnapshot",summary:"Read this build's environment: every EXPO_PUBLIC_-style variable it was compiled with, plus the app's required-env-var checks.",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). It also returns `checks`: the tool\'s own verdict for each required variable (required_present, required_missing, required_wrong_type, required_wrong_value). Answer "is anything missing or wrong?" from `checks`: every env value is a string, so a number-typed variable like "1856" passes.',params:{type:"object",properties:{},additionalProperties:false},effect:"read",release:"works"}],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."},{toolId:"console",title:"Console",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\xB7level\xB7message 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).`,actions:[{action:"getSnapshot",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.",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".',params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N entries after filtering. Default 40."},level:{type:"string",enum:["verbose","debug","info","log","warn","error"],description:"Minimum severity, e.g. 'warn' for warnings and errors only."},pattern:{type:"string",description:"Case-insensitive substring the message must contain."}},additionalProperties:false},effect:"read",release:"works"},{action:"clearEntries",summary:"Permanently wipe every captured console entry on the device, including [FATAL] crash records and the preserved-log buffer on disk.",params:{type:"object",properties:{},additionalProperties:false,description:"No parameters. Any params object is ignored by the handler."},effect:"destructive",release:"works",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.",requires:["@buoy-gg/console installed in the app",'<FloatingDevTools /> rendered \u2014 it auto-mounts ConsoleRoot (capture) and registers consoleSyncAdapter as the "console" capability',"external-sync connection to the broker (:42831)","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"]}],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."},{toolId:"sentry",title:"Sentry",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.`,actions:[{action:"getSnapshot",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?".',params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N envelopes after filtering. Default 10."},type:{type:"string",description:"Only envelopes carrying an item of this Sentry type, e.g. 'event' (errors), 'transaction', 'log', 'session'."},pattern:{type:"string",description:"Case-insensitive substring an item summary must contain."}},additionalProperties:false},effect:"read",release:"works",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". It also returns `drops`: events the SDK discarded before sending, each with its `reason` (before_send = the app\'s own beforeSend returned null, sample_rate, event_processor, client_report\u2026) and a `detail` naming the event. Read it to answer "why isn\'t this in Sentry?".'},{action:"clearEnvelopes",summary:"Wipe the captured envelope list on the device. Does not affect anything already delivered to Sentry's servers.",params:{type:"object",properties:{},additionalProperties:false,description:"No parameters."},effect:"destructive",release:"works",description:"Empties the on-device capture buffer and returns {cleared:true} even if it was already empty. There is no undo; read what you need first. Legitimate use is narrow: zeroing the list right before reproducing a bug so the next read contains only that repro's traffic."}],unavailableWhen:"@buoy-gg/sentry is not installed in the app, or the app does not use @sentry/react-native at all. Separately, in a release build the sync transport is off unless the app passes externalSync={{enableInRelease:true}} with a real Pro license, in which case no action reaches the device at all."},{toolId:"jotai",title:"Jotai",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.",actions:[{action:"getSnapshot",summary:"Read the Jotai change log: the app's recent atom changes, newest first, each with the atom and a preview of its new value.",description:"Returns `{ changes: [{ id, at, atom, changed, summary, value }], total, returned }` newest first. `value` is a short preview; getChangeDetail(id) has the full prevValue and nextValue. Pass `limit` (default 20) and `atom` to list one atom's changes.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N changes. Default 20."},atom:{type:"string",description:"Only changes to atoms whose label contains this text."}},additionalProperties:false},effect:"read",release:"works"},{action:"listAtoms",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.",params:{type:"object",properties:{includeValues:{type:"boolean",description:"Include each atom's full current value (HEAVY, no size cap). Default false."},limit:{type:"number",description:"Return only the first N registered atoms. Applied only when > 0; otherwise all atoms are returned."}},additionalProperties:false},effect:"read",release:"works",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.",requires:["@buoy-gg/jotai installed in the app","watchAtoms(store, atoms) or watchDefaultStoreAtoms(atoms) called at app startup"]},{action:"getAtomValue",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.",params:{type:"object",properties:{label:{type:"string",description:'Atom label exactly as registered \u2014 the object key in watchAtoms(store, { countAtom }), e.g. "countAtom". Get exact labels from listAtoms.'},path:{type:"string",description:"Return only the value at this path instead of the whole atom value."}},required:["label"],additionalProperties:false},effect:"read",release:"works",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.",requires:["The label must already be registered via watchAtoms \u2014 call listAtoms first for exact labels"]},{action:"getChangeDetail",summary:"Fetch the real prevValue/nextValue behind one recorded atom change.",params:{type:"object",properties:{id:{type:"string",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'}.`}},required:["id"],additionalProperties:false},effect:"read",release:"works",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}.`,requires:["A change id from the jotai snapshot or get_events sources:['jotai']"]},{action:"setAtom",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.',params:{type:"object",properties:{label:{type:"string",description:"Atom label from listAtoms; must be writable:true. The wire param is `label` (the MCP tool calls it `atom`)."},value:{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."},merge:{type:"boolean",description:"Merge `value` into the atom's current value instead of replacing it, under the typed-edit rule. Default false."},path:{type:"string",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."},force:{type:"boolean",description:"Bypass the typed-edit safety and write raw. This is what crashes screens; use ONLY to deliberately replace the whole shape."}},required:["label","value"],additionalProperties:false},effect:"write",release:"works",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.',requires:["listAtoms reports writable:true for this label (atom has a write fn AND the store exposes set())"]},{action:"clearEvents",summary:"Wipe the recorded atom-change timeline and reset every atom's change count to 0.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."}],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).`},{toolId:"route-events",title:"Routes",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.',actions:[{action:"getSnapshot",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".',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.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N navigation events. Default 15."}},additionalProperties:false},effect:"read",release:"works"},{action:"getCurrentRoute",summary:"Just the current route \u2014 {path, params, at, source} \u2014 without the sitemap or history. The cheap way to confirm a navigation landed.",params:{type:"object",properties:{},additionalProperties:false,description:"No parameters."},effect:"read",release:"works",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.'},{action:"navigate",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.",params:{type:"object",properties:{path:{type:"string",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."},replace:{type:"boolean",description:"Replace the current screen instead of pushing it on top. Default false. Use true when resetting the app to a base route."}},required:["path"],additionalProperties:false},effect:"write",release:"works",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.",requires:["expo-router installed and initialized in the app","<FloatingDevTools> mounted (adapter registered)"]},{action:"stackGoBack",summary:"Pop one screen off the navigation stack (the hardware/back-gesture equivalent).",params:{type:"object",properties:{},additionalProperties:false},effect:"write",release:"works",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.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"stackNavigateToIndex",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.",params:{type:"object",properties:{index:{type:"number",description:"0-based index into the synced `stack` array (top-most last; 0 = root). Must be a number \u2014 strings throw."}},required:["index"],additionalProperties:false},effect:"write",release:"works",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 />.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"stackPopToIndex",summary:"Rewind the navigation stack down to a 0-based index, discarding every screen above it.",params:{type:"object",properties:{index:{type:"number",description:"0-based index into the synced `stack` array to rewind DOWN to (top-most last; 0 = root). Screens above it are popped and their state discarded."}},required:["index"],additionalProperties:false},effect:"destructive",release:"works",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'.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"stackPopToTop",summary:"Reset the navigation stack to its root screen, discarding every screen above it.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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 />.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"clearEvents",summary:"Wipe the recorded route-change history buffer (max 500 events) on the device.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.",requires:["<RouteTracker /> mounted inside the navigation tree (for events to exist at all)"],armsCapture:true}],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.'},{toolId:"debug-borders",title:"Debug Borders",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.',actions:[{action:"cycleMode",summary:"Advance the border overlay one step: off -> borders -> labels -> off (Pro), or off -> borders -> off without a Pro license.",params:{type:"object",properties:{},additionalProperties:false},effect:"write",release:"noop",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.`,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.',requires:["@buoy-gg/debug-borders installed in the app","<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root",'a Pro license (@buoy-gg/license isPro()) for the cycle to include "labels"']},{action:"setMode",summary:'Set the border overlay directly to "off", "borders", or "labels". "labels" is Pro-only and silently does nothing on a free device.',params:{type:"object",properties:{mode:{type:"string",enum:["off","borders","labels"],description:"off = clear the overlay; borders = outline every native view, colored by depth; labels = Pro-only, outline + tappable chip for views that have a testID or accessibilityLabel. Required; not validated by the adapter, so any other string is stored as-is and leaves the overlay in a broken state."}},required:["mode"],additionalProperties:false},effect:"write",release:"noop",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".',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.",requires:["@buoy-gg/debug-borders installed in the app","<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root",'a Pro license (@buoy-gg/license isPro()) for mode:"labels" to take effect']}],unavailableWhen:"The app doesn't have `@buoy-gg/debug-borders` installed (autoExternalSync's optional require fails at packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:125, so \"debug-borders\" never appears in the device's tool list). It is also present-but-inert when the app renders `<FloatingDevTools headless />`: the headless branch returns before the overlay at FloatingDevTools.tsx:785, so the actions succeed and change `mode` with no overlay mounted to draw anything."},{toolId:"zustand",title:"Zustand",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.",actions:[{action:"getSnapshot",summary:`Read the Zustand change log: the app's recent store changes, newest first, each with the store, the keys it changed and what it set. Answers "what changed in my cart?" and finds the change to undo.`,description:"Returns `{ changes: [{ id, at, store, changed, summary, partial }], total, returned }` newest first. `partial` is what that setState sent, cut to 400 characters. For an undo, getChangeDetail(id) has that change's full prevState: write the keys it changed back from prevState with setState. Changes are recorded from app start, whether or not the Events tool is on. Pass `limit` (default 20) and `store` to list one store's changes.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N changes. Default 20."},store:{type:"string",description:"Only changes to stores whose name contains this text."}},additionalProperties:false},effect:"read",release:"works"},{action:"listStores",summary:"List every registered Zustand store with its name, change count, persistence, and (by default) just its top-level keys.",params:{type:"object",properties:{includeValues:{type:"boolean",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."},limit:{type:"number",description:"Return only the first N stores, in registration order. Ignored unless greater than 0; default is all stores."}},additionalProperties:false},effect:"read",release:"works",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.",requires:["@buoy-gg/zustand installed and the zustand tool registered with FloatingDevTools","the app calls watchStores({...}) or wraps stores with buoyDevTools() \u2014 otherwise the registry is empty"]},{action:"getStoreState",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.',params:{type:"object",properties:{storeName:{type:"string",description:'Registered store name exactly as listStores reports it (e.g. "counterStore", "authStore", "cartStore"). Case-sensitive; no fuzzy matching.'},path:{type:"string",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."}},required:["storeName"],additionalProperties:false},effect:"read",release:"works",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.',requires:["the store must already be registered \u2014 get the exact name from listStores"]},{action:"getChangeDetail",summary:"Fetch the real prevState / nextState / partial for one recorded state change, by change id.",params:{type:"object",properties:{id:{type:"string",description:'Change id from a zustand change row, format `<epochMs>-<counter>` (e.g. "1761580000123-42"). Only the most recent 200 changes are retained.'}},required:["id"],additionalProperties:false},effect:"read",release:"works",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.',requires:["a change id from the zustand change log (the tool snapshot, or get_events sources:['zustand'])"]},{action:"setState",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.`,params:{type:"object",properties:{storeName:{type:"string",description:'Registered store name exactly as listStores reports it (e.g. "counterStore"). Unknown names are rejected with ok:false.'},state:{type:"object",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}).`,additionalProperties:true},replace:{type:"boolean",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.`,default:false},force:{type:"boolean",description:"Bypass the typed-edit safety on a merge and write raw. Default false \u2014 a violating merge is refused."},path:{type:"string",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.'},value:{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."}},required:["storeName"],additionalProperties:false},effect:"destructive",release:"works",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.",requires:["the store must be registered \u2014 get the exact name from listStores","the store's registered setState handle: watchStores registers the raw setState, buoyDevTools registers its instrumented set (both apply the write)"]},{action:"rehydrate",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.",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.",params:{type:"object",properties:{storeName:{type:"string",description:"Registered store name exactly as listStores reports it. It must be a persisted store (listStores shows isPersisted and persistName)."}},required:["storeName"],additionalProperties:false},effect:"write",release:"works",requires:["the store uses zustand's persist middleware and was registered with watchStores/the Buoy middleware"]},{action:"clearEvents",summary:"Wipe the recorded Zustand state-change timeline and reset every store's change count to 0.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.`}],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.`},{toolId:"redux",title:"Redux",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.",actions:[{action:"getSnapshot",summary:"Read the Redux action log: the app's recent actions, newest first, each with its real type, payload and meta. Shows the action shapes this app really uses before you dispatch one.",description:"Returns `{ actions: [{ id, at, type, payload, meta, error, changed }], total, returned }` newest first. `payload`, `meta` and `error` are cut to 400 characters; getActionDetail(id) has the full action and its before/after state. Redux Toolkit names actions `<slice>/<reducer>`, and async thunks add `/pending`, `/fulfilled` or `/rejected` with the thunk's argument in `meta.arg`. Copy a real action's shape when you dispatch one. Pass `limit` (default 20) and `type` to list only actions whose type contains that text.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N actions. Default 20."},type:{type:"string",description:"Only actions whose type contains this text."}},additionalProperties:false},effect:"read",release:"works"},{action:"getState",summary:"Read the app's CURRENT Redux state \u2014 top-level slice names by default, full state tree with includeValues:true.",params:{type:"object",properties:{includeValues:{type:"boolean",description:"Include the full current state tree (HEAVY \u2014 the whole store is serialized over the wire). Default false: slice names only."}},additionalProperties:false},effect:"read",release:"works",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.",requires:["react-redux installed in the app","a Redux <Provider> above <FloatingDevTools /> \u2014 or an explicit registerReduxStore(store) from @buoy-gg/redux"]},{action:"getActionDetail",summary:"Fetch one recorded action's real prevState/nextState trees, payload, meta and error by action id.",params:{type:"object",properties:{id:{type:"string",description:'Action id from the redux action log, formatted "<epochMillis>-<counter>" (e.g. "1724612345678-42"). Omitting it returns {found:false, reason:"missing id"}.'}},required:["id"],additionalProperties:false},effect:"read",release:"works",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.`,requires:["an instrumented store that has already recorded the action (log holds the 200 most recent actions; raw state trees only the 25 most recent)"]},{action:"dispatch",summary:"Dispatch a plain action object into the app's live Redux store \u2014 really changes app state.",params:{type:"object",properties:{action:{type:"object",description:"The plain Redux action object, dispatched as-is. Extra keys (payload, meta, error) are passed straight through.",properties:{type:{type:"string",description:'Action type, e.g. "counter/increment" or "auth/logout".'},payload:{description:"Optional action payload (any JSON)."}},required:["type"]}},required:["action"],additionalProperties:false},effect:"destructive",release:"works",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. Use dispatch when the app's own action does what you want (copy its shape from getSnapshot). When no action you can see does it, such as removing one entry, use redux.setState on the value instead of guessing action names.`,requires:["react-redux installed in the app","a Redux <Provider> above <FloatingDevTools /> \u2014 or an explicit registerReduxStore(store)","the app must actually handle the action type; an unknown type dispatches successfully and changes nothing"]},{action:"setState",summary:"Set or remove ONE value in the live Redux store by path, without knowing the app's action names.",description:'Changes the state directly through Buoy\'s time-travel reducer: Buoy copies the current state, sets `value` at `path` (or deletes it with `remove:true`) and jumps the store to the result. Screens update at once. `path` is dot-separated from the root, slice first: "offers.added.<id>" or "cart.items.0.qty". Returns `{ ok, path, previous }`; `previous` is what was there, so you can put it back. Reach for it when the app has no action you can see for the change (getSnapshot lists the real ones), like removing one added item. It needs the store to be wrapped by Buoy; otherwise it refuses and you dispatch the app\'s own action instead.',params:{type:"object",properties:{path:{type:"string",description:'Dot path from the root, slice first, e.g. "offers.added.ab14".'},value:{description:"The new value. null is a real value."},remove:{type:"boolean",description:"Delete the key at path instead of setting it."}},required:["path"],additionalProperties:false},effect:"destructive",release:"works"},{action:"clearEvents",summary:"Wipe the recorded Redux action log on the device (state trees and all). Irreversible.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."}],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.'},{toolId:"impersonate",title:"Impersonate",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.",actions:[{action:"searchUsers",summary:"Search the app's own user directory and get back User objects you can feed to startImpersonation.",params:{type:"object",properties:{query:{type:"string",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'."}},required:[],additionalProperties:false},effect:"read",release:"works",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().`,requires:["createImpersonateTool({ onSearchUsers }) called by the host app"]},{action:"startImpersonation",summary:"Begin impersonating a user \u2014 every subsequent fetch/XHR carries the impersonation header, and app caches are wiped per dataNukeSettings.",params:{type:"object",properties:{user:{type:"object",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.",properties:{id:{type:"string",description:"Required. This exact string becomes the impersonation header value (default header x-impersonate-user-id)."},displayName:{type:"string",description:"Shown on the user card and banner; falls back to email, then id."},email:{type:"string"},avatarUrl:{type:"string"},metadata:{type:"object",description:"Free-form key/value shown on the user card; a string metadata.role is rendered as a badge."}},required:["id"]}},required:["user"],additionalProperties:false},effect:"destructive",release:"works",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."},{action:"stopImpersonation",summary:"End impersonation and go back to the real logged-in account; also runs the data nuke.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."},{action:"pauseImpersonation",summary:"Temporarily stop injecting the header while keeping the session and current user.",params:{type:"object",properties:{},additionalProperties:false},effect:"write",release:"works",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."},{action:"resumeImpersonation",summary:"Resume header injection for the already-selected user after a pause.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."},{action:"updateSettings",summary:"Change the header key, URL ignore patterns, banner visibility, or which caches get wiped on every user switch.",params:{type:"object",properties:{settings:{type:"object",description:"Required wrapper. Partial patch \u2014 omitted keys keep their current value.",properties:{headerKey:{type:"string",description:"HTTP header name injected on every request. Default 'x-impersonate-user-id'."},ignorePatterns:{type:"array",items:{type:"string"},description:"Regex SOURCE strings (e.g. '/health$', 'analytics\\\\.example\\\\.com') for URLs that must not get the header. Replaces the whole list; Metro/dev URLs are always excluded regardless."},showBanner:{type:"boolean",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."},dataNukeSettings:{type:"object",description:"Which stores are cleared on every start/stop of impersonation.",properties:{reactQuery:{type:"boolean",description:"Clear the react-query cache. Default true."},redux:{type:"boolean",description:"Reset redux state. Default true."},asyncStorage:{type:"boolean",description:"DANGEROUS: wipe app AsyncStorage on every switch. Default false."},mmkv:{type:"boolean",description:"DANGEROUS: wipe app MMKV storage on every switch. Default false."}},required:[]}},required:[]}},required:["settings"],additionalProperties:false},effect:"write",release:"works",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."},{action:"removeFromHistory",summary:"Delete one user from the recently-impersonated history list.",params:{type:"object",properties:{userId:{type:"string",description:"The User.id to drop, exactly as it appears in the snapshot's history[].user.id. Required."}},required:["userId"],additionalProperties:false},effect:"destructive",release:"works",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."},{action:"clearHistory",summary:"Wipe the entire recently-impersonated user list.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."}],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()".`},{toolId:"query",title:"React Query",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'].`,actions:[{action:"listQueries",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.",params:{type:"object",properties:{includeData:{type:"boolean",description:"Include each query's full cached data payload verbatim and UNCAPPED. Default false. Heavy \u2014 one cached list can be megabytes."},staleOnly:{type:"boolean",description:"Only queries whose isStale() is true. Default false."},limit:{type:"number",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."}},additionalProperties:false},effect:"read",release:"works",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. `refetchEveryMs` appears on a query a mounted screen refetches on a timer: any cache edit, error or loading pin on it is replaced within that many ms, so use a network override for anything that must last \u2014 and it answers \"is the app calling this API over and over?\".",requires:["QueryClientProvider above <FloatingDevTools/>","@tanstack/react-query v5"]},{action:"getQueryData",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.',params:{type:"object",properties:{queryHash:{type:"string",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.`},path:{type:"string",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."}},required:["queryHash"],additionalProperties:false},effect:"read",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"refetch",summary:"Force one query to re-run its queryFn right now (a real network request), by queryHash.",params:{type:"object",properties:{queryHash:{type:"string",description:`Target query's hash from listQueries, e.g. '["todos",{"page":1}]'. Required \u2014 an unknown hash throws.`}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",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.`,requires:["QueryClientProvider above <FloatingDevTools/>","the query must have a queryFn to actually fetch"]},{action:"invalidate",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`.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries. Note the prefix-match blast radius described above."}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",description:`Looks the query up by hash, then passes the Query itself to queryClient.invalidateQueries() as the filter. Because the filter carries only queryKey with no exact:true, matching is a NON-EXACT prefix match: invalidating '[\\"todos\\"]' also invalidates '[\\"todos\\",{\\"page\\":1}]' and any other key that extends it. Mounted (observed) queries refetch immediately, so this can fire real API calls; inactive ones just go stale. Prefer this over refetch when you want the app's own screens to re-render with fresh data. THROWS for an unknown hash. Takes the query's \`queryHash\`.`,requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"reset",summary:"Reset a query to its initial state \u2014 DISCARDS its cached data, then refetches if it is active. Takes the query's `queryHash`.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries. Prefix-matches, so it can reset sibling/nested keys too."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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`.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"remove",summary:"Delete a query from the cache entirely \u2014 the entry, its data, and its state are gone. Takes the query's `queryHash`.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries. Prefix-matches \u2014 it can delete more than the one row you picked."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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`.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"setQueryData",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.',params:{type:"object",properties:{queryKey:{type:"array",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.`},data:{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."},queryHash:{type:"string",description:"Declared by the adapter's param type but never read by the handler \u2014 passing it has no effect."},merge:{type:"boolean",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."},force:{type:"boolean",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."},path:{type:"string",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.'},value:{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."}},required:["queryKey"],additionalProperties:false},effect:"write",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"triggerError",summary:"Force a query into error status with a fake Error, to exercise the app's error UI. The QA move for 'show me this screen's error state'; undo with restoreError.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries."}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"restoreError",summary:"Undo triggerError \u2014 clears the fake error. Implemented as resetQueries, so it also discards cached data.",params:{type:"object",properties:{queryHash:{type:"string",description:"The hash you passed to triggerError."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"triggerLoading",summary:"Pin a query in a permanent loading/suspense state to exercise skeletons and spinners. MUST be undone. The QA move for 'show me this screen's loading state'; undo with restoreLoading.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"restoreLoading",summary:"Undo triggerLoading \u2014 cancel the never-resolving fetch and refetch with the query's real options.",params:{type:"object",properties:{queryHash:{type:"string",description:"The hash you passed to triggerLoading."}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",description:"Silently cancels the fake fetch, restores the previous state with fetchStatus:'idle' and fetchMeta cleared, then re-runs query.fetch(__previousQueryOptions) if those stashed options exist (fetch rejections are swallowed). If triggerLoading was never called for this query there is nothing stashed, so it just cancels any in-flight fetch and idles the query. THROWS for an unknown hash.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"clearQueryCache",summary:"Wipe the ENTIRE query cache \u2014 every query on the device, not just one.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"clearMutationCache",summary:"Wipe the entire mutation cache \u2014 all recorded mutations and their states.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"setOnline",summary:"Flip TanStack's onlineManager to simulate offline mode app-wide (the WiFi toggle).",params:{type:"object",properties:{online:{type:"boolean",description:"true = online (normal), false = simulate offline: React Query pauses fetches and queues mutations app-wide."}},required:["online"],additionalProperties:false},effect:"write",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]}],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.'},{toolId:"events",title:"Events",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.',actions:[{action:"exportEvents",summary:"Read the recorded cross-tool timeline as a formatted string (markdown/json/plaintext/mermaid), optionally filtered by source and status.",params:{type:"object",properties:{preset:{type:"string",enum:["llm","bugReport","json","errors","minimal","mermaid"],description:"Named Copy-Settings preset used as the BASE (settings is merged over it). llm = compact markdown, no payloads. bugReport = markdown + full payloads (10KB cap). json = machine-readable, unlimited payloads. errors = failed events only + payloads. minimal = one plaintext line per event. mermaid = sequence diagram. Unknown names silently fall back to the compact default."},settings:{type:"object",description:"Partial EventsCopySettings merged over the preset/default. Every field optional.",properties:{filterSources:{type:"array",items:{type:"string",enum:["storage-async","storage-mmkv","redux","network","react-query","react-query-query","react-query-mutation","route","zustand","jotai","render"]},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."},filterMode:{type:"string",enum:["all","errors","success","pending"],description:"Status filter. 'errors' keeps only status==='error' (HTTP >=400 or thrown, rejected redux thunks, query/mutation errors). Applied AFTER limit."},includeEventData:{type:"boolean",description:"Include each event's raw originalEvent payload. HEAVY (network bodies, redux state trees). Default false."},dataSizeThreshold:{type:"number",enum:[1,5,10,50,-1],description:"KB cap per embedded payload when includeEventData is true; -1 = unlimited. Default 5."},format:{type:"string",enum:["markdown","json","plaintext","mermaid"],description:"Output format. Note the on-wire value is 'plaintext', not 'text'. Default markdown."},timestampFormat:{type:"string",enum:["relative","absolute","both"],description:"Default relative (+120ms from the first event)."},compactMode:{type:"boolean",description:"One line per event, no JSON blocks. Default false."},includeSource:{type:"boolean",description:"Show the source label (Network/Redux/Query/...). Default true."},includeStatus:{type:"boolean",description:"Show the status icon. Default true."},includeTitle:{type:"boolean",description:"Show the event title (URL, action type, query key, atom label). Default true."},includeSubtitle:{type:"boolean",description:"Show the secondary line (status code, duration, changed keys). Default true."},includeCorrelation:{type:"boolean",description:"Group correlated events (e.g. rq-query-<hash> start/settle pairs). Default true."},includeDuration:{type:"boolean",description:"Show per-event duration. Default true."},includeSummaryHeader:{type:"boolean",description:"Prepend the counts-by-status summary block. Default true."},includeTotalDuration:{type:"boolean",description:"Show total elapsed time across the window. Default true."},smartJsonParsing:{type:"boolean",description:"Parse JSON-looking strings so payloads aren't double-escaped. Default true."},reduxChangedOnly:{type:"boolean",description:"For redux, print only changed slices instead of whole state. Default true."},showStorageDiff:{type:"boolean",description:"For storage writes, show prevValue -> value diff. Default true."},stripVerboseFields:{type:"boolean",description:"Drop noise fields (imageUrl, thumbnail, icon, description...) from payloads. Default true."}},additionalProperties:false},limit:{type:"number",description:"Cap to the most-recent N events BEFORE filtering and formatting. Omitted or <=0 means the whole buffer (max 200). Because it runs before filterSources/filterMode, a small limit plus a source filter can return nothing."}},additionalProperties:false},effect:"read",release:"works",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.
1
+ "use client";var bn=(()=>{try{let e=globalThis.Capacitor;if(e&&typeof e.isNativePlatform=="function"&&e.isNativePlatform())return e.DEBUG===true}catch{}return process.env.NODE_ENV!=="production"})();typeof window<"u"&&typeof globalThis.global>"u"&&(globalThis.global=globalThis);var He=[{toolId:"env",title:"Env",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).',actions:[{action:"getSnapshot",summary:"Read this build's environment: every EXPO_PUBLIC_-style variable it was compiled with, plus the app's required-env-var checks.",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). It also returns `checks`: the tool\'s own verdict for each required variable (required_present, required_missing, required_wrong_type, required_wrong_value). Answer "is anything missing or wrong?" from `checks`: every env value is a string, so a number-typed variable like "1856" passes.',params:{type:"object",properties:{},additionalProperties:false},effect:"read",release:"works"}],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."},{toolId:"console",title:"Console",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\xB7level\xB7message 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).`,actions:[{action:"getSnapshot",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.",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".',params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N entries after filtering. Default 40."},level:{type:"string",enum:["verbose","debug","info","log","warn","error"],description:"Minimum severity, e.g. 'warn' for warnings and errors only."},pattern:{type:"string",description:"Case-insensitive substring the message must contain."}},additionalProperties:false},effect:"read",release:"works"},{action:"clearEntries",summary:"Permanently wipe every captured console entry on the device, including [FATAL] crash records and the preserved-log buffer on disk.",params:{type:"object",properties:{},additionalProperties:false,description:"No parameters. Any params object is ignored by the handler."},effect:"destructive",release:"works",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.",requires:["@buoy-gg/console installed in the app",'<FloatingDevTools /> rendered \u2014 it auto-mounts ConsoleRoot (capture) and registers consoleSyncAdapter as the "console" capability',"external-sync connection to the broker (:42831)","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"]}],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."},{toolId:"sentry",title:"Sentry",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.`,actions:[{action:"getSnapshot",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?".',params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N envelopes after filtering. Default 10."},type:{type:"string",description:"Only envelopes carrying an item of this Sentry type, e.g. 'event' (errors), 'transaction', 'log', 'session'."},pattern:{type:"string",description:"Case-insensitive substring an item summary must contain."}},additionalProperties:false},effect:"read",release:"works",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". It also returns `drops`: events the SDK discarded before sending, each with its `reason` (before_send = the app\'s own beforeSend returned null, sample_rate, event_processor, client_report\u2026) and a `detail` naming the event. Read it to answer "why isn\'t this in Sentry?".'},{action:"clearEnvelopes",summary:"Wipe the captured envelope list on the device. Does not affect anything already delivered to Sentry's servers.",params:{type:"object",properties:{},additionalProperties:false,description:"No parameters."},effect:"destructive",release:"works",description:"Empties the on-device capture buffer and returns {cleared:true} even if it was already empty. There is no undo; read what you need first. Legitimate use is narrow: zeroing the list right before reproducing a bug so the next read contains only that repro's traffic."}],unavailableWhen:"@buoy-gg/sentry is not installed in the app, or the app does not use @sentry/react-native at all. Separately, in a release build the sync transport is off unless the app passes externalSync={{enableInRelease:true}} with a real Pro license, in which case no action reaches the device at all."},{toolId:"jotai",title:"Jotai",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.",actions:[{action:"getSnapshot",summary:"Read the Jotai change log: the app's recent atom changes, newest first, each with the atom and a preview of its new value.",description:"Returns `{ changes: [{ id, at, atom, changed, summary, value }], total, returned }` newest first. `value` is a short preview; getChangeDetail(id) has the full prevValue and nextValue. Pass `limit` (default 20) and `atom` to list one atom's changes.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N changes. Default 20."},atom:{type:"string",description:"Only changes to atoms whose label contains this text."}},additionalProperties:false},effect:"read",release:"works"},{action:"listAtoms",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.",params:{type:"object",properties:{includeValues:{type:"boolean",description:"Include each atom's full current value (HEAVY, no size cap). Default false."},limit:{type:"number",description:"Return only the first N registered atoms. Applied only when > 0; otherwise all atoms are returned."}},additionalProperties:false},effect:"read",release:"works",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.",requires:["@buoy-gg/jotai installed in the app","watchAtoms(store, atoms) or watchDefaultStoreAtoms(atoms) called at app startup"]},{action:"getAtomValue",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.",params:{type:"object",properties:{label:{type:"string",description:'Atom label exactly as registered \u2014 the object key in watchAtoms(store, { countAtom }), e.g. "countAtom". Get exact labels from listAtoms.'},path:{type:"string",description:"Return only the value at this path instead of the whole atom value."}},required:["label"],additionalProperties:false},effect:"read",release:"works",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.",requires:["The label must already be registered via watchAtoms \u2014 call listAtoms first for exact labels"]},{action:"getChangeDetail",summary:"Fetch the real prevValue/nextValue behind one recorded atom change.",params:{type:"object",properties:{id:{type:"string",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'}.`}},required:["id"],additionalProperties:false},effect:"read",release:"works",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}.`,requires:["A change id from the jotai snapshot or get_events sources:['jotai']"]},{action:"setAtom",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.',params:{type:"object",properties:{label:{type:"string",description:"Atom label from listAtoms; must be writable:true. The wire param is `label` (the MCP tool calls it `atom`)."},value:{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."},merge:{type:"boolean",description:"Merge `value` into the atom's current value instead of replacing it, under the typed-edit rule. Default false."},path:{type:"string",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."},force:{type:"boolean",description:"Bypass the typed-edit safety and write raw. This is what crashes screens; use ONLY to deliberately replace the whole shape."}},required:["label","value"],additionalProperties:false},effect:"write",release:"works",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.',requires:["listAtoms reports writable:true for this label (atom has a write fn AND the store exposes set())"]},{action:"clearEvents",summary:"Wipe the recorded atom-change timeline and reset every atom's change count to 0.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."}],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).`},{toolId:"route-events",title:"Routes",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.',actions:[{action:"getSnapshot",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".',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.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N navigation events. Default 15."}},additionalProperties:false},effect:"read",release:"works"},{action:"getCurrentRoute",summary:"Just the current route \u2014 {path, params, at, source} \u2014 without the sitemap or history. The cheap way to confirm a navigation landed.",params:{type:"object",properties:{},additionalProperties:false,description:"No parameters."},effect:"read",release:"works",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.'},{action:"navigate",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.",params:{type:"object",properties:{path:{type:"string",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."},replace:{type:"boolean",description:"Replace the current screen instead of pushing it on top. Default false. Use true when resetting the app to a base route."}},required:["path"],additionalProperties:false},effect:"write",release:"works",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.",requires:["expo-router installed and initialized in the app","<FloatingDevTools> mounted (adapter registered)"]},{action:"stackGoBack",summary:"Pop one screen off the navigation stack (the hardware/back-gesture equivalent).",params:{type:"object",properties:{},additionalProperties:false},effect:"write",release:"works",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.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"stackNavigateToIndex",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.",params:{type:"object",properties:{index:{type:"number",description:"0-based index into the synced `stack` array (top-most last; 0 = root). Must be a number \u2014 strings throw."}},required:["index"],additionalProperties:false},effect:"write",release:"works",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 />.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"stackPopToIndex",summary:"Rewind the navigation stack down to a 0-based index, discarding every screen above it.",params:{type:"object",properties:{index:{type:"number",description:"0-based index into the synced `stack` array to rewind DOWN to (top-most last; 0 = root). Screens above it are popped and their state discarded."}},required:["index"],additionalProperties:false},effect:"destructive",release:"works",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'.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"stackPopToTop",summary:"Reset the navigation stack to its root screen, discarding every screen above it.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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 />.",requires:["<RouteTracker /> mounted inside the navigation tree"]},{action:"clearEvents",summary:"Wipe the recorded route-change history buffer (max 500 events) on the device.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.",requires:["<RouteTracker /> mounted inside the navigation tree (for events to exist at all)"],armsCapture:true}],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.'},{toolId:"debug-borders",title:"Debug Borders",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.',actions:[{action:"cycleMode",summary:"Advance the border overlay one step: off -> borders -> labels -> off (Pro), or off -> borders -> off without a Pro license.",params:{type:"object",properties:{},additionalProperties:false},effect:"write",release:"noop",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.`,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.',requires:["@buoy-gg/debug-borders installed in the app","<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root",'a Pro license (@buoy-gg/license isPro()) for the cycle to include "labels"']},{action:"setMode",summary:'Set the border overlay directly to "off", "borders", or "labels". "labels" is Pro-only and silently does nothing on a free device.',params:{type:"object",properties:{mode:{type:"string",enum:["off","borders","labels"],description:"off = clear the overlay; borders = outline every native view, colored by depth; labels = Pro-only, outline + tappable chip for views that have a testID or accessibilityLabel. Required; not validated by the adapter, so any other string is stored as-is and leaves the overlay in a broken state."}},required:["mode"],additionalProperties:false},effect:"write",release:"noop",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".',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.",requires:["@buoy-gg/debug-borders installed in the app","<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root",'a Pro license (@buoy-gg/license isPro()) for mode:"labels" to take effect']}],unavailableWhen:"The app doesn't have `@buoy-gg/debug-borders` installed (autoExternalSync's optional require fails at packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:125, so \"debug-borders\" never appears in the device's tool list). It is also present-but-inert when the app renders `<FloatingDevTools headless />`: the headless branch returns before the overlay at FloatingDevTools.tsx:785, so the actions succeed and change `mode` with no overlay mounted to draw anything."},{toolId:"zustand",title:"Zustand",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.",actions:[{action:"getSnapshot",summary:`Read the Zustand change log: the app's recent store changes, newest first, each with the store, the keys it changed and what it set. Answers "what changed in my cart?" and finds the change to undo.`,description:"Returns `{ changes: [{ id, at, store, changed, summary, partial }], total, returned }` newest first. `partial` is what that setState sent, cut to 400 characters. For an undo, getChangeDetail(id) has that change's full prevState: write the keys it changed back from prevState with setState. Changes are recorded from app start, whether or not the Events tool is on. Pass `limit` (default 20) and `store` to list one store's changes.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N changes. Default 20."},store:{type:"string",description:"Only changes to stores whose name contains this text."}},additionalProperties:false},effect:"read",release:"works"},{action:"listStores",summary:"List every registered Zustand store with its name, change count, persistence, and (by default) just its top-level keys.",params:{type:"object",properties:{includeValues:{type:"boolean",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."},limit:{type:"number",description:"Return only the first N stores, in registration order. Ignored unless greater than 0; default is all stores."}},additionalProperties:false},effect:"read",release:"works",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.",requires:["@buoy-gg/zustand installed and the zustand tool registered with FloatingDevTools","the app calls watchStores({...}) or wraps stores with buoyDevTools() \u2014 otherwise the registry is empty"]},{action:"getStoreState",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.',params:{type:"object",properties:{storeName:{type:"string",description:'Registered store name exactly as listStores reports it (e.g. "counterStore", "authStore", "cartStore"). Case-sensitive; no fuzzy matching.'},path:{type:"string",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."}},required:["storeName"],additionalProperties:false},effect:"read",release:"works",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.',requires:["the store must already be registered \u2014 get the exact name from listStores"]},{action:"getChangeDetail",summary:"Fetch the real prevState / nextState / partial for one recorded state change, by change id.",params:{type:"object",properties:{id:{type:"string",description:'Change id from a zustand change row, format `<epochMs>-<counter>` (e.g. "1761580000123-42"). Only the most recent 200 changes are retained.'}},required:["id"],additionalProperties:false},effect:"read",release:"works",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.',requires:["a change id from the zustand change log (the tool snapshot, or get_events sources:['zustand'])"]},{action:"setState",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.`,params:{type:"object",properties:{storeName:{type:"string",description:'Registered store name exactly as listStores reports it (e.g. "counterStore"). Unknown names are rejected with ok:false.'},state:{type:"object",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}).`,additionalProperties:true},replace:{type:"boolean",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.`,default:false},force:{type:"boolean",description:"Bypass the typed-edit safety on a merge and write raw. Default false \u2014 a violating merge is refused."},path:{type:"string",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.'},value:{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."}},required:["storeName"],additionalProperties:false},effect:"destructive",release:"works",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.",requires:["the store must be registered \u2014 get the exact name from listStores","the store's registered setState handle: watchStores registers the raw setState, buoyDevTools registers its instrumented set (both apply the write)"]},{action:"rehydrate",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.",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.",params:{type:"object",properties:{storeName:{type:"string",description:"Registered store name exactly as listStores reports it. It must be a persisted store (listStores shows isPersisted and persistName)."}},required:["storeName"],additionalProperties:false},effect:"write",release:"works",requires:["the store uses zustand's persist middleware and was registered with watchStores/the Buoy middleware"]},{action:"clearEvents",summary:"Wipe the recorded Zustand state-change timeline and reset every store's change count to 0.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.`}],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.`},{toolId:"redux",title:"Redux",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.",actions:[{action:"getSnapshot",summary:"Read the Redux action log: the app's recent actions, newest first, each with its real type, payload and meta. Shows the action shapes this app really uses before you dispatch one.",description:"Returns `{ actions: [{ id, at, type, payload, meta, error, changed }], total, returned }` newest first. `payload`, `meta` and `error` are cut to 400 characters; getActionDetail(id) has the full action and its before/after state. Redux Toolkit names actions `<slice>/<reducer>`, and async thunks add `/pending`, `/fulfilled` or `/rejected` with the thunk's argument in `meta.arg`. Copy a real action's shape when you dispatch one. Pass `limit` (default 20) and `type` to list only actions whose type contains that text.",params:{type:"object",properties:{limit:{type:"number",description:"Most-recent N actions. Default 20."},type:{type:"string",description:"Only actions whose type contains this text."}},additionalProperties:false},effect:"read",release:"works"},{action:"getState",summary:"Read the app's CURRENT Redux state \u2014 top-level slice names by default, full state tree with includeValues:true.",params:{type:"object",properties:{includeValues:{type:"boolean",description:"Include the full current state tree (HEAVY \u2014 the whole store is serialized over the wire). Default false: slice names only."}},additionalProperties:false},effect:"read",release:"works",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.",requires:["react-redux installed in the app","a Redux <Provider> above <FloatingDevTools /> \u2014 or an explicit registerReduxStore(store) from @buoy-gg/redux"]},{action:"getActionDetail",summary:"Fetch one recorded action's real prevState/nextState trees, payload, meta and error by action id.",params:{type:"object",properties:{id:{type:"string",description:'Action id from the redux action log, formatted "<epochMillis>-<counter>" (e.g. "1724612345678-42"). Omitting it returns {found:false, reason:"missing id"}.'}},required:["id"],additionalProperties:false},effect:"read",release:"works",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.`,requires:["an instrumented store that has already recorded the action (log holds the 200 most recent actions; raw state trees only the 25 most recent)"]},{action:"dispatch",summary:"Dispatch a plain action object into the app's live Redux store \u2014 really changes app state.",params:{type:"object",properties:{action:{type:"object",description:"The plain Redux action object, dispatched as-is. Extra keys (payload, meta, error) are passed straight through.",properties:{type:{type:"string",description:'Action type, e.g. "counter/increment" or "auth/logout".'},payload:{description:"Optional action payload (any JSON)."}},required:["type"]}},required:["action"],additionalProperties:false},effect:"destructive",release:"works",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. Use dispatch when the app's own action does what you want (copy its shape from getSnapshot). When no action you can see does it, such as removing one entry, use redux.setState on the value instead of guessing action names.`,requires:["react-redux installed in the app","a Redux <Provider> above <FloatingDevTools /> \u2014 or an explicit registerReduxStore(store)","the app must actually handle the action type; an unknown type dispatches successfully and changes nothing"]},{action:"setState",summary:"Set or remove ONE value in the live Redux store by path, without knowing the app's action names.",description:'Changes the state directly through Buoy\'s time-travel reducer: Buoy copies the current state, sets `value` at `path` (or deletes it with `remove:true`) and jumps the store to the result. Screens update at once. `path` is dot-separated from the root, slice first: "offers.added.<id>" or "cart.items.0.qty". Returns `{ ok, path, previous }`; `previous` is what was there, so you can put it back. Reach for it when the app has no action you can see for the change (getSnapshot lists the real ones), like removing one added item. It needs the store to be wrapped by Buoy; otherwise it refuses and you dispatch the app\'s own action instead.',params:{type:"object",properties:{path:{type:"string",description:'Dot path from the root, slice first, e.g. "offers.added.ab14".'},value:{description:"The new value. null is a real value."},remove:{type:"boolean",description:"Delete the key at path instead of setting it."}},required:["path"],additionalProperties:false},effect:"destructive",release:"works"},{action:"clearEvents",summary:"Wipe the recorded Redux action log on the device (state trees and all). Irreversible.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."}],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.'},{toolId:"impersonate",title:"Impersonate",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.",actions:[{action:"searchUsers",summary:"Search the app's own user directory and get back User objects you can feed to startImpersonation.",params:{type:"object",properties:{query:{type:"string",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'."}},required:[],additionalProperties:false},effect:"read",release:"works",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().`,requires:["createImpersonateTool({ onSearchUsers }) called by the host app"]},{action:"startImpersonation",summary:"Begin impersonating a user \u2014 every subsequent fetch/XHR carries the impersonation header, and app caches are wiped per dataNukeSettings.",params:{type:"object",properties:{user:{type:"object",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.",properties:{id:{type:"string",description:"Required. This exact string becomes the impersonation header value (default header x-impersonate-user-id)."},displayName:{type:"string",description:"Shown on the user card and banner; falls back to email, then id."},email:{type:"string"},avatarUrl:{type:"string"},metadata:{type:"object",description:"Free-form key/value shown on the user card; a string metadata.role is rendered as a badge."}},required:["id"]}},required:["user"],additionalProperties:false},effect:"destructive",release:"works",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."},{action:"stopImpersonation",summary:"End impersonation and go back to the real logged-in account; also runs the data nuke.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."},{action:"pauseImpersonation",summary:"Temporarily stop injecting the header while keeping the session and current user.",params:{type:"object",properties:{},additionalProperties:false},effect:"write",release:"works",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."},{action:"resumeImpersonation",summary:"Resume header injection for the already-selected user after a pause.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."},{action:"updateSettings",summary:"Change the header key, URL ignore patterns, banner visibility, or which caches get wiped on every user switch.",params:{type:"object",properties:{settings:{type:"object",description:"Required wrapper. Partial patch \u2014 omitted keys keep their current value.",properties:{headerKey:{type:"string",description:"HTTP header name injected on every request. Default 'x-impersonate-user-id'."},ignorePatterns:{type:"array",items:{type:"string"},description:"Regex SOURCE strings (e.g. '/health$', 'analytics\\\\.example\\\\.com') for URLs that must not get the header. Replaces the whole list; Metro/dev URLs are always excluded regardless."},showBanner:{type:"boolean",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."},dataNukeSettings:{type:"object",description:"Which stores are cleared on every start/stop of impersonation.",properties:{reactQuery:{type:"boolean",description:"Clear the react-query cache. Default true."},redux:{type:"boolean",description:"Reset redux state. Default true."},asyncStorage:{type:"boolean",description:"DANGEROUS: wipe app AsyncStorage on every switch. Default false."},mmkv:{type:"boolean",description:"DANGEROUS: wipe app MMKV storage on every switch. Default false."}},required:[]}},required:[]}},required:["settings"],additionalProperties:false},effect:"write",release:"works",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."},{action:"removeFromHistory",summary:"Delete one user from the recently-impersonated history list.",params:{type:"object",properties:{userId:{type:"string",description:"The User.id to drop, exactly as it appears in the snapshot's history[].user.id. Required."}},required:["userId"],additionalProperties:false},effect:"destructive",release:"works",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."},{action:"clearHistory",summary:"Wipe the entire recently-impersonated user list.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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."}],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()".`},{toolId:"query",title:"React Query",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'].`,actions:[{action:"listQueries",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.",params:{type:"object",properties:{includeData:{type:"boolean",description:"Include each query's full cached data payload verbatim and UNCAPPED. Default false. Heavy \u2014 one cached list can be megabytes."},staleOnly:{type:"boolean",description:"Only queries whose isStale() is true. Default false."},limit:{type:"number",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."}},additionalProperties:false},effect:"read",release:"works",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. `refetchEveryMs` appears on a query a mounted screen refetches on a timer: any cache edit, error or loading pin on it is replaced within that many ms, so use a network override for anything that must last \u2014 and it answers \"is the app calling this API over and over?\".",requires:["QueryClientProvider above <FloatingDevTools/>","@tanstack/react-query v5"]},{action:"getQueryData",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.',params:{type:"object",properties:{queryHash:{type:"string",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.`},path:{type:"string",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."}},required:["queryHash"],additionalProperties:false},effect:"read",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"refetch",summary:"Force one query to re-run its queryFn right now (a real network request), by queryHash.",params:{type:"object",properties:{queryHash:{type:"string",description:`Target query's hash from listQueries, e.g. '["todos",{"page":1}]'. Required \u2014 an unknown hash throws.`}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",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.`,requires:["QueryClientProvider above <FloatingDevTools/>","the query must have a queryFn to actually fetch"]},{action:"invalidate",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`.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries. Note the prefix-match blast radius described above."}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",description:`Looks the query up by hash, then passes the Query itself to queryClient.invalidateQueries() as the filter. Because the filter carries only queryKey with no exact:true, matching is a NON-EXACT prefix match: invalidating '[\\"todos\\"]' also invalidates '[\\"todos\\",{\\"page\\":1}]' and any other key that extends it. Mounted (observed) queries refetch immediately, so this can fire real API calls; inactive ones just go stale. Prefer this over refetch when you want the app's own screens to re-render with fresh data. THROWS for an unknown hash. Takes the query's \`queryHash\`.`,requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"reset",summary:"Reset a query to its initial state \u2014 DISCARDS its cached data, then refetches if it is active. Takes the query's `queryHash`.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries. Prefix-matches, so it can reset sibling/nested keys too."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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`.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"remove",summary:"Delete a query from the cache entirely \u2014 the entry, its data, and its state are gone. Takes the query's `queryHash`.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries. Prefix-matches \u2014 it can delete more than the one row you picked."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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`.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"setQueryData",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.',params:{type:"object",properties:{queryKey:{type:"array",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.`},data:{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."},queryHash:{type:"string",description:"Declared by the adapter's param type but never read by the handler \u2014 passing it has no effect."},merge:{type:"boolean",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."},force:{type:"boolean",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."},path:{type:"string",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.'},value:{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."}},required:["queryKey"],additionalProperties:false},effect:"write",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"triggerError",summary:"Force a query into error status with a fake Error, to exercise the app's error UI. The QA move for 'show me this screen's error state'; undo with restoreError.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries."}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"restoreError",summary:"Undo triggerError \u2014 clears the fake error. Implemented as resetQueries, so it also discards cached data.",params:{type:"object",properties:{queryHash:{type:"string",description:"The hash you passed to triggerError."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"triggerLoading",summary:"Pin a query in a permanent loading/suspense state to exercise skeletons and spinners. MUST be undone. The QA move for 'show me this screen's loading state'; undo with restoreLoading.",params:{type:"object",properties:{queryHash:{type:"string",description:"Target query's hash from listQueries."}},required:["queryHash"],additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"restoreLoading",summary:"Undo triggerLoading \u2014 cancel the never-resolving fetch and refetch with the query's real options.",params:{type:"object",properties:{queryHash:{type:"string",description:"The hash you passed to triggerLoading."}},required:["queryHash"],additionalProperties:false},effect:"write",release:"works",description:"Silently cancels the fake fetch, restores the previous state with fetchStatus:'idle' and fetchMeta cleared, then re-runs query.fetch(__previousQueryOptions) if those stashed options exist (fetch rejections are swallowed). If triggerLoading was never called for this query there is nothing stashed, so it just cancels any in-flight fetch and idles the query. THROWS for an unknown hash.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"clearQueryCache",summary:"Wipe the ENTIRE query cache \u2014 every query on the device, not just one.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"clearMutationCache",summary:"Wipe the entire mutation cache \u2014 all recorded mutations and their states.",params:{type:"object",properties:{},additionalProperties:false},effect:"destructive",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]},{action:"setOnline",summary:"Flip TanStack's onlineManager to simulate offline mode app-wide (the WiFi toggle).",params:{type:"object",properties:{online:{type:"boolean",description:"true = online (normal), false = simulate offline: React Query pauses fetches and queues mutations app-wide."}},required:["online"],additionalProperties:false},effect:"write",release:"works",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.",requires:["QueryClientProvider above <FloatingDevTools/>"]}],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.'},{toolId:"events",title:"Events",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.',actions:[{action:"exportEvents",summary:"Read the recorded cross-tool timeline as a formatted string (markdown/json/plaintext/mermaid), optionally filtered by source and status.",params:{type:"object",properties:{preset:{type:"string",enum:["llm","bugReport","json","errors","minimal","mermaid"],description:"Named Copy-Settings preset used as the BASE (settings is merged over it). llm = compact markdown, no payloads. bugReport = markdown + full payloads (10KB cap). json = machine-readable, unlimited payloads. errors = failed events only + payloads. minimal = one plaintext line per event. mermaid = sequence diagram. Unknown names silently fall back to the compact default."},settings:{type:"object",description:"Partial EventsCopySettings merged over the preset/default. Every field optional.",properties:{filterSources:{type:"array",items:{type:"string",enum:["storage-async","storage-mmkv","redux","network","react-query","react-query-query","react-query-mutation","route","zustand","jotai","render"]},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."},filterMode:{type:"string",enum:["all","errors","success","pending"],description:"Status filter. 'errors' keeps only status==='error' (HTTP >=400 or thrown, rejected redux thunks, query/mutation errors). Applied AFTER limit."},includeEventData:{type:"boolean",description:"Include each event's raw originalEvent payload. HEAVY (network bodies, redux state trees). Default false."},dataSizeThreshold:{type:"number",enum:[1,5,10,50,-1],description:"KB cap per embedded payload when includeEventData is true; -1 = unlimited. Default 5."},format:{type:"string",enum:["markdown","json","plaintext","mermaid"],description:"Output format. Note the on-wire value is 'plaintext', not 'text'. Default markdown."},timestampFormat:{type:"string",enum:["relative","absolute","both"],description:"Default relative (+120ms from the first event)."},compactMode:{type:"boolean",description:"One line per event, no JSON blocks. Default false."},includeSource:{type:"boolean",description:"Show the source label (Network/Redux/Query/...). Default true."},includeStatus:{type:"boolean",description:"Show the status icon. Default true."},includeTitle:{type:"boolean",description:"Show the event title (URL, action type, query key, atom label). Default true."},includeSubtitle:{type:"boolean",description:"Show the secondary line (status code, duration, changed keys). Default true."},includeCorrelation:{type:"boolean",description:"Group correlated events (e.g. rq-query-<hash> start/settle pairs). Default true."},includeDuration:{type:"boolean",description:"Show per-event duration. Default true."},includeSummaryHeader:{type:"boolean",description:"Prepend the counts-by-status summary block. Default true."},includeTotalDuration:{type:"boolean",description:"Show total elapsed time across the window. Default true."},smartJsonParsing:{type:"boolean",description:"Parse JSON-looking strings so payloads aren't double-escaped. Default true."},reduxChangedOnly:{type:"boolean",description:"For redux, print only changed slices instead of whole state. Default true."},showStorageDiff:{type:"boolean",description:"For storage writes, show prevValue -> value diff. Default true."},stripVerboseFields:{type:"boolean",description:"Drop noise fields (imageUrl, thumbnail, icon, description...) from payloads. Default true."}},additionalProperties:false},limit:{type:"number",description:"Cap to the most-recent N events BEFORE filtering and formatting. Omitted or <=0 means the whole buffer (max 200). Because it runs before filterSources/filterMode, a small limit plus a source filter can return nothing."}},additionalProperties:false},effect:"read",release:"works",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.
2
2
 
3
3
  THREE traps a caller must know:
4
4
  (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.
@@ -45,7 +45,7 @@ ${t}`,inputSchema:{type:"object",properties:{action:{type:"string",enum:[...be],
45
45
 
46
46
  [truncated \u2014 ${r} characters total; ref ${t}. Call ${ua} {"ref":"${t}"} to see its shape, then {"ref":"${t}","path":"\u2026"} or {"ref":"${t}","pattern":"\u2026"} to read the part you need.]`:`
47
47
 
48
- [truncated \u2014 ${r} characters total. Ask for a narrower slice if you need more.]`}function li(e,t){let r=e.toLocaleString("en-US");return t?`[earlier result \u2014 ${r} characters, already read; ref ${t}. ${ua} {"ref":"${t}",\u2026} re-reads exactly what you saw then; calling the tool again gives the CURRENT value.]`:`[earlier result \u2014 ${r} characters, already read. Ask again if you need it.]`}var Vt=class{constructor(){this.ratio=4;this.samples=0}get charsPerToken(){return this.ratio}get sampleCount(){return this.samples}observe(e,t){if(!(e>0)||!(t>=500))return;let r=Math.min(5,Math.max(2.5,e/t));this.ratio=this.samples===0?r:this.ratio+.3*(r-this.ratio),this.samples+=1}chars(e){return Math.floor(e*this.ratio)}reset(){this.ratio=4,this.samples=0}};function di(e,t){return t==="anthropic"?e.input+(e.cacheRead??0)+(e.cacheWrite??0):e.input}var Wt=37500,ca=1e5,ui=Wt*4,bn=ca*4,ci=4,hi=400;function oe(e){try{return JSON.stringify(e).length}catch{return 0}}function wt(e,t=true){let r=e.length;if(t){r=-1;for(let l=e.length-1;l>=0;l--)if(e[l].role==="tool-results"){r=l;break}if(r<=0)return e}let a=new Set;for(let l=0;l<e.length;l++){let i=e[l];if(!(i.role!=="assistant"||!i.toolCalls?.length)&&i.toolCalls.some(n=>pi.test(JSON.stringify(n.input??{})+n.name))){for(let n=l-1;n>=0&&n>=l-3;n--)if(e[n].role==="tool-results"){a.add(n);break}}}let o=false,s=e.map((l,i)=>{if(l.role!=="tool-results"||i>=r||a.has(i))return l;let n=false,d=l.results.map(u=>u.isError||u.content.length<hi||u.content.startsWith("[earlier result")?u:(n=true,{...u,content:li(u.content.length,u.ref)}));return n?(o=true,{...l,results:d}):l});return o?s:e}var pi=/set|write|dispatch|navigate|tap|override|restore|delete|remove|clear|save|run|impersonat|reload/i;function ha(e,t){let r=[];for(let a of e){(a.role==="user"||r.length===0)&&r.push({messages:[],size:0,pinned:false});let o=r[r.length-1];o.messages.push(a),o.size+=oe(a),t?.size&&a.role==="assistant"&&a.toolCalls?.some(s=>t.has(s.id))&&(o.pinned=true)}return r}function pa(e,t,r,a){let o=[...e],s=o.reduce((d,u)=>d+u.size,0),l=0,i=0,n=()=>o.reduce((d,u)=>d+u.messages.length,0);for(;o.length>0&&s+r>t&&n()>a;){let d=o.findIndex(u=>!u.pinned);d<0&&(d=0,i+=1),s-=o[d].size,o.splice(d,1),l+=1}return{kept:o.flatMap(d=>d.messages),droppedRounds:l,droppedPinned:i}}function ma(e,t=4,r){let a=Math.floor(Wt*t),o=0;for(let u of e)o+=oe(u);if(o<=a)return{messages:e,droppedRounds:0,droppedPinned:0};let s=wt(e),l=0;for(let u of s)l+=oe(u);if(l<=a)return{messages:s,droppedRounds:0,droppedPinned:0};let{kept:i,droppedRounds:n,droppedPinned:d}=pa(ha(s,r),a,0,ci);return{messages:i,droppedRounds:n,droppedPinned:d}}function ya(e,t,r=ui,a=4,o){let s=Math.min(r,Math.floor(ca*a)-t),l=0;for(let E of e)l+=oe(E);if(l<=s)return{messages:e,droppedRounds:0,droppedPinned:0};let i=-1;for(let E=e.length-1;E>=0;E--)if(e[E].role==="user"){i=E;break}if(i<=0)return{messages:wt(e),droppedRounds:0,droppedPinned:0};let n=e.slice(0,i),d=e.slice(i),u=0;for(let E of d)u+=oe(E);let h=wt(n,false),g=0;for(let E of h)g+=oe(E);if(g+u<=s)return{messages:[...h,...d],droppedRounds:0,droppedPinned:0};let{kept:v,droppedRounds:m,droppedPinned:w}=pa(ha(h,o),s,u,0);g=v.reduce((E,M)=>E+oe(M),0);let y=v.length===0&&g+u>s?wt(d):d;return{messages:[...v,...y],droppedRounds:m,droppedPinned:w}}function mi(e,t){if(!e)return{ok:false,error:"There is no evidence store in this session, so earlier results cannot be re-read. Call the tool again for the current value."};let r=e.get(t.ref);if(!r)return e.wasEvicted(t.ref)?{ok:false,error:`${t.ref} was kept but has since been evicted to make room (the store holds the most recent ~4 MB of results). Call the tool again for the current value.`}:{ok:false,error:`No result with ref ${t.ref}. Refs look like ev_12 and appear in [truncated \u2014 \u2026] and [earlier result \u2014 \u2026] markers.`};let a={ref:r.ref,from:`${r.toolId}.${r.action}`,capturedAt:new Date(r.capturedAt).toISOString(),totalChars:r.text.length},o,s=true;try{o=JSON.parse(r.text)}catch{s=false}if(t.pattern!==void 0&&t.pattern!=="")return{...a,...fi(s?JSON.stringify(o,null,1):r.text,t.pattern,t.contextLines??2)};if(!s){let n=t.slice?r.text.slice(t.slice[0],t.slice[1]):r.text;return{...a,text:n}}let l=o,i="(root)";if(t.path){let n=gi(o,t.path);if(!n.ok)return{...a,ok:false,error:n.error};l=n.value,i=t.path}if(t.slice){let[n,d]=t.slice;return Array.isArray(l)?{...a,at:i,sliced:[n,d],of:l.length,value:l.slice(n,d)}:typeof l=="string"?{...a,at:i,sliced:[n,d],of:l.length,value:l.slice(n,d)}:{...a,ok:false,error:`slice applies to an array or a string; the value at ${i} is ${ga(l)}.`}}return t.path?{...a,at:i,value:l}:{...a,shape:yi(o),hint:'Pass path (e.g. "stats.0") or pattern to read a part.'}}function ga(e){return e===null?"null":Array.isArray(e)?"array":typeof e}function yi(e){if(Array.isArray(e))return{type:"array",length:e.length,first:e.length?zt(e[0]):void 0};if(e&&typeof e=="object"){let t=Object.keys(e),r=t.slice(0,60),a={};for(let o of r)a[o]=zt(e[o]);return t.length>r.length?{...a,"\u2026":`${t.length-r.length} more keys`}:a}return zt(e)}function zt(e){if(e==null)return String(e);if(Array.isArray(e))return`array(${e.length})`;if(typeof e=="object"){let t=Object.keys(e),r=JSON.stringify(e).length;return`object{${t.slice(0,6).join(", ")}${t.length>6?", \u2026":""}} ~${r.toLocaleString("en-US")} chars`}return typeof e=="string"?e.length>40?`string(${e.length})`:JSON.stringify(e):`${typeof e}: ${String(e)}`}function gi(e,t){let r=t.replace(/^\/+/,"").split(/[./]/).filter(s=>s.length>0),a=e,o=[];for(let s of r){if(a===null||typeof a!="object")return{ok:false,error:`Cannot go into ${o.join(".")||"(root)"} \u2014 it is ${ga(a)}.`};if(Array.isArray(a)){let l=Number(s);if(!Number.isInteger(l)||l<0||l>=a.length)return{ok:false,error:`${o.join(".")||"(root)"} is an array of ${a.length}; "${s}" is not an index in it.`};a=a[l]}else{let l=a;if(!(s in l))return{ok:false,error:`No field "${s}" at ${o.join(".")||"(root)"}. It has: ${Object.keys(l).slice(0,30).join(", ")}.`};a=l[s]}o.push(s)}return{ok:true,value:a}}function fi(e,t,r){let a=e.split(`
48
+ [truncated \u2014 ${r} characters total. Ask for a narrower slice if you need more.]`}function li(e,t){let r=e.toLocaleString("en-US");return t?`[earlier result \u2014 ${r} characters, already read; ref ${t}. ${ua} {"ref":"${t}",\u2026} re-reads exactly what you saw then; calling the tool again gives the CURRENT value.]`:`[earlier result \u2014 ${r} characters, already read. Ask again if you need it.]`}var Vt=class{constructor(){this.ratio=4;this.samples=0}get charsPerToken(){return this.ratio}get sampleCount(){return this.samples}observe(e,t){if(!(e>0)||!(t>=500))return;let r=Math.min(5,Math.max(2.5,e/t));this.ratio=this.samples===0?r:this.ratio+.3*(r-this.ratio),this.samples+=1}chars(e){return Math.floor(e*this.ratio)}reset(){this.ratio=4,this.samples=0}};function di(e,t){return t==="anthropic"?e.input+(e.cacheRead??0)+(e.cacheWrite??0):e.input}var Wt=37500,ca=1e5,ui=Wt*4,kn=ca*4,ci=4,hi=400;function oe(e){try{return JSON.stringify(e).length}catch{return 0}}function wt(e,t=true){let r=e.length;if(t){r=-1;for(let l=e.length-1;l>=0;l--)if(e[l].role==="tool-results"){r=l;break}if(r<=0)return e}let a=new Set;for(let l=0;l<e.length;l++){let i=e[l];if(!(i.role!=="assistant"||!i.toolCalls?.length)&&i.toolCalls.some(n=>pi.test(JSON.stringify(n.input??{})+n.name))){for(let n=l-1;n>=0&&n>=l-3;n--)if(e[n].role==="tool-results"){a.add(n);break}}}let o=false,s=e.map((l,i)=>{if(l.role!=="tool-results"||i>=r||a.has(i))return l;let n=false,d=l.results.map(u=>u.isError||u.content.length<hi||u.content.startsWith("[earlier result")?u:(n=true,{...u,content:li(u.content.length,u.ref)}));return n?(o=true,{...l,results:d}):l});return o?s:e}var pi=/set|write|dispatch|navigate|tap|override|restore|delete|remove|clear|save|run|impersonat|reload/i;function ha(e,t){let r=[];for(let a of e){(a.role==="user"||r.length===0)&&r.push({messages:[],size:0,pinned:false});let o=r[r.length-1];o.messages.push(a),o.size+=oe(a),t?.size&&a.role==="assistant"&&a.toolCalls?.some(s=>t.has(s.id))&&(o.pinned=true)}return r}function pa(e,t,r,a){let o=[...e],s=o.reduce((d,u)=>d+u.size,0),l=0,i=0,n=()=>o.reduce((d,u)=>d+u.messages.length,0);for(;o.length>0&&s+r>t&&n()>a;){let d=o.findIndex(u=>!u.pinned);d<0&&(d=0,i+=1),s-=o[d].size,o.splice(d,1),l+=1}return{kept:o.flatMap(d=>d.messages),droppedRounds:l,droppedPinned:i}}function ma(e,t=4,r){let a=Math.floor(Wt*t),o=0;for(let u of e)o+=oe(u);if(o<=a)return{messages:e,droppedRounds:0,droppedPinned:0};let s=wt(e),l=0;for(let u of s)l+=oe(u);if(l<=a)return{messages:s,droppedRounds:0,droppedPinned:0};let{kept:i,droppedRounds:n,droppedPinned:d}=pa(ha(s,r),a,0,ci);return{messages:i,droppedRounds:n,droppedPinned:d}}function ya(e,t,r=ui,a=4,o){let s=Math.min(r,Math.floor(ca*a)-t),l=0;for(let E of e)l+=oe(E);if(l<=s)return{messages:e,droppedRounds:0,droppedPinned:0};let i=-1;for(let E=e.length-1;E>=0;E--)if(e[E].role==="user"){i=E;break}if(i<=0)return{messages:wt(e),droppedRounds:0,droppedPinned:0};let n=e.slice(0,i),d=e.slice(i),u=0;for(let E of d)u+=oe(E);let h=wt(n,false),g=0;for(let E of h)g+=oe(E);if(g+u<=s)return{messages:[...h,...d],droppedRounds:0,droppedPinned:0};let{kept:v,droppedRounds:m,droppedPinned:w}=pa(ha(h,o),s,u,0);g=v.reduce((E,M)=>E+oe(M),0);let y=v.length===0&&g+u>s?wt(d):d;return{messages:[...v,...y],droppedRounds:m,droppedPinned:w}}function mi(e,t){if(!e)return{ok:false,error:"There is no evidence store in this session, so earlier results cannot be re-read. Call the tool again for the current value."};let r=e.get(t.ref);if(!r)return e.wasEvicted(t.ref)?{ok:false,error:`${t.ref} was kept but has since been evicted to make room (the store holds the most recent ~4 MB of results). Call the tool again for the current value.`}:{ok:false,error:`No result with ref ${t.ref}. Refs look like ev_12 and appear in [truncated \u2014 \u2026] and [earlier result \u2014 \u2026] markers.`};let a={ref:r.ref,from:`${r.toolId}.${r.action}`,capturedAt:new Date(r.capturedAt).toISOString(),totalChars:r.text.length},o,s=true;try{o=JSON.parse(r.text)}catch{s=false}if(t.pattern!==void 0&&t.pattern!=="")return{...a,...fi(s?JSON.stringify(o,null,1):r.text,t.pattern,t.contextLines??2)};if(!s){let n=t.slice?r.text.slice(t.slice[0],t.slice[1]):r.text;return{...a,text:n}}let l=o,i="(root)";if(t.path){let n=gi(o,t.path);if(!n.ok)return{...a,ok:false,error:n.error};l=n.value,i=t.path}if(t.slice){let[n,d]=t.slice;return Array.isArray(l)?{...a,at:i,sliced:[n,d],of:l.length,value:l.slice(n,d)}:typeof l=="string"?{...a,at:i,sliced:[n,d],of:l.length,value:l.slice(n,d)}:{...a,ok:false,error:`slice applies to an array or a string; the value at ${i} is ${ga(l)}.`}}return t.path?{...a,at:i,value:l}:{...a,shape:yi(o),hint:'Pass path (e.g. "stats.0") or pattern to read a part.'}}function ga(e){return e===null?"null":Array.isArray(e)?"array":typeof e}function yi(e){if(Array.isArray(e))return{type:"array",length:e.length,first:e.length?zt(e[0]):void 0};if(e&&typeof e=="object"){let t=Object.keys(e),r=t.slice(0,60),a={};for(let o of r)a[o]=zt(e[o]);return t.length>r.length?{...a,"\u2026":`${t.length-r.length} more keys`}:a}return zt(e)}function zt(e){if(e==null)return String(e);if(Array.isArray(e))return`array(${e.length})`;if(typeof e=="object"){let t=Object.keys(e),r=JSON.stringify(e).length;return`object{${t.slice(0,6).join(", ")}${t.length>6?", \u2026":""}} ~${r.toLocaleString("en-US")} chars`}return typeof e=="string"?e.length>40?`string(${e.length})`:JSON.stringify(e):`${typeof e}: ${String(e)}`}function gi(e,t){let r=t.replace(/^\/+/,"").split(/[./]/).filter(s=>s.length>0),a=e,o=[];for(let s of r){if(a===null||typeof a!="object")return{ok:false,error:`Cannot go into ${o.join(".")||"(root)"} \u2014 it is ${ga(a)}.`};if(Array.isArray(a)){let l=Number(s);if(!Number.isInteger(l)||l<0||l>=a.length)return{ok:false,error:`${o.join(".")||"(root)"} is an array of ${a.length}; "${s}" is not an index in it.`};a=a[l]}else{let l=a;if(!(s in l))return{ok:false,error:`No field "${s}" at ${o.join(".")||"(root)"}. It has: ${Object.keys(l).slice(0,30).join(", ")}.`};a=l[s]}o.push(s)}return{ok:true,value:a}}function fi(e,t,r){let a=e.split(`
49
49
  `),o=t.toLowerCase(),s=[];for(let i=0;i<a.length;i++)a[i].toLowerCase().includes(o)&&s.push(i);let l=Math.max(0,Math.min(10,r));return{matches:s.slice(0,20).map(i=>({line:i+1,text:a.slice(Math.max(0,i-l),i+l+1).join(`
50
50
  `)})),totalMatches:s.length}}var O=e=>e&&typeof e=="object"&&!Array.isArray(e)?e:void 0;function bt(e,t){return new Promise(r=>{if(t?.aborted)return r();let a=ct(o,e);function o(){Ct(a),t?.removeEventListener("abort",o),r()}t?.addEventListener("abort",o,{once:true})})}async function Ne(e,t,r,a,o){let s=U()+r;for(;;){let l=await e();if(t(l))return{value:l,satisfied:true};if(o?.aborted||U()+a>s)return{value:l,satisfied:false};await bt(a,o)}}function Xe(e,t){if(e===null||typeof e!="object"||Array.isArray(e))return JSON.stringify(e)===JSON.stringify(t);let r=O(t);if(!r)return false;for(let[a,o]of Object.entries(e))if(!(a in r)||!Xe(o,r[a]))return false;return true}function Kt(e,t){let r=t.replace(/^\/+/,"").match(/\[[^\]]*\]|[^.[\]/]+/g)??[],a=e;for(let o of r){if(a===null||typeof a!="object")return;if(o.startsWith("[")){let s=o.slice(1,-1).trim();if(!Array.isArray(a))return;let l=s.indexOf("=");if(l<0)a=a[Number(s)];else{let i=s.slice(0,l).trim(),n=s.slice(l+1).trim().replace(/^["']|["']$/g,"");a=a.find(d=>d&&typeof d=="object"&&String(d[i])===n)}}else a=a[o]}return a}var ue=e=>{let t=JSON.stringify(e);return t===void 0?"undefined":t.length>80?`${t.slice(0,77)}\u2026`:t},vi=async({params:e,dispatch:t})=>{if(typeof e.key!="string")return;let r=await t("storage","async.getItem",{key:e.key}),a=typeof r=="string"?r:O(r)?.value,o=typeof e.value=="string"?e.value:JSON.stringify(e.value);return a===o?{status:"verified",detail:`Read back: "${e.key}" now holds the written value.`}:{status:"failed",detail:`Read back after the write: "${e.key}" holds ${ue(a)}, not what was written. The write did not take.`}};function wi(e){if(typeof e.path=="string"){let a=e.path.split(".").pop()?.replace(/\[.*\]$/,"")??"";return a?[[a,e.value]]:[]}let t=[],r=(a,o,s)=>{let l=O(a);if(l&&s<4)for(let[i,n]of Object.entries(l))r(n,i,s+1);else o&&(typeof a=="number"||typeof a=="string"||typeof a=="boolean")&&t.push([o,a])};return r(e.state,"",0),t}function fa(e,t,r,a=0){let o=O(e);if(!o||a>4)return[];let s=[];for(let[l,i]of Object.entries(o))l===t&&(typeof i=="number"||typeof i=="string"||typeof i=="boolean")&&JSON.stringify(i)!==JSON.stringify(r)?s.push(i):O(i)&&s.push(...fa(i,t,r,a+1));return s}var bi=new Set(["id","key","type","name","title","status","value","label"]);async function ki(e,t){let r=wi(e).filter(([s])=>s.length>2&&!bi.has(s));if(!r.length)return;let a=O(await t("query","listQueries",{limit:25}).catch(()=>{})),o=(Array.isArray(a?.queries)?a.queries:[]).map(O).filter(s=>!!s&&Number(s.observers)>0&&typeof s.queryHash=="string").slice(0,5);for(let s of o){let l=O(await t("query","getQueryData",{queryHash:s.queryHash}).catch(()=>{}));if(!(!l||l.found===false))for(let[i,n]of r){let d=fa(l.data,i,n);if(d.length)return`The query ${s.queryHash} on screen also has ${i} = ${ue(d[0])}. If the screen reads that query, it still shows ${ue(d[0])}: check the screen, and change the query (setQueryData) if so.`}}}var Si=async({params:e,dispatch:t,after:r})=>{let a=await Ti({params:e,dispatch:t,after:r});if(a?.status!=="verified")return a;let o=await ki(e,t).catch(()=>{});return o?{status:"unverified",detail:`${a.detail.replace(/\.$/,"")}, but that is the store, not the screen. ${o}`}:a},Ti=async({params:e,dispatch:t,after:r})=>{if(typeof e.storeName!="string")return;let a=O(r!==void 0?r:await t("zustand","getStoreState",{storeName:e.storeName}));if(!a||a.found===false)return{status:"failed",detail:`Read back: store "${e.storeName}" could not be read after the write.`};let o=a.currentState;if(typeof e.path=="string"){let i=Kt(o,e.path);return JSON.stringify(i)===JSON.stringify(e.value)?{status:"verified",detail:`Read back: ${e.storeName}.${e.path} is now ${ue(i)}.`}:{status:"failed",detail:`Read back: ${e.storeName}.${e.path} is ${ue(i)}, not ${ue(e.value)}. The store did not take the write.`}}let s=O(e.state);if(!s)return;if(Xe(s,o))return{status:"verified",detail:`Read back: "${e.storeName}" now holds the written fields (${Object.keys(s).join(", ")}).`};let l=Object.keys(s).filter(i=>!Xe(s[i],O(o)?.[i]));return{status:"failed",detail:`Read back: "${e.storeName}" does not hold the written value for ${l.join(", ")}. The store did not take the write \u2014 read it before deciding what to do.`}},Ri=async({params:e,dispatch:t,after:r,signal:a})=>{let o=e.queryHash!==void 0?{queryHash:e.queryHash}:e.queryKey!==void 0?{queryKey:e.queryKey}:void 0;if(!o)return;let s=O(r!==void 0?r:await t("query","getQueryData",o));if(!s||s.found===false)return{status:"failed",detail:"Read back: the query could not be read after the write."};let l=s.data;if(typeof e.path=="string"){let i=Kt(l,e.path);return JSON.stringify(i)===JSON.stringify(e.value)?va(e,t,`Read back: the cached ${e.path} is now ${ue(i)}.`,a):{status:"failed",detail:`Read back: the cached ${e.path} is ${ue(i)}, not ${ue(e.value)}. The cache did not take the write \u2014 the app may have refetched over it.`}}if(e.data!==void 0)return Xe(e.data,l)?va(e,t,"Read back: the cache now holds the written data.",a):{status:"failed",detail:"Read back: the cache does not hold the written data \u2014 the app may have refetched over it, or the shape differed."}};async function va(e,t,r,a){let o=typeof e.queryHash=="string"?e.queryHash:Array.isArray(e.queryKey)?JSON.stringify(e.queryKey):void 0,s=o?await wa(t,o).catch(()=>{}):void 0,l=typeof s?.refetchEveryMs=="number"?s.refetchEveryMs:void 0;if(!o||!l||l>1e4)return{status:"verified",detail:r};if(await bt(l+750,a),a?.aborted)return{status:"verified",detail:r};let i=O(await t("query","getQueryData",{queryHash:o}).catch(()=>{}));return!i||i.found===false?{status:"verified",detail:r}:(typeof e.path=="string"?JSON.stringify(Kt(i.data,e.path))===JSON.stringify(e.value):e.data!==void 0&&Xe(e.data,i.data))?{status:"verified",detail:`${r} It is still there after the screen's ${l} ms refetch.`}:{status:"failed",detail:`${r} But this screen refetches every ${l} ms, and it already has: the server's data replaced the edit. For a change that stays, override the API response \u2014 network.upsertOverrideRule (fromRequestId + bodyPatch, or a full body), then query.invalidate.`}}var Ai=async({params:e,dispatch:t,signal:r})=>{if(typeof e.path!="string")return;let a=e.path.split("?")[0],{value:o,satisfied:s}=await Ne(async()=>O(await t("route-events","getCurrentRoute",{})),l=>typeof l?.path=="string"&&(l.path===a||l.path.split("?")[0]===a),2e3,250,r);return s?{status:"verified",detail:`The app is now on ${a}.`}:{status:"failed",detail:`Two seconds after navigating, the app is on ${typeof o?.path=="string"?o.path:"an unknown route"}, not ${a}. Do not navigate again blindly \u2014 check the route exists (the sitemap) and whether something redirected.`}},qi=async({params:e,result:t,dispatch:r,signal:a})=>{let o=O(O(t)?.rule)?.id??O(e.rule)?.id;if(typeof o!="string")return;let s=async()=>{let n=O(await r("network","listOverrideRules",{}));return(Array.isArray(n?.rules)?n.rules:[]).map(O).find(d=>d?.id===o)},{value:l,satisfied:i}=await Ne(s,n=>typeof n?.hits=="number"&&n.hits>0,3e3,500,a);return l?l.enabled===false?{status:"failed",detail:"The rule is installed but disabled, so it will not fire."}:i?{status:"verified",detail:`The rule has matched ${l.hits} request${l.hits===1?"":"s"} \u2014 the app has already received the overridden response.`}:{status:"unverified",detail:"The rule is installed, but no request has matched it yet \u2014 the app is still showing whatever it loaded before. A refetch or a visit to the screen that fetches this will exercise it; until then, do not say the screen shows the override."}:{status:"failed",detail:"The rule is not in the override list after the write. It did not install."}};async function wa(e,t){let r=O(await e("query","listQueries",{limit:100}));return(Array.isArray(r?.queries)?r.queries:[]).map(O).find(a=>a?.queryHash===t)}function ba(e,t,r){return async({params:a,dispatch:o,signal:s})=>{if(typeof a.queryHash!="string")return;let l=async()=>{let d=await wa(o,a.queryHash);return typeof d?.status=="string"?d.status:void 0},{value:i,satisfied:n}=await Ne(l,d=>d!==void 0&&!e(d),3e3,500,s);if(i!==void 0)return n?{status:"failed",detail:`Within three seconds the app refetched and the query is "${i}" again: this screen polls, so a cache ${t} pin cannot last. ${r}`}:{status:"verified",detail:`Still ${t} three seconds later.`}}}var xi=ba(e=>e==="error","error","For an error that stays, make the API fail instead \u2014 network.upsertOverrideRule with status 500 for this endpoint, then query.invalidate \u2014 and restoreError this query."),Ei=ba(e=>e==="fetching"||e==="pending"||e==="loading","loading","To keep it loading, delay the API instead \u2014 network.upsertOverrideRule with a long delayMs (60000) for this endpoint, then query.invalidate \u2014 and restoreLoading this query."),Ii=async({dispatch:e,signal:t})=>{await bt(2e3,t);let{satisfied:r}=await Ne(async()=>{try{return O(await e("app","ping",{}))?.ok===true}catch{return false}},a=>a,4e4,1500,t);return r?{status:"verified",detail:"The app reloaded and is answering again \u2014 read its screen or state now."}:{status:"failed",detail:"The app has not answered for 40 seconds after the relaunch. It may have crashed on start: check the console, then reload_app."}},Oi={"phone-call":2e4,"quick-switch":5e3,"low-memory":1e4},ka=async({action:e,params:t,dispatch:r,signal:a})=>{let o;if(e==="runRecipe")o=Oi[String(t.id)];else if(e==="background"&&t.duration!==void 0&&t.skipWait!==true){let u=t.duration,h=/^(\d+(?:\.\d+)?)\s*(ms|s|m)$/.exec(String(u));o=typeof u=="number"?u:h?Number(h[1])*(h[2]==="ms"?1:h[2]==="s"?1e3:6e4):void 0}if(!o||o>12e4)return;let{satisfied:s}=await Ne(async()=>O(await r("lifecycle","getState",{}).catch(()=>{})),u=>!!u&&(u.away===null||u.away===void 0)&&u.realAppState!=="background",o+8e3,1e3,a);if(!s)return{status:"unverified",detail:`The app has not come back yet (expected about ${Math.round(o/1e3)} s). Do not report the result until it has.`};let l=(await Ne(async()=>O(O(await r("lifecycle","getState",{}).catch(()=>{}))?.report),u=>!u||u.collectingUntil===null||u.collectingUntil===void 0,8e3,1e3,a)).value,i=O(l?.counts),n=i&&Object.keys(i).length?` Seen during it: ${Object.entries(i).map(([u,h])=>`${u} ${h}`).join(", ")}.`:" Nothing was recorded during it.",d=typeof l?.finding=="string"?` ${l.finding}`:"";return{status:"verified",detail:`The interruption is over and the app is active again.${l?`${d}${n}`:""}`}},Pi=async({readSnapshot:e,signal:t})=>{if(!e)return;let r=U();await bt(1500,t);let a=O(await Promise.resolve(e("console")).catch(()=>{})),o=(Array.isArray(a?.entries)?a.entries:Array.isArray(a)?a:[]).map(O).filter(s=>!!s&&Number(s.timestamp??s.at??0)>=r-250).slice(-6).map(s=>`${String(s.level??s.type??"log")}: ${String(s.message??(Array.isArray(s.args)?s.args.join(" "):"")).slice(0,160)}`);return o.length?{status:"verified",detail:`Delivered. The app logged right after: ${o.join(" | ")}`}:{status:"unverified",detail:"Delivered. The app logged nothing in the next 1.5 s; check the screen or report for its reaction."}},Ni=/^\s*(save|submit|update|apply|done|confirm)\b/i,Di=async({result:e,dispatch:t})=>{let r=O(O(e)?.matched),a=typeof r?.via=="string"?r.via:"";if(a!=="onValueChange"&&a!=="onChangeText")return;let o=O(await t("highlight-updates","describeScreen",{}).catch(()=>{})),s=(Array.isArray(o?.elements)?o.elements.map(O):[]).find(l=>!!l&&l.interactive===true&&Ni.test(String(l.label??l.text??""))&&l.nativeTag!==r?.nativeTag);return s?{status:"unverified",detail:`Changed on screen only. This screen has a "${String(s.label??s.text).trim()}" button: the change isn't saved until it's pressed. Press it, then check the result.`}:void 0},ji={"highlight-updates.tapElement":Di,"lifecycle.memoryWarning":Pi,"lifecycle.relaunch":Ii,"lifecycle.runRecipe":ka,"lifecycle.background":ka,"query.triggerError":xi,"query.triggerLoading":Ei,"storage.async.setItem":vi,"zustand.setState":Si,"query.setQueryData":Ri,"route-events.navigate":Ai,"network.upsertOverrideRule":qi};async function Ci(e){let t=ji[`${e.toolId}.${e.action}`];if(t)try{return await t(e)}catch{return}}function _i(e){switch(e.status){case"verified":return`
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buoy-gg/agent-core",
3
- "version": "7.0.55",
3
+ "version": "7.0.57",
4
4
  "description": "Headless agent core for Ask Buoy - the tool catalog, BYO-model providers, turn loop and effect ledger that let an LLM drive Buoy devtools",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",