@avasapp/agent-bridge 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -2,31 +2,9 @@
2
2
 
3
3
  Let coding agents drive a running React Native app directly. Seed data, flip flags, fake realtime messages, navigate and check the screen in **milliseconds**, without tapping through the app. It works alongside [agent-device](https://github.com/callstack/agent-device): agent-device opens the app and handles native UI, and agent-bridge handles everything inside the app.
4
4
 
5
- ## Demo
6
-
7
5
  ![The same checks with agent-device alone (22.4 s) and with agent-bridge (1.4 s): hide a tab, turn on dark mode, fill in a form, seed data, fake a live message and undo it all](example/media/demo.gif)
8
6
 
9
- The same checks on [the example app](example) in Expo Go, both at real speed, all on one Mac:
10
-
11
- - **agent-device alone** ([`demo-agent-device.mjs`](example/flows/demo-agent-device.mjs)) taps and types through the UI, edits the fake backend and reloads to change the flag and the data, and asks the realtime server to push a message.
12
- - **agent-device + agent-bridge** ([`demo.mjs`](example/flows/demo.mjs)) makes each change with one call, then undoes them all with `bridge.restore`.
13
-
14
- agent-device 0.21.15 won't press or fill most of this screen by selector on the iOS 27 simulator ([callstack/agent-device#2996](https://github.com/callstack/agent-device/issues/2996)), so the agent-device side looks up each element's frame and taps its centre. That adds one lookup per tap or fill.
15
-
16
- | Call | Round trip |
17
- | --- | --- |
18
- | `bridge.ping` | 1–4 ms |
19
- | `screen.findText`, `screen.waitFor` (already there) | 3–9 ms |
20
- | `screen.fill`, `screen.press` | 20–120 ms, render included |
21
- | `query.pin`, `store.call`, `router.navigate` | 11–55 ms, render included |
22
- | 19 steps, no pauses | 1.3–1.5 s wall, 0.9 s of it waiting on the fake backend |
23
-
24
- ```
25
- 19 steps with agent-bridge: 1.4 s wall
26
- same checks, agent-device alone: 22.4 s wall
27
- same flow from another machine: ~57 ms per call (network)
28
- same screen check via a11y tree: 450–970 ms
29
- ```
7
+ The same 19 checks on [the example app](example), at real speed: 22.4 s with agent-device alone, 1.4 s with agent-bridge. Most calls take a few milliseconds ([benchmarks](docs/benchmarks.md)).
30
8
 
31
9
  Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Dev builds only: release builds get empty stubs.
32
10
 
@@ -36,8 +14,6 @@ Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Dev
36
14
  bun add -d @avasapp/agent-bridge # or npm / yarn / pnpm
37
15
  ```
38
16
 
39
- ### Teach your agent
40
-
41
17
  The package ships an agent skill that tells your agent when and how to drive the app. Add it to Claude Code, Cursor, Codex and other agents:
42
18
 
43
19
  ```sh
@@ -53,7 +29,7 @@ In Claude Code you can install it as a plugin instead:
53
29
 
54
30
  ## In the app
55
31
 
56
- Mount the hook in a file that only runs in development. You choose every tool; adapters for common libraries are one import away.
32
+ Mount the hook in a file that only runs in development. You choose every tool; [adapters](docs/adapters.md) for common libraries are one import away.
57
33
 
58
34
  ```tsx
59
35
  import { cdpTransport, useAgentBridge } from '@avasapp/agent-bridge'
@@ -72,7 +48,14 @@ export function AgentBridge() {
72
48
  transports: [expoTransport(), cdpTransport()],
73
49
  tools: {
74
50
  ...queryTools(queryClient),
75
- ...storeTools({ settings: useSettingsStore, auth: useAuthStore }),
51
+ ...storeTools(
52
+ { settings: useSettingsStore, auth: useAuthStore },
53
+ // Masks these paths in every store.* output; `store.set` and
54
+ // `store.call` never echo the whole store. An action's return value
55
+ // is matched by store path only if it is a piece of the state itself,
56
+ // not a copy; use a hook function for those.
57
+ { redact: { auth: ['accessToken', 'refreshToken', 'user.phone'] } },
58
+ ),
76
59
  ...mmkvTools({ storage }),
77
60
  ...routerTools(router, { navigation: useNavigationContainerRef() }),
78
61
  ...networkTools(),
@@ -84,225 +67,41 @@ export function AgentBridge() {
84
67
  }
85
68
  ```
86
69
 
87
- Every app also gets:
88
-
89
- - `screen.snapshot`: buttons, inputs, text and `testID` views on screen, with positions.
90
- - `screen.fill`, `screen.press`: call an input's or button's own handlers, found by `testID`, label, placeholder or text, and return once React has rendered the result.
91
- - `screen.waitFor`, `screen.findText`: wait for, or check, text or a target on screen.
92
- - `bridge.restore`: undo what the agent changed, by running every `*.restore` tool.
93
- - `bridge.logs`, `bridge.ping`, `bridge.tools`.
70
+ Every app also gets `screen.snapshot`, `screen.press`, `screen.fill`, `screen.scroll`, `screen.waitFor`, `screen.findText`, `app.reload`, and `bridge.restore` to undo everything the agent changed. See [built-in tools and restore](docs/built-in-tools.md), including how to give agents your own hooks and register their undo.
94
71
 
95
- Custom tools that change the screen can `await settle()` (from `@avasapp/agent-bridge`) so the next check sees the render.
96
-
97
- Errors come back on their own: each reply carries what the app logged with `console.error`, threw or left unhandled since the previous reply, tagged with the call it happened during or after.
72
+ Most flows need the app in a known state first, such as a user signed in locally with every request mocked. Define that once as a [scenario](docs/scenarios.md).
98
73
 
99
74
  ## From the agent
100
75
 
101
76
  ```sh
102
77
  npx agent-bridge session start # hold one connection; call/tools/run reuse it
103
78
  npx agent-bridge tools
104
- npx agent-bridge scenarios # setups the app defines, e.g. signedIn
105
79
  npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
106
80
  npx agent-bridge call query.pin '[["features"], {"beta": false}]'
107
81
  npx agent-bridge call screen.press '"add-plant"'
108
- npx agent-bridge call screen.fill '["plant-name", "Fiddle leaf fig"]'
109
82
  npx agent-bridge call screen.waitFor '"Name is required"'
110
83
  npx agent-bridge run flows/add-plant.mjs --strict # fail if the app logged an error
111
84
  npx agent-bridge session stop # runs bridge.restore, then disconnects
112
85
  ```
113
86
 
114
- A session stops itself, restore included, after 15 minutes without calls (`--idle`), and reconnects if the app reloads.
115
-
116
- ```ts
117
- import { connect } from '@avasapp/agent-bridge/client'
118
-
119
- const app = await connect({ metro: 'localhost:8081' })
120
- await app.call('router.navigate', '/add')
121
- await app.call('screen.fill', 'plant-name', 'Fiddle leaf fig')
122
- await app.call('screen.press', 'save-plant')
123
- ```
124
-
125
- A flow is a module the CLI runs without a model in the loop:
126
-
127
- ```js
128
- export const scenario = 'signedIn' // optional: see Scenarios
129
-
130
- export default async ({ step }) => {
131
- await step('flag: beta off', 'query.pin', ['features'], { beta: false })
132
- await step('save empty form', 'screen.press', 'save-plant')
133
- await step('error shown', 'screen.waitFor', 'Name is required')
134
- await step('undo', 'bridge.restore')
135
- }
136
- ```
137
-
138
- To run flows from your own script or CI job, import them and pass them to `runFlow`. It applies declared scenarios, restores afterwards, and throws what the flow threw:
139
-
140
- ```ts
141
- import { connect, runFlow } from '@avasapp/agent-bridge/client'
142
- import * as mainTabs from './flows/main-tabs.flow.mjs'
143
-
144
- const app = await connect({ metro: 'localhost:8081' })
145
- const { errors, restoreErrors } = await runFlow(app, mainTabs, { scenarios: [{ name: 'signedIn' }] })
146
- app.close()
147
- if (errors || restoreErrors.length) process.exit(1)
148
- ```
87
+ Agents should run `./node_modules/.bin/agent-bridge` (or `bunx agent-bridge`) rather than `npx`, which is slower and can print npm warnings into their output. See [the CLI](docs/cli.md) for flows, batch mode, the REPL, sessions and the `connect()` client.
149
88
 
150
89
  No device tool is needed. Pair one (such as agent-device) with the bridge for what it can't reach: system alerts, permission prompts, the keyboard, screenshots, and one real tap per flow.
151
90
 
152
- ## Scenarios
153
-
154
- Most flows need the app in a known state first, above all a signed-in user. A scenario is a named setup the app defines once, for example a user who is signed in **locally**: a fake token, a fake user, and every request answered in the app, never by the real server.
155
-
156
- ```ts
157
- import { type Scenarios, useAgentBridge } from '@avasapp/agent-bridge'
158
- import { mockApi, strictNetwork } from '@avasapp/agent-bridge/network'
159
-
160
- const scenarios: Scenarios = {
161
- signedIn: {
162
- description: 'Signed in locally; no request reaches a server.',
163
- // JSON Schema: bad options fail before apply runs, and say why.
164
- options: {
165
- type: 'object',
166
- properties: {
167
- user: { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' } } },
168
- },
169
- },
170
- apply: async ({ options, call, onUndo }) => {
171
- const user = { ...defaultUser, ...options?.user }
172
- // Guards first, so nothing leaves the app with the fake token.
173
- onUndo(realtimeGate.close())
174
- onUndo(strictNetwork({ allow: ['cdn.example.com'] }))
175
- onUndo(mockApi(API_URL, {
176
- 'GET /me': { json: user },
177
- 'GET /orders/:id': ({ params }) => ({ json: orders[params.id] }),
178
- }))
179
- onUndo(await addNativeRewriteRule(tilesHost, localTiles)) // any app code
180
- await call('store.set', 'auth', { token: 'local', user }) // store.restore undoes it
181
- return { user }
182
- },
183
- },
184
- }
185
-
186
- useAgentBridge({ tools, scenarios })
187
- ```
188
-
189
- - `options` is a JSON Schema (2020-12, checked with [`@cfworker/json-schema`](https://github.com/cfworker/cfworker/tree/main/packages/json-schema), which needs no `eval` and runs on Hermes). `scenario.apply` checks the options before anything changes, and lists every problem at once, such as `options/user/email: String does not match format "email"`. An object schema that lists `properties` rejects other keys (`options: unknown option "usr". Known: user`) unless it sets `additionalProperties`. So a typo fails instead of being ignored. No options are checked as `{}`, so `required` ones are reported.
190
- - `apply` is ordinary app code. `call(tool, ...args)` runs a bridge tool, and that tool's own restorer undoes the change. `onUndo(fn)` covers everything else: app mocks, gates, native state such as a URL-rewrite rule.
191
- - `bridge.restore` runs every scenario's undo callbacks **last**, newest first, after the store, query and network restorers. The guards stay up until the app is back in its real state. If `apply` throws, the callbacks it had registered run straight away.
192
- - Tools: `scenario.list`, `scenario.apply [name, options?]` (applying an active one again undoes it first) and `scenario.restore`. `npx agent-bridge scenarios` lists them.
193
- - A flow declares what it needs with `export const scenario = 'signedIn'`, or `export const scenarios = ['signedIn', { name: 'cart', options: { items: 2 } }]`. `agent-bridge run` applies them before the flow and runs `bridge.restore` afterwards, even when the flow fails. The flow gets what each `apply` returned as `scenarios.signedIn`. A failed restorer makes the run exit non-zero. `run --scenario signedIn` adds one to any flow, with JSON options after `=` (`--scenario 'signedIn={"user":{"name":"Ada"}}'`); it replaces a declared one of the same name.
194
-
195
- **Strict network.** `strictNetwork({ allow?, status?, offline? })` from `@avasapp/agent-bridge/network` (or `net.strict` from the agent) fails every `fetch` or `XMLHttpRequest` that no mock answers. The request gets a 501 with `{ error: "agent-bridge strict network: no mock for GET https://…" }` and a `console.error`, so the agent sees it with the reply. With `offline: true` it fails like a network failure instead. `net.strict` lists the requests it blocked. `allow` lets hosts through (substrings or RegExps); Metro always gets through. It covers JS requests only: images, native SDKs (a map's tile fetches, say), and WebSockets go around it, so a scenario handles those with app code and `onUndo`.
196
-
197
- **Fixtures.** `mockApi(baseUrl, routes, options?)` answers `'METHOD /path'` routes, or `'/path'` for any method. `:name` matches one path segment and `*` the rest; the query string is ignored. A route's value is a response (`{ json }`, `{ status, body }`…) or a handler that gets the request with `params` and `query`. It returns a function that removes the routes.
198
-
199
- **Gates.** Some side effects must wait while a scenario runs, such as a realtime client connecting with the fake token and signing the user out when auth fails. `createGate(name)` from `@avasapp/agent-bridge` is a counted switch: `gate.close()` returns the function that reopens it (hand that to `onUndo`), `gate.closed` is what app code checks, and `gate.subscribe(fn)` hears it change. In a release build it's always open. `realtime.connection` isn't enough on its own: it hides the state from the app, but the client still connects.
200
-
201
- The example app's [`src/dev/scenarios.ts`](example/src/dev/scenarios.ts) is a complete local `signedIn`, and [`flows/checks/signed-in.mjs`](example/flows/checks/signed-in.mjs) declares it.
91
+ ## Docs
202
92
 
203
- ## Adapters
204
-
205
- | Import | Tools | Needs |
206
- | --- | --- | --- |
207
- | `@avasapp/agent-bridge/tanstack-query` | `query.list` `get` `set` `pin` `unpin` `unpinAll` `refetch` `invalidate` `restore` | your `QueryClient` |
208
- | `@avasapp/agent-bridge/zustand` | `store.list` `get` `set` `call` `restore` | your stores |
209
- | `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
210
- | `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `current` | `router` and `useNavigationContainerRef()` from expo-router |
211
- | `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `strict` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
212
- | `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
213
- | `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
214
- | `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
215
-
216
- A pin keeps seeded data in place through refetches until you unpin it. `net.mock('/inbox', { status: 500 })` or `{ offline: true }` fails a route; apps can fake a whole backend with `mockRequests` or `mockApi` from the same import, and turn on strict mode with `strictNetwork`.
217
-
218
- ## Realtime
219
-
220
- The realtime tools let an agent see realtime messages, send the app a fake one, and stop real ones from overwriting a state it set up. They work with any library.
221
-
222
- With Ably or socket.io, pass the client where you create it, **before the app subscribes**. Listeners added earlier stay invisible.
223
-
224
- ```ts
225
- import { ablyTools } from '@avasapp/agent-bridge/ably'
226
- import { socketIoTools } from '@avasapp/agent-bridge/socket.io'
227
-
228
- export const ably = new Ably.Realtime(options)
229
- export const realtimeDevTools = ablyTools(ably) // or socketIoTools(socket)
230
-
231
- // in AgentBridge: tools: { ...realtimeDevTools, ... }
232
- ```
233
-
234
- With any other library, or your own subscribe layer, wrap each listener:
235
-
236
- ```ts
237
- import { createRealtimeTap } from '@avasapp/agent-bridge/realtime'
238
-
239
- export const realtimeTap = createRealtimeTap() // tools: { ...realtimeTap.tools }
240
-
241
- export function subscribe(channel: string, onMessage: (message: Message) => void) {
242
- const { listener, unsubscribe } = realtimeTap.wrap(channel, onMessage)
243
- const off = client.subscribe(channel, listener)
244
- return () => { off(); unsubscribe() }
245
- }
246
- ```
247
-
248
- ```sh
249
- npx agent-bridge call realtime.channels
250
- npx agent-bridge call realtime.mute '"order-42"' # the real feed goes quiet
251
- npx agent-bridge call realtime.emit '["order-42", {"name": "status", "data": {"status": "arrived"}}]'
252
- npx agent-bridge call realtime.log '{"channel": "order-42"}'
253
- npx agent-bridge call realtime.connection '"disconnected"' # null goes back
254
- ```
255
-
256
- - `realtime.emit` runs the app's own handlers. Ably gets an Ably message (`{ id, name, data, timestamp }` plus what you pass), socket.io gets the arguments after the event name (`["chat", "a", "b"]` calls `listener("a", "b")`), and `createRealtimeTap` gets the value as is, or what its `toMessage` option builds.
257
- - `realtime.mute` drops real messages; injected ones still get through unless you pass `{ "dropInjected": true }`. `"*"` mutes every channel.
258
- - `realtime.connection` fakes a state through the client's own events (Ably's `connection.on` and `connection.state`, socket.io's `connect` / `disconnect`) and drops real messages until the state is `connected` again. Ably channel states don't follow. `socket.connected` keeps its real value: socket.io reads it itself, and faking it would hold real traffic back until the next reconnect.
259
- - `realtime.log` keeps the last 50 messages, and logs a message once however many listeners get it.
260
- - `createRealtimeTap` takes `describe` (what to log), `toMessage` (what `emit` delivers), `channelInfo` (extras for `realtime.channels`), `connection` (to add `realtime.connection`) and `namespace` (for a second tap).
261
- - socket.io `onAny` listeners don't get injected events.
262
-
263
- ### Writing an adapter
264
-
265
- To patch a client instead of wrapping each listener, name its add and remove methods and say which channel a call is for. `tapListeners` handles the bookkeeping and every remove form (all, by event, by listener). `realtimeAdapter` patches each client once. A Pusher-style `channel.bind(event, fn)` is this much:
266
-
267
- ```ts
268
- import { createRealtimeTap, realtimeAdapter, tapListeners } from '@avasapp/agent-bridge/realtime'
269
-
270
- export const pusherTools = realtimeAdapter((channel: Channel, options: {}) => {
271
- const tap = createRealtimeTap()
272
- tapListeners(tap, channel, {
273
- add: 'bind',
274
- remove: 'unbind',
275
- channel: ([event]) => `${channel.name}:${event}`, // undefined leaves a call alone
276
- })
277
- return tap
278
- })
279
- ```
280
-
281
- Pass `accepts` when a listener filters by event, and `message: 'args'` when the message is every argument (as in socket.io). `fakeableConnection(client, { property, events, states, announce })` adds `realtime.connection`: it fakes the state property and silences the client's own change events while a fake is on. Pass `fakeProperty: false` if the client reads that property itself to decide whether to deliver or send. [`src/adapters/`](src/adapters) has the Ably and socket.io adapters built this way.
282
-
283
- ## Transports
284
-
285
- | | Expo dev-tools socket | CDP |
286
- | --- | --- | --- |
287
- | Round trip, local | 0.5 ms | 1.3 ms |
288
- | Emoji in payloads | yes | yes (escaped for you) |
289
- | Works with | Expo CLI | any RN app on Metro |
290
-
291
- `connect()` tries Expo first and falls back to CDP.
292
-
293
- ## Release builds
294
-
295
- Every entry point is gated on `process.env.NODE_ENV`, like `react/index.js`: Metro inlines it and a release bundle gets empty stubs. Check a bundle in CI:
296
-
297
- ```sh
298
- npx agent-bridge assert-absent path/to/main.jsbundle
299
- ```
93
+ - [Built-in tools and restore](docs/built-in-tools.md)
94
+ - [The CLI, flows and the client](docs/cli.md)
95
+ - [Scenarios](docs/scenarios.md): signed-in state, strict network, fixtures, gates
96
+ - [Adapters](docs/adapters.md): TanStack Query, Zustand, MMKV, Expo Router, network
97
+ - [WebViews](docs/webviews.md)
98
+ - [Realtime](docs/realtime.md): Ably, socket.io, any other library
99
+ - [Security and release builds](docs/security.md)
100
+ - [Benchmarks](docs/benchmarks.md)
300
101
 
301
- ## What runs where
102
+ ## Contributing
302
103
 
303
- - **On your machine:** `agent-bridge run` and `runFlow` run the flow module you point them at, with your permissions, the same as `node flow.mjs`. Only run flows you'd run as scripts. In CI, don't run flows from untrusted forks with secrets in the environment, as with any test.
304
- - **In the app:** the bridge only calls tools and scenarios the app registered. Arguments and scenario options arrive as JSON data. Over CDP the client evaluates one fixed call with the message as a JSON string; the Expo transport sends plain JSON. Nothing the agent sends is evaluated as code.
305
- - **Who can reach it:** anyone who can reach Metro's debugger can already run any code in a dev build. The bridge adds no new way in, and release builds carry none of it (`assert-absent` checks).
104
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
306
105
 
307
106
  ## License
308
107
 
@@ -218,7 +218,7 @@ function createRealtimeTap(options = {}) {
218
218
  }
219
219
  },
220
220
  [`${ns}.emit`]: {
221
- description: "Deliver a fake message to every listener on a channel: [channel, message]. Runs the app's own handlers. Returns { id, delivered }.",
221
+ description: "Deliver a fake message to every listener on a channel: [channel, message]. Runs the app's own handlers. Returns { id, delivered }." + (options.emitUsage ? ` ${options.emitUsage}` : ""),
222
222
  run: (channel, ...values) => {
223
223
  const entries = channels.get(channel);
224
224
  if (!entries?.size)
@@ -254,6 +254,7 @@ function createRealtimeTap(options = {}) {
254
254
  run: (channel) => setMute(channel, null)
255
255
  },
256
256
  [`${ns}.restore`]: {
257
+ pending: () => mutes.size > 0 || faked !== null,
257
258
  description: "Undo the agent: unmute every channel and go back to the real connection state.",
258
259
  run: () => {
259
260
  const unmuted = [...mutes.keys()];
@@ -295,11 +296,27 @@ function accepts(filter, message) {
295
296
  }
296
297
  return true;
297
298
  }
299
+ var USAGE = 'Pass the message as { name, data }, e.g. ["chat", { "name": "typing", "data": { "user": "sam" } }]';
298
300
  function toMessage(values, { id }) {
299
301
  const [input] = values;
300
- if (!input || typeof input !== "object" || Array.isArray(input))
301
- throw new Error('Pass the message as { name, data }, e.g. ["chat", { "name": "typing", "data": { "user": "sam" } }]');
302
- return { id, timestamp: Date.now(), ...input };
302
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(USAGE);
303
+ const given = input;
304
+ if (given.name === void 0 && given.event !== void 0)
305
+ throw new Error(`Ably messages have "name", not "event". ${USAGE}`);
306
+ const { event: _event, ...fields } = given;
307
+ const message = {
308
+ id,
309
+ timestamp: Date.now(),
310
+ action: "message.create",
311
+ serial: id,
312
+ annotations: { summary: {} },
313
+ ...fields
314
+ };
315
+ const version = { ...message.version };
316
+ if (!version.serial && message.serial) version.serial = message.serial;
317
+ if (!version.timestamp && message.timestamp) version.timestamp = message.timestamp;
318
+ message.version = version;
319
+ return message;
303
320
  }
304
321
  var ablyTools = realtimeAdapter(
305
322
  (client, options) => {
@@ -308,6 +325,7 @@ var ablyTools = realtimeAdapter(
308
325
  namespace: options.namespace,
309
326
  describe: (m) => ({ event: m.name, data: m.data, id: m.id }),
310
327
  toMessage,
328
+ emitUsage: 'Ably: [channel, { name, data, clientId?, extras? }]. "name" is the event name (subscribe filters and message.name see it) and "data" the payload; there is no "event" field. Filled in like a real message: id, timestamp, action, version, annotations.',
311
329
  channelInfo: (name) => ({ state: channels.get(name)?.state }),
312
330
  // ably-js's connection is an event emitter; `emit` isn't in its public types.
313
331
  connection: fakeableConnection(client.connection, {
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-6kKQsLC1.cjs';
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
2
 
3
3
  /** The fields of an Ably message this adapter reads. */
4
4
  type AblyMessageLike = {
@@ -6,6 +6,13 @@ type AblyMessageLike = {
6
6
  name?: string;
7
7
  data?: unknown;
8
8
  clientId?: string;
9
+ connectionId?: string;
10
+ extras?: unknown;
11
+ timestamp?: number;
12
+ action?: string;
13
+ serial?: string;
14
+ version?: unknown;
15
+ annotations?: unknown;
9
16
  };
10
17
  /** The Ably realtime channel surface this adapter uses. */
11
18
  type AblyChannelLike = {
@@ -104,6 +104,13 @@ function settle(navigation, go) {
104
104
  }
105
105
  });
106
106
  }
107
+ function needDismiss(fn) {
108
+ if (!fn)
109
+ throw new Error(
110
+ "this router has no dismiss; update expo-router or close the modal with the app's own tool"
111
+ );
112
+ return fn;
113
+ }
107
114
  function routerTools(router, options = {}) {
108
115
  const { navigation } = options;
109
116
  const go = (fn, value) => navigation ? settle(navigation, fn).then(() => value) : (fn(), value);
@@ -121,12 +128,26 @@ function routerTools(router, options = {}) {
121
128
  run: (href) => go(() => router.replace(href), href)
122
129
  },
123
130
  "router.back": {
124
- description: "Go back if possible. Returns whether it could.",
131
+ description: "Pop the navigator once if it can. Returns whether it could. Ignores modals and sheets the app renders itself, so it can pop the screen under one: close those with the app's own tool, e.g. modal.close.",
125
132
  run: () => {
126
133
  const could = router.canGoBack();
127
134
  return could ? go(() => router.back(), true) : false;
128
135
  }
129
136
  },
137
+ "router.dismiss": {
138
+ description: "Close the top route-based modal (or `count` screens of the nearest stack). Returns whether it could. Does not close app-rendered modals or sheets.",
139
+ run: (count) => {
140
+ const dismiss = needDismiss(router.dismiss?.bind(router));
141
+ return router.canDismiss?.() === false ? false : go(() => dismiss(count), true);
142
+ }
143
+ },
144
+ "router.dismissAll": {
145
+ description: "Pop the nearest stack back to its first screen. Returns false if no stack has more than one screen. Does not close app-rendered modals or sheets.",
146
+ run: () => {
147
+ const dismissAll = needDismiss(router.dismissAll?.bind(router));
148
+ return router.canDismiss?.() === false ? false : go(() => dismissAll(), true);
149
+ }
150
+ },
130
151
  "router.current": {
131
152
  description: "The current route: pathname, href, params, segments, name, canGoBack.",
132
153
  run: () => {
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-6kKQsLC1.cjs';
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
2
 
3
3
  /** The imperative `router` from expo-router. Passed in, so this adds no dependency. */
4
4
  type RouterLike = {
@@ -7,6 +7,10 @@ type RouterLike = {
7
7
  replace(href: never): void;
8
8
  back(): void;
9
9
  canGoBack(): boolean;
10
+ /** Missing before expo-router 3; `router.dismiss` and `router.dismissAll` then throw. */
11
+ dismiss?(count?: number): void;
12
+ dismissAll?(): void;
13
+ canDismiss?(): boolean;
10
14
  };
11
15
  /** The root navigation container, from `useNavigationContainerRef()` in expo-router. */
12
16
  type NavigationLike = {
@@ -81,6 +81,7 @@ function mmkvTools(instances) {
81
81
  }
82
82
  },
83
83
  "mmkv.restore": {
84
+ pending: () => Object.values(instances).some((m) => originals.has(m)),
84
85
  description: "Undo the agent: put back keys changed with mmkv.set or mmkv.delete, deleting ones that did not exist. Returns how many.",
85
86
  run: () => {
86
87
  let restored = 0;
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-6kKQsLC1.cjs';
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
2
 
3
3
  /** The MMKV surface this adapter uses. v3 has `delete`, v4 has `remove`. */
4
4
  type MMKVLike = {