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

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.ts CHANGED
@@ -5,12 +5,1054 @@ 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 `native: { camera: true }`.
24
+ * Lives in '@adas/plugin-sdk'.
25
+ *
26
+ * props.deviceState.* SEMANTIC. "What is true right now, and how well do
27
+ * we know it?"
28
+ * STATE names: getSnapshot, subscribe, start, stop.
29
+ * Returns a SNAPSHOT carrying freshness + confidence,
30
+ * and able to say `unknown` in every field.
31
+ * A read NEVER throws for absence.
32
+ * Declared in the manifest as `stateDomains: ['visual']`.
33
+ * Lives in '@adas/device-state' (this file).
34
+ *
35
+ * The two containers are the discriminator. Every semantic call in a plugin
36
+ * begins with the eight characters `deviceState.`; every mechanical call
37
+ * begins with `native.`. There is no line of code where the layer is
38
+ * ambiguous, and no object on which both appear.
39
+ *
40
+ * This is a deliberate departure from
41
+ * Docs/device-bridge-v2/DEVICE_BRIDGE_V2_IMPLEMENTATION_GROUNDING_2026-08-07.md
42
+ * §"Migration strategy", which proposed adding `visual`, `spatial`,
43
+ * `connectivityState`… as fields ON `PluginNative`. See the design note at the
44
+ * bottom of this file for why that layout is rejected and what it costs.
45
+ * ─────────────────────────────────────────────────────────────────────────
46
+ *
47
+ * PROVENANCE MARKERS used throughout. They are not decoration — the freeze
48
+ * really does fix some of this and really does leave the rest open, and an
49
+ * implementor needs to know which is which before changing a line:
50
+ *
51
+ * FROZEN — API_FREEZE 2026-08-07 §15 lists it as a frozen candidate.
52
+ * V0.1 — "v0.1 freeze candidate; may change only when implementation
53
+ * grounding proves a field impossible/non-portable."
54
+ * OPEN — the docs explicitly leave it undecided. Do not quietly close it.
55
+ * DECISION — not in the docs at all; decided here, and reversible.
56
+ */
57
+ /** FROZEN (API_FREEZE §1). The eight domain keys. */
58
+ type DeviceBridgeDomain = 'spatial' | 'visual' | 'activity' | 'deviceContext' | 'connectivity' | 'capability' | 'environment' | 'interaction';
59
+ type FreshnessState = 'fresh' | 'aging' | 'stale' | 'unknown';
60
+ /**
61
+ * FROZEN. Both fields are nullable on purpose: "we have no observation at all"
62
+ * has to be expressible without inventing a timestamp.
63
+ *
64
+ * `aging` is a real third state, not a rounding of fresh/stale — a consumer
65
+ * that only branches on fresh-vs-stale is misreading the contract.
66
+ *
67
+ * API_FREEZE §1 "Why observedAt + ageMs": the host may compute `ageMs` at read
68
+ * time, so it can be more accurate than `Date.now() - observedAt` measured on
69
+ * the JS side after a bridge hop. Read `ageMs` when you have it.
70
+ */
71
+ interface Freshness {
72
+ state: FreshnessState;
73
+ /** Epoch ms of the observation itself. `null` = never observed. */
74
+ observedAt: number | null;
75
+ /** Age at read time. `null` = never observed. */
76
+ ageMs: number | null;
77
+ }
78
+ type ConfidenceLevel = 'high' | 'medium' | 'low' | 'unknown';
79
+ /**
80
+ * FROZEN. `level` is mandatory and ordinal; `score` is optional and exists
81
+ * only "when a stable numeric estimate exists" (API_FREEZE §1).
82
+ *
83
+ * The asymmetry is the point: native must not manufacture 0.5 to fill a field.
84
+ * A missing `score` is information — it says the estimator has no calibrated
85
+ * number, only a bucket.
86
+ */
87
+ interface Confidence {
88
+ level: ConfidenceLevel;
89
+ /** 0..1. Omitted when no stable numeric estimate exists. */
90
+ score?: number;
91
+ }
92
+ /**
93
+ * FROZEN. Every snapshot in v2 carries exactly one of these.
94
+ *
95
+ * `freshness` required, `confidence` optional — because some domains report
96
+ * exact OS facts (battery level, audio route) where a confidence is
97
+ * meaningless, and others report inference (activity, indoor/outdoor) where it
98
+ * is mandatory. Cross-domain review: "Freshness is mandatory for dynamic
99
+ * state. Confidence is mandatory for inferred/estimated state; optional for
100
+ * exact OS facts."
101
+ *
102
+ * NOTE (OPEN): freshness is per-SNAPSHOT, never per-field. So a snapshot
103
+ * cannot today say "lighting is fresh but acoustics is stale". The
104
+ * architecture doc raised this for Spatial and Environment and never resolved
105
+ * it. Do not work around it by inventing a second freshness field.
106
+ */
107
+ interface StateQuality {
108
+ freshness: Freshness;
109
+ confidence?: Confidence;
110
+ }
111
+ type SupportState = 'supported' | 'unsupported' | 'unknown';
112
+ type PermissionState$1 = 'granted' | 'denied' | 'restricted' | 'notDetermined' | 'limited' | 'unavailable' | 'unknown';
113
+ type AvailabilityState = 'available' | 'busy' | 'temporarilyUnavailable' | 'unavailable' | 'unknown';
114
+ type RestrictionState = 'none' | 'backgroundRestricted' | 'systemRestricted' | 'policyRestricted' | 'lowPowerRestricted' | 'unknown';
115
+ /**
116
+ * FROZEN (API_FREEZE core rule 4): "Events are meaningful semantic changes,
117
+ * never raw sensor streams." If you find yourself wanting an event per frame
118
+ * or per GPS fix, you are in the wrong layer.
119
+ */
120
+ interface StateEvent<TName extends string = string> {
121
+ domain: DeviceBridgeDomain;
122
+ name: TName;
123
+ /** Epoch ms. */
124
+ timestamp: number;
125
+ /**
126
+ * OPEN: per-event payload schemas are not specified anywhere in the three
127
+ * design docs. Typed `unknown` rather than `any` so a consumer is forced to
128
+ * narrow it rather than read a field that may not exist.
129
+ */
130
+ payload?: unknown;
131
+ }
132
+ interface Subscription {
133
+ unsubscribe(): void;
134
+ }
135
+ type StateListener<TName extends string = string> = (event: StateEvent<TName>) => void;
136
+ /**
137
+ * VOCABULARY §"Event Naming Rule", expressed as a type.
138
+ *
139
+ * Four domains (visual, activity, spatial, deviceContext) have NO frozen event
140
+ * vocabulary — the vocabulary doc publishes "Initial … Events" lists for
141
+ * connectivity, capability, interaction and environment only. Rather than mint
142
+ * names those docs deliberately did not mint, their events are typed against
143
+ * the naming RULE. A wrong-shaped name is a compile error; the specific
144
+ * vocabulary stays open, as the docs left it.
145
+ */
146
+ type ConformingEventName = `${string}Changed` | `${string}Started` | `${string}Stopped` | `${string}Available` | `${string}Unavailable` | `${string}StateStale`;
147
+ /** FROZEN (API_FREEZE §12). */
148
+ type DeviceBridgeErrorCode = 'UNSUPPORTED' | 'PERMISSION_DENIED' | 'RESTRICTED' | 'BUSY' | 'TEMPORARILY_UNAVAILABLE' | 'STALE' | 'TIMEOUT' | 'CANCELLED' | 'INTERNAL';
149
+ /**
150
+ * FROZEN shape (API_FREEZE §12).
151
+ *
152
+ * READ THE SCOPE LINE CAREFULLY. This is for FAILED OPERATIONS —
153
+ * `visual.capture()`, `visual.start()`. It is NOT how the surface reports that
154
+ * a camera is denied or a sensor is missing: "Expected state should still be
155
+ * exposed in snapshots."
156
+ *
157
+ * So: `getSnapshot()` does not throw this. `capture()` and `start()` do.
158
+ * That split is the whole no-fabrication contract in one sentence — a read
159
+ * always returns something honest, an action either happens or explains
160
+ * itself in a typed code.
161
+ */
162
+ declare class DeviceBridgeError extends Error {
163
+ readonly code: DeviceBridgeErrorCode;
164
+ readonly domain?: DeviceBridgeDomain;
165
+ /** Whether retrying could plausibly succeed (BUSY yes, UNSUPPORTED no). */
166
+ readonly recoverable?: boolean;
167
+ constructor(code: DeviceBridgeErrorCode, message: string, opts?: {
168
+ domain?: DeviceBridgeDomain;
169
+ recoverable?: boolean;
170
+ });
171
+ }
172
+ interface StateSession {
173
+ id: string;
174
+ /** The intent this session was started with. */
175
+ intent: string;
176
+ startedAt: number;
177
+ }
178
+ interface StartOptions<TIntent extends string> {
179
+ /**
180
+ * MANDATORY, and that is the single most important line in this file.
181
+ *
182
+ * You cannot open a v2 session without naming what it is FOR. Native then
183
+ * owns sampling rate, resolution, buffering, fusion and power — API_FREEZE
184
+ * core rule 6. `start()` means "maintain state according to this intent",
185
+ * not "open a particular native API" (§2).
186
+ *
187
+ * Contrast the mechanical layer, where `camera.takePhoto()` is a legal,
188
+ * complete, intent-free call. That difference is visible at every call site.
189
+ */
190
+ intent: TIntent;
191
+ /**
192
+ * DECISION (not frozen). §13 requires bounded timeout/cancellation for
193
+ * session starts and says chain STOP must propagate to native, but leaves
194
+ * the plumbing "implementation-specific". `AbortSignal` is what
195
+ * `DeviceToolContext.signal` already carries in this SDK, so reuse it rather
196
+ * than invent a second cancellation idiom.
197
+ */
198
+ signal?: AbortSignal;
199
+ }
200
+ /**
201
+ * FROZEN pattern (API_FREEZE §2), reproduced as documentation. The eight
202
+ * concrete domains below do NOT extend this — each spells out exactly the
203
+ * methods it has, because §11 warns "domains should not be forced to implement
204
+ * meaningless lifecycle methods" and three domains genuinely have no intent
205
+ * vocabulary at all.
206
+ *
207
+ * Two invariants apply to every domain:
208
+ * - `getSnapshot()` reads CACHED state. It must not trigger expensive raw
209
+ * sensing (no camera open, no network probe, no GPS cold start).
210
+ * - "Multiple plugins may eventually share one native engine; session
211
+ * ownership must not imply duplicate sensor pipelines." (OPEN: the
212
+ * arbitration mechanism is undefined. See DESIGN NOTE 3.)
213
+ */
214
+ interface StatefulDomain<TSnapshot, TIntent extends string = never, TEvent extends string = string> {
215
+ getSnapshot(): Promise<TSnapshot>;
216
+ start?(options: StartOptions<TIntent>): Promise<StateSession>;
217
+ stop?(sessionId?: string): Promise<void>;
218
+ subscribe?(listener: StateListener<TEvent>): Subscription;
219
+ }
220
+ /**
221
+ * Why every read is `Promise<T>` even though it is a cached read.
222
+ *
223
+ * Not because it samples — it must not. Because it crosses the RN bridge, and
224
+ * this SDK has no synchronous JSI module today (verified: everything in
225
+ * PluginNative except haptics/connectivity.isOnline is Promise-based). The
226
+ * grounding doc's own target for a warm cached snapshot is p50 < 10 ms /
227
+ * p95 < 30 ms, which is an async-bridge budget, not a sampling budget.
228
+ */
229
+ /**
230
+ * FROZEN (API_FREEZE §3). Copied verbatim, not designed here.
231
+ *
232
+ * This landed as a placeholder first, on the belief that §3's field schema had
233
+ * never been written. It had — the placeholder was a gap in what got read, not
234
+ * a gap in the freeze. Worth recording, because "the docs leave this open" and
235
+ * "I did not find it" produce identical-looking comments and only one of them
236
+ * is a reason to invent a shape.
237
+ */
238
+ type SpatialIntent = 'navigation' | 'fitness' | 'geofence' | 'lowPowerTracking' | 'preciseLocate';
239
+ /** FROZEN (API_FREEZE §3). */
240
+ interface SpatialPoint {
241
+ latitude: number;
242
+ longitude: number;
243
+ altitude?: number | null;
244
+ /** Metres. `null` when the platform gives no estimate — never 0, which
245
+ * would read as a perfect fix. */
246
+ accuracyM?: number | null;
247
+ timestamp: number;
248
+ }
249
+ /** FROZEN (API_FREEZE §3). */
250
+ interface SpatialSnapshot {
251
+ /** `null` when there is no usable fix. Not a zero coordinate — (0,0) is a
252
+ * real place in the Gulf of Guinea. */
253
+ position: SpatialPoint | null;
254
+ movement: {
255
+ speedMps: number | null;
256
+ bearingDeg: number | null;
257
+ distanceRecentM: number | null;
258
+ state?: 'stationary' | 'moving' | 'unknown';
259
+ };
260
+ orientation: {
261
+ headingDeg: number | null;
262
+ headingAccuracyDeg?: number | null;
263
+ pitchDeg?: number | null;
264
+ rollDeg?: number | null;
265
+ };
266
+ /** Newest last. Empty when nothing has been recorded — the ring is a native
267
+ * concern, and its window is chosen by intent. */
268
+ recentPath: SpatialPoint[];
269
+ changes?: {
270
+ lastMeaningfulTurnDeg?: number | null;
271
+ timestamp?: number | null;
272
+ };
273
+ quality: StateQuality;
274
+ }
275
+ interface SpatialDomain {
276
+ getSnapshot(): Promise<SpatialSnapshot>;
277
+ start(options: StartOptions<SpatialIntent>): Promise<StateSession>;
278
+ stop(sessionId?: string): Promise<void>;
279
+ /** OPEN: no frozen spatial event vocabulary; rule-conforming names only. */
280
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
281
+ }
282
+ /** FROZEN (API_FREEZE §4) + VOCABULARY §"Visual Intents". Nine values. */
283
+ type VisualIntent = 'navigation' | 'document' | 'ocr' | 'shopping' | 'inspection' | 'accessibility' | 'stream' | 'highQuality' | 'lowPower';
284
+ /**
285
+ * Media crosses by REFERENCE, never as inline bytes (core rule 3).
286
+ *
287
+ * AN ID AND NOTHING ELSE. The `uri` field is gone (2026-08-16), and its
288
+ * removal answers the question this comment used to record as OPEN — "how does
289
+ * a plugin turn a handle into pixels?" It doesn't. Ever.
290
+ *
291
+ * Frames now live in a native ring keyed by id and are never written to disk.
292
+ * `native.vision.readText/describe/assessQuality` take this id and resolve it
293
+ * INSIDE the trusted layer; the pixels do not cross into plugin code, so a
294
+ * solution's bundle cannot copy, upload or retain them. That is what makes
295
+ * "frames never leave the phone" enforceable rather than a promise.
296
+ *
297
+ * Do not add `uri` or `bytes` back. A plugin that believes it needs the pixels
298
+ * needs a new method on `native.vision` instead — one whose implementation
299
+ * stays on this side of the boundary.
300
+ */
301
+ interface MediaHandle {
302
+ id: string;
303
+ mimeType?: string;
304
+ /**
305
+ * Epoch ms after which this id no longer resolves — the frame's real
306
+ * eviction time from the native ring, not a guess.
307
+ *
308
+ * Past it, `native.vision` throws rather than returning an empty result:
309
+ * "I cannot look at that frame" and "I looked and found nothing" are
310
+ * different answers and must never be confused by a caller about to speak.
311
+ * In practice a caller rarely hits this, because the timeline is clamped to
312
+ * native retention — see `visualEngine.effectiveWindowMs()`.
313
+ */
314
+ expiresAt?: number | null;
315
+ }
316
+ /**
317
+ * V0.1. Per-frame usability is CATEGORICAL, not numeric — 'degraded' rather
318
+ * than a blur score. Coarse honest classes beat false precision; the same
319
+ * principle the connectivity doc states outright for connection quality.
320
+ *
321
+ * Every member carries `'unknown'` so a platform that cannot assess blur says
322
+ * so instead of defaulting to 'good'.
323
+ */
324
+ interface VisualFrameQuality {
325
+ blur?: 'good' | 'degraded' | 'unknown';
326
+ exposure?: 'good' | 'dark' | 'bright' | 'unknown';
327
+ /** `null` = cannot tell. Distinct from `false` = checked, not obstructed. */
328
+ obstructed?: boolean | null;
329
+ }
330
+ interface VisualFrameRef {
331
+ handle: MediaHandle;
332
+ timestamp: number;
333
+ orientation?: {
334
+ headingDeg?: number;
335
+ pitchDeg?: number;
336
+ rollDeg?: number;
337
+ };
338
+ quality?: VisualFrameQuality;
339
+ }
340
+ interface VisualSnapshot {
341
+ camera: {
342
+ available: boolean;
343
+ /**
344
+ * Independent of `available` on purpose. A camera can be present and
345
+ * permitted (`available: true`) while no session is running
346
+ * (`active: false`) — that is a different situation from a camera the app
347
+ * cannot use at all, and Guide has to say different things about them.
348
+ *
349
+ * DRIFT (flagged, not resolved): the architecture doc's conceptual block
350
+ * also carried `camera.orientation`; the frozen §4 block does not. Not
351
+ * added here.
352
+ */
353
+ active: boolean;
354
+ facing?: 'front' | 'back' | 'unknown';
355
+ };
356
+ /**
357
+ * `null` when there is no usable current frame.
358
+ *
359
+ * This is the anti-fabrication field. The alternative implementation —
360
+ * returning the last frame you happen to still hold — is precisely how a
361
+ * guidance loop ends up describing a corridor the user left thirty seconds
362
+ * ago. If it is not current, it is `null`, and the timeline below is where
363
+ * you go looking for history, knowingly.
364
+ */
365
+ latest: VisualFrameRef | null;
366
+ /**
367
+ * TIME-based, not frame-count-based. Default window ~5s; documented bounded
368
+ * range roughly 1–15s, selected by profile/intent. Native decides how
369
+ * densely to retain frames inside the window and evicts everything outside
370
+ * it automatically.
371
+ *
372
+ * `windowMs` is exposed; FPS knobs are NOT, and must never be added
373
+ * (API_FREEZE §4). If a plugin wants "more frames", it declares a different
374
+ * intent — it does not turn a dial.
375
+ */
376
+ recentTimeline: {
377
+ windowMs: number;
378
+ frames: VisualFrameRef[];
379
+ };
380
+ quality: StateQuality;
381
+ }
382
+ interface VisualCaptureResult {
383
+ frame: VisualFrameRef;
384
+ }
385
+ /**
386
+ * OPEN, worth knowing before you use it: whether `capture({intent})` may pass
387
+ * an intent DIFFERENT from the running session's intent is not addressed
388
+ * anywhere in the three docs.
389
+ */
390
+ interface VisualCaptureOptions {
391
+ intent: VisualIntent;
392
+ /** §13: capture must support bounded timeout/cancellation. */
393
+ signal?: AbortSignal;
394
+ timeoutMs?: number;
395
+ }
396
+ interface VisualDomain {
397
+ /**
398
+ * Cached read. Does NOT open the camera and does NOT take a picture.
399
+ *
400
+ * This is the call a guidance loop makes on every turn. The hot path is:
401
+ * native camera pipeline → rolling native timeline → cached visual state →
402
+ * one snapshot read from RN. "Avoid repeatedly starting the camera or
403
+ * capturing a new image for every plugin-tool call during an interactive
404
+ * session."
405
+ */
406
+ getSnapshot(): Promise<VisualSnapshot>;
407
+ /** Maintain visual state according to this intent. Not "open the camera". */
408
+ start(options: StartOptions<VisualIntent>): Promise<StateSession>;
409
+ /**
410
+ * An intentional, one-shot, high-quality-ish acquisition. THROWS
411
+ * DeviceBridgeError on failure — it is an operation, not a read.
412
+ *
413
+ * If you are about to call this once per conversational turn, stop: that is
414
+ * `getSnapshot()`'s job, and doing it here is the frame-timer pattern this
415
+ * whole layer exists to replace.
416
+ */
417
+ capture(options: VisualCaptureOptions): Promise<VisualCaptureResult>;
418
+ /**
419
+ * OPEN (doc conflict): the architecture doc shows `visual.subscribe(events)`
420
+ * — a filter — while API_FREEZE shows `visual.subscribe(listener)`. The
421
+ * freeze is followed here. Unresolved.
422
+ *
423
+ * OPEN: there is no "Initial Visual Events" list anywhere. The candidate set
424
+ * in prose (camera became available/unavailable, frame quality degraded/
425
+ * recovered, camera appears obstructed, significant scene change, visual
426
+ * session suspended/resumed, visual state became stale) is NOT a minted
427
+ * vocabulary. Typed against the naming rule only.
428
+ */
429
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
430
+ stop(sessionId?: string): Promise<void>;
431
+ }
432
+ /** FROZEN (API_FREEZE §5, VOCABULARY §"Human Activity Values"). */
433
+ type HumanActivity = 'stationary' | 'walking' | 'running' | 'cycling' | 'driving' | 'unknown';
434
+ /** FROZEN. Independent of HumanActivity — walking+pocket, driving+mounted. */
435
+ type DevicePlacement = 'handheld' | 'pocket' | 'table' | 'mounted' | 'unknown';
436
+ /**
437
+ * FROZEN. Note `driving` is BOTH an intent here and a HumanActivity value
438
+ * above. The docs define no relationship between the two and do not flag the
439
+ * collision: declaring intent `driving` does not assert the user is driving.
440
+ */
441
+ type ActivityIntent = 'navigation' | 'fitness' | 'driving' | 'safety' | 'accessibility' | 'lowPower';
442
+ interface ActivitySnapshot {
443
+ human: {
444
+ state: HumanActivity;
445
+ /** `null` when the start of the current activity is not known. */
446
+ since: number | null;
447
+ /** REQUIRED, unlike StateQuality.confidence — an inferred activity may
448
+ * never be handed over without saying how sure the inference is. */
449
+ confidence: Confidence;
450
+ };
451
+ devicePose: {
452
+ placement: DevicePlacement;
453
+ /** V0.1. These four values appear only in the freeze TS, not in the
454
+ * vocabulary doc — unlike the placement values. Mild drift, flagged. */
455
+ orientation?: 'portrait' | 'landscape' | 'flat' | 'unknown';
456
+ confidence: Confidence;
457
+ };
458
+ /**
459
+ * Advisory only. `likelyNext` is a hint and never a fact.
460
+ * OPEN: transition names are bare `string` — no controlled vocabulary exists
461
+ * anywhere, which sits awkwardly beside "Intents are not arbitrary strings".
462
+ */
463
+ transition?: {
464
+ current?: string | null;
465
+ likelyNext?: string | null;
466
+ confidence?: Confidence;
467
+ };
468
+ /** OPEN: no window length or cap is specified for Activity (Spatial gets
469
+ * ~10–20s, Visual ~5s). Required array — an empty one is a real answer. */
470
+ recentTransitions: Array<{
471
+ name: string;
472
+ timestamp: number;
473
+ }>;
474
+ quality: StateQuality;
475
+ }
476
+ interface ActivityDomain {
477
+ getSnapshot(): Promise<ActivitySnapshot>;
478
+ /**
479
+ * OPEN: §11's naming list shows only `activity.getSnapshot()`, and §2 makes
480
+ * start/stop optional. Whether Activity is session-based or a passive
481
+ * always-on domain is genuinely unstated. Included here because the six
482
+ * frozen ActivityIntent values would otherwise have no seam to arrive
483
+ * through — but this is a DECISION, not a reading of the freeze.
484
+ */
485
+ start(options: StartOptions<ActivityIntent>): Promise<StateSession>;
486
+ stop(sessionId?: string): Promise<void>;
487
+ /** OPEN: no "Initial Activity Events" list exists. */
488
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
489
+ }
490
+ interface DeviceContextSnapshot {
491
+ power: {
492
+ /** 0..1 FRACTION, not a percentage. `null` = not known. */
493
+ batteryLevel: number | null;
494
+ charging: boolean | null;
495
+ lowPowerMode: boolean | null;
496
+ };
497
+ thermal: {
498
+ state: 'nominal' | 'fair' | 'serious' | 'critical' | 'unknown';
499
+ };
500
+ resources: {
501
+ memoryPressure?: 'normal' | 'warning' | 'critical' | 'unknown';
502
+ storagePressure?: 'normal' | 'warning' | 'critical' | 'unknown';
503
+ };
504
+ /** Confidence is legitimately omitted here — these are exact OS facts, not
505
+ * estimates. Freshness still matters: a populated batteryLevel says nothing
506
+ * about whether it was sampled a second or a minute ago. */
507
+ quality: StateQuality;
508
+ }
509
+ /**
510
+ * NO INTENT TYPE, and that is deliberate — not an oversight, and not a gap to
511
+ * fill in later. There is no `DeviceContextIntent` in the freeze and no
512
+ * "Device Context Intents" section in the vocabulary, because
513
+ * (cross-domain review) "A domain may expose an intent only if it changes
514
+ * native implementation strategy in a meaningful way." Reading battery and
515
+ * thermal does not.
516
+ *
517
+ * The direction of flow is the other way round: Device Context is an INPUT to
518
+ * how OTHER domains satisfy their intents. Visual intent `navigation` +
519
+ * device context `battery 5% + low-power mode` → native picks a lower-power
520
+ * camera strategy while still honouring the Visual contract. The plugin does
521
+ * not retune anything, and must not be asked to.
522
+ *
523
+ * Do not add `deviceContext.start({intent:'lowPower'})` because five other
524
+ * domains have a lowPower intent.
525
+ */
526
+ interface DeviceContextDomain {
527
+ getSnapshot(): Promise<DeviceContextSnapshot>;
528
+ /**
529
+ * OPEN: `DeviceContextDomain`'s interface body is never written in the
530
+ * freeze, and no Device Context event names are frozen (the vocabulary has
531
+ * "Initial … Events" for connectivity/capability/interaction/environment
532
+ * only). DECISION: expose subscribe, because the candidate events —
533
+ * low-power mode changed, charging changed, thermal pressure changed — are
534
+ * exactly what an adaptive session needs and polling for them is wasteful.
535
+ *
536
+ * OPEN: "battery crossed significant threshold" and "changed materially"
537
+ * have no numbers anywhere, and Device Context has no profiles to
538
+ * parameterize them.
539
+ */
540
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
541
+ }
542
+ /** FROZEN (API_FREEZE §7, VOCABULARY §"Connectivity Intents"). */
543
+ type ConnectivityIntent = 'lowLatencyConversation' | 'interactive' | 'bulkTransfer' | 'backgroundSync' | 'offlineResilient' | 'lowPower';
544
+ /**
545
+ * V0.1, and note it is FLAT — the connectivity design doc sketched a nested
546
+ * shape (internet{…}, quality{…}, recent_history{…}) and the freeze flattened
547
+ * it. Two unreconciled deltas, carried here as-is rather than merged:
548
+ * - `bandwidthClass` appears in the freeze but in neither the design doc nor
549
+ * the vocabulary's Connectivity Values.
550
+ * - there is NO recent-history field, although the design doc requires native
551
+ * to maintain recent transitions and leaves the default window OPEN.
552
+ */
553
+ interface ConnectivitySnapshot {
554
+ /** NOTE: the design doc says "online / offline / uncertain"; the frozen
555
+ * third value is `unknown`. The two words are never reconciled. */
556
+ reachability: 'online' | 'offline' | 'unknown';
557
+ transport: 'wifi' | 'cellular' | 'ethernet' | 'other' | 'none' | 'unknown';
558
+ qualityClass: 'excellent' | 'good' | 'degraded' | 'poor' | 'unusable' | 'unknown';
559
+ stability: 'stable' | 'changing' | 'flapping' | 'unknown';
560
+ /** `null` when no recent measurement exists. Explicitly preferred over
561
+ * reporting a stale number: "return unknown rather than stale precision." */
562
+ latencyMs?: number | null;
563
+ bandwidthClass?: 'high' | 'medium' | 'low' | 'unknown';
564
+ metered?: boolean | null;
565
+ constrained?: boolean | null;
566
+ quality: StateQuality;
567
+ }
568
+ /** FROZEN v0.1 (VOCABULARY §"Initial Connectivity Events"). All ten. */
569
+ type ConnectivityEvent = 'connectivityOnline' | 'connectivityOffline' | 'transportChanged' | 'connectivityDegraded' | 'connectivityRecovered' | 'connectivityUnstable' | 'connectivityStable' | 'meteredStateChanged' | 'constrainedStateChanged' | 'connectivityStateStale';
570
+ interface ConnectivityDomain {
571
+ /**
572
+ * A fast local cached read. It must NOT ping, and must NOT run a bandwidth
573
+ * or speed test. Latency is sampled on native's own schedule, chosen by
574
+ * intent, and cached with a timestamp.
575
+ */
576
+ getSnapshot(): Promise<ConnectivitySnapshot>;
577
+ /**
578
+ * OPEN: `ConnectivityDomain` is named in §11 but never defined, so whether
579
+ * connectivity is session-based is unstated. DECISION: yes — six frozen
580
+ * intents with documented behavioural differences (lowLatencyConversation
581
+ * "prioritize fresh latency knowledge"; lowPower "rely primarily on passive
582
+ * OS signals and minimize active probes") only mean something if there is a
583
+ * way to declare one.
584
+ */
585
+ start(options: StartOptions<ConnectivityIntent>): Promise<StateSession>;
586
+ stop(sessionId?: string): Promise<void>;
587
+ subscribe(listener: StateListener<ConnectivityEvent>): Subscription;
588
+ }
589
+ /**
590
+ * FROZEN. Four independent axes, deliberately not collapsed. The canonical
591
+ * worked example from the capability doc: supported + granted + busy + none —
592
+ * a camera you own, are allowed to use, and cannot have right now.
593
+ */
594
+ interface CapabilityStatus {
595
+ support: SupportState;
596
+ permission?: PermissionState$1;
597
+ availability: AvailabilityState;
598
+ restriction?: RestrictionState;
599
+ }
600
+ interface CapabilitySnapshot {
601
+ camera: CapabilityStatus;
602
+ microphone: CapabilityStatus;
603
+ /** The only capability the freeze gives scoped detail to. Health and
604
+ * photos/media do NOT get one — see the gap note below. */
605
+ location: CapabilityStatus & {
606
+ precision?: 'precise' | 'approximate' | 'unknown';
607
+ backgroundPermission?: PermissionState$1;
608
+ };
609
+ motion: CapabilityStatus;
610
+ bluetooth: CapabilityStatus;
611
+ uwb: CapabilityStatus;
612
+ nfc: CapabilityStatus;
613
+ notifications: CapabilityStatus;
614
+ /**
615
+ * GAP (flagged): the capability doc notes health "authorization is often
616
+ * record-type specific rather than a single global permission" and its
617
+ * candidate shape had a `permission_summary`. The frozen §9 gives health a
618
+ * plain CapabilityStatus, so scoped health authorization is NOT
619
+ * representable in v0.1. Its own exit criterion ("scoped permissions are
620
+ * representable") is therefore only partly met.
621
+ */
622
+ health: CapabilityStatus;
623
+ backgroundExecution: CapabilityStatus;
624
+ /** GAP (flagged): photos/media appears in the design doc's native inputs and
625
+ * its full-vs-limited scope discussion, but there is NO photos key in the
626
+ * frozen CapabilitySnapshot. Not added here. */
627
+ quality: StateQuality;
628
+ }
629
+ /** FROZEN v0.1 (VOCABULARY §"Initial Capability Events"). All ten. */
630
+ type CapabilityEvent = 'capabilityChanged' | 'permissionChanged' | 'capabilityAvailable' | 'capabilityUnavailable'
631
+ /** NOTE: this pair does not match the documented naming patterns. The docs
632
+ * neither flag nor reconcile it; carried verbatim rather than "fixed". */
633
+ | 'capabilityBusy' | 'capabilityFree' | 'restrictionChanged' | 'locationPrecisionChanged' | 'backgroundPermissionChanged' | 'capabilityStateStale';
634
+ /**
635
+ * NO INTENT TYPE — the one domain that is intent-less by explicit reasoning
636
+ * rather than by omission: "Capability State itself should generally be cheap
637
+ * and mostly event-driven, so it does not need many sensing profiles."
638
+ * A candidate `interactive/background/minimal` vocabulary is floated in the
639
+ * design doc and explicitly conditional ("only be kept if implementation
640
+ * proves they add value"). Nothing frozen. Not added.
641
+ *
642
+ * Also NOT here: any `requestPermission()`. Permission-request UX is outside
643
+ * this state engine by design, and a floated generic primitive is future work.
644
+ */
645
+ interface CapabilityDomain {
646
+ getSnapshot(): Promise<CapabilitySnapshot>;
647
+ subscribe(listener: StateListener<CapabilityEvent>): Subscription;
648
+ }
649
+ /** FROZEN (VOCABULARY §"Environment Intents"). */
650
+ type EnvironmentIntent = 'ambient' | 'navigation' | 'accessibility' | 'proximity' | 'safety' | 'lowPower';
651
+ interface EnvironmentSnapshot {
652
+ /** Privacy is part of the SHAPE, not advice: this says `dim`, never a camera
653
+ * frame; `loud`, never microphone audio; a beacon count, never a BLE
654
+ * payload. Raw audio/images/BLE payloads are not part of this domain. */
655
+ lighting: {
656
+ level: 'dark' | 'dim' | 'normal' | 'bright' | 'veryBright' | 'unknown';
657
+ lux?: number | null;
658
+ confidence?: Confidence;
659
+ };
660
+ acoustics: {
661
+ level: 'quiet' | 'moderate' | 'loud' | 'veryLoud' | 'unknown';
662
+ approximateDb?: number | null;
663
+ confidence?: Confidence;
664
+ };
665
+ /** `uncertain` and `unknown` are different: uncertain = we looked and cannot
666
+ * decide; unknown = we have no basis to decide. */
667
+ enclosure: {
668
+ state: 'indoor' | 'outdoor' | 'uncertain' | 'unknown';
669
+ confidence?: Confidence;
670
+ };
671
+ proximity?: {
672
+ distanceClass?: 'immediate' | 'near' | 'far' | 'unknown';
673
+ confidence?: Confidence;
674
+ };
675
+ /** The only sub-block with no confidence — these are presence booleans, not
676
+ * estimates. Note they cannot distinguish unsupported / denied / switched
677
+ * off; that distinction lives in `capability`. */
678
+ nearbySignals?: {
679
+ bleAvailable?: boolean;
680
+ beaconCount?: number | null;
681
+ uwbAvailable?: boolean;
682
+ nfcAvailable?: boolean;
683
+ };
684
+ quality: StateQuality;
685
+ }
686
+ /** FROZEN v0.1 (VOCABULARY §"Initial Environment Events"). All ten. */
687
+ type EnvironmentEvent = 'lightingChanged' | 'environmentBecameDark' | 'environmentBecameBright' | 'noiseLevelChanged' | 'environmentBecameLoud' | 'indoorOutdoorChanged' | 'proximityChanged' | 'nearbySignalAppeared' | 'nearbySignalDisappeared' | 'environmentStateStale';
688
+ interface EnvironmentDomain {
689
+ getSnapshot(): Promise<EnvironmentSnapshot>;
690
+ start(options: StartOptions<EnvironmentIntent>): Promise<StateSession>;
691
+ stop(sessionId?: string): Promise<void>;
692
+ subscribe(listener: StateListener<EnvironmentEvent>): Subscription;
693
+ }
694
+ interface InteractionSnapshot {
695
+ lifecycle: {
696
+ appState: 'foreground' | 'background' | 'inactive' | 'unknown';
697
+ screenOn: boolean | null;
698
+ userActive?: boolean | null;
699
+ idleDurationMs?: number | null;
700
+ };
701
+ audio: {
702
+ captureActive: boolean | null;
703
+ playbackActive: boolean | null;
704
+ route: 'speaker' | 'earpiece' | 'wiredHeadset' | 'bluetooth' | 'carAudio' | 'other' | 'unknown';
705
+ /** `none` (checked, nothing interrupting) and `unknown` (no knowledge) are
706
+ * deliberately different values. */
707
+ interruption: 'none' | 'temporary' | 'system' | 'unknown';
708
+ };
709
+ /** Optional GROUP: a platform that cannot report voice session state omits
710
+ * the whole block rather than guessing `false` for each field. */
711
+ voice?: {
712
+ sessionActive: boolean | null;
713
+ userSpeaking?: boolean | null;
714
+ assistantSpeaking?: boolean | null;
715
+ };
716
+ /** May report that a camera session is running; must NOT duplicate frame
717
+ * state. Frames belong to `visual`. */
718
+ visual?: {
719
+ cameraSessionActive: boolean | null;
720
+ };
721
+ quality: StateQuality;
722
+ }
723
+ /** FROZEN v0.1 (VOCABULARY §"Initial Interaction Events"). All twelve. */
724
+ type InteractionEvent = 'appStateChanged' | 'screenStateChanged' | 'audioRouteChanged' | 'audioInterruptionStarted' | 'audioInterruptionEnded' | 'voiceSessionStarted' | 'voiceSessionStopped' | 'cameraSessionStarted' | 'cameraSessionStopped' | 'userBecameActive' | 'userBecameIdle' | 'interactionStateStale';
725
+ /**
726
+ * NO INTENT TYPE. §10 declares only `InteractionSnapshot`, and the vocabulary
727
+ * — which does publish intents for spatial, visual, activity, connectivity and
728
+ * environment — has no "Interaction Intents" section. Five profiles are
729
+ * floated in the design doc, hedged twice ("Likely profiles, if needed") with
730
+ * no obligation attached to any of them. Not added.
731
+ */
732
+ interface InteractionDomain {
733
+ getSnapshot(): Promise<InteractionSnapshot>;
734
+ subscribe(listener: StateListener<InteractionEvent>): Subscription;
735
+ }
736
+ /**
737
+ * FROZEN name (API_FREEZE §11) and FROZEN member names.
738
+ *
739
+ * §11's "Recommended naming rule" says to call it `visual.getSnapshot()`, not
740
+ * `device.visual.*`. Read here as a rule about the WIRE/tool-name convention
741
+ * (`device.*` stays a human/backend routing convention for callable plugin
742
+ * tools) rather than a ban on a plugin-prop container. That reading is a
743
+ * judgement call and is flagged as such — if it is wrong, the fix is to rename
744
+ * the PROP, never to merge these eight members into `PluginNative`.
745
+ */
746
+ interface DeviceBridgeV2 {
747
+ readonly spatial: SpatialDomain;
748
+ readonly visual: VisualDomain;
749
+ readonly activity: ActivityDomain;
750
+ readonly deviceContext: DeviceContextDomain;
751
+ readonly connectivity: ConnectivityDomain;
752
+ readonly capability: CapabilityDomain;
753
+ readonly environment: EnvironmentDomain;
754
+ readonly interaction: InteractionDomain;
755
+ }
756
+ /** The name plugin authors see. See DESIGN NOTE 1 for why not `deviceBridge`. */
757
+ type DeviceState = DeviceBridgeV2;
758
+ /**
759
+ * DECISION (not in the docs). A second, differently-named manifest field, so a
760
+ * plugin cannot half-declare its way across the boundary:
761
+ *
762
+ * native: { camera: true } → props.device.camera.takePhoto()
763
+ * stateDomains: ['visual', 'spatial'] → props.deviceState.visual.getSnapshot()
764
+ *
765
+ * Declaring `native.camera` grants NOTHING in the semantic layer, and vice
766
+ * versa. There is no field whose meaning spans both.
767
+ *
768
+ * How an UNDECLARED domain must behave — and this is the load-bearing part:
769
+ * it returns a normal snapshot in which every enum is `unknown`, every
770
+ * nullable is `null`, and `quality.freshness` is
771
+ * `{ state:'unknown', observedAt:null, ageMs:null }`. It does NOT throw and it
772
+ * does NOT return undefined.
773
+ *
774
+ * Why that matters here specifically: today's `gatePluginNative` gives an
775
+ * undeclared capability a proxy whose methods `console.warn` and return
776
+ * `undefined` (packages/plugin-runtime/src/host.ts:67-76). So
777
+ * `await native.location.getCurrent()` on an undeclared plugin resolves to
778
+ * `undefined`, and `pos?.latitude` is `undefined` — which then flows into an
779
+ * LLM answer as a confident nothing. A silent undefined is a fabrication
780
+ * vector; a snapshot of explicit unknowns is not.
781
+ *
782
+ * The "why" is readable from `capability.getSnapshot()` using an existing
783
+ * frozen enum member: a manifest-gate denial maps to
784
+ * `restriction: 'policyRestricted'` — the plugin manifest IS a policy. No new
785
+ * vocabulary needed. (The docs never consider a manifest gate, so this
786
+ * mapping is a DECISION, but it uses only frozen values.)
787
+ */
788
+ type StateDomainDeclaration = readonly DeviceBridgeDomain[];
789
+ /**
790
+ * Is this snapshot good enough to act on?
791
+ *
792
+ * Deliberately requires the caller to state a budget: there is no universal
793
+ * "fresh enough", and the docs give no numeric thresholds for any domain
794
+ * (native chooses sampling behaviour). A guidance loop wants ~1500 ms; a
795
+ * battery-aware background job is happy with 60 s.
796
+ *
797
+ * `unknown` freshness fails: it means nothing was ever observed, which is not
798
+ * the same as an old-but-real reading and must not be treated as one.
799
+ */
800
+ declare function isActionable(quality: StateQuality, budget: {
801
+ maxAgeMs: number;
802
+ minConfidence?: ConfidenceLevel;
803
+ }): boolean;
804
+ /** The honest "we have never observed this" value. Use it instead of
805
+ * inventing a timestamp when constructing a degraded snapshot. This is the
806
+ * canonical source of the `unknown` freshness shape — the engine's
807
+ * `qualityUnknown()` derives from it so the two never drift. */
808
+ declare function neverObserved(): Freshness;
809
+ /**
810
+ * One human-readable sentence for why a capability cannot answer, built ONLY
811
+ * from what the four axes actually say. Returns null when the capability is
812
+ * fine — so a caller cannot accidentally print a reason that does not exist.
813
+ */
814
+ declare function explainCapability(status: CapabilityStatus): string | null;
815
+ /**
816
+ * Additive extension of `PluginProps` (sdk/types.ts:259).
817
+ *
818
+ * `deviceState` is OPTIONAL because a host that has not installed a v2
819
+ * provider must not pretend to have one. `undefined` means "this host has no
820
+ * semantic layer", which a plugin can check once; it does NOT mean "the
821
+ * sensors said nothing". Those are different, and collapsing them would
822
+ * reintroduce exactly the ambiguity this file exists to remove.
823
+ */
824
+ interface PluginPropsV2Extension {
825
+ readonly deviceState?: DeviceState;
826
+ }
827
+ /**
828
+ * Additive extension of `DeviceToolContext` (sdk/types.ts:320-327).
829
+ *
830
+ * This is the seam that matters most: device tools are what the AI calls, and
831
+ * device tools are where a fabricated value reaches a user. The lint rule
832
+ * proposed alongside this file (`no-mechanical-sensing-in-device-tools`) bans
833
+ * `ctx.native.camera`, `ctx.native.location` and `ctx.native.connectivity`
834
+ * inside a `DeviceToolDefinition.run` body — a device tool must reach sensing
835
+ * through `ctx.deviceState`. UI code is unaffected.
836
+ */
837
+ interface DeviceToolContextV2Extension {
838
+ readonly deviceState?: DeviceState;
839
+ }
840
+
841
+ type deviceState_ActivityDomain = ActivityDomain;
842
+ type deviceState_ActivityIntent = ActivityIntent;
843
+ type deviceState_ActivitySnapshot = ActivitySnapshot;
844
+ type deviceState_AvailabilityState = AvailabilityState;
845
+ type deviceState_CapabilityDomain = CapabilityDomain;
846
+ type deviceState_CapabilityEvent = CapabilityEvent;
847
+ type deviceState_CapabilitySnapshot = CapabilitySnapshot;
848
+ type deviceState_CapabilityStatus = CapabilityStatus;
849
+ type deviceState_Confidence = Confidence;
850
+ type deviceState_ConfidenceLevel = ConfidenceLevel;
851
+ type deviceState_ConformingEventName = ConformingEventName;
852
+ type deviceState_ConnectivityDomain = ConnectivityDomain;
853
+ type deviceState_ConnectivityEvent = ConnectivityEvent;
854
+ type deviceState_ConnectivityIntent = ConnectivityIntent;
855
+ type deviceState_ConnectivitySnapshot = ConnectivitySnapshot;
856
+ type deviceState_DeviceBridgeDomain = DeviceBridgeDomain;
857
+ type deviceState_DeviceBridgeError = DeviceBridgeError;
858
+ declare const deviceState_DeviceBridgeError: typeof DeviceBridgeError;
859
+ type deviceState_DeviceBridgeErrorCode = DeviceBridgeErrorCode;
860
+ type deviceState_DeviceBridgeV2 = DeviceBridgeV2;
861
+ type deviceState_DeviceContextDomain = DeviceContextDomain;
862
+ type deviceState_DeviceContextSnapshot = DeviceContextSnapshot;
863
+ type deviceState_DevicePlacement = DevicePlacement;
864
+ type deviceState_DeviceState = DeviceState;
865
+ type deviceState_DeviceToolContextV2Extension = DeviceToolContextV2Extension;
866
+ type deviceState_EnvironmentDomain = EnvironmentDomain;
867
+ type deviceState_EnvironmentEvent = EnvironmentEvent;
868
+ type deviceState_EnvironmentIntent = EnvironmentIntent;
869
+ type deviceState_EnvironmentSnapshot = EnvironmentSnapshot;
870
+ type deviceState_Freshness = Freshness;
871
+ type deviceState_FreshnessState = FreshnessState;
872
+ type deviceState_HumanActivity = HumanActivity;
873
+ type deviceState_InteractionDomain = InteractionDomain;
874
+ type deviceState_InteractionEvent = InteractionEvent;
875
+ type deviceState_InteractionSnapshot = InteractionSnapshot;
876
+ type deviceState_MediaHandle = MediaHandle;
877
+ type deviceState_PluginPropsV2Extension = PluginPropsV2Extension;
878
+ type deviceState_RestrictionState = RestrictionState;
879
+ type deviceState_SpatialDomain = SpatialDomain;
880
+ type deviceState_SpatialIntent = SpatialIntent;
881
+ type deviceState_SpatialPoint = SpatialPoint;
882
+ type deviceState_SpatialSnapshot = SpatialSnapshot;
883
+ type deviceState_StartOptions<TIntent extends string> = StartOptions<TIntent>;
884
+ type deviceState_StateDomainDeclaration = StateDomainDeclaration;
885
+ type deviceState_StateEvent<TName extends string = string> = StateEvent<TName>;
886
+ type deviceState_StateListener<TName extends string = string> = StateListener<TName>;
887
+ type deviceState_StateQuality = StateQuality;
888
+ type deviceState_StateSession = StateSession;
889
+ type deviceState_StatefulDomain<TSnapshot, TIntent extends string = never, TEvent extends string = string> = StatefulDomain<TSnapshot, TIntent, TEvent>;
890
+ type deviceState_Subscription = Subscription;
891
+ type deviceState_SupportState = SupportState;
892
+ type deviceState_VisualCaptureOptions = VisualCaptureOptions;
893
+ type deviceState_VisualCaptureResult = VisualCaptureResult;
894
+ type deviceState_VisualDomain = VisualDomain;
895
+ type deviceState_VisualFrameQuality = VisualFrameQuality;
896
+ type deviceState_VisualFrameRef = VisualFrameRef;
897
+ type deviceState_VisualIntent = VisualIntent;
898
+ type deviceState_VisualSnapshot = VisualSnapshot;
899
+ declare const deviceState_explainCapability: typeof explainCapability;
900
+ declare const deviceState_isActionable: typeof isActionable;
901
+ declare const deviceState_neverObserved: typeof neverObserved;
902
+ declare namespace deviceState {
903
+ export { type deviceState_ActivityDomain as ActivityDomain, type deviceState_ActivityIntent as ActivityIntent, type deviceState_ActivitySnapshot as ActivitySnapshot, 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_SpatialIntent as SpatialIntent, type deviceState_SpatialPoint as SpatialPoint, type deviceState_SpatialSnapshot as SpatialSnapshot, 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 };
904
+ }
905
+
906
+ /**
907
+ * props.voice — the live conversational SESSION a plugin may drive.
908
+ *
909
+ * Voice is a SESSION, not a device sensor. `props.device.*` is mechanical verbs
910
+ * that throw; `props.deviceState.*` is eight semantic domains describing the
911
+ * device. A Realtime conversation is neither — it is a long-lived loop with its
912
+ * own state (connecting/connected), its own transcript, and two ways to take a
913
+ * turn (the user speaks, or the agent speaks first). So it gets its own gated
914
+ * surface, mirroring the deviceState GRAMMAR (start / stop / getSnapshot /
915
+ * subscribe) because a reasoning loop reads it the same way — state with
916
+ * freshness, not bare values.
917
+ *
918
+ * TRUST BOUNDARY. This type is the WHOLE surface. There is deliberately NO way
919
+ * from here to inject fake user speech, read raw audio, reach the WebView, or
920
+ * hide the mic-active indicator — those are new doors, and the reason
921
+ * `CameraPreview` is a component rather than a module is that we keep closing
922
+ * doors of that shape. `prompt()` is an AGENT turn, not a forged user turn.
923
+ *
924
+ * See docs/handoff/SPEC_voice_session_without_the_voice_screen_2026-09-12.md.
925
+ */
926
+ /** The session lifecycle — identical vocabulary to the built-in `CallState`. */
927
+ type VoiceState = 'idle' | 'connecting' | 'connected' | 'error';
928
+ /** One line of the conversation. `tool` entries are device/skill tool activity. */
929
+ interface VoiceTranscriptEntry {
930
+ role: 'user' | 'ai' | 'tool';
931
+ text: string;
932
+ ts: number;
933
+ }
934
+ /** A point-in-time read of the session — what a reasoning loop polls. */
935
+ interface VoiceSnapshot {
936
+ state: VoiceState;
937
+ sessionId: string | null;
938
+ muted: boolean;
939
+ /** The agent is currently producing audio. */
940
+ speaking: boolean;
941
+ /** The user is currently being heard (partial user transcript in flight). */
942
+ listening: boolean;
943
+ /** The user is currently speaking, per the session's own VAD (authoritative,
944
+ * distinct from `listening` which tracks a partial transcript). */
945
+ userSpeaking: boolean;
946
+ /**
947
+ * Epoch ms of the last moment the user STOPPED speaking, or `null` if they
948
+ * have not spoken yet this session. `null` is deliberate and load-bearing: a
949
+ * silence timer must arm on the first real `speaking:false`, never at session
950
+ * start — "not speaking" and "has never spoken" are different, and arming on
951
+ * the wrong one fires before anyone has said anything.
952
+ */
953
+ lastUserSpeechEndedAt: number | null;
954
+ /** The last error, if `state === 'error'`; also carries a refusal reason when
955
+ * this surface was handed to a plugin that did not declare `voice`. */
956
+ lastError: string | null;
957
+ }
958
+ interface VoiceStartOptions {
959
+ /** Hand the mic to a specific skill on connect (voice /session → takeMic),
960
+ * exactly as `sys.focusUiPlugin("platform:voice", { skillSlug })` does. */
961
+ skillSlug?: string;
962
+ }
963
+ /**
964
+ * What changed. Coarse on purpose: `state` covers connect/mute/session/partials,
965
+ * `transcript` fires when a finalized line was appended. A subscriber re-reads
966
+ * `getSnapshot()` / `transcript()` — the event is a nudge, not a payload, so the
967
+ * surface never has to keep two shapes of the same fact in sync.
968
+ */
969
+ type VoiceEvent = {
970
+ type: 'state';
971
+ } | {
972
+ type: 'transcript';
973
+ };
974
+ interface VoiceSubscription {
975
+ unsubscribe(): void;
976
+ }
977
+ /**
978
+ * WHO is asking. The one physical session has an OWNER; access is scoped to it.
979
+ *
980
+ * A plugin's caller is its `pluginId` (a string), stamped by gateVoice so the
981
+ * plugin never supplies its own identity. The PLATFORM caller — the built-in
982
+ * voice screen, the user's own surface — is a Symbol, deliberately: a Symbol can
983
+ * never equal any pluginId string, so a plugin cannot forge platform access by
984
+ * naming itself `platform:voice`.
985
+ */
986
+ type VoiceCaller = string | symbol;
987
+ /** The privileged caller — the platform's own surfaces (the built-in sheet, the
988
+ * mic indicator, the session host). Sees and controls any live session, because
989
+ * it is the USER's direct surface onto their own conversation. Unforgeable. */
990
+ declare const PLATFORM_VOICE_CALLER: unique symbol;
991
+ /**
992
+ * The OWNER-AWARE session the HOST implements and hands to setVoiceProvider.
993
+ *
994
+ * This is NOT what a plugin receives — a plugin gets a `VoiceAPI` facade
995
+ * (gateVoice) that stamps its `pluginId` as the caller on every call, so the
996
+ * provider can enforce that a plugin only ever reads, controls, or attaches to a
997
+ * session IT owns. One physical session, isolated per authorized identity.
998
+ */
999
+ interface VoiceSessionProvider {
1000
+ start(opts: VoiceStartOptions | undefined, caller: VoiceCaller): Promise<VoiceSnapshot>;
1001
+ stop(caller: VoiceCaller): void;
1002
+ setMuted(muted: boolean, caller: VoiceCaller): void;
1003
+ prompt(input: string, caller: VoiceCaller): void;
1004
+ observe(text: string, caller: VoiceCaller): void;
1005
+ getSnapshot(caller: VoiceCaller): VoiceSnapshot;
1006
+ subscribe(cb: (event: VoiceEvent) => void, caller: VoiceCaller): VoiceSubscription;
1007
+ transcript(caller: VoiceCaller): VoiceTranscriptEntry[];
1008
+ }
1009
+ /**
1010
+ * The gated surface a plugin receives as `props.voice`. Present on EVERY plugin;
1011
+ * a plugin that did not declare `capabilities: { voice: true }` gets a version
1012
+ * whose reads say so and whose actions throw — never a silent no-op, never a
1013
+ * fake `idle` (see gateVoice in voiceHost.ts).
1014
+ */
1015
+ interface VoiceAPI {
1016
+ /** Open the live loop. Resolves once connected (or rejects on connect error).
1017
+ * If a session is ALREADY live, ATTACHES to it and resolves immediately —
1018
+ * never a second Realtime loop (one phone, one mic, one bill). */
1019
+ start(opts?: VoiceStartOptions): Promise<VoiceSnapshot>;
1020
+ /** End the live loop. Safe to call when idle. */
1021
+ stop(): void;
1022
+ /** Mute/unmute the microphone. The session stays live; the mic indicator
1023
+ * reflects it. */
1024
+ setMuted(muted: boolean): void;
1025
+ /**
1026
+ * Make the agent take a turn WITHOUT the user speaking — the guide speaking
1027
+ * first. `input` is the instruction for that turn. This is an agent-initiated
1028
+ * turn into the SAME session, NOT fabricated user speech: one session, two
1029
+ * trigger sources. Throws if the session is not connected.
1030
+ */
1031
+ prompt(input: string): void;
1032
+ /**
1033
+ * Stream a silent OBSERVATION into the session — tell the model something
1034
+ * without asking it to speak (a `system` conversation item). Cheap and
1035
+ * high-frequency (safe once or twice a second): this is the channel for what
1036
+ * the camera sees while the user walks. The model answers with the
1037
+ * observations in hand the next time it takes a turn (user VAD, or prompt()) —
1038
+ * an observation never triggers a turn by itself. Throws if not connected.
1039
+ */
1040
+ observe(text: string): void;
1041
+ /** Point-in-time session state. */
1042
+ getSnapshot(): VoiceSnapshot;
1043
+ /** Subscribe to state + transcript changes. Returns an unsubscribe handle. */
1044
+ subscribe(cb: (event: VoiceEvent) => void): VoiceSubscription;
1045
+ /** The conversation so far. */
1046
+ transcript(): VoiceTranscriptEntry[];
1047
+ }
1048
+
8
1049
  /**
9
1050
  * @adas/plugin-sdk — Type definitions
10
1051
  *
11
1052
  * This is the developer contract for native mobile plugins.
12
1053
  * Both UI and service plugins use these interfaces.
13
1054
  */
1055
+
14
1056
  interface PluginContext {
15
1057
  tenant: string;
16
1058
  pluginId: string;
@@ -59,6 +1101,11 @@ interface CameraAPI {
59
1101
  width: number;
60
1102
  height: number;
61
1103
  }>;
1104
+ /**
1105
+ * @deprecated Use `native.scanner.scanOnce()`. Scanning needs a MOUNTED
1106
+ * camera, which a bare function call cannot provide; this refuses by name
1107
+ * and points there.
1108
+ */
62
1109
  scanBarcode(opts?: {
63
1110
  formats?: string[];
64
1111
  }): Promise<{
@@ -70,6 +1117,26 @@ interface CameraAPI {
70
1117
  }): Promise<{
71
1118
  uri: string;
72
1119
  }[]>;
1120
+ /**
1121
+ * Record a video clip the user deliberately makes — a damage claim, a
1122
+ * how-it-sounds clip, something to attach.
1123
+ *
1124
+ * NOT the Visual domain. That is an ephemeral frame ring a reasoning loop
1125
+ * reads and nobody keeps; this produces a FILE. Different jobs, and the
1126
+ * grammar says so: this is a verb that throws, Visual is a state read that
1127
+ * says `unknown`.
1128
+ *
1129
+ * Rides the same mounted camera surface as `scanner.scanOnce`, and throws
1130
+ * the same coded failures. `withAudio` needs microphone permission and
1131
+ * REFUSES without it rather than quietly recording silence.
1132
+ */
1133
+ recordVideo(opts?: {
1134
+ maxDurationMs?: number;
1135
+ withAudio?: boolean;
1136
+ }): Promise<{
1137
+ uri: string;
1138
+ durationMs: number | null;
1139
+ }>;
73
1140
  }
74
1141
  interface LocationAPI {
75
1142
  getCurrent(opts?: {
@@ -124,6 +1191,16 @@ interface HapticsAPI {
124
1191
  impact(style: 'light' | 'medium' | 'heavy'): void;
125
1192
  notification(type: 'success' | 'warning' | 'error'): void;
126
1193
  selection(): void;
1194
+ /**
1195
+ * Shorthand for notification('error').
1196
+ *
1197
+ * Documented in the platform guide and already CALLED by shipped plugins
1198
+ * (connections-panel), which only compiled because they reached it through
1199
+ * optional chaining — `native?.haptics?.error?.()` silently does nothing when
1200
+ * the method is absent, so a failure the user was meant to FEEL produced no
1201
+ * buzz and no error. Declared here so the call site is checked.
1202
+ */
1203
+ error(): void;
127
1204
  }
128
1205
  interface ConnectivityAPI {
129
1206
  isOnline(): boolean;
@@ -177,6 +1254,29 @@ interface ScannerAPI {
177
1254
  granted: boolean;
178
1255
  status: string;
179
1256
  }>;
1257
+ /**
1258
+ * Show the scanner and resolve with the FIRST code read.
1259
+ *
1260
+ * The plugin no longer mounts anything: the host renders one
1261
+ * `CameraTaskHost`, this raises it, and the promise settles when a code is
1262
+ * found. That is why the capture lifecycle is owned in exactly one place
1263
+ * instead of by every plugin that wants to scan.
1264
+ *
1265
+ * THROWS rather than resolving empty, with a code that says which:
1266
+ * TIMEOUT nothing was found in time
1267
+ * CANCELLED the user dismissed it — a choice, not a fault
1268
+ * PERMISSION_DENIED camera permission refused
1269
+ * BUSY another camera task is running
1270
+ * UNAVAILABLE no camera surface is mounted in this app
1271
+ * An empty string would read as a code that scanned to nothing.
1272
+ */
1273
+ scanOnce(opts?: {
1274
+ formats?: string[];
1275
+ timeoutMs?: number;
1276
+ }): Promise<{
1277
+ data: string;
1278
+ format: string;
1279
+ }>;
180
1280
  }
181
1281
  interface NfcNdefRecord {
182
1282
  type: string;
@@ -212,10 +1312,133 @@ interface BiometricsAPIv2 {
212
1312
  error?: string;
213
1313
  }>;
214
1314
  }
1315
+ interface BluetoothDevice {
1316
+ id: string;
1317
+ name: string | null;
1318
+ /** Signal strength in dBm. Null when not reported — never 0, which is a
1319
+ * legitimate (and very strong) reading. */
1320
+ rssi: number | null;
1321
+ /** Manufacturer/service payload, if the advertisement carried one. */
1322
+ serviceUUIDs?: string[];
1323
+ }
1324
+ interface VisionText {
1325
+ /** The recognised string. */
1326
+ text: string;
1327
+ /** 0..1 recogniser confidence. Null when the platform gives no estimate —
1328
+ * never 1, which would read as certainty nothing measured. */
1329
+ confidence: number | null;
1330
+ /** Normalised 0..1 box in the frame, origin top-left. Lets a guide say
1331
+ * "the sign on your right" rather than just quoting words. */
1332
+ box?: {
1333
+ x: number;
1334
+ y: number;
1335
+ width: number;
1336
+ height: number;
1337
+ };
1338
+ }
1339
+ interface VisionLabel {
1340
+ /** A label from the platform's classifier vocabulary — NOT free text. */
1341
+ label: string;
1342
+ confidence: number | null;
1343
+ }
1344
+ interface VisionObject {
1345
+ /** A detector-vocabulary class name (e.g. "Car", "Traffic light", "Person") —
1346
+ * NOT free text. */
1347
+ label: string;
1348
+ /** 0..1 detector confidence. Null when the platform gives no estimate — never
1349
+ * 1, which would read as certainty nothing measured. */
1350
+ confidence: number | null;
1351
+ /** Normalised 0..1 box in the frame, origin top-left. Where the thing is,
1352
+ * so a guide can say "on your right" rather than just naming it. */
1353
+ box?: {
1354
+ x: number;
1355
+ y: number;
1356
+ width: number;
1357
+ height: number;
1358
+ };
1359
+ }
1360
+ /**
1361
+ * How usable a frame is, as measured rather than guessed.
1362
+ *
1363
+ * This one is the exception to the split above: it is a MEASUREMENT of the
1364
+ * image, not an interpretation of the scene, so its results belong on
1365
+ * `VisualFrameRef.quality` — which currently reports `unknown` for all three
1366
+ * because nothing looks at the pixels. Wiring this is what lets a guide say
1367
+ * "it's too dark for me to tell" instead of describing a black frame.
1368
+ */
1369
+ interface VisionFrameQuality {
1370
+ blur: 'sharp' | 'slight' | 'severe' | 'unknown';
1371
+ exposure: 'good' | 'dark' | 'bright' | 'unknown';
1372
+ /** True when the lens is covered. Null = not assessed. */
1373
+ obstructed: boolean | null;
1374
+ }
1375
+ interface VisionAPI {
1376
+ /** Is on-device vision available at all? False is a real answer. */
1377
+ isSupported(): Promise<boolean>;
1378
+ /**
1379
+ * Read text in a frame (OCR).
1380
+ *
1381
+ * Returns an EMPTY ARRAY when it looked and found no text — that is a real
1382
+ * finding, distinct from throwing, which means it could not look.
1383
+ */
1384
+ readText(frameId: string, opts?: {
1385
+ languages?: string[];
1386
+ }): Promise<VisionText[]>;
1387
+ /**
1388
+ * Label what is in a frame.
1389
+ *
1390
+ * Deliberately labels, not prose. A sentence is the SKILL's job — this layer
1391
+ * must not put words in the model's mouth, and a classifier that returns
1392
+ * "a bathroom sign ahead on the left" is asserting a spatial relationship it
1393
+ * did not measure.
1394
+ */
1395
+ describe(frameId: string, opts?: {
1396
+ maxLabels?: number;
1397
+ }): Promise<VisionLabel[]>;
1398
+ /**
1399
+ * Detect the objects in a frame, each with a box.
1400
+ *
1401
+ * Distinct from `describe`: describe LABELS the whole scene ("kitchen"),
1402
+ * detectObjects LOCATES the things in it ("Person" on the left, "Car" ahead) —
1403
+ * which is what a guidance loop reads to say where something is. Returns an
1404
+ * EMPTY ARRAY when it looked and found nothing, distinct from throwing (could
1405
+ * not look). On-device (bundled detector); frames never leave the phone.
1406
+ */
1407
+ detectObjects(frameId: string, opts?: {
1408
+ maxItems?: number;
1409
+ minConfidence?: number;
1410
+ }): Promise<VisionObject[]>;
1411
+ /** Measure whether a frame is usable. Feeds VisualFrameRef.quality. */
1412
+ assessQuality(frameId: string): Promise<VisionFrameQuality>;
1413
+ }
1414
+ interface BluetoothAPI {
1415
+ /** Is BLE hardware present and permitted? */
1416
+ isSupported(): Promise<boolean>;
1417
+ /** Is the radio switched on? (Separate from permission — both can block.) */
1418
+ isEnabled(): Promise<boolean>;
1419
+ /**
1420
+ * Scan for advertising devices for `timeoutMs`, then stop.
1421
+ *
1422
+ * Bounded rather than open-ended on purpose: a scan left running is a
1423
+ * battery drain the caller cannot see, and the mistake is invisible until a
1424
+ * user notices their phone is flat.
1425
+ */
1426
+ scan(opts?: {
1427
+ timeoutMs?: number;
1428
+ serviceUUIDs?: string[];
1429
+ }): Promise<BluetoothDevice[]>;
1430
+ }
215
1431
  interface PluginNative {
216
1432
  camera: CameraAPI;
217
1433
  location: LocationAPI;
218
- storage: StorageAPI;
1434
+ /**
1435
+ * @deprecated The gate never hands this out — it uses the per-plugin
1436
+ * `storageFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
1437
+ * single shared storage across plugins was a cross-plugin read/write hole;
1438
+ * there is no shared bucket any more. Present only so older providers still
1439
+ * satisfy the type; new providers implement `storageFor`.
1440
+ */
1441
+ storage?: StorageAPI;
219
1442
  /** @deprecated use `biometricsV2` for the native-backed API. This stub
220
1443
  * remains for backwards compatibility. */
221
1444
  biometrics: BiometricsAPI;
@@ -223,11 +1446,41 @@ interface PluginNative {
223
1446
  notifications: NotificationsAPI;
224
1447
  haptics: HapticsAPI;
225
1448
  connectivity: ConnectivityAPI;
226
- fileSystem: FileSystemAPI;
1449
+ /**
1450
+ * @deprecated The gate never hands this out — it uses the per-plugin
1451
+ * `fileSystemFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
1452
+ * single shared filesystem across plugins was a cross-plugin read/write hole;
1453
+ * there is no shared bucket any more. Present only so older providers still
1454
+ * satisfy the type; new providers implement `fileSystemFor`.
1455
+ */
1456
+ fileSystem?: FileSystemAPI;
1457
+ /**
1458
+ * Per-plugin filesystem: returns a FileSystemAPI whose sandbox root is scoped
1459
+ * to THIS plugin (`plugin-files/<pluginId>/`), so one plugin cannot read,
1460
+ * write or reach another plugin's files even via traversal. `gatePluginNative`
1461
+ * uses it (passing the plugin's id) to isolate each plugin. If it is absent or
1462
+ * no id is known, the gate FAILS CLOSED (blocks) rather than fall back to a
1463
+ * shared filesystem — there is no shared bucket.
1464
+ */
1465
+ fileSystemFor?(pluginId: string): FileSystemAPI;
1466
+ /**
1467
+ * Per-plugin storage: returns a StorageAPI whose KV namespace AND SQLite
1468
+ * database are scoped to THIS plugin, so one plugin cannot read, overwrite,
1469
+ * or query another plugin's key-value entries or tables. `gatePluginNative`
1470
+ * uses it (passing the plugin's id) to isolate each plugin. Identity is
1471
+ * threaded through the TRUSTED provider here — never a caller-supplied
1472
+ * key/table prefix. If it is absent or no id is known, the gate FAILS CLOSED
1473
+ * (blocks) rather than fall back to a shared store — there is no shared bucket.
1474
+ */
1475
+ storageFor?(pluginId: string): StorageAPI;
227
1476
  cookies: CookiesAPI;
228
1477
  sharing: SharingAPI;
229
1478
  scanner: ScannerAPI;
230
1479
  nfc: NfcAPI;
1480
+ /** SHAPE ONLY in this build — see BluetoothAPI. Present so it fails by name. */
1481
+ bluetooth: BluetoothAPI;
1482
+ /** SHAPE ONLY in this build — see VisionAPI. On-device image understanding. */
1483
+ vision: VisionAPI;
231
1484
  }
232
1485
  interface NativeCapabilities$1 {
233
1486
  camera?: boolean;
@@ -237,7 +1490,17 @@ interface NativeCapabilities$1 {
237
1490
  backgroundTasks?: boolean;
238
1491
  fileSystem?: boolean;
239
1492
  notifications?: boolean;
1493
+ /** BLE. Declaring it passes the gate but every call refuses by name until
1494
+ * the native side exists — see BluetoothAPI. */
240
1495
  bluetooth?: boolean;
1496
+ /** On-device image understanding (OCR, labels, frame quality). Refuses by
1497
+ * name until the native module lands — see VisionAPI. */
1498
+ vision?: boolean;
1499
+ /** The live voice SESSION (props.voice). A privacy surface — a declaring
1500
+ * plugin can read the conversation transcript — so it is gated like any
1501
+ * other capability, and coupled to the platform's unsuppressable mic
1502
+ * indicator. See VoiceAPI. */
1503
+ voice?: boolean;
241
1504
  haptics?: boolean;
242
1505
  /** Read the platform cookie jar after a WebView login flow. */
243
1506
  cookies?: boolean;
@@ -305,7 +1568,43 @@ interface ThemeTokens {
305
1568
  }
306
1569
  interface PluginProps {
307
1570
  bridge: PluginBridge;
308
- native: PluginNative;
1571
+ /**
1572
+ * L2 Device Primitives — the camera/location/storage/haptics/… surface, gated
1573
+ * to the capabilities this plugin declared. This is the canonical name (R4);
1574
+ * the underlying L1 native modules keep the `native` name, but what a plugin
1575
+ * receives is `props.device`.
1576
+ */
1577
+ device: PluginNative;
1578
+ /**
1579
+ * @deprecated Renamed to `device` in R4. Still passed (the SAME gated object)
1580
+ * so existing/remote plugin bundles keep working — migrate to `props.device`.
1581
+ */
1582
+ native?: PluginNative;
1583
+ /**
1584
+ * L3 DeviceState — the SEMANTIC layer (`props.deviceState.spatial/visual/…`),
1585
+ * gated to the domains this plugin declared in `stateDomains`. Reads answer
1586
+ * `unknown` rather than throwing; a domain the build has no engine for, or the
1587
+ * plugin did not declare, returns an explicit refusal.
1588
+ *
1589
+ * REQUIRED, deliberately: an optional field is exactly the compile-time escape
1590
+ * hatch that let a renderer ship without it (props.deviceState === undefined,
1591
+ * which crashed on drawer/card). Every renderer builds this through
1592
+ * resolvePluginRuntimeProps, so the constructor side always has it; making it
1593
+ * required means a NEW renderer that forgets to pass it fails to compile.
1594
+ */
1595
+ deviceState: DeviceState;
1596
+ /**
1597
+ * The live conversational SESSION, gated to plugins that declared
1598
+ * `capabilities: { voice: true }`. A SESSION, not a device sensor: start/stop
1599
+ * the loop, read + subscribe to the transcript, and speak first via
1600
+ * `prompt()`. The mic-active indicator is platform-owned and cannot be
1601
+ * suppressed from here.
1602
+ *
1603
+ * REQUIRED, like `deviceState`: every renderer builds it through
1604
+ * resolvePluginRuntimeProps, so the object is always present. An undeclared
1605
+ * plugin gets a refusal (reads say so, actions throw) — never a fake `idle`.
1606
+ */
1607
+ voice: VoiceAPI;
309
1608
  theme: ThemeTokens;
310
1609
  context: PluginContext;
311
1610
  /** Engine-supplied props from `sys.focusUiPlugin(pluginId, props)`.
@@ -349,6 +1648,10 @@ interface PluginManifest$1 {
349
1648
  actions: string[];
350
1649
  };
351
1650
  capabilities?: Record<string, unknown>;
1651
+ /** L3 DeviceState domains this plugin reads (props.deviceState), as Core passes
1652
+ * them through from the bundle's declaration. Absent → fall back to the loaded
1653
+ * bundle's own `stateDomains`. Read via resolvePluginRuntimeProps. */
1654
+ stateDomains?: DeviceBridgeDomain[];
352
1655
  _source?: string;
353
1656
  _connectorId?: string;
354
1657
  _mcpEndpoint?: string;
@@ -357,7 +1660,13 @@ interface RegisteredUIPlugin {
357
1660
  id: string;
358
1661
  type: 'ui' | 'hybrid';
359
1662
  capabilities?: NativeCapabilities$1;
1663
+ /** L3 DeviceState domains this plugin's UI reads (props.deviceState). The
1664
+ * trusted source for a HOST-registered plugin (no server manifest), read by
1665
+ * resolvePluginRuntimeProps. Carried by register(), same as registerTools. */
1666
+ stateDomains?: DeviceBridgeDomain[];
360
1667
  Component: React.FC<PluginProps>;
1668
+ /** Optional `tools/` section — a plugin may ship both UI and local tools. */
1669
+ tools?: DeviceToolDefinition[];
361
1670
  }
362
1671
  interface RegisteredServicePlugin {
363
1672
  id: string;
@@ -367,7 +1676,46 @@ interface RegisteredServicePlugin {
367
1676
  onStart: (props: Omit<PluginProps, 'theme'>) => Promise<void> | void;
368
1677
  onStop?: () => Promise<void> | void;
369
1678
  }
370
- type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin;
1679
+ /** What a device tool implementation is handed at call time. */
1680
+ interface DeviceToolContext {
1681
+ bridge: PluginBridge;
1682
+ /** Device Bridge — camera, location, sensors, storage. */
1683
+ native: PluginNative;
1684
+ /**
1685
+ * L3 semantic DeviceState (visual/spatial/activity/…), gated to the domains
1686
+ * the plugin declared in `stateDomains`. Every domain is always present; an
1687
+ * undeclared one answers with an explicit "not declared" rather than data —
1688
+ * the same gating a UI plugin's `props.deviceState` gets. This is what lets an
1689
+ * EXTERNAL tool (e.g. Ada Guide's `guide.observe`) read `ctx.deviceState.visual`
1690
+ * over the public surface, with no SDK-internal access.
1691
+ */
1692
+ deviceState: DeviceState;
1693
+ /** Cooperative cancellation: caller cancelled, or the session went away. */
1694
+ signal?: AbortSignal;
1695
+ }
1696
+ type DeviceToolFn = (args: any, ctx: DeviceToolContext) => Promise<any> | any;
1697
+ interface DeviceToolDefinition {
1698
+ /** Domain name, as the catalog and the caller see it. e.g. "guide.observe" */
1699
+ name: string;
1700
+ description?: string;
1701
+ /** JSON Schema for arguments — advertised to Core, not enforced here. */
1702
+ input_schema?: Record<string, unknown>;
1703
+ run: DeviceToolFn;
1704
+ }
1705
+ /** A plugin that ships tools and no UI at all. */
1706
+ interface RegisteredToolsPlugin {
1707
+ id: string;
1708
+ type: 'tools';
1709
+ capabilities?: NativeCapabilities$1;
1710
+ /**
1711
+ * L3 DeviceState domains this plugin's tools may read via `ctx.deviceState`
1712
+ * (e.g. `['visual']`). Undeclared domains answer "not declared". Same
1713
+ * mechanism as a UI plugin's manifest `stateDomains`.
1714
+ */
1715
+ stateDomains?: DeviceBridgeDomain[];
1716
+ tools: DeviceToolDefinition[];
1717
+ }
1718
+ type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin | RegisteredToolsPlugin;
371
1719
 
372
1720
  /**
373
1721
  * Plugin registration API.
@@ -376,12 +1724,34 @@ type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin;
376
1724
 
377
1725
  /**
378
1726
  * Register a UI or hybrid plugin (has a visual component).
1727
+ *
1728
+ * `tools` is optional: a plugin may ship UI and local tools together. Tools are
1729
+ * picked up when the bundle loads, independently of whether the UI is mounted.
379
1730
  */
380
1731
  declare function register(id: string, config: {
381
1732
  type?: 'ui' | 'hybrid';
382
1733
  capabilities?: NativeCapabilities$1;
1734
+ /** L3 DeviceState domains this plugin's UI reads via props.deviceState (e.g.
1735
+ * ['spatial','visual']). Carried through so a HOST-registered plugin (no
1736
+ * server manifest) can declare its domains — resolvePluginRuntimeProps reads
1737
+ * this as the trusted source when there is no manifest. Dropping it here left
1738
+ * every registered UI plugin with no L3, silently. Mirrors registerTools. */
1739
+ stateDomains?: DeviceBridgeDomain[];
383
1740
  Component: React.FC<PluginProps>;
1741
+ tools?: DeviceToolDefinition[];
384
1742
  }): RegisteredUIPlugin;
1743
+ /**
1744
+ * Register a tools-only plugin — a `tools/` section with no UI at all.
1745
+ *
1746
+ * Nothing here mounts. The tools become callable as soon as the bundle is
1747
+ * evaluated, and stay callable with nothing of the plugin on screen.
1748
+ */
1749
+ declare function registerTools(id: string, config: {
1750
+ capabilities?: NativeCapabilities$1;
1751
+ /** L3 DeviceState domains the tools may read via ctx.deviceState (e.g. ['visual']). */
1752
+ stateDomains?: DeviceBridgeDomain[];
1753
+ tools: DeviceToolDefinition[];
1754
+ }): RegisteredToolsPlugin;
385
1755
  /**
386
1756
  * Register a headless service plugin (no UI).
387
1757
  */
@@ -460,6 +1830,20 @@ interface ActionButtonProps {
460
1830
  }
461
1831
  declare function ActionButton({ theme, label, icon, onPress, style }: ActionButtonProps): React$1.FunctionComponentElement<react_native.PressableProps & React$1.RefAttributes<View>>;
462
1832
 
1833
+ interface CameraPreviewProps {
1834
+ /**
1835
+ * The plugin's own `props.native`. REQUIRED, and it is the gate: the runtime
1836
+ * stamps the declared capabilities onto this object, so passing it is how the
1837
+ * component knows whether this plugin may show a camera at all.
1838
+ */
1839
+ native: PluginNative;
1840
+ facing?: 'front' | 'back';
1841
+ style?: any;
1842
+ /** Rendered instead of the built-in panel when the preview is unavailable. */
1843
+ renderUnavailable?: (reason: string) => React$1.ReactElement | null;
1844
+ }
1845
+ declare function CameraPreview({ native, facing, style, renderUnavailable }: CameraPreviewProps): react_jsx_runtime.JSX.Element | null;
1846
+
463
1847
  type ThemeColors = ThemeTokens['colors'];
464
1848
  /** Override the plugin theme (host brand). Call once at startup — mounted
465
1849
  * plugins re-render with the new tokens. */
@@ -486,8 +1870,88 @@ declare function useApi(bridge: PluginBridge): PluginApi;
486
1870
  declare const PluginSDK: {
487
1871
  readonly register: typeof register;
488
1872
  readonly registerService: typeof registerService;
1873
+ readonly registerTools: typeof registerTools;
489
1874
  };
490
1875
 
1876
+ /**
1877
+ * Host DI for the SEMANTIC layer — the `props.deviceState` counterpart of
1878
+ * `setPluginNativeProvider`.
1879
+ *
1880
+ * Kept in its own file rather than added to host.ts, for the same reason the
1881
+ * two layers have separate namespaces: the moment they share a module, the
1882
+ * next person adds a domain to `PluginNative` or a capability to
1883
+ * `stateDomains` and the boundary starts to blur.
1884
+ *
1885
+ * THE GATE IS NOT THE MECHANICAL ONE. `gatePluginNative` replaces an
1886
+ * undeclared capability with a proxy whose methods warn and return `undefined`
1887
+ * — so `await native.location.getCurrent()` resolves to `undefined`, and
1888
+ * `pos?.latitude` is `undefined`, which flows into a model answer as a
1889
+ * confident nothing. A silent undefined is a fabrication vector.
1890
+ *
1891
+ * Here, an undeclared domain returns a real snapshot in which everything is
1892
+ * explicitly `unknown` / `null`, with `freshness.state = 'unknown'`. The
1893
+ * plugin gets a well-formed answer that says "I know nothing", which no
1894
+ * reasonable code path can mistake for data.
1895
+ */
1896
+
1897
+ /** Host DI: the app supplies the engines it has. Called once at startup. */
1898
+ declare function setDeviceStateProvider(state: Partial<DeviceState>): void;
1899
+ /**
1900
+ * The `deviceState` a plugin receives, gated by what it declared.
1901
+ *
1902
+ * Every domain is always present — a plugin can call any of the eight without
1903
+ * an existence check. What differs is whether it answers with data, or with
1904
+ * an explicit "I know nothing" and a reason.
1905
+ */
1906
+ declare function gateDeviceState(declared?: readonly DeviceBridgeDomain[]): DeviceState;
1907
+ /** Which domains this build can actually answer — for diagnostics. */
1908
+ declare function implementedStateDomains(): DeviceBridgeDomain[];
1909
+
1910
+ /**
1911
+ * Host DI + gate for `props.voice` — the session counterpart of
1912
+ * setDeviceStateProvider / setPluginNativeProvider.
1913
+ *
1914
+ * The app mounts one live voice session (VoiceSessionHost in @ateam-ai-mobile/ui)
1915
+ * and hands it here once at startup via setVoiceProvider. gateVoice then decides,
1916
+ * per plugin, whether that session is exposed:
1917
+ *
1918
+ * - declared (`capabilities: { voice: true }`) + a provider present → the real
1919
+ * session.
1920
+ * - declared but no provider (no session host in this build) → a refusal that
1921
+ * names the reason.
1922
+ * - NOT declared → a refusal that names the reason.
1923
+ *
1924
+ * ABSENCE IS AN ANSWER (spec invariant 6). A denied surface never fakes an
1925
+ * `idle` snapshot and never silently no-ops: reads report `state: 'error'` with
1926
+ * an explicit `lastError`, and actions throw. A silent `idle` would read to a
1927
+ * reasoning loop as "a working session that simply isn't connected yet" — a
1928
+ * fabrication, the same class of bug as an undefined that flows into an answer.
1929
+ */
1930
+
1931
+ /** Host DI: the app supplies its live (owner-aware) voice session. Called once
1932
+ * at startup; pass null to detach (e.g. in a build with no voice). */
1933
+ declare function setVoiceProvider(provider: VoiceSessionProvider | null): void;
1934
+ /** Whether this build has a voice session to expose — for diagnostics. */
1935
+ declare function implementedVoice(): boolean;
1936
+ /**
1937
+ * The `voice` a plugin receives, gated by whether it declared the capability AND
1938
+ * scoped to its identity.
1939
+ *
1940
+ * `declared` is `!!capabilities.voice` from the plugin's TRUSTED declaration
1941
+ * (resolved in pluginRuntime.ts — the server manifest for a remote plugin, the
1942
+ * registry entry for a host-owned one). A downloaded bundle cannot grant itself
1943
+ * voice. `pluginId` is the trusted identity the returned facade is bound to.
1944
+ *
1945
+ * Reading the transcript of a live session is a privacy surface, not just an
1946
+ * API: this gate is the only thing between a plugin and everything the user
1947
+ * says. Two protections stack: (1) it is coupled to evidence — a live session
1948
+ * shows the platform-owned, unsuppressable mic indicator, so there is no path to
1949
+ * a transcript without visible proof the mic is/was live; and (2) it is scoped
1950
+ * to the owner, so declaring `voice` lets a plugin run ITS OWN conversation, not
1951
+ * eavesdrop on another solution's. A missing identity FAILS CLOSED.
1952
+ */
1953
+ declare function gateVoice(declared: boolean, pluginId: string): VoiceAPI;
1954
+
491
1955
  /**
492
1956
  * Host integration points — the DI seams that keep this package free of
493
1957
  * Expo imports. The host app (or @ateam-ai-mobile/expo) plugs in concrete providers
@@ -500,7 +1964,8 @@ declare function setHapticsProvider(haptics: HapticsAPI | null): void;
500
1964
  /** No-op safe accessor — surfaces call this for tap feedback. */
501
1965
  declare function getHaptics(): HapticsAPI | null;
502
1966
  /** Install the device-capabilities implementation plugins receive as
503
- * `props.native`. The host owns the native modules. */
1967
+ * `props.device` (the L1 native modules keep the `native` name; the plugin-
1968
+ * facing prop is `device`). The host owns the native modules. */
504
1969
  declare function setPluginNativeProvider(native: PluginNative): void;
505
1970
  declare function getPluginNative(): PluginNative;
506
1971
  /**
@@ -512,7 +1977,18 @@ declare function getPluginNative(): PluginNative;
512
1977
  * Flag mapping mirrors the manifest contract: `storage` is gated by
513
1978
  * `offline` (offline data ⇒ local storage).
514
1979
  */
515
- declare function gatePluginNative(native: PluginNative, declared?: NativeCapabilities$1): PluginNative;
1980
+ declare function gatePluginNative(native: PluginNative, declared?: NativeCapabilities$1, pluginId?: string): PluginNative;
1981
+ /** Props the host's preview implementation receives. */
1982
+ interface CameraPreviewImplProps {
1983
+ facing?: 'front' | 'back';
1984
+ style?: any;
1985
+ renderUnavailable?: (reason: string) => any;
1986
+ }
1987
+ /** Install the host's camera preview (e.g. an expo-camera CameraView). */
1988
+ declare function setCameraPreviewProvider(Component: ((props: CameraPreviewImplProps) => any) | null): void;
1989
+ /** Null when the host installed none — the SDK then renders a stated reason,
1990
+ * never a black rectangle that looks like a dark room. */
1991
+ declare function getCameraPreviewProvider(): ((props: CameraPreviewImplProps) => any) | null;
516
1992
  type PluginResolver = RegisteredPlugin | (() => Promise<RegisteredPlugin | null>);
517
1993
  declare function registerNativePlugin(id: string, plugin: PluginResolver): void;
518
1994
  declare function unregisterNativePlugin(id: string): void;
@@ -522,11 +1998,249 @@ declare const nativePluginRegistry: {
522
1998
  };
523
1999
  declare function registerBundleModules(modules: Record<string, any>): void;
524
2000
 
2001
+ /** The gated surface a plugin Component receives. `device` and `native` are the
2002
+ * SAME object (native is the deprecated alias). */
2003
+ interface PluginRuntimeProps {
2004
+ device: PluginNative;
2005
+ native: PluginNative;
2006
+ deviceState: DeviceState;
2007
+ voice: VoiceAPI;
2008
+ }
2009
+ /**
2010
+ * Where a plugin's declarations come from. TRUSTED source resolution lives here,
2011
+ * not in each caller: the server `manifest` wins, the loaded bundle's own default
2012
+ * export is the fallback.
2013
+ */
2014
+ /** One declaration source — either the server manifest or the registry entry. */
2015
+ interface PluginDeclaration {
2016
+ capabilities?: NativeCapabilities$1 | Record<string, unknown>;
2017
+ native?: NativeCapabilities$1;
2018
+ stateDomains?: readonly DeviceBridgeDomain[];
2019
+ }
2020
+ interface PluginRuntimeSource {
2021
+ pluginId: string;
2022
+ /**
2023
+ * The server manifest (Core-passed) — the TRUSTED authority for a REMOTE
2024
+ * plugin. `capabilities` is the current field Core emits; `native` is the
2025
+ * legacy one. A grant absent here is NOT GRANTED (fail closed).
2026
+ */
2027
+ manifest?: PluginDeclaration | null;
2028
+ /**
2029
+ * The registry entry — the trusted authority for a HOST-OWNED plugin
2030
+ * (registered locally, no server manifest). For a REMOTE plugin this is the
2031
+ * downloaded bundle's own claim and is UNTRUSTED for gating; it is used ONLY
2032
+ * when there is no manifest.
2033
+ */
2034
+ plugin?: PluginDeclaration | null;
2035
+ }
2036
+ /**
2037
+ * Turn a plugin's declarations into the gated { device, native, deviceState } it
2038
+ * receives — the SINGLE place this happens, so every surface is identical.
2039
+ *
2040
+ * TRUST BOUNDARY. Exactly ONE source is authoritative: the server `manifest` for
2041
+ * a remote plugin, the registry `plugin` for a host-owned one. They are NEVER
2042
+ * mixed, and a remote plugin's own downloaded bundle claim NEVER expands its
2043
+ * capabilities or L3 domains past what the trusted source granted. If the
2044
+ * trusted source omits a grant, it is not granted — fail closed. (If Core does
2045
+ * not carry `stateDomains` for a remote plugin, the fix is Core passing it, not
2046
+ * trusting the bundle: Core already holds the solution's declaration.)
2047
+ */
2048
+ declare function resolvePluginRuntimeProps(src: PluginRuntimeSource): PluginRuntimeProps;
2049
+
2050
+ interface DeviceToolSummary {
2051
+ name: string;
2052
+ pluginId: string;
2053
+ description?: string;
2054
+ input_schema?: Record<string, unknown>;
2055
+ }
2056
+ type DeviceToolOutcome = {
2057
+ ok: true;
2058
+ result: any;
2059
+ } | {
2060
+ ok: false;
2061
+ error: string;
2062
+ reason: 'not_found' | 'no_context' | 'timeout' | 'failed';
2063
+ };
2064
+ /**
2065
+ * Override how a device tool reaches the Device Bridge. Rarely needed — the
2066
+ * default below builds the same bridge/native pair a plugin gets when it
2067
+ * renders, gated by the capabilities the plugin declared.
2068
+ */
2069
+ declare function setDeviceToolContextProvider(fn: (pluginId: string, capabilities?: NativeCapabilities$1, stateDomains?: DeviceBridgeDomain[]) => DeviceToolContext): void;
2070
+ /**
2071
+ * Register a plugin's tools. Called from the bundle pipeline at load time.
2072
+ *
2073
+ * A name already taken by ANOTHER plugin is refused — a bundle loaded later
2074
+ * must not be able to take over "guide.observe" from the plugin that owns it.
2075
+ * Re-registering the same plugin (bundle reload) replaces its own entries.
2076
+ */
2077
+ declare function registerDeviceTools(pluginId: string, tools: DeviceToolDefinition[], capabilities?: NativeCapabilities$1, stateDomains?: DeviceBridgeDomain[]): string[];
2078
+ /** Drop every tool owned by a plugin (bundle eviction / plugin revoked). */
2079
+ declare function unregisterDeviceTools(pluginId: string): void;
2080
+ declare function hasDeviceTool(name: string): boolean;
2081
+ declare function listDeviceTools(): DeviceToolSummary[];
2082
+ /**
2083
+ * Invoke a device tool. Never throws — every failure comes back as a typed
2084
+ * outcome, because the caller is usually a reasoning loop that has to say
2085
+ * something useful out loud rather than swallow an exception.
2086
+ */
2087
+ declare function callDeviceTool(name: string, args?: any, opts?: {
2088
+ timeoutMs?: number;
2089
+ signal?: AbortSignal;
2090
+ }): Promise<DeviceToolOutcome>;
2091
+ /** Test/host reset. */
2092
+ declare function clearDeviceTools(): void;
2093
+
2094
+ /**
2095
+ * Load every plugin that declares `capabilities.device_tools`, so its tools are
2096
+ * registered and callable. Best-effort and idempotent: safe to call on every
2097
+ * call start. Returns the tool names currently registered.
2098
+ *
2099
+ * Pass `force` after a plugin catalog change (a newly deployed connector).
2100
+ */
2101
+ declare function ensureDeviceToolsLoaded({ force }?: {
2102
+ force?: boolean | undefined;
2103
+ }): Promise<string[]>;
2104
+ /** Forget that we loaded, so the next call re-reads the catalog. */
2105
+ declare function resetDeviceToolLoader(): void;
2106
+
2107
+ declare function registerPlatformDeviceTools(): void;
2108
+
2109
+ interface SessionSnapshot {
2110
+ authenticated: boolean;
2111
+ /** The tenant the session is scoped to (derive it; never a stored copy). */
2112
+ tenant: string | null;
2113
+ }
2114
+ type DeviceToolsAction = 'load' | 'reset' | 'none';
2115
+ /**
2116
+ * PURE: what should the device-tool registry do for this session transition?
2117
+ * - a new authenticated tenant (initial sign-in OR a switch) → 'load'
2118
+ * - the same authenticated tenant again (e.g. a silent token refresh) → 'none'
2119
+ * - leaving authenticated (sign-out) → 'reset'
2120
+ */
2121
+ declare function deviceToolsActionForSession(next: SessionSnapshot, prevTenant: string | null): {
2122
+ action: DeviceToolsAction;
2123
+ tenant: string | null;
2124
+ };
2125
+ /** The effects a session transition drives — injected so a test can fake them. */
2126
+ interface DeviceToolsSessionDeps {
2127
+ /** Drop the previous tenant's device tools (a switch does not run logout teardown). */
2128
+ clear: () => void;
2129
+ /** Ensure the static, host-owned platform device tools are present. */
2130
+ registerPlatform: () => void;
2131
+ /** Forget the "already loaded" latch so a later (non-forced) load runs — sign-out. */
2132
+ resetLoader: () => void;
2133
+ /** Fire-and-forget reload of the current tenant's external device-tool bundles. */
2134
+ load: () => void;
2135
+ }
2136
+ /**
2137
+ * Apply a session transition to the device-tool registry. Call from the host's
2138
+ * existing auth-session subscription with a derived snapshot. Idempotent per
2139
+ * tenant: repeated snapshots for the same authenticated tenant do nothing, so a
2140
+ * silent token refresh does not churn the registry. Returns the action taken.
2141
+ */
2142
+ declare function syncDeviceToolsForSession(next: SessionSnapshot, deps?: DeviceToolsSessionDeps): DeviceToolsAction;
2143
+ /** Test seam: forget the last-seen tenant so a test starts from signed-out. */
2144
+ declare function __resetSessionSyncForTest(): void;
2145
+
2146
+ type PostResult = (correlationId: string, result: any, error?: string) => Promise<void>;
2147
+ /**
2148
+ * Host DI: how to answer the server. `@ateam-ai-mobile/core` supplies
2149
+ * postCommandResult; kept injectable so this package stays free of the REST
2150
+ * client and so tests can observe the answer.
2151
+ */
2152
+ declare function setDeviceToolResultPoster(fn: PostResult): void;
2153
+ /** Is this stream command a device tool call? */
2154
+ declare function isDeviceToolCommand(cmd: any): boolean;
2155
+ /**
2156
+ * Run a server-dispatched device tool and answer.
2157
+ *
2158
+ * Never throws — the server is blocked on the correlationId, so the one thing
2159
+ * we must not do is stay silent. Every path posts something back.
2160
+ */
2161
+ declare function handleServerDeviceToolCall(cmd: any): Promise<void>;
2162
+
2163
+ /** Where the bytes currently running came from. */
2164
+ type LoadedBundleSource = 'download' | 'disk-cache' | 'memory';
2165
+ /**
2166
+ * Provenance of one loaded bundle — what is RUNNING, not what was requested.
2167
+ *
2168
+ * `hash` is the sha256 of the evaluated bytes. `expected` is the hash the
2169
+ * server's manifest advertised for that url. Equal means the phone is running
2170
+ * the build the server currently describes; different means it is not, and the
2171
+ * pair says so without needing a rebuild, a reinstall or a guess.
2172
+ */
2173
+ interface LoadedBundleInfo {
2174
+ bundleUrl: string;
2175
+ version: string;
2176
+ /** sha256 (hex, lowercase) of the bytes that were evaluated. */
2177
+ hash: string | null;
2178
+ /** sha256 the server advertised for this url at load time, if any. */
2179
+ expected: string | null;
2180
+ source: LoadedBundleSource;
2181
+ tenant: string | null;
2182
+ loadedAt: number;
2183
+ }
525
2184
  declare class BundleManager {
526
- /** In-memory module cache: bundleUrl → evaluated module.exports */
2185
+ /** In-memory module cache: bundleUrl → evaluated module.exports. Holds ONE
2186
+ * tenant's modules at a time — the tenant boundary is `cacheTenant` /
2187
+ * `requireTenant()`, NOT the key. */
527
2188
  private modules;
2189
+ /** What is actually RUNNING, keyed by bundleUrl (for the current tenant). See
2190
+ * getLoadedBundles(). */
2191
+ private provenance;
528
2192
  /** In-flight download deduplication: bundleUrl → Promise */
529
2193
  private inflight;
2194
+ /**
2195
+ * The tenant the in-memory caches (modules / provenance / inflight) currently
2196
+ * belong to. This is the tenant BOUNDARY for evaluated code.
2197
+ *
2198
+ * A bundleUrl resolves PER-TENANT on the server — one url, different bytes for
2199
+ * different tenants — so a memory hit keyed on url alone would hand tenant B
2200
+ * the module tenant A already evaluated and ran. PR #9 scoped the DISK cache
2201
+ * (per-tenant index + hash gate); the in-memory map was left url-keyed. This
2202
+ * closes that: the caches only ever hold ONE tenant's modules, and a switch
2203
+ * drops them before anything is served.
2204
+ */
2205
+ private cacheTenant;
2206
+ /**
2207
+ * pluginIds this manager registered device tools for. A tenant switch drops
2208
+ * the in-memory modules AND unregisters these, so the incoming tenant's bundle
2209
+ * re-registers into a clean registry instead of being refused as a duplicate
2210
+ * of the previous tenant's tools.
2211
+ */
2212
+ private registeredToolPluginIds;
2213
+ /**
2214
+ * Enforce the in-memory tenant boundary. Call at the top of every public load
2215
+ * path, BEFORE any cache read.
2216
+ *
2217
+ * FAIL CLOSED: without a tenant identity we cannot prove a cached module is
2218
+ * this session's, so we drop everything and refuse rather than risk serving
2219
+ * one tenant's evaluated code to another. On a tenant change we drop the
2220
+ * previous tenant's modules (and their tool registrations) before proceeding.
2221
+ */
2222
+ /**
2223
+ * Monotonic load generation. Bumped on every in-memory wipe (a tenant change,
2224
+ * fail-closed, or clearAll). A load captures it at ENTRY; a completion whose
2225
+ * generation no longer matches is stale and is discarded before it can commit.
2226
+ * This closes the async race the sequential guard cannot: tenant A's in-flight
2227
+ * loadModule can resolve AFTER a switch to B — clearing `inflight` does not
2228
+ * cancel that Promise — and would otherwise write A's module / provenance /
2229
+ * device tools into the singleton now marked cacheTenant=B.
2230
+ */
2231
+ private epoch;
2232
+ private requireTenant;
2233
+ /** Drop every in-memory cache and unregister the device tools this manager
2234
+ * registered — used on a tenant change and by clearAll(). */
2235
+ private dropInMemory;
2236
+ /** True once the boundary has moved since a load captured `epoch` — i.e. a
2237
+ * tenant switch (or wipe) happened while this load was in flight. Its results
2238
+ * belong to a tenant that is no longer current and must be discarded. */
2239
+ private isStale;
2240
+ /** Flip an existing provenance record to source:'memory' — a later serve of an
2241
+ * already-evaluated bundle came from the in-memory cache, not a fresh
2242
+ * download/disk read. No-op if nothing was recorded yet. */
2243
+ private markMemoryHit;
530
2244
  /**
531
2245
  * Load a plugin component. Returns the RegisteredUIPlugin object.
532
2246
  * Handles: in-memory cache → disk cache → download → evaluate → extract.
@@ -537,6 +2251,14 @@ declare class BundleManager {
537
2251
  * native capability the manifest declares.
538
2252
  */
539
2253
  load(pluginId: string, bundleUrl: string, version: string, expectedHash?: string): Promise<RegisteredUIPlugin>;
2254
+ /**
2255
+ * Load a bundle for its `tools/` section only — no UI required.
2256
+ *
2257
+ * Same download, same integrity check, same evaluation as `load()`; the only
2258
+ * difference is that a bundle exporting tools and no `Component` is a success
2259
+ * here rather than "no component found". Returns the tool names registered.
2260
+ */
2261
+ loadTools(pluginId: string, bundleUrl: string, version: string, expectedHash?: string): Promise<string[]>;
540
2262
  /**
541
2263
  * Prewarm: load every plugin bundle in the background after app boot, so a
542
2264
  * later open is instant (in-memory hit) instead of round-tripping the
@@ -557,7 +2279,22 @@ declare class BundleManager {
557
2279
  * Clear cache for a specific plugin/bundle.
558
2280
  */
559
2281
  clearPlugin(pluginId: string): Promise<void>;
2282
+ /**
2283
+ * What every loaded bundle IS, right now: the sha256 of the bytes running,
2284
+ * the hash the server advertised, and where they came from.
2285
+ *
2286
+ * Deliberately not filtered or prettified — this is the raw answer to "did my
2287
+ * redeploy reach the phone?", and it is comparable directly against the
2288
+ * manifest `bundleHash` and the server's X-Bundle-SHA256 header.
2289
+ */
2290
+ getLoadedBundles(): LoadedBundleInfo[];
560
2291
  private loadModule;
2292
+ /**
2293
+ * Pick up every `tools` section in an evaluated bundle and put it in the
2294
+ * device tool registry. Runs for UI, hybrid and tools-only bundles alike: a
2295
+ * plugin that ships both gets its tools registered without its UI mounting.
2296
+ */
2297
+ private registerToolsFromModule;
561
2298
  /**
562
2299
  * Download + integrity-verify a bundle. Refuses to return when:
563
2300
  * - manifest hash and computed hash disagree
@@ -591,6 +2328,20 @@ interface BundleCacheEntry {
591
2328
  bundleUrl: string;
592
2329
  /** SHA-256 (hex, lowercase) of the cached bundle bytes — integrity proof */
593
2330
  bundleHash?: string;
2331
+ /**
2332
+ * Tenant whose session downloaded these bytes.
2333
+ *
2334
+ * A bundle URL is NOT tenant-independent: `/api/ui-plugins/<id>/bundle.js`
2335
+ * resolves per-tenant on the server, so the SAME url returns different code
2336
+ * for different tenants. On the normal load path the hash gate catches this
2337
+ * for free — the expected hash comes from the current tenant's manifest and
2338
+ * will not match another tenant's cached bytes. The disk-cache recovery path
2339
+ * has no manifest to check against, so it must discriminate by tenant.
2340
+ *
2341
+ * Absent on entries written before this field existed: unknown ownership, so
2342
+ * untrusted. Never read a missing tenant as "mine".
2343
+ */
2344
+ tenant?: string;
594
2345
  }
595
2346
  interface BundleStorage {
596
2347
  loadIndex(): Promise<Record<string, BundleCacheEntry>>;
@@ -1139,4 +2890,4 @@ declare function isInlineSurface(m: SurfaceManifest): m is InlineSurfaceManifest
1139
2890
 
1140
2891
  declare function SurfaceDispatcher(): react_jsx_runtime.JSX.Element;
1141
2892
 
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 };
2893
+ 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, MemoryBundleStorage, type MountSurfaceManifest, type NativeCapabilities$1 as NativeCapabilities, type NfcAPI, type NotificationsAPI, PLATFORM_VOICE_CALLER, type PluginManifest as PlatformPluginManifest, type PluginBridge, type PluginContext, 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 SpatialIntent, type SpatialPoint, type SpatialSnapshot, type StateQuality, type StorageAPI, SurfaceCard, type SurfaceCardProps, SurfaceDispatcher, SurfaceDrawer, type SurfaceDrawerProps, SurfaceFullscreen, type SurfaceFullscreenProps, type SurfaceManifest, type SurfacePlacement, type SurfaceType, type SurfaceVisibility, TabBar, type ThemeTokens, type VisionAPI, type VisionFrameQuality, type VisionLabel, type VisionObject, type VisionText, type VisualSnapshot, type VoiceAPI, type VoiceCaller, type VoiceEvent, 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, resolvePluginRuntimeProps, resolvePluginUrl, setBundleStorage, setCameraPreviewProvider, setDeviceStateProvider, setDeviceToolContextProvider, setDeviceToolResultPoster, setHapticsProvider, setPluginNativeProvider, setPluginThemeColors, setVoiceProvider, supportsNative, supportsWebView, syncDeviceToolsForSession, unregisterDeviceTools, unregisterNativePlugin, useApi, useNativeBridge, usePlugin, usePluginTheme, useRemotePlugins, useSurfaceStore };