@ateam-ai-mobile/plugin-runtime 0.1.5 → 0.1.8

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/dist/index.d.mts CHANGED
@@ -5,12 +5,1185 @@ import * as react_native from 'react-native';
5
5
  import { View, TextInput } from 'react-native';
6
6
  export { ActivityIndicator, Animated, FlatList, Image, Modal, Platform, Pressable, RefreshControl, ScrollView, SectionList, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
7
7
 
8
+ /**
9
+ * @adas/device-state — Device Bridge v2, the SEMANTIC layer.
10
+ *
11
+ * Intended repo path:
12
+ * packages/plugin-runtime/src/sdk/device-state.ts
13
+ * Intended module specifier for plugin bundles:
14
+ * '@adas/device-state' (NOT '@adas/plugin-sdk')
15
+ *
16
+ * ─────────────────────────────────────────────────────────────────────────
17
+ * WHICH LAYER AM I IN?
18
+ *
19
+ * props.device.* MECHANICAL. "Do this to the hardware, now."
20
+ * VERB names: takePhoto, getCurrent, scanBarcode,
21
+ * readOnce, authenticate, pickImage.
22
+ * Returns a BARE value. THROWS when it cannot.
23
+ * Declared in the manifest as `capabilities: { camera: true }`
24
+ * (the legacy `native: {}` field is still honoured).
25
+ * Lives in '@adas/plugin-sdk'.
26
+ *
27
+ * props.deviceState.* SEMANTIC. "What is true right now, and how well do
28
+ * we know it?"
29
+ * STATE names: getSnapshot, subscribe, start, stop.
30
+ * Returns a SNAPSHOT carrying freshness + confidence,
31
+ * and able to say `unknown` in every field.
32
+ * A read NEVER throws for absence.
33
+ * Declared in the manifest as `stateDomains: ['visual']`.
34
+ * Lives in '@adas/device-state' (this file).
35
+ *
36
+ * The two containers are the discriminator. Every semantic call in a plugin
37
+ * begins with the eight characters `deviceState.`; every mechanical call
38
+ * begins with `native.`. There is no line of code where the layer is
39
+ * ambiguous, and no object on which both appear.
40
+ *
41
+ * This is a deliberate departure from
42
+ * Docs/device-bridge-v2/DEVICE_BRIDGE_V2_IMPLEMENTATION_GROUNDING_2026-08-07.md
43
+ * §"Migration strategy", which proposed adding `visual`, `spatial`,
44
+ * `connectivityState`… as fields ON `PluginNative`. See the design note at the
45
+ * bottom of this file for why that layout is rejected and what it costs.
46
+ * ─────────────────────────────────────────────────────────────────────────
47
+ *
48
+ * PROVENANCE MARKERS used throughout. They are not decoration — the freeze
49
+ * really does fix some of this and really does leave the rest open, and an
50
+ * implementor needs to know which is which before changing a line:
51
+ *
52
+ * FROZEN — API_FREEZE 2026-08-07 §15 lists it as a frozen candidate.
53
+ * V0.1 — "v0.1 freeze candidate; may change only when implementation
54
+ * grounding proves a field impossible/non-portable."
55
+ * OPEN — the docs explicitly leave it undecided. Do not quietly close it.
56
+ * DECISION — not in the docs at all; decided here, and reversible.
57
+ */
58
+ /** FROZEN (API_FREEZE §1). The eight domain keys. */
59
+ type DeviceBridgeDomain = 'spatial' | 'visual' | 'activity' | 'deviceContext' | 'connectivity' | 'capability' | 'environment' | 'interaction';
60
+ type FreshnessState = 'fresh' | 'aging' | 'stale' | 'unknown';
61
+ /**
62
+ * FROZEN. Both fields are nullable on purpose: "we have no observation at all"
63
+ * has to be expressible without inventing a timestamp.
64
+ *
65
+ * `aging` is a real third state, not a rounding of fresh/stale — a consumer
66
+ * that only branches on fresh-vs-stale is misreading the contract.
67
+ *
68
+ * API_FREEZE §1 "Why observedAt + ageMs": the host may compute `ageMs` at read
69
+ * time, so it can be more accurate than `Date.now() - observedAt` measured on
70
+ * the JS side after a bridge hop. Read `ageMs` when you have it.
71
+ */
72
+ interface Freshness {
73
+ state: FreshnessState;
74
+ /** Epoch ms of the observation itself. `null` = never observed. */
75
+ observedAt: number | null;
76
+ /** Age at read time. `null` = never observed. */
77
+ ageMs: number | null;
78
+ }
79
+ type ConfidenceLevel = 'high' | 'medium' | 'low' | 'unknown';
80
+ /**
81
+ * FROZEN. `level` is mandatory and ordinal; `score` is optional and exists
82
+ * only "when a stable numeric estimate exists" (API_FREEZE §1).
83
+ *
84
+ * The asymmetry is the point: native must not manufacture 0.5 to fill a field.
85
+ * A missing `score` is information — it says the estimator has no calibrated
86
+ * number, only a bucket.
87
+ */
88
+ interface Confidence {
89
+ level: ConfidenceLevel;
90
+ /** 0..1. Omitted when no stable numeric estimate exists. */
91
+ score?: number;
92
+ }
93
+ /**
94
+ * FROZEN. Every snapshot in v2 carries exactly one of these.
95
+ *
96
+ * `freshness` required, `confidence` optional — because some domains report
97
+ * exact OS facts (battery level, audio route) where a confidence is
98
+ * meaningless, and others report inference (activity, indoor/outdoor) where it
99
+ * is mandatory. Cross-domain review: "Freshness is mandatory for dynamic
100
+ * state. Confidence is mandatory for inferred/estimated state; optional for
101
+ * exact OS facts."
102
+ *
103
+ * NOTE (OPEN): freshness is per-SNAPSHOT, never per-field. So a snapshot
104
+ * cannot today say "lighting is fresh but acoustics is stale". The
105
+ * architecture doc raised this for Spatial and Environment and never resolved
106
+ * it. Do not work around it by inventing a second freshness field.
107
+ */
108
+ interface StateQuality {
109
+ freshness: Freshness;
110
+ confidence?: Confidence;
111
+ }
112
+ type SupportState = 'supported' | 'unsupported' | 'unknown';
113
+ type PermissionState$1 = 'granted' | 'denied' | 'restricted' | 'notDetermined' | 'limited' | 'unavailable' | 'unknown';
114
+ type AvailabilityState = 'available' | 'busy' | 'temporarilyUnavailable' | 'unavailable' | 'unknown';
115
+ type RestrictionState = 'none' | 'backgroundRestricted' | 'systemRestricted' | 'policyRestricted' | 'lowPowerRestricted' | 'unknown';
116
+ /**
117
+ * FROZEN (API_FREEZE core rule 4): "Events are meaningful semantic changes,
118
+ * never raw sensor streams." If you find yourself wanting an event per frame
119
+ * or per GPS fix, you are in the wrong layer.
120
+ */
121
+ interface StateEvent<TName extends string = string, TPayload = unknown> {
122
+ domain: DeviceBridgeDomain;
123
+ name: TName;
124
+ /** Epoch ms. */
125
+ timestamp: number;
126
+ /**
127
+ * OPEN: per-event payload schemas are not specified anywhere in the three
128
+ * design docs. Defaults to `unknown` rather than `any` so a consumer is forced
129
+ * to narrow it rather than read a field that may not exist. A domain that
130
+ * decides its schemas (spatial — see `SpatialStateEvent`) passes `TPayload`,
131
+ * which is the freeze's own second parameter, dropped here until one did.
132
+ */
133
+ payload?: TPayload;
134
+ }
135
+ interface Subscription {
136
+ unsubscribe(): void;
137
+ }
138
+ type StateListener<TName extends string = string> = (event: StateEvent<TName>) => void;
139
+ /**
140
+ * VOCABULARY §"Event Naming Rule", expressed as a type.
141
+ *
142
+ * Four domains (visual, activity, spatial, deviceContext) have NO frozen event
143
+ * vocabulary — the vocabulary doc publishes "Initial … Events" lists for
144
+ * connectivity, capability, interaction and environment only. Rather than mint
145
+ * names those docs deliberately did not mint, their events are typed against
146
+ * the naming RULE. A wrong-shaped name is a compile error; the specific
147
+ * vocabulary stays open, as the docs left it — except where a DECISION closes
148
+ * it (spatial, see `SpatialEvent`), and that list is checked against the rule
149
+ * through `AssertConforming`.
150
+ */
151
+ type ConformingEventName = `${string}Changed` | `${string}Started` | `${string}Stopped` | `${string}Available` | `${string}Unavailable` | `${string}StateStale`;
152
+ /**
153
+ * A closed list of names, checked against the naming rule at compile time.
154
+ * Wrap a decided vocabulary in this and a name like `turnCompleted` stops
155
+ * compiling instead of reaching a plugin.
156
+ */
157
+ type AssertConforming<T extends ConformingEventName> = T;
158
+ /** FROZEN (API_FREEZE §12). */
159
+ type DeviceBridgeErrorCode = 'UNSUPPORTED' | 'PERMISSION_DENIED' | 'RESTRICTED' | 'BUSY' | 'TEMPORARILY_UNAVAILABLE' | 'STALE' | 'TIMEOUT' | 'CANCELLED' | 'INTERNAL';
160
+ /**
161
+ * FROZEN shape (API_FREEZE §12).
162
+ *
163
+ * READ THE SCOPE LINE CAREFULLY. This is for FAILED OPERATIONS —
164
+ * `visual.capture()`, `visual.start()`. It is NOT how the surface reports that
165
+ * a camera is denied or a sensor is missing: "Expected state should still be
166
+ * exposed in snapshots."
167
+ *
168
+ * So: `getSnapshot()` does not throw this. `capture()` and `start()` do.
169
+ * That split is the whole no-fabrication contract in one sentence — a read
170
+ * always returns something honest, an action either happens or explains
171
+ * itself in a typed code.
172
+ */
173
+ declare class DeviceBridgeError extends Error {
174
+ readonly code: DeviceBridgeErrorCode;
175
+ readonly domain?: DeviceBridgeDomain;
176
+ /** Whether retrying could plausibly succeed (BUSY yes, UNSUPPORTED no). */
177
+ readonly recoverable?: boolean;
178
+ constructor(code: DeviceBridgeErrorCode, message: string, opts?: {
179
+ domain?: DeviceBridgeDomain;
180
+ recoverable?: boolean;
181
+ });
182
+ }
183
+ interface StateSession {
184
+ id: string;
185
+ /** The intent this session was started with. */
186
+ intent: string;
187
+ startedAt: number;
188
+ }
189
+ interface StartOptions<TIntent extends string> {
190
+ /**
191
+ * MANDATORY, and that is the single most important line in this file.
192
+ *
193
+ * You cannot open a v2 session without naming what it is FOR. Native then
194
+ * owns sampling rate, resolution, buffering, fusion and power — API_FREEZE
195
+ * core rule 6. `start()` means "maintain state according to this intent",
196
+ * not "open a particular native API" (§2).
197
+ *
198
+ * Contrast the mechanical layer, where `camera.takePhoto()` is a legal,
199
+ * complete, intent-free call. That difference is visible at every call site.
200
+ */
201
+ intent: TIntent;
202
+ /**
203
+ * DECISION (not frozen). §13 requires bounded timeout/cancellation for
204
+ * session starts and says chain STOP must propagate to native, but leaves
205
+ * the plumbing "implementation-specific". `AbortSignal` is what
206
+ * `DeviceToolContext.signal` already carries in this SDK, so reuse it rather
207
+ * than invent a second cancellation idiom.
208
+ */
209
+ signal?: AbortSignal;
210
+ }
211
+ /**
212
+ * FROZEN pattern (API_FREEZE §2), reproduced as documentation. The eight
213
+ * concrete domains below do NOT extend this — each spells out exactly the
214
+ * methods it has, because §11 warns "domains should not be forced to implement
215
+ * meaningless lifecycle methods" and three domains genuinely have no intent
216
+ * vocabulary at all.
217
+ *
218
+ * Two invariants apply to every domain:
219
+ * - `getSnapshot()` reads CACHED state. It must not trigger expensive raw
220
+ * sensing (no camera open, no network probe, no GPS cold start).
221
+ * - "Multiple plugins may eventually share one native engine; session
222
+ * ownership must not imply duplicate sensor pipelines." (OPEN: the
223
+ * arbitration mechanism is undefined. See DESIGN NOTE 3.)
224
+ */
225
+ interface StatefulDomain<TSnapshot, TIntent extends string = never, TEvent extends string = string> {
226
+ getSnapshot(): Promise<TSnapshot>;
227
+ start?(options: StartOptions<TIntent>): Promise<StateSession>;
228
+ stop?(sessionId?: string): Promise<void>;
229
+ subscribe?(listener: StateListener<TEvent>): Subscription;
230
+ }
231
+ /**
232
+ * Why every read is `Promise<T>` even though it is a cached read.
233
+ *
234
+ * Not because it samples — it must not. Because it crosses the RN bridge, and
235
+ * this SDK has no synchronous JSI module today (verified: everything in
236
+ * PluginNative except haptics/connectivity.isOnline is Promise-based). The
237
+ * grounding doc's own target for a warm cached snapshot is p50 < 10 ms /
238
+ * p95 < 30 ms, which is an async-bridge budget, not a sampling budget.
239
+ */
240
+ /**
241
+ * FROZEN (API_FREEZE §3). Copied verbatim, not designed here.
242
+ *
243
+ * This landed as a placeholder first, on the belief that §3's field schema had
244
+ * never been written. It had — the placeholder was a gap in what got read, not
245
+ * a gap in the freeze. Worth recording, because "the docs leave this open" and
246
+ * "I did not find it" produce identical-looking comments and only one of them
247
+ * is a reason to invent a shape.
248
+ */
249
+ type SpatialIntent = 'navigation' | 'fitness' | 'geofence' | 'lowPowerTracking' | 'preciseLocate';
250
+ /** FROZEN (API_FREEZE §3). */
251
+ interface SpatialPoint {
252
+ latitude: number;
253
+ longitude: number;
254
+ altitude?: number | null;
255
+ /** Metres. `null` when the platform gives no estimate — never 0, which
256
+ * would read as a perfect fix. */
257
+ accuracyM?: number | null;
258
+ timestamp: number;
259
+ }
260
+ /** FROZEN (API_FREEZE §3). */
261
+ interface SpatialSnapshot {
262
+ /** `null` when there is no usable fix. Not a zero coordinate — (0,0) is a
263
+ * real place in the Gulf of Guinea. */
264
+ position: SpatialPoint | null;
265
+ movement: {
266
+ speedMps: number | null;
267
+ bearingDeg: number | null;
268
+ distanceRecentM: number | null;
269
+ state?: 'stationary' | 'moving' | 'unknown';
270
+ };
271
+ orientation: {
272
+ headingDeg: number | null;
273
+ headingAccuracyDeg?: number | null;
274
+ pitchDeg?: number | null;
275
+ rollDeg?: number | null;
276
+ };
277
+ /** Newest last. Empty when nothing has been recorded — the ring is a native
278
+ * concern, and its window is chosen by intent. */
279
+ recentPath: SpatialPoint[];
280
+ changes?: {
281
+ lastMeaningfulTurnDeg?: number | null;
282
+ timestamp?: number | null;
283
+ };
284
+ quality: StateQuality;
285
+ }
286
+ /**
287
+ * DECISION (not frozen) — the spatial event vocabulary. Six names, all ending
288
+ * in a suffix the naming rule allows, which `AssertConforming` enforces.
289
+ *
290
+ * Drawn from the architecture doc's spatial candidates (meaningful movement,
291
+ * meaningful turn, location quality degraded/recovered, state became stale)
292
+ * and named after the SNAPSHOT fields they report, so an event and a read use
293
+ * one vocabulary: `lastMeaningfulTurnChanged` is `changes.lastMeaningfulTurnDeg`
294
+ * changing. The names an earlier note floated (`turnCompleted`, `arrived`,
295
+ * `headingLost`) are rejected — the first two fail the rule, and "arrived" is a
296
+ * destination, which the vocabulary's Governance puts above Device Bridge.
297
+ */
298
+ type SpatialEvent = AssertConforming<'movementStarted' | 'movementStopped' | 'lastMeaningfulTurnChanged' | 'positionConfidenceChanged' | 'positionChanged' | 'spatialStateStale'>;
299
+ /** A spatial event: the frozen envelope, with its payload always present. */
300
+ type SpatialEventOf<TName extends SpatialEvent, TPayload> = StateEvent<TName, TPayload> & {
301
+ domain: 'spatial';
302
+ payload: TPayload;
303
+ };
304
+ /**
305
+ * DECISION — what each spatial event carries. Only the facts of the
306
+ * transition, in the snapshot's own field names; everything else is one
307
+ * `getSnapshot()` away, which keeps two shapes of the same fact from drifting.
308
+ *
309
+ * `timestamp` on the envelope is when the transition was RECOGNISED. Where a
310
+ * hold delays that on purpose, `since` carries the true onset.
311
+ */
312
+ type SpatialStateEvent =
313
+ /** `movement.state` became `moving` and held (navigation: 3 s). */
314
+ SpatialEventOf<'movementStarted', {
315
+ previous: 'stationary' | 'unknown';
316
+ speedMps: number | null;
317
+ bearingDeg: number | null;
318
+ since: number;
319
+ }>
320
+ /** `moving` → `stationary`, held (navigation: 5 s). Never fired on moving →
321
+ * `unknown`: losing the speed is not evidence of stopping. */
322
+ | SpatialEventOf<'movementStopped', {
323
+ previous: 'moving';
324
+ since: number;
325
+ }>
326
+ /** A new completed turn. Signed: positive = clockwise = right. */
327
+ | SpatialEventOf<'lastMeaningfulTurnChanged', {
328
+ lastMeaningfulTurnDeg: number;
329
+ timestamp: number;
330
+ }>
331
+ /** The GPS fix band (high ≤10 m, medium ≤50 m, else low) changed and held.
332
+ * The fix alone — a compass calibration change is not a position event. */
333
+ | SpatialEventOf<'positionConfidenceChanged', {
334
+ level: ConfidenceLevel;
335
+ previous: ConfidenceLevel;
336
+ accuracyM: number | null;
337
+ direction: 'dropped' | 'recovered';
338
+ }>
339
+ /** Moved at least max(profile floor, 2 × accuracyM) from the last reported
340
+ * position, so GPS jitter never fires it. The session's first fix fires
341
+ * with `previous: null`. */
342
+ | SpatialEventOf<'positionChanged', {
343
+ position: SpatialPoint;
344
+ previous: SpatialPoint | null;
345
+ distanceM: number | null;
346
+ }>
347
+ /** `quality.freshness.state` became `stale`. Once; re-armed by a fresh read. */
348
+ | SpatialEventOf<'spatialStateStale', {
349
+ observedAt: number | null;
350
+ ageMs: number | null;
351
+ }>;
352
+ interface SpatialDomain {
353
+ getSnapshot(): Promise<SpatialSnapshot>;
354
+ start(options: StartOptions<SpatialIntent>): Promise<StateSession>;
355
+ stop(sessionId?: string): Promise<void>;
356
+ /**
357
+ * DECISION: the six `SpatialEvent`s, typed per name — narrow on `e.name` and
358
+ * `e.payload` is that event's shape.
359
+ *
360
+ * Events flow only while a spatial session is open; subscribing opens no
361
+ * sensor. They are derived from the same snapshot `getSnapshot()` returns: a
362
+ * listener that re-reads inside the callback sees the snapshot its event came
363
+ * from. A state change must hold, on at least two fixes, before it fires.
364
+ *
365
+ * From a device tool's `ctx.deviceState` this refuses (RESTRICTED): a call
366
+ * has no lifetime to own the listener.
367
+ */
368
+ subscribe(listener: (event: SpatialStateEvent) => void): Subscription;
369
+ }
370
+ /** FROZEN (API_FREEZE §4) + VOCABULARY §"Visual Intents". Nine values. */
371
+ type VisualIntent = 'navigation' | 'document' | 'ocr' | 'shopping' | 'inspection' | 'accessibility' | 'stream' | 'highQuality' | 'lowPower';
372
+ /**
373
+ * Media crosses by REFERENCE, never as inline bytes (core rule 3).
374
+ *
375
+ * AN ID AND NOTHING ELSE. The `uri` field is gone (2026-08-16), and its
376
+ * removal answers the question this comment used to record as OPEN — "how does
377
+ * a plugin turn a handle into pixels?" It doesn't. Ever.
378
+ *
379
+ * Frames now live in a native ring keyed by id and are never written to disk.
380
+ * `native.vision.readText/describe/assessQuality` take this id and resolve it
381
+ * INSIDE the trusted layer; the pixels do not cross into plugin code, so a
382
+ * solution's bundle cannot copy, upload or retain them. That is what makes
383
+ * "frames never leave the phone" enforceable rather than a promise.
384
+ *
385
+ * Do not add `uri` or `bytes` back. A plugin that believes it needs the pixels
386
+ * needs a new method on `native.vision` instead — one whose implementation
387
+ * stays on this side of the boundary.
388
+ */
389
+ interface MediaHandle {
390
+ id: string;
391
+ mimeType?: string;
392
+ /**
393
+ * Epoch ms after which this id no longer resolves — the frame's real
394
+ * eviction time from the native ring, not a guess.
395
+ *
396
+ * Past it, `native.vision` throws rather than returning an empty result:
397
+ * "I cannot look at that frame" and "I looked and found nothing" are
398
+ * different answers and must never be confused by a caller about to speak.
399
+ * In practice a caller rarely hits this, because the timeline is clamped to
400
+ * native retention — see `visualEngine.effectiveWindowMs()`.
401
+ */
402
+ expiresAt?: number | null;
403
+ }
404
+ /**
405
+ * V0.1. Per-frame usability is CATEGORICAL, not numeric — 'degraded' rather
406
+ * than a blur score. Coarse honest classes beat false precision; the same
407
+ * principle the connectivity doc states outright for connection quality.
408
+ *
409
+ * Every member carries `'unknown'` so a platform that cannot assess blur says
410
+ * so instead of defaulting to 'good'.
411
+ */
412
+ interface VisualFrameQuality {
413
+ blur?: 'good' | 'degraded' | 'unknown';
414
+ exposure?: 'good' | 'dark' | 'bright' | 'unknown';
415
+ /** `null` = cannot tell. Distinct from `false` = checked, not obstructed. */
416
+ obstructed?: boolean | null;
417
+ }
418
+ interface VisualFrameRef {
419
+ handle: MediaHandle;
420
+ timestamp: number;
421
+ orientation?: {
422
+ headingDeg?: number;
423
+ pitchDeg?: number;
424
+ rollDeg?: number;
425
+ };
426
+ quality?: VisualFrameQuality;
427
+ }
428
+ interface VisualSnapshot {
429
+ camera: {
430
+ available: boolean;
431
+ /**
432
+ * Independent of `available` on purpose. A camera can be present and
433
+ * permitted (`available: true`) while no session is running
434
+ * (`active: false`) — that is a different situation from a camera the app
435
+ * cannot use at all, and Guide has to say different things about them.
436
+ *
437
+ * DRIFT (flagged, not resolved): the architecture doc's conceptual block
438
+ * also carried `camera.orientation`; the frozen §4 block does not. Not
439
+ * added here.
440
+ */
441
+ active: boolean;
442
+ facing?: 'front' | 'back' | 'unknown';
443
+ };
444
+ /**
445
+ * `null` when there is no usable current frame.
446
+ *
447
+ * This is the anti-fabrication field. The alternative implementation —
448
+ * returning the last frame you happen to still hold — is precisely how a
449
+ * guidance loop ends up describing a corridor the user left thirty seconds
450
+ * ago. If it is not current, it is `null`, and the timeline below is where
451
+ * you go looking for history, knowingly.
452
+ */
453
+ latest: VisualFrameRef | null;
454
+ /**
455
+ * TIME-based, not frame-count-based. Default window ~5s; documented bounded
456
+ * range roughly 1–15s, selected by profile/intent. Native decides how
457
+ * densely to retain frames inside the window and evicts everything outside
458
+ * it automatically.
459
+ *
460
+ * `windowMs` is exposed; FPS knobs are NOT, and must never be added
461
+ * (API_FREEZE §4). If a plugin wants "more frames", it declares a different
462
+ * intent — it does not turn a dial.
463
+ */
464
+ recentTimeline: {
465
+ windowMs: number;
466
+ frames: VisualFrameRef[];
467
+ };
468
+ quality: StateQuality;
469
+ }
470
+ interface VisualCaptureResult {
471
+ frame: VisualFrameRef;
472
+ }
473
+ /**
474
+ * OPEN, worth knowing before you use it: whether `capture({intent})` may pass
475
+ * an intent DIFFERENT from the running session's intent is not addressed
476
+ * anywhere in the three docs.
477
+ */
478
+ interface VisualCaptureOptions {
479
+ intent: VisualIntent;
480
+ /** §13: capture must support bounded timeout/cancellation. */
481
+ signal?: AbortSignal;
482
+ timeoutMs?: number;
483
+ }
484
+ interface VisualDomain {
485
+ /**
486
+ * Cached read. Does NOT open the camera and does NOT take a picture.
487
+ *
488
+ * This is the call a guidance loop makes on every turn. The hot path is:
489
+ * native camera pipeline → rolling native timeline → cached visual state →
490
+ * one snapshot read from RN. "Avoid repeatedly starting the camera or
491
+ * capturing a new image for every plugin-tool call during an interactive
492
+ * session."
493
+ */
494
+ getSnapshot(): Promise<VisualSnapshot>;
495
+ /** Maintain visual state according to this intent. Not "open the camera". */
496
+ start(options: StartOptions<VisualIntent>): Promise<StateSession>;
497
+ /**
498
+ * An intentional, one-shot, high-quality-ish acquisition. THROWS
499
+ * DeviceBridgeError on failure — it is an operation, not a read.
500
+ *
501
+ * If you are about to call this once per conversational turn, stop: that is
502
+ * `getSnapshot()`'s job, and doing it here is the frame-timer pattern this
503
+ * whole layer exists to replace.
504
+ */
505
+ capture(options: VisualCaptureOptions): Promise<VisualCaptureResult>;
506
+ /**
507
+ * OPEN (doc conflict): the architecture doc shows `visual.subscribe(events)`
508
+ * — a filter — while API_FREEZE shows `visual.subscribe(listener)`. The
509
+ * freeze is followed here. Unresolved.
510
+ *
511
+ * OPEN: there is no "Initial Visual Events" list anywhere. The candidate set
512
+ * in prose (camera became available/unavailable, frame quality degraded/
513
+ * recovered, camera appears obstructed, significant scene change, visual
514
+ * session suspended/resumed, visual state became stale) is NOT a minted
515
+ * vocabulary. Typed against the naming rule only.
516
+ */
517
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
518
+ stop(sessionId?: string): Promise<void>;
519
+ }
520
+ /** FROZEN (API_FREEZE §5, VOCABULARY §"Human Activity Values"). */
521
+ type HumanActivity = 'stationary' | 'walking' | 'running' | 'cycling' | 'driving' | 'unknown';
522
+ /** FROZEN. Independent of HumanActivity — walking+pocket, driving+mounted. */
523
+ type DevicePlacement = 'handheld' | 'pocket' | 'table' | 'mounted' | 'unknown';
524
+ /**
525
+ * FROZEN. Note `driving` is BOTH an intent here and a HumanActivity value
526
+ * above. The docs define no relationship between the two and do not flag the
527
+ * collision: declaring intent `driving` does not assert the user is driving.
528
+ */
529
+ type ActivityIntent = 'navigation' | 'fitness' | 'driving' | 'safety' | 'accessibility' | 'lowPower';
530
+ interface ActivitySnapshot {
531
+ human: {
532
+ state: HumanActivity;
533
+ /** `null` when the start of the current activity is not known. */
534
+ since: number | null;
535
+ /** REQUIRED, unlike StateQuality.confidence — an inferred activity may
536
+ * never be handed over without saying how sure the inference is. */
537
+ confidence: Confidence;
538
+ };
539
+ devicePose: {
540
+ placement: DevicePlacement;
541
+ /** V0.1. These four values appear only in the freeze TS, not in the
542
+ * vocabulary doc — unlike the placement values. Mild drift, flagged. */
543
+ orientation?: 'portrait' | 'landscape' | 'flat' | 'unknown';
544
+ confidence: Confidence;
545
+ };
546
+ /**
547
+ * Advisory only. `likelyNext` is a hint and never a fact.
548
+ * OPEN: transition names are bare `string` — no controlled vocabulary exists
549
+ * anywhere, which sits awkwardly beside "Intents are not arbitrary strings".
550
+ */
551
+ transition?: {
552
+ current?: string | null;
553
+ likelyNext?: string | null;
554
+ confidence?: Confidence;
555
+ };
556
+ /** OPEN: no window length or cap is specified for Activity (Spatial gets
557
+ * ~10–20s, Visual ~5s). Required array — an empty one is a real answer. */
558
+ recentTransitions: Array<{
559
+ name: string;
560
+ timestamp: number;
561
+ }>;
562
+ quality: StateQuality;
563
+ }
564
+ interface ActivityDomain {
565
+ getSnapshot(): Promise<ActivitySnapshot>;
566
+ /**
567
+ * OPEN: §11's naming list shows only `activity.getSnapshot()`, and §2 makes
568
+ * start/stop optional. Whether Activity is session-based or a passive
569
+ * always-on domain is genuinely unstated. Included here because the six
570
+ * frozen ActivityIntent values would otherwise have no seam to arrive
571
+ * through — but this is a DECISION, not a reading of the freeze.
572
+ */
573
+ start(options: StartOptions<ActivityIntent>): Promise<StateSession>;
574
+ stop(sessionId?: string): Promise<void>;
575
+ /** OPEN: no "Initial Activity Events" list exists. */
576
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
577
+ }
578
+ interface DeviceContextSnapshot {
579
+ power: {
580
+ /** 0..1 FRACTION, not a percentage. `null` = not known. */
581
+ batteryLevel: number | null;
582
+ charging: boolean | null;
583
+ lowPowerMode: boolean | null;
584
+ };
585
+ thermal: {
586
+ state: 'nominal' | 'fair' | 'serious' | 'critical' | 'unknown';
587
+ };
588
+ resources: {
589
+ memoryPressure?: 'normal' | 'warning' | 'critical' | 'unknown';
590
+ storagePressure?: 'normal' | 'warning' | 'critical' | 'unknown';
591
+ };
592
+ /** Confidence is legitimately omitted here — these are exact OS facts, not
593
+ * estimates. Freshness still matters: a populated batteryLevel says nothing
594
+ * about whether it was sampled a second or a minute ago. */
595
+ quality: StateQuality;
596
+ }
597
+ /**
598
+ * NO INTENT TYPE, and that is deliberate — not an oversight, and not a gap to
599
+ * fill in later. There is no `DeviceContextIntent` in the freeze and no
600
+ * "Device Context Intents" section in the vocabulary, because
601
+ * (cross-domain review) "A domain may expose an intent only if it changes
602
+ * native implementation strategy in a meaningful way." Reading battery and
603
+ * thermal does not.
604
+ *
605
+ * The direction of flow is the other way round: Device Context is an INPUT to
606
+ * how OTHER domains satisfy their intents. Visual intent `navigation` +
607
+ * device context `battery 5% + low-power mode` → native picks a lower-power
608
+ * camera strategy while still honouring the Visual contract. The plugin does
609
+ * not retune anything, and must not be asked to.
610
+ *
611
+ * Do not add `deviceContext.start({intent:'lowPower'})` because five other
612
+ * domains have a lowPower intent.
613
+ */
614
+ interface DeviceContextDomain {
615
+ getSnapshot(): Promise<DeviceContextSnapshot>;
616
+ /**
617
+ * OPEN: `DeviceContextDomain`'s interface body is never written in the
618
+ * freeze, and no Device Context event names are frozen (the vocabulary has
619
+ * "Initial … Events" for connectivity/capability/interaction/environment
620
+ * only). DECISION: expose subscribe, because the candidate events —
621
+ * low-power mode changed, charging changed, thermal pressure changed — are
622
+ * exactly what an adaptive session needs and polling for them is wasteful.
623
+ *
624
+ * OPEN: "battery crossed significant threshold" and "changed materially"
625
+ * have no numbers anywhere, and Device Context has no profiles to
626
+ * parameterize them.
627
+ */
628
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
629
+ }
630
+ /** FROZEN (API_FREEZE §7, VOCABULARY §"Connectivity Intents"). */
631
+ type ConnectivityIntent = 'lowLatencyConversation' | 'interactive' | 'bulkTransfer' | 'backgroundSync' | 'offlineResilient' | 'lowPower';
632
+ /**
633
+ * V0.1, and note it is FLAT — the connectivity design doc sketched a nested
634
+ * shape (internet{…}, quality{…}, recent_history{…}) and the freeze flattened
635
+ * it. Two unreconciled deltas, carried here as-is rather than merged:
636
+ * - `bandwidthClass` appears in the freeze but in neither the design doc nor
637
+ * the vocabulary's Connectivity Values.
638
+ * - there is NO recent-history field, although the design doc requires native
639
+ * to maintain recent transitions and leaves the default window OPEN.
640
+ */
641
+ interface ConnectivitySnapshot {
642
+ /** NOTE: the design doc says "online / offline / uncertain"; the frozen
643
+ * third value is `unknown`. The two words are never reconciled. */
644
+ reachability: 'online' | 'offline' | 'unknown';
645
+ transport: 'wifi' | 'cellular' | 'ethernet' | 'other' | 'none' | 'unknown';
646
+ qualityClass: 'excellent' | 'good' | 'degraded' | 'poor' | 'unusable' | 'unknown';
647
+ stability: 'stable' | 'changing' | 'flapping' | 'unknown';
648
+ /** `null` when no recent measurement exists. Explicitly preferred over
649
+ * reporting a stale number: "return unknown rather than stale precision." */
650
+ latencyMs?: number | null;
651
+ bandwidthClass?: 'high' | 'medium' | 'low' | 'unknown';
652
+ metered?: boolean | null;
653
+ constrained?: boolean | null;
654
+ quality: StateQuality;
655
+ }
656
+ /** FROZEN v0.1 (VOCABULARY §"Initial Connectivity Events"). All ten. */
657
+ type ConnectivityEvent = 'connectivityOnline' | 'connectivityOffline' | 'transportChanged' | 'connectivityDegraded' | 'connectivityRecovered' | 'connectivityUnstable' | 'connectivityStable' | 'meteredStateChanged' | 'constrainedStateChanged' | 'connectivityStateStale';
658
+ interface ConnectivityDomain {
659
+ /**
660
+ * A fast local cached read. It must NOT ping, and must NOT run a bandwidth
661
+ * or speed test. Latency is sampled on native's own schedule, chosen by
662
+ * intent, and cached with a timestamp.
663
+ */
664
+ getSnapshot(): Promise<ConnectivitySnapshot>;
665
+ /**
666
+ * OPEN: `ConnectivityDomain` is named in §11 but never defined, so whether
667
+ * connectivity is session-based is unstated. DECISION: yes — six frozen
668
+ * intents with documented behavioural differences (lowLatencyConversation
669
+ * "prioritize fresh latency knowledge"; lowPower "rely primarily on passive
670
+ * OS signals and minimize active probes") only mean something if there is a
671
+ * way to declare one.
672
+ */
673
+ start(options: StartOptions<ConnectivityIntent>): Promise<StateSession>;
674
+ stop(sessionId?: string): Promise<void>;
675
+ subscribe(listener: StateListener<ConnectivityEvent>): Subscription;
676
+ }
677
+ /**
678
+ * FROZEN. Four independent axes, deliberately not collapsed. The canonical
679
+ * worked example from the capability doc: supported + granted + busy + none —
680
+ * a camera you own, are allowed to use, and cannot have right now.
681
+ */
682
+ interface CapabilityStatus {
683
+ support: SupportState;
684
+ permission?: PermissionState$1;
685
+ availability: AvailabilityState;
686
+ restriction?: RestrictionState;
687
+ }
688
+ interface CapabilitySnapshot {
689
+ camera: CapabilityStatus;
690
+ microphone: CapabilityStatus;
691
+ /** The only capability the freeze gives scoped detail to. Health and
692
+ * photos/media do NOT get one — see the gap note below. */
693
+ location: CapabilityStatus & {
694
+ precision?: 'precise' | 'approximate' | 'unknown';
695
+ backgroundPermission?: PermissionState$1;
696
+ };
697
+ motion: CapabilityStatus;
698
+ bluetooth: CapabilityStatus;
699
+ uwb: CapabilityStatus;
700
+ nfc: CapabilityStatus;
701
+ notifications: CapabilityStatus;
702
+ /**
703
+ * GAP (flagged): the capability doc notes health "authorization is often
704
+ * record-type specific rather than a single global permission" and its
705
+ * candidate shape had a `permission_summary`. The frozen §9 gives health a
706
+ * plain CapabilityStatus, so scoped health authorization is NOT
707
+ * representable in v0.1. Its own exit criterion ("scoped permissions are
708
+ * representable") is therefore only partly met.
709
+ */
710
+ health: CapabilityStatus;
711
+ backgroundExecution: CapabilityStatus;
712
+ /** GAP (flagged): photos/media appears in the design doc's native inputs and
713
+ * its full-vs-limited scope discussion, but there is NO photos key in the
714
+ * frozen CapabilitySnapshot. Not added here. */
715
+ quality: StateQuality;
716
+ }
717
+ /** FROZEN v0.1 (VOCABULARY §"Initial Capability Events"). All ten. */
718
+ type CapabilityEvent = 'capabilityChanged' | 'permissionChanged' | 'capabilityAvailable' | 'capabilityUnavailable'
719
+ /** NOTE: this pair does not match the documented naming patterns. The docs
720
+ * neither flag nor reconcile it; carried verbatim rather than "fixed". */
721
+ | 'capabilityBusy' | 'capabilityFree' | 'restrictionChanged' | 'locationPrecisionChanged' | 'backgroundPermissionChanged' | 'capabilityStateStale';
722
+ /**
723
+ * NO INTENT TYPE — the one domain that is intent-less by explicit reasoning
724
+ * rather than by omission: "Capability State itself should generally be cheap
725
+ * and mostly event-driven, so it does not need many sensing profiles."
726
+ * A candidate `interactive/background/minimal` vocabulary is floated in the
727
+ * design doc and explicitly conditional ("only be kept if implementation
728
+ * proves they add value"). Nothing frozen. Not added.
729
+ *
730
+ * Also NOT here: any `requestPermission()`. Permission-request UX is outside
731
+ * this state engine by design, and a floated generic primitive is future work.
732
+ */
733
+ interface CapabilityDomain {
734
+ getSnapshot(): Promise<CapabilitySnapshot>;
735
+ subscribe(listener: StateListener<CapabilityEvent>): Subscription;
736
+ }
737
+ /** FROZEN (VOCABULARY §"Environment Intents"). */
738
+ type EnvironmentIntent = 'ambient' | 'navigation' | 'accessibility' | 'proximity' | 'safety' | 'lowPower';
739
+ interface EnvironmentSnapshot {
740
+ /** Privacy is part of the SHAPE, not advice: this says `dim`, never a camera
741
+ * frame; `loud`, never microphone audio; a beacon count, never a BLE
742
+ * payload. Raw audio/images/BLE payloads are not part of this domain. */
743
+ lighting: {
744
+ level: 'dark' | 'dim' | 'normal' | 'bright' | 'veryBright' | 'unknown';
745
+ lux?: number | null;
746
+ confidence?: Confidence;
747
+ };
748
+ acoustics: {
749
+ level: 'quiet' | 'moderate' | 'loud' | 'veryLoud' | 'unknown';
750
+ approximateDb?: number | null;
751
+ confidence?: Confidence;
752
+ };
753
+ /** `uncertain` and `unknown` are different: uncertain = we looked and cannot
754
+ * decide; unknown = we have no basis to decide. */
755
+ enclosure: {
756
+ state: 'indoor' | 'outdoor' | 'uncertain' | 'unknown';
757
+ confidence?: Confidence;
758
+ };
759
+ proximity?: {
760
+ distanceClass?: 'immediate' | 'near' | 'far' | 'unknown';
761
+ confidence?: Confidence;
762
+ };
763
+ /** The only sub-block with no confidence — these are presence booleans, not
764
+ * estimates. Note they cannot distinguish unsupported / denied / switched
765
+ * off; that distinction lives in `capability`. */
766
+ nearbySignals?: {
767
+ bleAvailable?: boolean;
768
+ beaconCount?: number | null;
769
+ uwbAvailable?: boolean;
770
+ nfcAvailable?: boolean;
771
+ };
772
+ quality: StateQuality;
773
+ }
774
+ /** FROZEN v0.1 (VOCABULARY §"Initial Environment Events"). All ten. */
775
+ type EnvironmentEvent = 'lightingChanged' | 'environmentBecameDark' | 'environmentBecameBright' | 'noiseLevelChanged' | 'environmentBecameLoud' | 'indoorOutdoorChanged' | 'proximityChanged' | 'nearbySignalAppeared' | 'nearbySignalDisappeared' | 'environmentStateStale';
776
+ interface EnvironmentDomain {
777
+ getSnapshot(): Promise<EnvironmentSnapshot>;
778
+ start(options: StartOptions<EnvironmentIntent>): Promise<StateSession>;
779
+ stop(sessionId?: string): Promise<void>;
780
+ subscribe(listener: StateListener<EnvironmentEvent>): Subscription;
781
+ }
782
+ interface InteractionSnapshot {
783
+ lifecycle: {
784
+ appState: 'foreground' | 'background' | 'inactive' | 'unknown';
785
+ screenOn: boolean | null;
786
+ userActive?: boolean | null;
787
+ idleDurationMs?: number | null;
788
+ };
789
+ audio: {
790
+ captureActive: boolean | null;
791
+ playbackActive: boolean | null;
792
+ route: 'speaker' | 'earpiece' | 'wiredHeadset' | 'bluetooth' | 'carAudio' | 'other' | 'unknown';
793
+ /** `none` (checked, nothing interrupting) and `unknown` (no knowledge) are
794
+ * deliberately different values. */
795
+ interruption: 'none' | 'temporary' | 'system' | 'unknown';
796
+ };
797
+ /** Optional GROUP: a platform that cannot report voice session state omits
798
+ * the whole block rather than guessing `false` for each field. */
799
+ voice?: {
800
+ sessionActive: boolean | null;
801
+ userSpeaking?: boolean | null;
802
+ assistantSpeaking?: boolean | null;
803
+ };
804
+ /** May report that a camera session is running; must NOT duplicate frame
805
+ * state. Frames belong to `visual`. */
806
+ visual?: {
807
+ cameraSessionActive: boolean | null;
808
+ };
809
+ quality: StateQuality;
810
+ }
811
+ /** FROZEN v0.1 (VOCABULARY §"Initial Interaction Events"). All twelve. */
812
+ type InteractionEvent = 'appStateChanged' | 'screenStateChanged' | 'audioRouteChanged' | 'audioInterruptionStarted' | 'audioInterruptionEnded' | 'voiceSessionStarted' | 'voiceSessionStopped' | 'cameraSessionStarted' | 'cameraSessionStopped' | 'userBecameActive' | 'userBecameIdle' | 'interactionStateStale';
813
+ /**
814
+ * NO INTENT TYPE. §10 declares only `InteractionSnapshot`, and the vocabulary
815
+ * — which does publish intents for spatial, visual, activity, connectivity and
816
+ * environment — has no "Interaction Intents" section. Five profiles are
817
+ * floated in the design doc, hedged twice ("Likely profiles, if needed") with
818
+ * no obligation attached to any of them. Not added.
819
+ */
820
+ interface InteractionDomain {
821
+ getSnapshot(): Promise<InteractionSnapshot>;
822
+ subscribe(listener: StateListener<InteractionEvent>): Subscription;
823
+ }
824
+ /**
825
+ * FROZEN name (API_FREEZE §11) and FROZEN member names.
826
+ *
827
+ * §11's "Recommended naming rule" says to call it `visual.getSnapshot()`, not
828
+ * `device.visual.*`. Read here as a rule about the WIRE/tool-name convention
829
+ * (`device.*` stays a human/backend routing convention for callable plugin
830
+ * tools) rather than a ban on a plugin-prop container. That reading is a
831
+ * judgement call and is flagged as such — if it is wrong, the fix is to rename
832
+ * the PROP, never to merge these eight members into `PluginNative`.
833
+ */
834
+ interface DeviceBridgeV2 {
835
+ readonly spatial: SpatialDomain;
836
+ readonly visual: VisualDomain;
837
+ readonly activity: ActivityDomain;
838
+ readonly deviceContext: DeviceContextDomain;
839
+ readonly connectivity: ConnectivityDomain;
840
+ readonly capability: CapabilityDomain;
841
+ readonly environment: EnvironmentDomain;
842
+ readonly interaction: InteractionDomain;
843
+ }
844
+ /** The name plugin authors see. See DESIGN NOTE 1 for why not `deviceBridge`. */
845
+ type DeviceState = DeviceBridgeV2;
846
+ /**
847
+ * DECISION (not in the docs). A second, differently-named manifest field, so a
848
+ * plugin cannot half-declare its way across the boundary:
849
+ *
850
+ * native: { camera: true } → props.device.camera.takePhoto()
851
+ * stateDomains: ['visual', 'spatial'] → props.deviceState.visual.getSnapshot()
852
+ *
853
+ * Declaring `native.camera` grants NOTHING in the semantic layer, and vice
854
+ * versa. There is no field whose meaning spans both.
855
+ *
856
+ * How an UNDECLARED domain must behave — and this is the load-bearing part:
857
+ * it returns a normal snapshot in which every enum is `unknown`, every
858
+ * nullable is `null`, and `quality.freshness` is
859
+ * `{ state:'unknown', observedAt:null, ageMs:null }`. It does NOT throw and it
860
+ * does NOT return undefined.
861
+ *
862
+ * Why that matters here specifically: today's `gatePluginNative` gives an
863
+ * undeclared capability a proxy whose methods `console.warn` and return
864
+ * `undefined` (packages/plugin-runtime/src/host.ts:67-76). So
865
+ * `await native.location.getCurrent()` on an undeclared plugin resolves to
866
+ * `undefined`, and `pos?.latitude` is `undefined` — which then flows into an
867
+ * LLM answer as a confident nothing. A silent undefined is a fabrication
868
+ * vector; a snapshot of explicit unknowns is not.
869
+ *
870
+ * The "why" is readable from `capability.getSnapshot()` using an existing
871
+ * frozen enum member: a manifest-gate denial maps to
872
+ * `restriction: 'policyRestricted'` — the plugin manifest IS a policy. No new
873
+ * vocabulary needed. (The docs never consider a manifest gate, so this
874
+ * mapping is a DECISION, but it uses only frozen values.)
875
+ */
876
+ type StateDomainDeclaration = readonly DeviceBridgeDomain[];
877
+ /**
878
+ * Is this snapshot good enough to act on?
879
+ *
880
+ * Deliberately requires the caller to state a budget: there is no universal
881
+ * "fresh enough", and the docs give no numeric thresholds for any domain
882
+ * (native chooses sampling behaviour). A guidance loop wants ~1500 ms; a
883
+ * battery-aware background job is happy with 60 s.
884
+ *
885
+ * `unknown` freshness fails: it means nothing was ever observed, which is not
886
+ * the same as an old-but-real reading and must not be treated as one.
887
+ */
888
+ declare function isActionable(quality: StateQuality, budget: {
889
+ maxAgeMs: number;
890
+ minConfidence?: ConfidenceLevel;
891
+ }): boolean;
892
+ /** The honest "we have never observed this" value. Use it instead of
893
+ * inventing a timestamp when constructing a degraded snapshot. This is the
894
+ * canonical source of the `unknown` freshness shape — the engine's
895
+ * `qualityUnknown()` derives from it so the two never drift. */
896
+ declare function neverObserved(): Freshness;
897
+ /**
898
+ * One human-readable sentence for why a capability cannot answer, built ONLY
899
+ * from what the four axes actually say. Returns null when the capability is
900
+ * fine — so a caller cannot accidentally print a reason that does not exist.
901
+ */
902
+ declare function explainCapability(status: CapabilityStatus): string | null;
903
+ /**
904
+ * Additive extension of `PluginProps` (sdk/types.ts:259).
905
+ *
906
+ * `deviceState` is OPTIONAL because a host that has not installed a v2
907
+ * provider must not pretend to have one. `undefined` means "this host has no
908
+ * semantic layer", which a plugin can check once; it does NOT mean "the
909
+ * sensors said nothing". Those are different, and collapsing them would
910
+ * reintroduce exactly the ambiguity this file exists to remove.
911
+ */
912
+ interface PluginPropsV2Extension {
913
+ readonly deviceState?: DeviceState;
914
+ }
915
+ /**
916
+ * Additive extension of `DeviceToolContext` (sdk/types.ts:320-327).
917
+ *
918
+ * This is the seam that matters most: device tools are what the AI calls, and
919
+ * device tools are where a fabricated value reaches a user. The lint rule
920
+ * proposed alongside this file (`no-mechanical-sensing-in-device-tools`) bans
921
+ * `ctx.native.camera`, `ctx.native.location` and `ctx.native.connectivity`
922
+ * inside a `DeviceToolDefinition.run` body — a device tool must reach sensing
923
+ * through `ctx.deviceState`. UI code is unaffected.
924
+ */
925
+ interface DeviceToolContextV2Extension {
926
+ readonly deviceState?: DeviceState;
927
+ }
928
+
929
+ type deviceState_ActivityDomain = ActivityDomain;
930
+ type deviceState_ActivityIntent = ActivityIntent;
931
+ type deviceState_ActivitySnapshot = ActivitySnapshot;
932
+ type deviceState_AssertConforming<T extends ConformingEventName> = AssertConforming<T>;
933
+ type deviceState_AvailabilityState = AvailabilityState;
934
+ type deviceState_CapabilityDomain = CapabilityDomain;
935
+ type deviceState_CapabilityEvent = CapabilityEvent;
936
+ type deviceState_CapabilitySnapshot = CapabilitySnapshot;
937
+ type deviceState_CapabilityStatus = CapabilityStatus;
938
+ type deviceState_Confidence = Confidence;
939
+ type deviceState_ConfidenceLevel = ConfidenceLevel;
940
+ type deviceState_ConformingEventName = ConformingEventName;
941
+ type deviceState_ConnectivityDomain = ConnectivityDomain;
942
+ type deviceState_ConnectivityEvent = ConnectivityEvent;
943
+ type deviceState_ConnectivityIntent = ConnectivityIntent;
944
+ type deviceState_ConnectivitySnapshot = ConnectivitySnapshot;
945
+ type deviceState_DeviceBridgeDomain = DeviceBridgeDomain;
946
+ type deviceState_DeviceBridgeError = DeviceBridgeError;
947
+ declare const deviceState_DeviceBridgeError: typeof DeviceBridgeError;
948
+ type deviceState_DeviceBridgeErrorCode = DeviceBridgeErrorCode;
949
+ type deviceState_DeviceBridgeV2 = DeviceBridgeV2;
950
+ type deviceState_DeviceContextDomain = DeviceContextDomain;
951
+ type deviceState_DeviceContextSnapshot = DeviceContextSnapshot;
952
+ type deviceState_DevicePlacement = DevicePlacement;
953
+ type deviceState_DeviceState = DeviceState;
954
+ type deviceState_DeviceToolContextV2Extension = DeviceToolContextV2Extension;
955
+ type deviceState_EnvironmentDomain = EnvironmentDomain;
956
+ type deviceState_EnvironmentEvent = EnvironmentEvent;
957
+ type deviceState_EnvironmentIntent = EnvironmentIntent;
958
+ type deviceState_EnvironmentSnapshot = EnvironmentSnapshot;
959
+ type deviceState_Freshness = Freshness;
960
+ type deviceState_FreshnessState = FreshnessState;
961
+ type deviceState_HumanActivity = HumanActivity;
962
+ type deviceState_InteractionDomain = InteractionDomain;
963
+ type deviceState_InteractionEvent = InteractionEvent;
964
+ type deviceState_InteractionSnapshot = InteractionSnapshot;
965
+ type deviceState_MediaHandle = MediaHandle;
966
+ type deviceState_PluginPropsV2Extension = PluginPropsV2Extension;
967
+ type deviceState_RestrictionState = RestrictionState;
968
+ type deviceState_SpatialDomain = SpatialDomain;
969
+ type deviceState_SpatialEvent = SpatialEvent;
970
+ type deviceState_SpatialIntent = SpatialIntent;
971
+ type deviceState_SpatialPoint = SpatialPoint;
972
+ type deviceState_SpatialSnapshot = SpatialSnapshot;
973
+ type deviceState_SpatialStateEvent = SpatialStateEvent;
974
+ type deviceState_StartOptions<TIntent extends string> = StartOptions<TIntent>;
975
+ type deviceState_StateDomainDeclaration = StateDomainDeclaration;
976
+ type deviceState_StateEvent<TName extends string = string, TPayload = unknown> = StateEvent<TName, TPayload>;
977
+ type deviceState_StateListener<TName extends string = string> = StateListener<TName>;
978
+ type deviceState_StateQuality = StateQuality;
979
+ type deviceState_StateSession = StateSession;
980
+ type deviceState_StatefulDomain<TSnapshot, TIntent extends string = never, TEvent extends string = string> = StatefulDomain<TSnapshot, TIntent, TEvent>;
981
+ type deviceState_Subscription = Subscription;
982
+ type deviceState_SupportState = SupportState;
983
+ type deviceState_VisualCaptureOptions = VisualCaptureOptions;
984
+ type deviceState_VisualCaptureResult = VisualCaptureResult;
985
+ type deviceState_VisualDomain = VisualDomain;
986
+ type deviceState_VisualFrameQuality = VisualFrameQuality;
987
+ type deviceState_VisualFrameRef = VisualFrameRef;
988
+ type deviceState_VisualIntent = VisualIntent;
989
+ type deviceState_VisualSnapshot = VisualSnapshot;
990
+ declare const deviceState_explainCapability: typeof explainCapability;
991
+ declare const deviceState_isActionable: typeof isActionable;
992
+ declare const deviceState_neverObserved: typeof neverObserved;
993
+ declare namespace deviceState {
994
+ export { type deviceState_ActivityDomain as ActivityDomain, type deviceState_ActivityIntent as ActivityIntent, type deviceState_ActivitySnapshot as ActivitySnapshot, type deviceState_AssertConforming as AssertConforming, type deviceState_AvailabilityState as AvailabilityState, type deviceState_CapabilityDomain as CapabilityDomain, type deviceState_CapabilityEvent as CapabilityEvent, type deviceState_CapabilitySnapshot as CapabilitySnapshot, type deviceState_CapabilityStatus as CapabilityStatus, type deviceState_Confidence as Confidence, type deviceState_ConfidenceLevel as ConfidenceLevel, type deviceState_ConformingEventName as ConformingEventName, type deviceState_ConnectivityDomain as ConnectivityDomain, type deviceState_ConnectivityEvent as ConnectivityEvent, type deviceState_ConnectivityIntent as ConnectivityIntent, type deviceState_ConnectivitySnapshot as ConnectivitySnapshot, type deviceState_DeviceBridgeDomain as DeviceBridgeDomain, deviceState_DeviceBridgeError as DeviceBridgeError, type deviceState_DeviceBridgeErrorCode as DeviceBridgeErrorCode, type deviceState_DeviceBridgeV2 as DeviceBridgeV2, type deviceState_DeviceContextDomain as DeviceContextDomain, type deviceState_DeviceContextSnapshot as DeviceContextSnapshot, type deviceState_DevicePlacement as DevicePlacement, type deviceState_DeviceState as DeviceState, type deviceState_DeviceToolContextV2Extension as DeviceToolContextV2Extension, type deviceState_EnvironmentDomain as EnvironmentDomain, type deviceState_EnvironmentEvent as EnvironmentEvent, type deviceState_EnvironmentIntent as EnvironmentIntent, type deviceState_EnvironmentSnapshot as EnvironmentSnapshot, type deviceState_Freshness as Freshness, type deviceState_FreshnessState as FreshnessState, type deviceState_HumanActivity as HumanActivity, type deviceState_InteractionDomain as InteractionDomain, type deviceState_InteractionEvent as InteractionEvent, type deviceState_InteractionSnapshot as InteractionSnapshot, type deviceState_MediaHandle as MediaHandle, type PermissionState$1 as PermissionState, type deviceState_PluginPropsV2Extension as PluginPropsV2Extension, type deviceState_RestrictionState as RestrictionState, type deviceState_SpatialDomain as SpatialDomain, type deviceState_SpatialEvent as SpatialEvent, type deviceState_SpatialIntent as SpatialIntent, type deviceState_SpatialPoint as SpatialPoint, type deviceState_SpatialSnapshot as SpatialSnapshot, type deviceState_SpatialStateEvent as SpatialStateEvent, type deviceState_StartOptions as StartOptions, type deviceState_StateDomainDeclaration as StateDomainDeclaration, type deviceState_StateEvent as StateEvent, type deviceState_StateListener as StateListener, type deviceState_StateQuality as StateQuality, type deviceState_StateSession as StateSession, type deviceState_StatefulDomain as StatefulDomain, type deviceState_Subscription as Subscription, type deviceState_SupportState as SupportState, type deviceState_VisualCaptureOptions as VisualCaptureOptions, type deviceState_VisualCaptureResult as VisualCaptureResult, type deviceState_VisualDomain as VisualDomain, type deviceState_VisualFrameQuality as VisualFrameQuality, type deviceState_VisualFrameRef as VisualFrameRef, type deviceState_VisualIntent as VisualIntent, type deviceState_VisualSnapshot as VisualSnapshot, deviceState_explainCapability as explainCapability, deviceState_isActionable as isActionable, deviceState_neverObserved as neverObserved };
995
+ }
996
+
997
+ /**
998
+ * props.voice — the live conversational SESSION a plugin may drive.
999
+ *
1000
+ * Voice is a SESSION, not a device sensor. `props.device.*` is mechanical verbs
1001
+ * that throw; `props.deviceState.*` is eight semantic domains describing the
1002
+ * device. A Realtime conversation is neither — it is a long-lived loop with its
1003
+ * own state (connecting/connected), its own transcript, and two ways to take a
1004
+ * turn (the user speaks, or the agent speaks first). So it gets its own gated
1005
+ * surface, mirroring the deviceState GRAMMAR (start / stop / getSnapshot /
1006
+ * subscribe) because a reasoning loop reads it the same way — state with
1007
+ * freshness, not bare values.
1008
+ *
1009
+ * TRUST BOUNDARY. This type is the WHOLE surface. There is deliberately NO way
1010
+ * from here to inject fake user speech, read raw audio, reach the WebView, or
1011
+ * hide the mic-active indicator — those are new doors, and the reason
1012
+ * `CameraPreview` is a component rather than a module is that we keep closing
1013
+ * doors of that shape. `prompt()` is an AGENT turn, not a forged user turn.
1014
+ *
1015
+ * See docs/handoff/SPEC_voice_session_without_the_voice_screen_2026-09-12.md.
1016
+ */
1017
+ /** The session lifecycle — identical vocabulary to the built-in `CallState`. */
1018
+ type VoiceState = 'idle' | 'connecting' | 'connected' | 'error';
1019
+ /** One line of the conversation. `tool` entries are device/skill tool activity. */
1020
+ interface VoiceTranscriptEntry {
1021
+ role: 'user' | 'ai' | 'tool';
1022
+ text: string;
1023
+ ts: number;
1024
+ }
1025
+ /** A point-in-time read of the session — what a reasoning loop polls. */
1026
+ interface VoiceSnapshot {
1027
+ state: VoiceState;
1028
+ sessionId: string | null;
1029
+ muted: boolean;
1030
+ /** The agent is currently producing audio. */
1031
+ speaking: boolean;
1032
+ /** The user is currently being heard (partial user transcript in flight). */
1033
+ listening: boolean;
1034
+ /** The user is currently speaking, per the session's own VAD (authoritative,
1035
+ * distinct from `listening` which tracks a partial transcript). */
1036
+ userSpeaking: boolean;
1037
+ /**
1038
+ * Epoch ms of the last moment the user STOPPED speaking, or `null` if they
1039
+ * have not spoken yet this session. `null` is deliberate and load-bearing: a
1040
+ * silence timer must arm on the first real `speaking:false`, never at session
1041
+ * start — "not speaking" and "has never spoken" are different, and arming on
1042
+ * the wrong one fires before anyone has said anything.
1043
+ */
1044
+ lastUserSpeechEndedAt: number | null;
1045
+ /** The last error, if `state === 'error'`; also carries a refusal reason when
1046
+ * this surface was handed to a plugin that did not declare `voice`. */
1047
+ lastError: string | null;
1048
+ /** The most recent LOCAL device-tool call the model made through the session
1049
+ * (name + epoch ms), or null if none yet. Diagnostic: proves whether the
1050
+ * model's call_device_tool actually reached the phone vs was never made. */
1051
+ lastDeviceTool: {
1052
+ name: string;
1053
+ at: number;
1054
+ } | null;
1055
+ /** The sessionId the Core allowset is bound to. Diagnostic: authorization
1056
+ * requires it to equal `sessionId`; a mismatch is why tools refuse. */
1057
+ authorizedSessionId: string | null;
1058
+ }
1059
+ interface VoiceStartOptions {
1060
+ /** Hand the mic to a specific skill on connect (voice /session → takeMic),
1061
+ * exactly as `sys.focusUiPlugin("platform:voice", { skillSlug })` does. */
1062
+ skillSlug?: string;
1063
+ /**
1064
+ * A PER-CALL persona override, same shape as the authored
1065
+ * `solution.voice.persona` ({ name, style }). Declared on the voice
1066
+ * session-create call (POST /session `persona`); the server sanitizes it
1067
+ * (name/style only, length-capped) and layers it OVER the solution's authored
1068
+ * persona for THIS call only — prompt-layer, so it can never touch caller
1069
+ * verification or tool allow-lists. Set once at session start and constant
1070
+ * for the call, so it adds no per-turn prompt pushes.
1071
+ *
1072
+ * Use it when a plugin needs its own voice persona without owning a skill
1073
+ * (e.g. a guide HUD). A solution-wide persona belongs in solution.voice.
1074
+ */
1075
+ persona?: VoicePersona;
1076
+ /**
1077
+ * `'none'` = a CLEAN call: the voice gets none of the workspace's skills — no
1078
+ * skill manifest, no skill tools, no platform welcome line, no mid-call
1079
+ * skill-state prompt refresh — only the voice basics, this call's persona and
1080
+ * the CALLING plugin's own device tools (other solutions' tools registered on
1081
+ * the same phone are neither offered nor run). For a plugin that owns its conversation (e.g.
1082
+ * a guide HUD). Reduce-only: it can remove what the voice gets, never add.
1083
+ * Absent = the workspace's skills, as today. Not combinable with `skillSlug`.
1084
+ */
1085
+ skills?: 'none';
1086
+ }
1087
+ /** The authored persona shape (solution.voice.persona). */
1088
+ interface VoicePersona {
1089
+ /** The name the voice uses for itself ("You are <name> …"). */
1090
+ name?: string;
1091
+ /** Free-text style/behaviour guidance rendered under the identity line. */
1092
+ style?: string;
1093
+ }
1094
+ /**
1095
+ * What changed. Coarse on purpose: `state` covers connect/mute/session/partials,
1096
+ * `transcript` fires when a finalized line was appended. A subscriber re-reads
1097
+ * `getSnapshot()` / `transcript()` — the event is a nudge, not a payload, so the
1098
+ * surface never has to keep two shapes of the same fact in sync.
1099
+ */
1100
+ type VoiceEvent = {
1101
+ type: 'state';
1102
+ } | {
1103
+ type: 'transcript';
1104
+ };
1105
+ interface VoiceSubscription {
1106
+ unsubscribe(): void;
1107
+ }
1108
+ /**
1109
+ * WHO is asking. The one physical session has an OWNER; access is scoped to it.
1110
+ *
1111
+ * A plugin's caller is its `pluginId` (a string), stamped by gateVoice so the
1112
+ * plugin never supplies its own identity. The PLATFORM caller — the built-in
1113
+ * voice screen, the user's own surface — is a Symbol, deliberately: a Symbol can
1114
+ * never equal any pluginId string, so a plugin cannot forge platform access by
1115
+ * naming itself `platform:voice`.
1116
+ */
1117
+ type VoiceCaller = string | symbol;
1118
+ /** The privileged caller — the platform's own surfaces (the built-in sheet, the
1119
+ * mic indicator, the session host). Sees and controls any live session, because
1120
+ * it is the USER's direct surface onto their own conversation. Unforgeable. */
1121
+ declare const PLATFORM_VOICE_CALLER: unique symbol;
1122
+ /**
1123
+ * The OWNER-AWARE session the HOST implements and hands to setVoiceProvider.
1124
+ *
1125
+ * This is NOT what a plugin receives — a plugin gets a `VoiceAPI` facade
1126
+ * (gateVoice) that stamps its `pluginId` as the caller on every call, so the
1127
+ * provider can enforce that a plugin only ever reads, controls, or attaches to a
1128
+ * session IT owns. One physical session, isolated per authorized identity.
1129
+ */
1130
+ interface VoiceSessionProvider {
1131
+ start(opts: VoiceStartOptions | undefined, caller: VoiceCaller): Promise<VoiceSnapshot>;
1132
+ stop(caller: VoiceCaller): void;
1133
+ setMuted(muted: boolean, caller: VoiceCaller): void;
1134
+ prompt(input: string, caller: VoiceCaller): void;
1135
+ observe(text: string, caller: VoiceCaller): void;
1136
+ getSnapshot(caller: VoiceCaller): VoiceSnapshot;
1137
+ subscribe(cb: (event: VoiceEvent) => void, caller: VoiceCaller): VoiceSubscription;
1138
+ transcript(caller: VoiceCaller): VoiceTranscriptEntry[];
1139
+ }
1140
+ /**
1141
+ * The gated surface a plugin receives as `props.voice`. Present on EVERY plugin;
1142
+ * a plugin that did not declare `capabilities: { voice: true }` gets a version
1143
+ * whose reads say so and whose actions throw — never a silent no-op, never a
1144
+ * fake `idle` (see gateVoice in voiceHost.ts).
1145
+ */
1146
+ interface VoiceAPI {
1147
+ /** Open the live loop. Resolves once connected (or rejects on connect error).
1148
+ * If a session is ALREADY live, ATTACHES to it and resolves immediately —
1149
+ * never a second Realtime loop (one phone, one mic, one bill). */
1150
+ start(opts?: VoiceStartOptions): Promise<VoiceSnapshot>;
1151
+ /** End the live loop. Safe to call when idle. */
1152
+ stop(): void;
1153
+ /** Mute/unmute the microphone. The session stays live; the mic indicator
1154
+ * reflects it. */
1155
+ setMuted(muted: boolean): void;
1156
+ /**
1157
+ * Make the agent take a turn WITHOUT the user speaking — the guide speaking
1158
+ * first. `input` is the instruction for that turn. This is an agent-initiated
1159
+ * turn into the SAME session, NOT fabricated user speech: one session, two
1160
+ * trigger sources. Throws if the session is not connected.
1161
+ */
1162
+ prompt(input: string): void;
1163
+ /**
1164
+ * Stream a silent OBSERVATION into the session — tell the model something
1165
+ * without asking it to speak (a `system` conversation item). Cheap and
1166
+ * high-frequency (safe once or twice a second): this is the channel for what
1167
+ * the camera sees while the user walks. The model answers with the
1168
+ * observations in hand the next time it takes a turn (user VAD, or prompt()) —
1169
+ * an observation never triggers a turn by itself. Throws if not connected.
1170
+ */
1171
+ observe(text: string): void;
1172
+ /** Point-in-time session state. */
1173
+ getSnapshot(): VoiceSnapshot;
1174
+ /** Subscribe to state + transcript changes. Returns an unsubscribe handle. */
1175
+ subscribe(cb: (event: VoiceEvent) => void): VoiceSubscription;
1176
+ /** The conversation so far. */
1177
+ transcript(): VoiceTranscriptEntry[];
1178
+ }
1179
+
8
1180
  /**
9
1181
  * @adas/plugin-sdk — Type definitions
10
1182
  *
11
1183
  * This is the developer contract for native mobile plugins.
12
1184
  * Both UI and service plugins use these interfaces.
13
1185
  */
1186
+
14
1187
  interface PluginContext {
15
1188
  tenant: string;
16
1189
  pluginId: string;
@@ -59,6 +1232,11 @@ interface CameraAPI {
59
1232
  width: number;
60
1233
  height: number;
61
1234
  }>;
1235
+ /**
1236
+ * @deprecated Use `native.scanner.scanOnce()`. Scanning needs a MOUNTED
1237
+ * camera, which a bare function call cannot provide; this refuses by name
1238
+ * and points there.
1239
+ */
62
1240
  scanBarcode(opts?: {
63
1241
  formats?: string[];
64
1242
  }): Promise<{
@@ -70,6 +1248,26 @@ interface CameraAPI {
70
1248
  }): Promise<{
71
1249
  uri: string;
72
1250
  }[]>;
1251
+ /**
1252
+ * Record a video clip the user deliberately makes — a damage claim, a
1253
+ * how-it-sounds clip, something to attach.
1254
+ *
1255
+ * NOT the Visual domain. That is an ephemeral frame ring a reasoning loop
1256
+ * reads and nobody keeps; this produces a FILE. Different jobs, and the
1257
+ * grammar says so: this is a verb that throws, Visual is a state read that
1258
+ * says `unknown`.
1259
+ *
1260
+ * Rides the same mounted camera surface as `scanner.scanOnce`, and throws
1261
+ * the same coded failures. `withAudio` needs microphone permission and
1262
+ * REFUSES without it rather than quietly recording silence.
1263
+ */
1264
+ recordVideo(opts?: {
1265
+ maxDurationMs?: number;
1266
+ withAudio?: boolean;
1267
+ }): Promise<{
1268
+ uri: string;
1269
+ durationMs: number | null;
1270
+ }>;
73
1271
  }
74
1272
  interface LocationAPI {
75
1273
  getCurrent(opts?: {
@@ -124,6 +1322,16 @@ interface HapticsAPI {
124
1322
  impact(style: 'light' | 'medium' | 'heavy'): void;
125
1323
  notification(type: 'success' | 'warning' | 'error'): void;
126
1324
  selection(): void;
1325
+ /**
1326
+ * Shorthand for notification('error').
1327
+ *
1328
+ * Documented in the platform guide and already CALLED by shipped plugins
1329
+ * (connections-panel), which only compiled because they reached it through
1330
+ * optional chaining — `native?.haptics?.error?.()` silently does nothing when
1331
+ * the method is absent, so a failure the user was meant to FEEL produced no
1332
+ * buzz and no error. Declared here so the call site is checked.
1333
+ */
1334
+ error(): void;
127
1335
  }
128
1336
  interface ConnectivityAPI {
129
1337
  isOnline(): boolean;
@@ -177,6 +1385,29 @@ interface ScannerAPI {
177
1385
  granted: boolean;
178
1386
  status: string;
179
1387
  }>;
1388
+ /**
1389
+ * Show the scanner and resolve with the FIRST code read.
1390
+ *
1391
+ * The plugin no longer mounts anything: the host renders one
1392
+ * `CameraTaskHost`, this raises it, and the promise settles when a code is
1393
+ * found. That is why the capture lifecycle is owned in exactly one place
1394
+ * instead of by every plugin that wants to scan.
1395
+ *
1396
+ * THROWS rather than resolving empty, with a code that says which:
1397
+ * TIMEOUT nothing was found in time
1398
+ * CANCELLED the user dismissed it — a choice, not a fault
1399
+ * PERMISSION_DENIED camera permission refused
1400
+ * BUSY another camera task is running
1401
+ * UNAVAILABLE no camera surface is mounted in this app
1402
+ * An empty string would read as a code that scanned to nothing.
1403
+ */
1404
+ scanOnce(opts?: {
1405
+ formats?: string[];
1406
+ timeoutMs?: number;
1407
+ }): Promise<{
1408
+ data: string;
1409
+ format: string;
1410
+ }>;
180
1411
  }
181
1412
  interface NfcNdefRecord {
182
1413
  type: string;
@@ -212,10 +1443,235 @@ interface BiometricsAPIv2 {
212
1443
  error?: string;
213
1444
  }>;
214
1445
  }
1446
+ interface BluetoothDevice {
1447
+ id: string;
1448
+ name: string | null;
1449
+ /** Signal strength in dBm. Null when not reported — never 0, which is a
1450
+ * legitimate (and very strong) reading. */
1451
+ rssi: number | null;
1452
+ /** Manufacturer/service payload, if the advertisement carried one. */
1453
+ serviceUUIDs?: string[];
1454
+ }
1455
+ interface VisionText {
1456
+ /** The recognised string. */
1457
+ text: string;
1458
+ /** 0..1 recogniser confidence. Null when the platform gives no estimate —
1459
+ * never 1, which would read as certainty nothing measured. */
1460
+ confidence: number | null;
1461
+ /** Normalised 0..1 box in the frame, origin top-left. Lets a guide say
1462
+ * "the sign on your right" rather than just quoting words. */
1463
+ box?: {
1464
+ x: number;
1465
+ y: number;
1466
+ width: number;
1467
+ height: number;
1468
+ };
1469
+ }
1470
+ interface VisionLabel {
1471
+ /** A label from the platform's classifier vocabulary — NOT free text. */
1472
+ label: string;
1473
+ confidence: number | null;
1474
+ }
1475
+ interface VisionObject {
1476
+ /** A detector-vocabulary class name (e.g. "Car", "Traffic light", "Person") —
1477
+ * NOT free text. */
1478
+ label: string;
1479
+ /** 0..1 detector confidence. Null when the platform gives no estimate — never
1480
+ * 1, which would read as certainty nothing measured. */
1481
+ confidence: number | null;
1482
+ /** Normalised 0..1 box in the frame, origin top-left. Where the thing is,
1483
+ * so a guide can say "on your right" rather than just naming it. */
1484
+ box?: {
1485
+ x: number;
1486
+ y: number;
1487
+ width: number;
1488
+ height: number;
1489
+ };
1490
+ }
1491
+ /**
1492
+ * How usable a frame is, as measured rather than guessed.
1493
+ *
1494
+ * This one is the exception to the split above: it is a MEASUREMENT of the
1495
+ * image, not an interpretation of the scene, so its results belong on
1496
+ * `VisualFrameRef.quality` — which currently reports `unknown` for all three
1497
+ * because nothing looks at the pixels. Wiring this is what lets a guide say
1498
+ * "it's too dark for me to tell" instead of describing a black frame.
1499
+ */
1500
+ interface VisionFrameQuality {
1501
+ blur: 'sharp' | 'slight' | 'severe' | 'unknown';
1502
+ exposure: 'good' | 'dark' | 'bright' | 'unknown';
1503
+ /** True when the lens is covered. Null = not assessed. */
1504
+ obstructed: boolean | null;
1505
+ }
1506
+ interface VisionAPI {
1507
+ /** Is on-device vision available at all? False is a real answer. */
1508
+ isSupported(): Promise<boolean>;
1509
+ /**
1510
+ * Read text in a frame (OCR).
1511
+ *
1512
+ * Returns an EMPTY ARRAY when it looked and found no text — that is a real
1513
+ * finding, distinct from throwing, which means it could not look.
1514
+ */
1515
+ readText(frameId: string, opts?: {
1516
+ languages?: string[];
1517
+ }): Promise<VisionText[]>;
1518
+ /**
1519
+ * Label what is in a frame.
1520
+ *
1521
+ * Deliberately labels, not prose. A sentence is the SKILL's job — this layer
1522
+ * must not put words in the model's mouth, and a classifier that returns
1523
+ * "a bathroom sign ahead on the left" is asserting a spatial relationship it
1524
+ * did not measure.
1525
+ */
1526
+ describe(frameId: string, opts?: {
1527
+ maxLabels?: number;
1528
+ }): Promise<VisionLabel[]>;
1529
+ /**
1530
+ * Detect the objects in a frame, each with a box.
1531
+ *
1532
+ * Distinct from `describe`: describe LABELS the whole scene ("kitchen"),
1533
+ * detectObjects LOCATES the things in it ("Person" on the left, "Car" ahead) —
1534
+ * which is what a guidance loop reads to say where something is. Returns an
1535
+ * EMPTY ARRAY when it looked and found nothing, distinct from throwing (could
1536
+ * not look). On-device (bundled detector); frames never leave the phone.
1537
+ */
1538
+ detectObjects(frameId: string, opts?: {
1539
+ maxItems?: number;
1540
+ minConfidence?: number;
1541
+ }): Promise<VisionObject[]>;
1542
+ /** Measure whether a frame is usable. Feeds VisualFrameRef.quality. */
1543
+ assessQuality(frameId: string): Promise<VisionFrameQuality>;
1544
+ }
1545
+ /** The same `{ latitude, longitude }` `location.getCurrent` returns, so a fix
1546
+ * passes straight in. */
1547
+ interface MapsCoordinate {
1548
+ latitude: number;
1549
+ longitude: number;
1550
+ }
1551
+ /** A postal address, field by field. `null` = the map data has no value — never
1552
+ * an empty string, which would read as a street with no name. */
1553
+ interface MapsAddress {
1554
+ /** The placemark's own name — a building, or often number + street. */
1555
+ name: string | null;
1556
+ street: string | null;
1557
+ streetNumber: string | null;
1558
+ /** Neighbourhood / sub-locality. */
1559
+ district: string | null;
1560
+ city: string | null;
1561
+ /** State / province / administrative area. */
1562
+ region: string | null;
1563
+ postalCode: string | null;
1564
+ country: string | null;
1565
+ /** ISO 3166-1 alpha-2, e.g. "GB". */
1566
+ isoCountryCode: string | null;
1567
+ }
1568
+ interface MapsPlace {
1569
+ /** What the map calls it ("Boots", "10 Downing St"). Null when unnamed. */
1570
+ name: string | null;
1571
+ coordinate: MapsCoordinate;
1572
+ address: MapsAddress;
1573
+ /** Apple's point-of-interest category as a vocabulary term ("Pharmacy",
1574
+ * "Cafe", "PublicTransport") — NOT free text. Null for a plain address, or
1575
+ * when the service gives none. */
1576
+ category: string | null;
1577
+ /** Straight-line metres from `opts.near`, when you passed one — NOT walking
1578
+ * distance (ask `route` for that). Null without `near`. */
1579
+ distanceM: number | null;
1580
+ }
1581
+ interface MapsRouteStep {
1582
+ /** Apple's instruction ("Turn right onto Baker St"). The FIRST step is usually
1583
+ * an origin step with an empty instruction — kept, not dropped, so step
1584
+ * indexes line up with Apple's. */
1585
+ instructions: string;
1586
+ distanceM: number;
1587
+ /** An advisory Apple attaches to this step. Null when there is none. */
1588
+ notice: string | null;
1589
+ polyline: MapsCoordinate[];
1590
+ }
1591
+ interface MapsRoute {
1592
+ mode: 'walking';
1593
+ distanceM: number;
1594
+ /** Apple's estimate for the whole route. */
1595
+ expectedTravelTimeMs: number;
1596
+ steps: MapsRouteStep[];
1597
+ /** The whole route, start to end. */
1598
+ polyline: MapsCoordinate[];
1599
+ }
1600
+ interface MapsAPI {
1601
+ /** Can this build search and route? True when the AdaMaps module is in the
1602
+ * binary (iOS). False on Android and on an older build — `reverseGeocode`
1603
+ * does not need the module and may still work there. */
1604
+ isSupported(): Promise<boolean>;
1605
+ /**
1606
+ * Find places by free text — "pharmacy", "10 Downing Street".
1607
+ *
1608
+ * `near` centres the search on a coordinate YOU supply, within `radiusM`
1609
+ * (default 3000, above 0 and at most 100 000). On iOS 18+ results are
1610
+ * confined to that area; before 18 it is a strong hint and a farther result
1611
+ * can come back — each result's `distanceM` says how far. At most `limit`
1612
+ * results (default 8, from 1; more than 25 is capped at 25).
1613
+ *
1614
+ * Network, Apple's servers. `[]` = Apple looked and found nothing. iOS only:
1615
+ * throws UNAVAILABLE on Android or a build without AdaMaps.
1616
+ */
1617
+ search(query: string, opts?: {
1618
+ near?: MapsCoordinate;
1619
+ radiusM?: number;
1620
+ limit?: number;
1621
+ }): Promise<MapsPlace[]>;
1622
+ /**
1623
+ * What is at a coordinate — its address and name.
1624
+ *
1625
+ * iOS AND Android (the platform geocoder); needs no AdaMaps module. The
1626
+ * result's `coordinate` is the point you asked about, and its
1627
+ * `category` / `distanceM` are null. `null` = nothing there.
1628
+ *
1629
+ * Network, not on-device. iOS reports a rate limit and a lost connection as
1630
+ * the SAME error, so OFFLINE here can also mean "too many requests". Android's
1631
+ * geocoder refuses without location permission (UNAVAILABLE) — that is the
1632
+ * platform's rule, not a location read.
1633
+ */
1634
+ reverseGeocode(at: MapsCoordinate): Promise<MapsPlace | null>;
1635
+ /**
1636
+ * A walking route between two coordinates YOU supply.
1637
+ *
1638
+ * `null` = Apple found no route — never a fake 0 m route, which would read as
1639
+ * "you have arrived". Walking is the only mode; any other refuses by name.
1640
+ * Network, Apple's servers. iOS only: throws UNAVAILABLE on Android or a build
1641
+ * without AdaMaps.
1642
+ */
1643
+ route(from: MapsCoordinate, to: MapsCoordinate, opts?: {
1644
+ mode?: 'walking';
1645
+ }): Promise<MapsRoute | null>;
1646
+ }
1647
+ interface BluetoothAPI {
1648
+ /** Is BLE hardware present and permitted? */
1649
+ isSupported(): Promise<boolean>;
1650
+ /** Is the radio switched on? (Separate from permission — both can block.) */
1651
+ isEnabled(): Promise<boolean>;
1652
+ /**
1653
+ * Scan for advertising devices for `timeoutMs`, then stop.
1654
+ *
1655
+ * Bounded rather than open-ended on purpose: a scan left running is a
1656
+ * battery drain the caller cannot see, and the mistake is invisible until a
1657
+ * user notices their phone is flat.
1658
+ */
1659
+ scan(opts?: {
1660
+ timeoutMs?: number;
1661
+ serviceUUIDs?: string[];
1662
+ }): Promise<BluetoothDevice[]>;
1663
+ }
215
1664
  interface PluginNative {
216
1665
  camera: CameraAPI;
217
1666
  location: LocationAPI;
218
- storage: StorageAPI;
1667
+ /**
1668
+ * @deprecated The gate never hands this out — it uses the per-plugin
1669
+ * `storageFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
1670
+ * single shared storage across plugins was a cross-plugin read/write hole;
1671
+ * there is no shared bucket any more. Present only so older providers still
1672
+ * satisfy the type; new providers implement `storageFor`.
1673
+ */
1674
+ storage?: StorageAPI;
219
1675
  /** @deprecated use `biometricsV2` for the native-backed API. This stub
220
1676
  * remains for backwards compatibility. */
221
1677
  biometrics: BiometricsAPI;
@@ -223,11 +1679,44 @@ interface PluginNative {
223
1679
  notifications: NotificationsAPI;
224
1680
  haptics: HapticsAPI;
225
1681
  connectivity: ConnectivityAPI;
226
- fileSystem: FileSystemAPI;
1682
+ /**
1683
+ * @deprecated The gate never hands this out — it uses the per-plugin
1684
+ * `fileSystemFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
1685
+ * single shared filesystem across plugins was a cross-plugin read/write hole;
1686
+ * there is no shared bucket any more. Present only so older providers still
1687
+ * satisfy the type; new providers implement `fileSystemFor`.
1688
+ */
1689
+ fileSystem?: FileSystemAPI;
1690
+ /**
1691
+ * Per-plugin filesystem: returns a FileSystemAPI whose sandbox root is scoped
1692
+ * to THIS plugin (`plugin-files/<pluginId>/`), so one plugin cannot read,
1693
+ * write or reach another plugin's files even via traversal. `gatePluginNative`
1694
+ * uses it (passing the plugin's id) to isolate each plugin. If it is absent or
1695
+ * no id is known, the gate FAILS CLOSED (blocks) rather than fall back to a
1696
+ * shared filesystem — there is no shared bucket.
1697
+ */
1698
+ fileSystemFor?(pluginId: string): FileSystemAPI;
1699
+ /**
1700
+ * Per-plugin storage: returns a StorageAPI whose KV namespace AND SQLite
1701
+ * database are scoped to THIS plugin, so one plugin cannot read, overwrite,
1702
+ * or query another plugin's key-value entries or tables. `gatePluginNative`
1703
+ * uses it (passing the plugin's id) to isolate each plugin. Identity is
1704
+ * threaded through the TRUSTED provider here — never a caller-supplied
1705
+ * key/table prefix. If it is absent or no id is known, the gate FAILS CLOSED
1706
+ * (blocks) rather than fall back to a shared store — there is no shared bucket.
1707
+ */
1708
+ storageFor?(pluginId: string): StorageAPI;
227
1709
  cookies: CookiesAPI;
228
1710
  sharing: SharingAPI;
229
1711
  scanner: ScannerAPI;
230
1712
  nfc: NfcAPI;
1713
+ /** SHAPE ONLY in this build — see BluetoothAPI. Present so it fails by name. */
1714
+ bluetooth: BluetoothAPI;
1715
+ /** SHAPE ONLY in this build — see VisionAPI. On-device image understanding. */
1716
+ vision: VisionAPI;
1717
+ /** Place search, reverse geocoding, walking routes — see MapsAPI. Sends
1718
+ * queries to Apple's map servers; NOT on-device. */
1719
+ maps: MapsAPI;
231
1720
  }
232
1721
  interface NativeCapabilities$1 {
233
1722
  camera?: boolean;
@@ -237,7 +1726,20 @@ interface NativeCapabilities$1 {
237
1726
  backgroundTasks?: boolean;
238
1727
  fileSystem?: boolean;
239
1728
  notifications?: boolean;
1729
+ /** BLE. Declaring it passes the gate but every call refuses by name until
1730
+ * the native side exists — see BluetoothAPI. */
240
1731
  bluetooth?: boolean;
1732
+ /** On-device image understanding (OCR, labels, frame quality). Refuses by
1733
+ * name until the native module lands — see VisionAPI. */
1734
+ vision?: boolean;
1735
+ /** Place search, reverse geocoding, walking routes. Means "may send place
1736
+ * queries to Apple Maps" — network, not on-device. See MapsAPI. */
1737
+ maps?: boolean;
1738
+ /** The live voice SESSION (props.voice). A privacy surface — a declaring
1739
+ * plugin can read the conversation transcript — so it is gated like any
1740
+ * other capability, and coupled to the platform's unsuppressable mic
1741
+ * indicator. See VoiceAPI. */
1742
+ voice?: boolean;
241
1743
  haptics?: boolean;
242
1744
  /** Read the platform cookie jar after a WebView login flow. */
243
1745
  cookies?: boolean;
@@ -305,7 +1807,43 @@ interface ThemeTokens {
305
1807
  }
306
1808
  interface PluginProps {
307
1809
  bridge: PluginBridge;
308
- native: PluginNative;
1810
+ /**
1811
+ * L2 Device Primitives — the camera/location/storage/haptics/… surface, gated
1812
+ * to the capabilities this plugin declared. This is the canonical name (R4);
1813
+ * the underlying L1 native modules keep the `native` name, but what a plugin
1814
+ * receives is `props.device`.
1815
+ */
1816
+ device: PluginNative;
1817
+ /**
1818
+ * @deprecated Renamed to `device` in R4. Still passed (the SAME gated object)
1819
+ * so existing/remote plugin bundles keep working — migrate to `props.device`.
1820
+ */
1821
+ native?: PluginNative;
1822
+ /**
1823
+ * L3 DeviceState — the SEMANTIC layer (`props.deviceState.spatial/visual/…`),
1824
+ * gated to the domains this plugin declared in `stateDomains`. Reads answer
1825
+ * `unknown` rather than throwing; a domain the build has no engine for, or the
1826
+ * plugin did not declare, returns an explicit refusal.
1827
+ *
1828
+ * REQUIRED, deliberately: an optional field is exactly the compile-time escape
1829
+ * hatch that let a renderer ship without it (props.deviceState === undefined,
1830
+ * which crashed on drawer/card). Every renderer builds this through
1831
+ * resolvePluginRuntimeProps, so the constructor side always has it; making it
1832
+ * required means a NEW renderer that forgets to pass it fails to compile.
1833
+ */
1834
+ deviceState: DeviceState;
1835
+ /**
1836
+ * The live conversational SESSION, gated to plugins that declared
1837
+ * `capabilities: { voice: true }`. A SESSION, not a device sensor: start/stop
1838
+ * the loop, read + subscribe to the transcript, and speak first via
1839
+ * `prompt()`. The mic-active indicator is platform-owned and cannot be
1840
+ * suppressed from here.
1841
+ *
1842
+ * REQUIRED, like `deviceState`: every renderer builds it through
1843
+ * resolvePluginRuntimeProps, so the object is always present. An undeclared
1844
+ * plugin gets a refusal (reads say so, actions throw) — never a fake `idle`.
1845
+ */
1846
+ voice: VoiceAPI;
309
1847
  theme: ThemeTokens;
310
1848
  context: PluginContext;
311
1849
  /** Engine-supplied props from `sys.focusUiPlugin(pluginId, props)`.
@@ -349,6 +1887,11 @@ interface PluginManifest$1 {
349
1887
  actions: string[];
350
1888
  };
351
1889
  capabilities?: Record<string, unknown>;
1890
+ /** L3 DeviceState domains this plugin reads (props.deviceState), as Core passes
1891
+ * them through from the solution's declaration. Absent → NO domains (fail
1892
+ * closed): the loaded bundle's own `stateDomains` is never consulted when a
1893
+ * manifest exists. Read via resolvePluginRuntimeProps. */
1894
+ stateDomains?: DeviceBridgeDomain[];
352
1895
  _source?: string;
353
1896
  _connectorId?: string;
354
1897
  _mcpEndpoint?: string;
@@ -357,7 +1900,13 @@ interface RegisteredUIPlugin {
357
1900
  id: string;
358
1901
  type: 'ui' | 'hybrid';
359
1902
  capabilities?: NativeCapabilities$1;
1903
+ /** L3 DeviceState domains this plugin's UI reads (props.deviceState). The
1904
+ * trusted source for a HOST-registered plugin (no server manifest), read by
1905
+ * resolvePluginRuntimeProps. Carried by register(), same as registerTools. */
1906
+ stateDomains?: DeviceBridgeDomain[];
360
1907
  Component: React.FC<PluginProps>;
1908
+ /** Optional `tools/` section — a plugin may ship both UI and local tools. */
1909
+ tools?: DeviceToolDefinition[];
361
1910
  }
362
1911
  interface RegisteredServicePlugin {
363
1912
  id: string;
@@ -367,7 +1916,50 @@ interface RegisteredServicePlugin {
367
1916
  onStart: (props: Omit<PluginProps, 'theme'>) => Promise<void> | void;
368
1917
  onStop?: () => Promise<void> | void;
369
1918
  }
370
- type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin;
1919
+ /** What a device tool implementation is handed at call time. */
1920
+ interface DeviceToolContext {
1921
+ bridge: PluginBridge;
1922
+ /** Device Bridge — camera, location, sensors, storage. */
1923
+ native: PluginNative;
1924
+ /**
1925
+ * L3 semantic DeviceState (visual/spatial/activity/…), gated to the domains
1926
+ * the plugin declared in `stateDomains`. Every domain is always present; an
1927
+ * undeclared one answers with an explicit "not declared" rather than data —
1928
+ * the same gating a UI plugin's `props.deviceState` gets. This is what lets an
1929
+ * EXTERNAL tool (e.g. Ada Guide's `guide.observe`) read `ctx.deviceState.visual`
1930
+ * over the public surface, with no SDK-internal access.
1931
+ *
1932
+ * `subscribe` refuses (RESTRICTED) on every domain: a call has no lifetime to
1933
+ * own a subscription, and the engines outlive it. Read instead.
1934
+ */
1935
+ deviceState: DeviceState;
1936
+ /** Cooperative cancellation: caller cancelled, or the session went away. */
1937
+ signal?: AbortSignal;
1938
+ }
1939
+ type DeviceToolFn = (args: any, ctx: DeviceToolContext) => Promise<any> | any;
1940
+ interface DeviceToolDefinition {
1941
+ /** Domain name, as the catalog and the caller see it. e.g. "guide.observe" */
1942
+ name: string;
1943
+ description?: string;
1944
+ /** JSON Schema for arguments — advertised to Core, not enforced here. */
1945
+ input_schema?: Record<string, unknown>;
1946
+ run: DeviceToolFn;
1947
+ }
1948
+ /** A plugin that ships tools and no UI at all. */
1949
+ interface RegisteredToolsPlugin {
1950
+ id: string;
1951
+ type: 'tools';
1952
+ capabilities?: NativeCapabilities$1;
1953
+ /**
1954
+ * L3 DeviceState domains this plugin's tools read via `ctx.deviceState` (e.g.
1955
+ * `['visual']`). For a remote bundle this is the author's statement, not a
1956
+ * grant: the tools get the manifest's `stateDomains` (resolvePluginGrants),
1957
+ * and a domain declared here but not there is denied with a warning.
1958
+ */
1959
+ stateDomains?: DeviceBridgeDomain[];
1960
+ tools: DeviceToolDefinition[];
1961
+ }
1962
+ type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin | RegisteredToolsPlugin;
371
1963
 
372
1964
  /**
373
1965
  * Plugin registration API.
@@ -376,12 +1968,35 @@ type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin;
376
1968
 
377
1969
  /**
378
1970
  * Register a UI or hybrid plugin (has a visual component).
1971
+ *
1972
+ * `tools` is optional: a plugin may ship UI and local tools together. Tools are
1973
+ * picked up when the bundle loads, independently of whether the UI is mounted.
379
1974
  */
380
1975
  declare function register(id: string, config: {
381
1976
  type?: 'ui' | 'hybrid';
382
1977
  capabilities?: NativeCapabilities$1;
1978
+ /** L3 DeviceState domains this plugin's UI reads via props.deviceState (e.g.
1979
+ * ['spatial','visual']). Carried through so a HOST-registered plugin (no
1980
+ * server manifest) can declare its domains — resolvePluginRuntimeProps reads
1981
+ * this as the trusted source when there is no manifest. Dropping it here left
1982
+ * every registered UI plugin with no L3, silently. Mirrors registerTools. */
1983
+ stateDomains?: DeviceBridgeDomain[];
383
1984
  Component: React.FC<PluginProps>;
1985
+ tools?: DeviceToolDefinition[];
384
1986
  }): RegisteredUIPlugin;
1987
+ /**
1988
+ * Register a tools-only plugin — a `tools/` section with no UI at all.
1989
+ *
1990
+ * Nothing here mounts. The tools become callable as soon as the bundle is
1991
+ * evaluated, and stay callable with nothing of the plugin on screen.
1992
+ */
1993
+ declare function registerTools(id: string, config: {
1994
+ capabilities?: NativeCapabilities$1;
1995
+ /** L3 DeviceState domains the tools read via ctx.deviceState (e.g. ['visual']).
1996
+ * For a remote bundle the MANIFEST grants them — see RegisteredToolsPlugin. */
1997
+ stateDomains?: DeviceBridgeDomain[];
1998
+ tools: DeviceToolDefinition[];
1999
+ }): RegisteredToolsPlugin;
385
2000
  /**
386
2001
  * Register a headless service plugin (no UI).
387
2002
  */
@@ -460,6 +2075,20 @@ interface ActionButtonProps {
460
2075
  }
461
2076
  declare function ActionButton({ theme, label, icon, onPress, style }: ActionButtonProps): React$1.FunctionComponentElement<react_native.PressableProps & React$1.RefAttributes<View>>;
462
2077
 
2078
+ interface CameraPreviewProps {
2079
+ /**
2080
+ * The plugin's own `props.native`. REQUIRED, and it is the gate: the runtime
2081
+ * stamps the declared capabilities onto this object, so passing it is how the
2082
+ * component knows whether this plugin may show a camera at all.
2083
+ */
2084
+ native: PluginNative;
2085
+ facing?: 'front' | 'back';
2086
+ style?: any;
2087
+ /** Rendered instead of the built-in panel when the preview is unavailable. */
2088
+ renderUnavailable?: (reason: string) => React$1.ReactElement | null;
2089
+ }
2090
+ declare function CameraPreview({ native, facing, style, renderUnavailable }: CameraPreviewProps): react_jsx_runtime.JSX.Element | null;
2091
+
463
2092
  type ThemeColors = ThemeTokens['colors'];
464
2093
  /** Override the plugin theme (host brand). Call once at startup — mounted
465
2094
  * plugins re-render with the new tokens. */
@@ -486,8 +2115,88 @@ declare function useApi(bridge: PluginBridge): PluginApi;
486
2115
  declare const PluginSDK: {
487
2116
  readonly register: typeof register;
488
2117
  readonly registerService: typeof registerService;
2118
+ readonly registerTools: typeof registerTools;
489
2119
  };
490
2120
 
2121
+ /**
2122
+ * Host DI for the SEMANTIC layer — the `props.deviceState` counterpart of
2123
+ * `setPluginNativeProvider`.
2124
+ *
2125
+ * Kept in its own file rather than added to host.ts, for the same reason the
2126
+ * two layers have separate namespaces: the moment they share a module, the
2127
+ * next person adds a domain to `PluginNative` or a capability to
2128
+ * `stateDomains` and the boundary starts to blur.
2129
+ *
2130
+ * THE GATE IS NOT THE MECHANICAL ONE. `gatePluginNative` replaces an
2131
+ * undeclared capability with a proxy whose methods warn and return `undefined`
2132
+ * — so `await native.location.getCurrent()` resolves to `undefined`, and
2133
+ * `pos?.latitude` is `undefined`, which flows into a model answer as a
2134
+ * confident nothing. A silent undefined is a fabrication vector.
2135
+ *
2136
+ * Here, an undeclared domain returns a real snapshot in which everything is
2137
+ * explicitly `unknown` / `null`, with `freshness.state = 'unknown'`. The
2138
+ * plugin gets a well-formed answer that says "I know nothing", which no
2139
+ * reasonable code path can mistake for data.
2140
+ */
2141
+
2142
+ /** Host DI: the app supplies the engines it has. Called once at startup. */
2143
+ declare function setDeviceStateProvider(state: Partial<DeviceState>): void;
2144
+ /**
2145
+ * The `deviceState` a plugin receives, gated by what it declared.
2146
+ *
2147
+ * Every domain is always present — a plugin can call any of the eight without
2148
+ * an existence check. What differs is whether it answers with data, or with
2149
+ * an explicit "I know nothing" and a reason.
2150
+ */
2151
+ declare function gateDeviceState(declared?: readonly DeviceBridgeDomain[]): DeviceState;
2152
+ /** Which domains this build can actually answer — for diagnostics. */
2153
+ declare function implementedStateDomains(): DeviceBridgeDomain[];
2154
+
2155
+ /**
2156
+ * Host DI + gate for `props.voice` — the session counterpart of
2157
+ * setDeviceStateProvider / setPluginNativeProvider.
2158
+ *
2159
+ * The app mounts one live voice session (VoiceSessionHost in @ateam-ai-mobile/ui)
2160
+ * and hands it here once at startup via setVoiceProvider. gateVoice then decides,
2161
+ * per plugin, whether that session is exposed:
2162
+ *
2163
+ * - declared (`capabilities: { voice: true }`) + a provider present → the real
2164
+ * session.
2165
+ * - declared but no provider (no session host in this build) → a refusal that
2166
+ * names the reason.
2167
+ * - NOT declared → a refusal that names the reason.
2168
+ *
2169
+ * ABSENCE IS AN ANSWER (spec invariant 6). A denied surface never fakes an
2170
+ * `idle` snapshot and never silently no-ops: reads report `state: 'error'` with
2171
+ * an explicit `lastError`, and actions throw. A silent `idle` would read to a
2172
+ * reasoning loop as "a working session that simply isn't connected yet" — a
2173
+ * fabrication, the same class of bug as an undefined that flows into an answer.
2174
+ */
2175
+
2176
+ /** Host DI: the app supplies its live (owner-aware) voice session. Called once
2177
+ * at startup; pass null to detach (e.g. in a build with no voice). */
2178
+ declare function setVoiceProvider(provider: VoiceSessionProvider | null): void;
2179
+ /** Whether this build has a voice session to expose — for diagnostics. */
2180
+ declare function implementedVoice(): boolean;
2181
+ /**
2182
+ * The `voice` a plugin receives, gated by whether it declared the capability AND
2183
+ * scoped to its identity.
2184
+ *
2185
+ * `declared` is `!!capabilities.voice` from the plugin's TRUSTED declaration
2186
+ * (resolved in pluginRuntime.ts — the server manifest for a remote plugin, the
2187
+ * registry entry for a host-owned one). A downloaded bundle cannot grant itself
2188
+ * voice. `pluginId` is the trusted identity the returned facade is bound to.
2189
+ *
2190
+ * Reading the transcript of a live session is a privacy surface, not just an
2191
+ * API: this gate is the only thing between a plugin and everything the user
2192
+ * says. Two protections stack: (1) it is coupled to evidence — a live session
2193
+ * shows the platform-owned, unsuppressable mic indicator, so there is no path to
2194
+ * a transcript without visible proof the mic is/was live; and (2) it is scoped
2195
+ * to the owner, so declaring `voice` lets a plugin run ITS OWN conversation, not
2196
+ * eavesdrop on another solution's. A missing identity FAILS CLOSED.
2197
+ */
2198
+ declare function gateVoice(declared: boolean, pluginId: string): VoiceAPI;
2199
+
491
2200
  /**
492
2201
  * Host integration points — the DI seams that keep this package free of
493
2202
  * Expo imports. The host app (or @ateam-ai-mobile/expo) plugs in concrete providers
@@ -500,7 +2209,8 @@ declare function setHapticsProvider(haptics: HapticsAPI | null): void;
500
2209
  /** No-op safe accessor — surfaces call this for tap feedback. */
501
2210
  declare function getHaptics(): HapticsAPI | null;
502
2211
  /** Install the device-capabilities implementation plugins receive as
503
- * `props.native`. The host owns the native modules. */
2212
+ * `props.device` (the L1 native modules keep the `native` name; the plugin-
2213
+ * facing prop is `device`). The host owns the native modules. */
504
2214
  declare function setPluginNativeProvider(native: PluginNative): void;
505
2215
  declare function getPluginNative(): PluginNative;
506
2216
  /**
@@ -512,7 +2222,18 @@ declare function getPluginNative(): PluginNative;
512
2222
  * Flag mapping mirrors the manifest contract: `storage` is gated by
513
2223
  * `offline` (offline data ⇒ local storage).
514
2224
  */
515
- declare function gatePluginNative(native: PluginNative, declared?: NativeCapabilities$1): PluginNative;
2225
+ declare function gatePluginNative(native: PluginNative, declared?: NativeCapabilities$1, pluginId?: string): PluginNative;
2226
+ /** Props the host's preview implementation receives. */
2227
+ interface CameraPreviewImplProps {
2228
+ facing?: 'front' | 'back';
2229
+ style?: any;
2230
+ renderUnavailable?: (reason: string) => any;
2231
+ }
2232
+ /** Install the host's camera preview (e.g. an expo-camera CameraView). */
2233
+ declare function setCameraPreviewProvider(Component: ((props: CameraPreviewImplProps) => any) | null): void;
2234
+ /** Null when the host installed none — the SDK then renders a stated reason,
2235
+ * never a black rectangle that looks like a dark room. */
2236
+ declare function getCameraPreviewProvider(): ((props: CameraPreviewImplProps) => any) | null;
516
2237
  type PluginResolver = RegisteredPlugin | (() => Promise<RegisteredPlugin | null>);
517
2238
  declare function registerNativePlugin(id: string, plugin: PluginResolver): void;
518
2239
  declare function unregisterNativePlugin(id: string): void;
@@ -522,11 +2243,262 @@ declare const nativePluginRegistry: {
522
2243
  };
523
2244
  declare function registerBundleModules(modules: Record<string, any>): void;
524
2245
 
2246
+ /** The gated surface a plugin Component receives. `device` and `native` are the
2247
+ * SAME object (native is the deprecated alias). */
2248
+ interface PluginRuntimeProps {
2249
+ device: PluginNative;
2250
+ native: PluginNative;
2251
+ deviceState: DeviceState;
2252
+ voice: VoiceAPI;
2253
+ }
2254
+ /** One declaration source — either the server manifest or the registry entry. */
2255
+ interface PluginDeclaration {
2256
+ capabilities?: NativeCapabilities$1 | Record<string, unknown>;
2257
+ native?: NativeCapabilities$1;
2258
+ stateDomains?: readonly DeviceBridgeDomain[];
2259
+ }
2260
+ interface PluginRuntimeSource {
2261
+ pluginId: string;
2262
+ /**
2263
+ * The server manifest (Core-passed) — the TRUSTED authority for a REMOTE
2264
+ * plugin. `capabilities` is the current field Core emits; `native` is the
2265
+ * legacy one. A grant absent here is NOT GRANTED (fail closed).
2266
+ */
2267
+ manifest?: PluginDeclaration | null;
2268
+ /**
2269
+ * The registry entry — the trusted authority for a HOST-OWNED plugin
2270
+ * (registered locally, no server manifest). For a REMOTE plugin this is the
2271
+ * downloaded bundle's own claim and is UNTRUSTED for gating; it is used ONLY
2272
+ * when there is no manifest.
2273
+ */
2274
+ plugin?: PluginDeclaration | null;
2275
+ }
2276
+ /** What a plugin is GRANTED — resolved from its one trusted source. The same
2277
+ * value gates its rendered surface and its device tools (the tool registry
2278
+ * receives it through bundleManager.load/loadTools). */
2279
+ interface PluginGrants {
2280
+ capabilities?: NativeCapabilities$1;
2281
+ stateDomains?: DeviceBridgeDomain[];
2282
+ }
2283
+ /**
2284
+ * Resolve a plugin's grants — the SINGLE place this happens, for every rendered
2285
+ * surface AND for the device tools its bundle registers, so a plugin gets
2286
+ * identical access through either.
2287
+ *
2288
+ * TRUST BOUNDARY. Exactly ONE source is authoritative: the server `manifest` for
2289
+ * a remote plugin, the registry `plugin` for a host-owned one. They are NEVER
2290
+ * mixed, and a remote plugin's own downloaded bundle claim NEVER expands its
2291
+ * capabilities or L3 domains past what the trusted source granted. If the
2292
+ * trusted source omits a grant, it is not granted — fail closed. (If Core does
2293
+ * not carry `stateDomains` for a remote plugin, the fix is Core passing it, not
2294
+ * trusting the bundle: Core already holds the solution's declaration.)
2295
+ */
2296
+ declare function resolvePluginGrants(src: PluginRuntimeSource): PluginGrants;
2297
+ /** Turn a plugin's grants into the gated { device, native, deviceState, voice } it
2298
+ * receives on every rendered surface. */
2299
+ declare function resolvePluginRuntimeProps(src: PluginRuntimeSource): PluginRuntimeProps;
2300
+
2301
+ interface DeviceToolSummary {
2302
+ name: string;
2303
+ pluginId: string;
2304
+ description?: string;
2305
+ input_schema?: Record<string, unknown>;
2306
+ }
2307
+ type DeviceToolOutcome = {
2308
+ ok: true;
2309
+ result: any;
2310
+ } | {
2311
+ ok: false;
2312
+ error: string;
2313
+ reason: 'not_found' | 'no_context' | 'timeout' | 'failed';
2314
+ };
2315
+ /**
2316
+ * Override how a device tool reaches the Device Bridge. Rarely needed — the
2317
+ * default below builds the same bridge/native pair a plugin gets when it
2318
+ * renders, gated by the capabilities the plugin declared.
2319
+ */
2320
+ declare function setDeviceToolContextProvider(fn: (pluginId: string, capabilities?: NativeCapabilities$1, stateDomains?: DeviceBridgeDomain[]) => DeviceToolContext): void;
2321
+ /**
2322
+ * Register a plugin's tools. Called from the bundle pipeline at load time.
2323
+ *
2324
+ * A name already taken by ANOTHER plugin is refused — a bundle loaded later
2325
+ * must not be able to take over "guide.observe" from the plugin that owns it.
2326
+ * Re-registering the same plugin (bundle reload) replaces its own entries.
2327
+ */
2328
+ declare function registerDeviceTools(pluginId: string, tools: DeviceToolDefinition[], capabilities?: NativeCapabilities$1, stateDomains?: DeviceBridgeDomain[]): string[];
2329
+ /** Drop every tool owned by a plugin (bundle eviction / plugin revoked). */
2330
+ declare function unregisterDeviceTools(pluginId: string): void;
2331
+ /** Is `name` registered — and, given `pluginId`, registered BY that plugin?
2332
+ * (A clean voice call only lets its owner's own tools run.) */
2333
+ declare function hasDeviceTool(name: string, pluginId?: string): boolean;
2334
+ declare function listDeviceTools(): DeviceToolSummary[];
2335
+ /**
2336
+ * Invoke a device tool. Never throws — every failure comes back as a typed
2337
+ * outcome, because the caller is usually a reasoning loop that has to say
2338
+ * something useful out loud rather than swallow an exception.
2339
+ */
2340
+ declare function callDeviceTool(name: string, args?: any, opts?: {
2341
+ timeoutMs?: number;
2342
+ signal?: AbortSignal;
2343
+ }): Promise<DeviceToolOutcome>;
2344
+ /** Test/host reset. */
2345
+ declare function clearDeviceTools(): void;
2346
+
2347
+ /**
2348
+ * Load every plugin that declares `capabilities.device_tools`, so its tools are
2349
+ * registered and callable. Best-effort and idempotent: safe to call on every
2350
+ * call start. Returns the tool names currently registered.
2351
+ *
2352
+ * Pass `force` after a plugin catalog change (a newly deployed connector).
2353
+ */
2354
+ declare function ensureDeviceToolsLoaded({ force }?: {
2355
+ force?: boolean | undefined;
2356
+ }): Promise<string[]>;
2357
+ /** Forget that we loaded, so the next call re-reads the catalog. */
2358
+ declare function resetDeviceToolLoader(): void;
2359
+
2360
+ declare function registerPlatformDeviceTools(): void;
2361
+
2362
+ interface SessionSnapshot {
2363
+ authenticated: boolean;
2364
+ /** The tenant the session is scoped to (derive it; never a stored copy). */
2365
+ tenant: string | null;
2366
+ }
2367
+ type DeviceToolsAction = 'load' | 'reset' | 'none';
2368
+ /**
2369
+ * PURE: what should the device-tool registry do for this session transition?
2370
+ * - a new authenticated tenant (initial sign-in OR a switch) → 'load'
2371
+ * - the same authenticated tenant again (e.g. a silent token refresh) → 'none'
2372
+ * - leaving authenticated (sign-out) → 'reset'
2373
+ */
2374
+ declare function deviceToolsActionForSession(next: SessionSnapshot, prevTenant: string | null): {
2375
+ action: DeviceToolsAction;
2376
+ tenant: string | null;
2377
+ };
2378
+ /** The effects a session transition drives — injected so a test can fake them. */
2379
+ interface DeviceToolsSessionDeps {
2380
+ /** Drop the previous tenant's device tools (a switch does not run logout teardown). */
2381
+ clear: () => void;
2382
+ /** Ensure the static, host-owned platform device tools are present. */
2383
+ registerPlatform: () => void;
2384
+ /** Forget the "already loaded" latch so a later (non-forced) load runs — sign-out. */
2385
+ resetLoader: () => void;
2386
+ /** Fire-and-forget reload of the current tenant's external device-tool bundles. */
2387
+ load: () => void;
2388
+ }
2389
+ /**
2390
+ * Apply a session transition to the device-tool registry. Call from the host's
2391
+ * existing auth-session subscription with a derived snapshot. Idempotent per
2392
+ * tenant: repeated snapshots for the same authenticated tenant do nothing, so a
2393
+ * silent token refresh does not churn the registry. Returns the action taken.
2394
+ */
2395
+ declare function syncDeviceToolsForSession(next: SessionSnapshot, deps?: DeviceToolsSessionDeps): DeviceToolsAction;
2396
+ /** Test seam: forget the last-seen tenant so a test starts from signed-out. */
2397
+ declare function __resetSessionSyncForTest(): void;
2398
+
2399
+ type PostResult = (correlationId: string, result: any, error?: string) => Promise<void>;
2400
+ /**
2401
+ * Host DI: how to answer the server. `@ateam-ai-mobile/core` supplies
2402
+ * postCommandResult; kept injectable so this package stays free of the REST
2403
+ * client and so tests can observe the answer.
2404
+ */
2405
+ declare function setDeviceToolResultPoster(fn: PostResult): void;
2406
+ /** Is this stream command a device tool call? */
2407
+ declare function isDeviceToolCommand(cmd: any): boolean;
2408
+ /**
2409
+ * Run a server-dispatched device tool and answer.
2410
+ *
2411
+ * Never throws — the server is blocked on the correlationId, so the one thing
2412
+ * we must not do is stay silent. Every path posts something back.
2413
+ */
2414
+ declare function handleServerDeviceToolCall(cmd: any): Promise<void>;
2415
+
2416
+ /** Where the bytes currently running came from. */
2417
+ type LoadedBundleSource = 'download' | 'disk-cache' | 'memory';
2418
+ /**
2419
+ * Provenance of one loaded bundle — what is RUNNING, not what was requested.
2420
+ *
2421
+ * `hash` is the sha256 of the evaluated bytes. `expected` is the hash the
2422
+ * server's manifest advertised for that url. Equal means the phone is running
2423
+ * the build the server currently describes; different means it is not, and the
2424
+ * pair says so without needing a rebuild, a reinstall or a guess.
2425
+ */
2426
+ interface LoadedBundleInfo {
2427
+ bundleUrl: string;
2428
+ version: string;
2429
+ /** sha256 (hex, lowercase) of the bytes that were evaluated. */
2430
+ hash: string | null;
2431
+ /** sha256 the server advertised for this url at load time, if any. */
2432
+ expected: string | null;
2433
+ source: LoadedBundleSource;
2434
+ tenant: string | null;
2435
+ loadedAt: number;
2436
+ }
525
2437
  declare class BundleManager {
526
- /** In-memory module cache: bundleUrl → evaluated module.exports */
2438
+ /** In-memory module cache: bundleUrl → evaluated module.exports. Holds ONE
2439
+ * tenant's modules at a time — the tenant boundary is `cacheTenant` /
2440
+ * `requireTenant()`, NOT the key. */
527
2441
  private modules;
2442
+ /** What is actually RUNNING, keyed by bundleUrl (for the current tenant). See
2443
+ * getLoadedBundles(). */
2444
+ private provenance;
528
2445
  /** In-flight download deduplication: bundleUrl → Promise */
529
2446
  private inflight;
2447
+ /**
2448
+ * The tenant the in-memory caches (modules / provenance / inflight) currently
2449
+ * belong to. This is the tenant BOUNDARY for evaluated code.
2450
+ *
2451
+ * A bundleUrl resolves PER-TENANT on the server — one url, different bytes for
2452
+ * different tenants — so a memory hit keyed on url alone would hand tenant B
2453
+ * the module tenant A already evaluated and ran. PR #9 scoped the DISK cache
2454
+ * (per-tenant index + hash gate); the in-memory map was left url-keyed. This
2455
+ * closes that: the caches only ever hold ONE tenant's modules, and a switch
2456
+ * drops them before anything is served.
2457
+ */
2458
+ private cacheTenant;
2459
+ /**
2460
+ * pluginIds this manager registered device tools for. A tenant switch drops
2461
+ * the in-memory modules AND unregisters these, so the incoming tenant's bundle
2462
+ * re-registers into a clean registry instead of being refused as a duplicate
2463
+ * of the previous tenant's tools.
2464
+ */
2465
+ private registeredToolPluginIds;
2466
+ /** bundleUrl → the trusted declaration last recorded on its disk-cache entry
2467
+ * this session, so a memory hit with unchanged grants skips the index I/O. */
2468
+ private recordedDeclarations;
2469
+ /**
2470
+ * Enforce the in-memory tenant boundary. Call at the top of every public load
2471
+ * path, BEFORE any cache read.
2472
+ *
2473
+ * FAIL CLOSED: without a tenant identity we cannot prove a cached module is
2474
+ * this session's, so we drop everything and refuse rather than risk serving
2475
+ * one tenant's evaluated code to another. On a tenant change we drop the
2476
+ * previous tenant's modules (and their tool registrations) before proceeding.
2477
+ */
2478
+ /**
2479
+ * Monotonic load generation. Bumped on every in-memory wipe (a tenant change,
2480
+ * fail-closed, or clearAll). A load captures it at ENTRY; a completion whose
2481
+ * generation no longer matches is stale and is discarded before it can commit.
2482
+ * This closes the async race the sequential guard cannot: tenant A's in-flight
2483
+ * loadModule can resolve AFTER a switch to B — clearing `inflight` does not
2484
+ * cancel that Promise — and would otherwise write A's module / provenance /
2485
+ * device tools into the singleton now marked cacheTenant=B.
2486
+ */
2487
+ private epoch;
2488
+ private requireTenant;
2489
+ /** Drop every in-memory cache and unregister the device tools this manager
2490
+ * registered — used on a tenant change and by clearAll(). */
2491
+ private dropInMemory;
2492
+ /** True once the boundary has moved since a load captured `epoch` — i.e. a
2493
+ * tenant switch (or wipe) happened while this load was in flight. Its results
2494
+ * belong to a tenant that is no longer current and must be discarded. */
2495
+ private isStale;
2496
+ /** Record a memory serve: the running bytes are the already-evaluated module,
2497
+ * and `expected` is refreshed from the manifest hash the caller JUST passed —
2498
+ * so after a redeploy the record reads hash !== expected (STALE) instead of
2499
+ * the old in-sync pair. The ONE place both memory paths (load()'s early return
2500
+ * and loadModule's) record reuse. No-op if nothing was recorded yet. */
2501
+ private markMemoryHit;
530
2502
  /**
531
2503
  * Load a plugin component. Returns the RegisteredUIPlugin object.
532
2504
  * Handles: in-memory cache → disk cache → download → evaluate → extract.
@@ -535,8 +2507,26 @@ declare class BundleManager {
535
2507
  * provided for any bundle the user has not previously verified — without
536
2508
  * it, a tampered bundle is `eval`'d as JS with full RN runtime + every
537
2509
  * native capability the manifest declares.
2510
+ *
2511
+ * `grants` is what the MANIFEST grants this plugin (resolvePluginGrants) — the
2512
+ * only authority its device tools are registered under. Omitted (e.g. a
2513
+ * prewarm from the slim plugin list) → the bundle is loaded but its tools are
2514
+ * NOT registered: without the manifest there is nothing trusted to gate them
2515
+ * on, and registering with nothing would downgrade a registration the
2516
+ * manifest-driven tool loader already made.
538
2517
  */
539
- load(pluginId: string, bundleUrl: string, version: string, expectedHash?: string): Promise<RegisteredUIPlugin>;
2518
+ load(pluginId: string, bundleUrl: string, version: string, expectedHash?: string, grants?: PluginGrants): Promise<RegisteredUIPlugin>;
2519
+ /**
2520
+ * Load a bundle for its `tools/` section only — no UI required.
2521
+ *
2522
+ * Same download, same integrity check, same evaluation as `load()`; the only
2523
+ * difference is that a bundle exporting tools and no `Component` is a success
2524
+ * here rather than "no component found". Returns the tool names registered.
2525
+ *
2526
+ * `grants` is REQUIRED: the tools are registered under `pluginId` with exactly
2527
+ * what the manifest grants (see load()), never what the bundle claims.
2528
+ */
2529
+ loadTools(pluginId: string, bundleUrl: string, version: string, expectedHash: string | undefined, grants: PluginGrants): Promise<string[]>;
540
2530
  /**
541
2531
  * Prewarm: load every plugin bundle in the background after app boot, so a
542
2532
  * later open is instant (in-memory hit) instead of round-tripping the
@@ -548,6 +2538,8 @@ declare class BundleManager {
548
2538
  bundleUrl: string;
549
2539
  version: string;
550
2540
  bundleHash?: string;
2541
+ /** The manifest's grants, when the caller has the manifest (see load()). */
2542
+ grants?: PluginGrants;
551
2543
  }>): Promise<void>;
552
2544
  /**
553
2545
  * Clear all caches (in-memory + storage).
@@ -557,7 +2549,47 @@ declare class BundleManager {
557
2549
  * Clear cache for a specific plugin/bundle.
558
2550
  */
559
2551
  clearPlugin(pluginId: string): Promise<void>;
2552
+ /**
2553
+ * What every loaded bundle IS, right now: the sha256 of the bytes running,
2554
+ * the hash the server advertised, and where they came from.
2555
+ *
2556
+ * Deliberately not filtered or prettified — this is the raw answer to "did my
2557
+ * redeploy reach the phone?", and it is comparable directly against the
2558
+ * manifest `bundleHash` and the server's X-Bundle-SHA256 header.
2559
+ */
2560
+ getLoadedBundles(): LoadedBundleInfo[];
560
2561
  private loadModule;
2562
+ /**
2563
+ * Register THIS plugin's device tools under the TRUSTED declaration, then
2564
+ * record that declaration on the disk-cache entry. Runs on every trusted load
2565
+ * — memory hit included — so the grants in force are always the latest the
2566
+ * manifest gave (a revoked grant stops applying on the next load, not the next
2567
+ * app start). Callers run it only after their stale check.
2568
+ */
2569
+ private commitTools;
2570
+ /**
2571
+ * Put the plugin's own `tools` section in the device tool registry — under the
2572
+ * pluginId and grants the MANIFEST resolved, never the bundle's own `id`,
2573
+ * `capabilities` or `stateDomains`. A bundle is code the manifest authorised
2574
+ * by hash; what it may touch is the manifest's to say, same as its rendered
2575
+ * surface (resolvePluginRuntimeProps). Its own `id` would also let it register
2576
+ * as another plugin — including the host's platform tools, which a same-id
2577
+ * re-registration replaces.
2578
+ *
2579
+ * Only this plugin's export (the same lookup the component uses) is
2580
+ * registered. Another export's tools in the same bundle belong to another
2581
+ * plugin and register when THAT plugin's manifest loads it.
2582
+ */
2583
+ private registerToolsFromModule;
2584
+ /**
2585
+ * Record the trusted declaration on this tenant's disk-cache entry for the
2586
+ * bytes RUNNING — the recovery path (tools/loader loadFromDiskCache) has no
2587
+ * manifest and re-registers with exactly this. Only onto an entry this tenant
2588
+ * owns whose hash is the running hash: the grant was issued for these bytes,
2589
+ * and those are the bytes recovery will evaluate. Best-effort — a lost write
2590
+ * leaves no `trusted`, which recovery treats as fail closed.
2591
+ */
2592
+ private recordTrustedDeclaration;
561
2593
  /**
562
2594
  * Download + integrity-verify a bundle. Refuses to return when:
563
2595
  * - manifest hash and computed hash disagree
@@ -567,11 +2599,15 @@ declare class BundleManager {
567
2599
  */
568
2600
  private download;
569
2601
  private evaluate;
2602
+ /** Find a plugin component in a loaded module. */
2603
+ private getComponentFromMemory;
570
2604
  /**
571
- * Find a plugin component in a loaded module.
572
- * Handles: default export, direct key match, multi-plugin .id match.
2605
+ * Find THIS plugin's export in a loaded module — the one lookup both the
2606
+ * component and the device tools use, so a plugin's UI and its tools always
2607
+ * come from the same export. `accept` says what the caller needs from it.
2608
+ * Handles: direct key match, PascalCase key, default export, multi-plugin .id match.
573
2609
  */
574
- private getComponentFromMemory;
2610
+ private findPluginExport;
575
2611
  private describeModule;
576
2612
  }
577
2613
  /** Singleton — shared across all plugin renderers */
@@ -584,6 +2620,7 @@ declare const bundleManager: BundleManager;
584
2620
  * default is in-memory (bundles re-download per app session — correct,
585
2621
  * just slower on cold start).
586
2622
  */
2623
+
587
2624
  interface BundleCacheEntry {
588
2625
  localPath: string;
589
2626
  version: string;
@@ -591,6 +2628,37 @@ interface BundleCacheEntry {
591
2628
  bundleUrl: string;
592
2629
  /** SHA-256 (hex, lowercase) of the cached bundle bytes — integrity proof */
593
2630
  bundleHash?: string;
2631
+ /**
2632
+ * Tenant whose session downloaded these bytes.
2633
+ *
2634
+ * A bundle URL is NOT tenant-independent: `/api/ui-plugins/<id>/bundle.js`
2635
+ * resolves per-tenant on the server, so the SAME url returns different code
2636
+ * for different tenants. On the normal load path the hash gate catches this
2637
+ * for free — the expected hash comes from the current tenant's manifest and
2638
+ * will not match another tenant's cached bytes. The disk-cache recovery path
2639
+ * has no manifest to check against, so it must discriminate by tenant.
2640
+ *
2641
+ * Absent on entries written before this field existed: unknown ownership, so
2642
+ * untrusted. Never read a missing tenant as "mine".
2643
+ */
2644
+ tenant?: string;
2645
+ /**
2646
+ * The trusted declaration these bytes' device tools were last registered
2647
+ * under: the pluginId and grants resolved from the server manifest that named
2648
+ * this bundle (resolvePluginGrants). Recorded so the disk-cache recovery path —
2649
+ * which runs exactly when there is NO manifest — re-registers with the same
2650
+ * authority instead of the bundle's own claim.
2651
+ *
2652
+ * Absent → no trusted declaration is known for these bytes, so recovery skips
2653
+ * the entry (fail closed) and the catalog path registers it once the owning
2654
+ * connector answers again.
2655
+ */
2656
+ trusted?: TrustedToolDeclaration;
2657
+ }
2658
+ /** Who a bundle's device tools belong to and what they are granted — always
2659
+ * from the manifest, never from the bundle. */
2660
+ interface TrustedToolDeclaration extends PluginGrants {
2661
+ pluginId: string;
594
2662
  }
595
2663
  interface BundleStorage {
596
2664
  loadIndex(): Promise<Record<string, BundleCacheEntry>>;
@@ -821,6 +2889,9 @@ interface PluginManifestLike {
821
2889
  name?: string;
822
2890
  render?: any;
823
2891
  _connectorId?: string;
2892
+ capabilities?: Record<string, unknown>;
2893
+ native?: NativeCapabilities$1;
2894
+ stateDomains?: DeviceBridgeDomain[];
824
2895
  }
825
2896
  interface UsePluginResult {
826
2897
  plugin: RegisteredUIPlugin | null;
@@ -1139,4 +3210,4 @@ declare function isInlineSurface(m: SurfaceManifest): m is InlineSurfaceManifest
1139
3210
 
1140
3211
  declare function SurfaceDispatcher(): react_jsx_runtime.JSX.Element;
1141
3212
 
1142
- export { ActionButton, AdaOrb, type AdaOrbState, Badge, type BiometricsAPI, type BiometricsAPIv2, type Bridge, type BundleCacheEntry, type BundleStorage, type CameraAPI, Card, type ConnectivityAPI, type CookiesAPI, EmptyState, ErrorState, type FileSystemAPI, type HapticsAPI, type InlineSurfaceManifest, ListItem, LoadingState, type LocationAPI, MemoryBundleStorage, type MountSurfaceManifest, type NativeCapabilities$1 as NativeCapabilities, type NfcAPI, type NotificationsAPI, type PluginManifest as PlatformPluginManifest, type PluginBridge, type PluginContext, type PluginHooks$1 as PluginHooks, type PluginManifest$1 as PluginManifest, type PluginNative, type PluginProps, PluginSDK, type PluginSummary, type PluginSurfaceMeta, type RegisteredPlugin, type RegisteredServicePlugin, type RegisteredUIPlugin, type RemotePlugin, type RenderMode, type ScannerAPI, SearchBar, type SharingAPI, type StorageAPI, SurfaceCard, type SurfaceCardProps, SurfaceDispatcher, SurfaceDrawer, type SurfaceDrawerProps, SurfaceFullscreen, type SurfaceFullscreenProps, type SurfaceManifest, type SurfacePlacement, type SurfaceType, type SurfaceVisibility, TabBar, type ThemeTokens, bundleManager, createNativeBridge, gatePluginNative, getHaptics, getNativeBundleHash, getNativeBundleUrl, getNativeComponentId, getPlugin, getPluginNative, isInlineSurface, isMountSurface, listPlugins, nativePluginRegistry, register, registerBundleModules, registerNativePlugin, registerService, resolvePluginUrl, setBundleStorage, setHapticsProvider, setPluginNativeProvider, setPluginThemeColors, supportsNative, supportsWebView, unregisterNativePlugin, useApi, useNativeBridge, usePlugin, usePluginTheme, useRemotePlugins, useSurfaceStore };
3213
+ export { ActionButton, type ActivitySnapshot, AdaOrb, type AdaOrbState, Badge, type BiometricsAPI, type BiometricsAPIv2, type BluetoothAPI, type BluetoothDevice, type Bridge, type BundleCacheEntry, type BundleStorage, type CameraAPI, CameraPreview, type CameraPreviewProps, type CapabilitySnapshot, type CapabilityStatus, Card, type Confidence, type ConnectivityAPI, type ConnectivitySnapshot, type CookiesAPI, type DeviceBridgeDomain, type DeviceContextSnapshot, type DeviceState, type DeviceToolContext, type DeviceToolDefinition, type DeviceToolFn, type DeviceToolOutcome, type DeviceToolSummary, type DeviceToolsAction, type DeviceToolsSessionDeps, EmptyState, type EnvironmentSnapshot, ErrorState, type FileSystemAPI, type Freshness, type HapticsAPI, type InlineSurfaceManifest, type InteractionSnapshot, ListItem, type LoadedBundleInfo, type LoadedBundleSource, LoadingState, type LocationAPI, type MapsAPI, type MapsAddress, type MapsCoordinate, type MapsPlace, type MapsRoute, type MapsRouteStep, MemoryBundleStorage, type MountSurfaceManifest, type NativeCapabilities$1 as NativeCapabilities, type NfcAPI, type NotificationsAPI, PLATFORM_VOICE_CALLER, type PluginManifest as PlatformPluginManifest, type PluginBridge, type PluginContext, type PluginGrants, type PluginHooks$1 as PluginHooks, type PluginManifest$1 as PluginManifest, type PluginNative, type PluginProps, type PluginRuntimeProps, type PluginRuntimeSource, PluginSDK, type PluginSummary, type PluginSurfaceMeta, type RegisteredPlugin, type RegisteredServicePlugin, type RegisteredToolsPlugin, type RegisteredUIPlugin, type RemotePlugin, type RenderMode, type ScannerAPI, SearchBar, type SessionSnapshot, type SharingAPI, type SpatialEvent, type SpatialIntent, type SpatialPoint, type SpatialSnapshot, type SpatialStateEvent, type StateEvent, type StateListener, type StateQuality, type StorageAPI, type Subscription, SurfaceCard, type SurfaceCardProps, SurfaceDispatcher, SurfaceDrawer, type SurfaceDrawerProps, SurfaceFullscreen, type SurfaceFullscreenProps, type SurfaceManifest, type SurfacePlacement, type SurfaceType, type SurfaceVisibility, TabBar, type ThemeTokens, type TrustedToolDeclaration, type VisionAPI, type VisionFrameQuality, type VisionLabel, type VisionObject, type VisionText, type VisualSnapshot, type VoiceAPI, type VoiceCaller, type VoiceEvent, type VoicePersona, type VoiceSessionProvider, type VoiceSnapshot, type VoiceStartOptions, type VoiceState, type VoiceSubscription, type VoiceTranscriptEntry, type WebViewCookie, __resetSessionSyncForTest, bundleManager, callDeviceTool, clearDeviceTools, createNativeBridge, deviceState as deviceStateV2, deviceToolsActionForSession, ensureDeviceToolsLoaded, explainCapability, gateDeviceState, gatePluginNative, gateVoice, getCameraPreviewProvider, getHaptics, getNativeBundleHash, getNativeBundleUrl, getNativeComponentId, getPlugin, getPluginNative, handleServerDeviceToolCall, hasDeviceTool, implementedStateDomains, implementedVoice, isActionable, isDeviceToolCommand, isInlineSurface, isMountSurface, listDeviceTools, listPlugins, nativePluginRegistry, neverObserved, register, registerBundleModules, registerDeviceTools, registerNativePlugin, registerPlatformDeviceTools, registerService, registerTools, resetDeviceToolLoader, resolvePluginGrants, resolvePluginRuntimeProps, resolvePluginUrl, setBundleStorage, setCameraPreviewProvider, setDeviceStateProvider, setDeviceToolContextProvider, setDeviceToolResultPoster, setHapticsProvider, setPluginNativeProvider, setPluginThemeColors, setVoiceProvider, supportsNative, supportsWebView, syncDeviceToolsForSession, unregisterDeviceTools, unregisterNativePlugin, useApi, useNativeBridge, usePlugin, usePluginTheme, useRemotePlugins, useSurfaceStore };