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

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/src/sdk/types.ts CHANGED
@@ -352,6 +352,139 @@ export interface VisionAPI {
352
352
  assessQuality(frameId: string): Promise<VisionFrameQuality>;
353
353
  }
354
354
 
355
+ // ── Maps — where is X, what is here, how do I walk there ──
356
+ //
357
+ // One-shot verbs that THROW, like vision — not a state domain. Following a
358
+ // route over time (progress, off-route, "turn left in 20 m") is the reasoning
359
+ // loop's job over deviceState.spatial; this layer answers each question once.
360
+ //
361
+ // NOT ON-DEVICE — the opposite of vision, and the thing not to get wrong when
362
+ // describing a feature built on it. Every call sends the query and the
363
+ // coordinates you pass to a map service over the network: Apple's (MapKit /
364
+ // CLGeocoder) on iOS, the platform geocoder on Android. So the `maps` flag
365
+ // means exactly one thing: "this plugin may send place queries to Apple Maps".
366
+ //
367
+ // IT NEVER READS THE USER'S LOCATION. Every coordinate is the caller's — take a
368
+ // fix from `location.getCurrent` or `deviceState.spatial` and pass it in. That
369
+ // keeps `maps` from becoming a second, differently-gated door to where the
370
+ // user is.
371
+ //
372
+ // FOUND NOTHING IS AN ANSWER; COULD NOT LOOK THROWS. `[]` / `null` mean the
373
+ // service looked and there is nothing. Anything else THROWS, with a code:
374
+ // UNAVAILABLE this app build has no AdaMaps module (needs a native rebuild),
375
+ // or the platform has none — search/route are iOS only (MapKit)
376
+ // and refuse by name on Android. Also the service's own failures.
377
+ // OFFLINE the map service could not be reached.
378
+ // THROTTLED Apple is rate-limiting this app. A burst of queries is what
379
+ // trips it — search once per intent, not once per frame.
380
+ // And one refusal that is not a failure to look:
381
+ // INVALID_ARGUMENT the call itself is malformed — an empty query, a
382
+ // coordinate that is not { latitude, longitude } finite numbers in
383
+ // range (`{ lat, lng }` refuses), a limit or radius out of range.
384
+ // Nothing was sent; fix the call rather than retrying it.
385
+
386
+ /** The same `{ latitude, longitude }` `location.getCurrent` returns, so a fix
387
+ * passes straight in. */
388
+ export interface MapsCoordinate {
389
+ latitude: number;
390
+ longitude: number;
391
+ }
392
+
393
+ /** A postal address, field by field. `null` = the map data has no value — never
394
+ * an empty string, which would read as a street with no name. */
395
+ export interface MapsAddress {
396
+ /** The placemark's own name — a building, or often number + street. */
397
+ name: string | null;
398
+ street: string | null;
399
+ streetNumber: string | null;
400
+ /** Neighbourhood / sub-locality. */
401
+ district: string | null;
402
+ city: string | null;
403
+ /** State / province / administrative area. */
404
+ region: string | null;
405
+ postalCode: string | null;
406
+ country: string | null;
407
+ /** ISO 3166-1 alpha-2, e.g. "GB". */
408
+ isoCountryCode: string | null;
409
+ }
410
+
411
+ export interface MapsPlace {
412
+ /** What the map calls it ("Boots", "10 Downing St"). Null when unnamed. */
413
+ name: string | null;
414
+ coordinate: MapsCoordinate;
415
+ address: MapsAddress;
416
+ /** Apple's point-of-interest category as a vocabulary term ("Pharmacy",
417
+ * "Cafe", "PublicTransport") — NOT free text. Null for a plain address, or
418
+ * when the service gives none. */
419
+ category: string | null;
420
+ /** Straight-line metres from `opts.near`, when you passed one — NOT walking
421
+ * distance (ask `route` for that). Null without `near`. */
422
+ distanceM: number | null;
423
+ }
424
+
425
+ export interface MapsRouteStep {
426
+ /** Apple's instruction ("Turn right onto Baker St"). The FIRST step is usually
427
+ * an origin step with an empty instruction — kept, not dropped, so step
428
+ * indexes line up with Apple's. */
429
+ instructions: string;
430
+ distanceM: number;
431
+ /** An advisory Apple attaches to this step. Null when there is none. */
432
+ notice: string | null;
433
+ polyline: MapsCoordinate[];
434
+ }
435
+
436
+ export interface MapsRoute {
437
+ mode: 'walking';
438
+ distanceM: number;
439
+ /** Apple's estimate for the whole route. */
440
+ expectedTravelTimeMs: number;
441
+ steps: MapsRouteStep[];
442
+ /** The whole route, start to end. */
443
+ polyline: MapsCoordinate[];
444
+ }
445
+
446
+ export interface MapsAPI {
447
+ /** Can this build search and route? True when the AdaMaps module is in the
448
+ * binary (iOS). False on Android and on an older build — `reverseGeocode`
449
+ * does not need the module and may still work there. */
450
+ isSupported(): Promise<boolean>;
451
+ /**
452
+ * Find places by free text — "pharmacy", "10 Downing Street".
453
+ *
454
+ * `near` centres the search on a coordinate YOU supply, within `radiusM`
455
+ * (default 3000, above 0 and at most 100 000). On iOS 18+ results are
456
+ * confined to that area; before 18 it is a strong hint and a farther result
457
+ * can come back — each result's `distanceM` says how far. At most `limit`
458
+ * results (default 8, from 1; more than 25 is capped at 25).
459
+ *
460
+ * Network, Apple's servers. `[]` = Apple looked and found nothing. iOS only:
461
+ * throws UNAVAILABLE on Android or a build without AdaMaps.
462
+ */
463
+ search(query: string, opts?: { near?: MapsCoordinate; radiusM?: number; limit?: number }): Promise<MapsPlace[]>;
464
+ /**
465
+ * What is at a coordinate — its address and name.
466
+ *
467
+ * iOS AND Android (the platform geocoder); needs no AdaMaps module. The
468
+ * result's `coordinate` is the point you asked about, and its
469
+ * `category` / `distanceM` are null. `null` = nothing there.
470
+ *
471
+ * Network, not on-device. iOS reports a rate limit and a lost connection as
472
+ * the SAME error, so OFFLINE here can also mean "too many requests". Android's
473
+ * geocoder refuses without location permission (UNAVAILABLE) — that is the
474
+ * platform's rule, not a location read.
475
+ */
476
+ reverseGeocode(at: MapsCoordinate): Promise<MapsPlace | null>;
477
+ /**
478
+ * A walking route between two coordinates YOU supply.
479
+ *
480
+ * `null` = Apple found no route — never a fake 0 m route, which would read as
481
+ * "you have arrived". Walking is the only mode; any other refuses by name.
482
+ * Network, Apple's servers. iOS only: throws UNAVAILABLE on Android or a build
483
+ * without AdaMaps.
484
+ */
485
+ route(from: MapsCoordinate, to: MapsCoordinate, opts?: { mode?: 'walking' }): Promise<MapsRoute | null>;
486
+ }
487
+
355
488
  export interface BluetoothAPI {
356
489
  /** Is BLE hardware present and permitted? */
357
490
  isSupported(): Promise<boolean>;
@@ -420,6 +553,9 @@ export interface PluginNative {
420
553
  bluetooth: BluetoothAPI;
421
554
  /** SHAPE ONLY in this build — see VisionAPI. On-device image understanding. */
422
555
  vision: VisionAPI;
556
+ /** Place search, reverse geocoding, walking routes — see MapsAPI. Sends
557
+ * queries to Apple's map servers; NOT on-device. */
558
+ maps: MapsAPI;
423
559
  }
424
560
 
425
561
  // ── Native capability flags (manifest declaration) ──────────
@@ -438,6 +574,9 @@ export interface NativeCapabilities {
438
574
  /** On-device image understanding (OCR, labels, frame quality). Refuses by
439
575
  * name until the native module lands — see VisionAPI. */
440
576
  vision?: boolean;
577
+ /** Place search, reverse geocoding, walking routes. Means "may send place
578
+ * queries to Apple Maps" — network, not on-device. See MapsAPI. */
579
+ maps?: boolean;
441
580
  /** The live voice SESSION (props.voice). A privacy surface — a declaring
442
581
  * plugin can read the conversation transcript — so it is gated like any
443
582
  * other capability, and coupled to the platform's unsuppressable mic
@@ -570,8 +709,9 @@ export interface PluginManifest {
570
709
  channels?: string[] | { events: string[]; actions: string[] };
571
710
  capabilities?: Record<string, unknown>;
572
711
  /** L3 DeviceState domains this plugin reads (props.deviceState), as Core passes
573
- * them through from the bundle's declaration. Absent → fall back to the loaded
574
- * bundle's own `stateDomains`. Read via resolvePluginRuntimeProps. */
712
+ * them through from the solution's declaration. Absent → NO domains (fail
713
+ * closed): the loaded bundle's own `stateDomains` is never consulted when a
714
+ * manifest exists. Read via resolvePluginRuntimeProps. */
575
715
  stateDomains?: DeviceBridgeDomain[];
576
716
  _source?: string;
577
717
  _connectorId?: string;
@@ -616,6 +756,9 @@ export interface DeviceToolContext {
616
756
  * the same gating a UI plugin's `props.deviceState` gets. This is what lets an
617
757
  * EXTERNAL tool (e.g. Ada Guide's `guide.observe`) read `ctx.deviceState.visual`
618
758
  * over the public surface, with no SDK-internal access.
759
+ *
760
+ * `subscribe` refuses (RESTRICTED) on every domain: a call has no lifetime to
761
+ * own a subscription, and the engines outlive it. Read instead.
619
762
  */
620
763
  deviceState: DeviceState;
621
764
  /** Cooperative cancellation: caller cancelled, or the session went away. */
@@ -624,6 +767,19 @@ export interface DeviceToolContext {
624
767
 
625
768
  export type DeviceToolFn = (args: any, ctx: DeviceToolContext) => Promise<any> | any;
626
769
 
770
+ /**
771
+ * Why running a device tool produced no result (`callDeviceTool`'s
772
+ * `{ ok: false, reason }`). The one list: the registry returns these, and the
773
+ * voice snapshot's `lastDeviceTool.reason` carries them unchanged.
774
+ * - `not_found` — no tool is registered under that name.
775
+ * - `no_context` — the tool's device context could not be built.
776
+ * - `timeout` — the tool did not finish within the call's timeout.
777
+ * - `failed` — the tool threw; the error is its message.
778
+ * A tool that RETURNS a value has succeeded as far as the platform is concerned,
779
+ * even if the value describes a failure — throw to report one.
780
+ */
781
+ export type DeviceToolFailureReason = 'not_found' | 'no_context' | 'timeout' | 'failed';
782
+
627
783
  export interface DeviceToolDefinition {
628
784
  /** Domain name, as the catalog and the caller see it. e.g. "guide.observe" */
629
785
  name: string;
@@ -639,9 +795,10 @@ export interface RegisteredToolsPlugin {
639
795
  type: 'tools';
640
796
  capabilities?: NativeCapabilities;
641
797
  /**
642
- * L3 DeviceState domains this plugin's tools may read via `ctx.deviceState`
643
- * (e.g. `['visual']`). Undeclared domains answer "not declared". Same
644
- * mechanism as a UI plugin's manifest `stateDomains`.
798
+ * L3 DeviceState domains this plugin's tools read via `ctx.deviceState` (e.g.
799
+ * `['visual']`). For a remote bundle this is the author's statement, not a
800
+ * grant: the tools get the manifest's `stateDomains` (resolvePluginGrants),
801
+ * and a domain declared here but not there is denied with a warning.
645
802
  */
646
803
  stateDomains?: DeviceBridgeDomain[];
647
804
  tools: DeviceToolDefinition[];
package/src/sdk/voice.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * See docs/handoff/SPEC_voice_session_without_the_voice_screen_2026-09-12.md.
20
20
  */
21
+ import type { DeviceToolFailureReason } from './types';
21
22
 
22
23
  /** The session lifecycle — identical vocabulary to the built-in `CallState`. */
23
24
  export type VoiceState = 'idle' | 'connecting' | 'connected' | 'error';
@@ -29,6 +30,41 @@ export interface VoiceTranscriptEntry {
29
30
  ts: number;
30
31
  }
31
32
 
33
+ /**
34
+ * Why a device-tool call the session received did not produce a result.
35
+ * - the tool RAN and did not return: `not_found` | `no_context` | `timeout` |
36
+ * `failed` (it threw) — see DeviceToolFailureReason;
37
+ * - the phone REFUSED it before running: `not_ready` (Core's tool allowset for
38
+ * this session had not arrived yet — transient, a retry can succeed) or
39
+ * `not_authorized` (not allowed in this session — final);
40
+ * - `invalid`: the call itself was malformed (it named no tool, or carried no
41
+ * correlationId to answer on) — not run.
42
+ */
43
+ export type VoiceDeviceToolReason = DeviceToolFailureReason | 'not_ready' | 'not_authorized' | 'invalid';
44
+
45
+ /**
46
+ * One finished device-tool call, as `VoiceSnapshot.lastDeviceTool` reports it.
47
+ * `name` and `at` keep their original meaning, so a reader of just those two is
48
+ * unaffected.
49
+ */
50
+ export type VoiceDeviceToolCall = {
51
+ /** The tool the model called ('' if the call named none — see `invalid`). */
52
+ name: string;
53
+ /** Epoch ms the call REACHED the phone (not when it finished — that is at + ms). */
54
+ at: number;
55
+ /** Milliseconds from arrival to outcome (≈0 for a refusal). */
56
+ ms: number;
57
+ } & (
58
+ | { ok: true; reason: null; error: null }
59
+ | {
60
+ ok: false;
61
+ reason: VoiceDeviceToolReason;
62
+ /** What went wrong, clipped to 200 characters: tool errors can echo the
63
+ * call's arguments, which may carry what the user said. */
64
+ error: string;
65
+ }
66
+ );
67
+
32
68
  /** A point-in-time read of the session — what a reasoning loop polls. */
33
69
  export interface VoiceSnapshot {
34
70
  state: VoiceState;
@@ -52,12 +88,58 @@ export interface VoiceSnapshot {
52
88
  /** The last error, if `state === 'error'`; also carries a refusal reason when
53
89
  * this surface was handed to a plugin that did not declare `voice`. */
54
90
  lastError: string | null;
91
+ /**
92
+ * The most recently FINISHED device-tool call the model made through this
93
+ * session, with its outcome — or null if none has finished yet. Set once the
94
+ * outcome is known (ran, refused, malformed, threw, timed out), never on
95
+ * arrival, so a refused call can no longer look like one that ran.
96
+ *
97
+ * SCOPED to the caller: a plugin sees only calls to tools IT registered (never
98
+ * another solution's tool names or errors, even on its own call); the platform
99
+ * sees every call. Cleared when the session ends or a new one starts.
100
+ * Diagnostic — every solution's debug view gets "why did that call fail" free.
101
+ */
102
+ lastDeviceTool: VoiceDeviceToolCall | null;
103
+ /** The sessionId the Core allowset is bound to. Diagnostic: authorization
104
+ * requires it to equal `sessionId`; a mismatch is why tools refuse. */
105
+ authorizedSessionId: string | null;
55
106
  }
56
107
 
57
108
  export interface VoiceStartOptions {
58
109
  /** Hand the mic to a specific skill on connect (voice /session → takeMic),
59
110
  * exactly as `sys.focusUiPlugin("platform:voice", { skillSlug })` does. */
60
111
  skillSlug?: string;
112
+ /**
113
+ * A PER-CALL persona override, same shape as the authored
114
+ * `solution.voice.persona` ({ name, style }). Declared on the voice
115
+ * session-create call (POST /session `persona`); the server sanitizes it
116
+ * (name/style only, length-capped) and layers it OVER the solution's authored
117
+ * persona for THIS call only — prompt-layer, so it can never touch caller
118
+ * verification or tool allow-lists. Set once at session start and constant
119
+ * for the call, so it adds no per-turn prompt pushes.
120
+ *
121
+ * Use it when a plugin needs its own voice persona without owning a skill
122
+ * (e.g. a guide HUD). A solution-wide persona belongs in solution.voice.
123
+ */
124
+ persona?: VoicePersona;
125
+ /**
126
+ * `'none'` = a CLEAN call: the voice gets none of the workspace's skills — no
127
+ * skill manifest, no skill tools, no platform welcome line, no mid-call
128
+ * skill-state prompt refresh — only the voice basics, this call's persona and
129
+ * the CALLING plugin's own device tools (other solutions' tools registered on
130
+ * the same phone are neither offered nor run). For a plugin that owns its conversation (e.g.
131
+ * a guide HUD). Reduce-only: it can remove what the voice gets, never add.
132
+ * Absent = the workspace's skills, as today. Not combinable with `skillSlug`.
133
+ */
134
+ skills?: 'none';
135
+ }
136
+
137
+ /** The authored persona shape (solution.voice.persona). */
138
+ export interface VoicePersona {
139
+ /** The name the voice uses for itself ("You are <name> …"). */
140
+ name?: string;
141
+ /** Free-text style/behaviour guidance rendered under the identity line. */
142
+ style?: string;
61
143
  }
62
144
 
63
145
  /**
@@ -13,7 +13,8 @@
13
13
  import { listPlugins, getPlugin, getNativeBundleUrl, getNativeBundleHash } from '../api/plugins';
14
14
  import { bundleManager } from '../bundles/bundleManager';
15
15
  import { listDeviceTools } from './registry';
16
- import { getBundleStorage, selectOwnedCacheEntries } from '../bundles/storage';
16
+ import { getBundleStorage, loadCacheIndex, selectOwnedCacheEntries } from '../bundles/storage';
17
+ import { resolvePluginGrants } from '../pluginRuntime';
17
18
  import { getTenant } from '@ateam-ai-mobile/core';
18
19
 
19
20
  let inflight: Promise<string[]> | null = null;
@@ -54,9 +55,9 @@ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<s
54
55
  // a fact about a server process, not about this phone — the bundle we
55
56
  // already downloaded still has the tools in it.
56
57
  //
57
- // So fall back to what is on disk. Re-evaluating a cached bundle
58
- // re-registers whatever it declares, and a bundle with no tools is a
59
- // no-op, so this is safe to do blind.
58
+ // So fall back to what is on disk: re-evaluate this tenant's cached
59
+ // bundles under the trusted declaration recorded when a manifest last
60
+ // loaded them (see loadFromDiskCache).
60
61
  const recovered = await loadFromDiskCache();
61
62
  if (recovered.length > 0) {
62
63
  console.log(`[deviceTools] catalog listed none — recovered from cache: ${recovered.join(', ')}`);
@@ -75,11 +76,14 @@ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<s
75
76
  return;
76
77
  }
77
78
  try {
79
+ // The manifest is the authority on what the tools may touch — the
80
+ // same grants its rendered surface gets, never the bundle's claim.
78
81
  await bundleManager.loadTools(
79
82
  m.id,
80
83
  bundleUrl,
81
84
  m.version || '1.0.0',
82
85
  getNativeBundleHash(m) || undefined,
86
+ resolvePluginGrants({ pluginId: m.id, manifest: m }),
83
87
  );
84
88
  } catch (err: any) {
85
89
  console.warn(`[deviceTools] ${m.id} failed to load:`, err?.message || String(err));
@@ -127,10 +131,20 @@ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<s
127
131
  * An entry with no recorded tenant predates the stamp. Its owner is unknown,
128
132
  * which means untrusted — skip it and let the normal catalog path re-download
129
133
  * it under a real hash gate.
134
+ *
135
+ * GRANTS COME FROM THE RECORDED MANIFEST, NEVER THE BUNDLE. With no manifest
136
+ * here, the tools register under the `trusted` declaration (pluginId +
137
+ * capabilities + stateDomains) the bundle manager recorded on the entry when a
138
+ * manifest last loaded these bytes. An entry without one — written before the
139
+ * record existed, or never loaded under a manifest — is skipped (fail closed):
140
+ * the only other source is the bundle's own claim, and the catalog path
141
+ * registers it properly once its connector answers.
130
142
  */
131
143
  async function loadFromDiskCache(): Promise<string[]> {
132
144
  try {
133
- const index = await getBundleStorage().loadIndex();
145
+ // Canonical keys only (loadCacheIndex): an entry an older build filed under
146
+ // the manifest's unencoded spelling is recovered under pluginBundleUrl.
147
+ const index = await loadCacheIndex(getBundleStorage());
134
148
  const entries = Object.values(index || {});
135
149
  if (entries.length === 0) return [];
136
150
 
@@ -145,11 +159,17 @@ async function loadFromDiskCache(): Promise<string[]> {
145
159
  if (skipped > 0) {
146
160
  console.log(`[deviceTools] disk-cache recovery: ${mine.length} for "${tenant}", skipped ${skipped} not owned by it`);
147
161
  }
148
- if (mine.length === 0) return [];
162
+ const declared = mine.filter((e) => !!e.trusted?.pluginId);
163
+ if (declared.length < mine.length) {
164
+ console.log(
165
+ `[deviceTools] disk-cache recovery: skipped ${mine.length - declared.length} with no recorded manifest grant (fail closed)`,
166
+ );
167
+ }
168
+ if (declared.length === 0) return [];
149
169
 
150
- await Promise.all(mine.map(async (e: any) => {
170
+ await Promise.all(declared.map(async (e) => {
151
171
  try {
152
- await bundleManager.loadTools('cache:' + e.bundleUrl, e.bundleUrl, e.version, e.bundleHash);
172
+ await bundleManager.loadTools(e.trusted!.pluginId, e.bundleUrl, e.version, e.bundleHash, e.trusted!);
153
173
  } catch {
154
174
  // A cached bundle that no longer loads is not worth failing a call over.
155
175
  }
@@ -14,8 +14,8 @@
14
14
  */
15
15
  import { createNativeBridge } from '../bridge/native';
16
16
  import { getPluginNative, gatePluginNative } from '../host';
17
- import { gateDeviceState } from '../deviceStateHost';
18
- import type { DeviceToolContext, DeviceToolDefinition, NativeCapabilities } from '../sdk/types';
17
+ import { gateDeviceState, withoutSubscribe } from '../deviceStateHost';
18
+ import type { DeviceToolContext, DeviceToolDefinition, DeviceToolFailureReason, NativeCapabilities } from '../sdk/types';
19
19
  import type { DeviceBridgeDomain } from '../sdk/device-state';
20
20
 
21
21
  export interface DeviceToolSummary {
@@ -27,7 +27,7 @@ export interface DeviceToolSummary {
27
27
 
28
28
  export type DeviceToolOutcome =
29
29
  | { ok: true; result: any }
30
- | { ok: false; error: string; reason: 'not_found' | 'no_context' | 'timeout' | 'failed' };
30
+ | { ok: false; error: string; reason: DeviceToolFailureReason };
31
31
 
32
32
  interface Entry {
33
33
  pluginId: string;
@@ -62,7 +62,8 @@ export function setDeviceToolContextProvider(
62
62
  * Default context: the plugin's own bridge + a capability-gated Device Bridge +
63
63
  * the L3 DeviceState gated to the domains the plugin declared. All three are the
64
64
  * SAME gated objects the plugin gets when it renders — a tool reaches nothing a
65
- * UI plugin couldn't.
65
+ * UI plugin couldn't. (One thing less: `callDeviceTool` takes `subscribe` away,
66
+ * because a call has no lifetime to own one.)
66
67
  */
67
68
  function defaultContext(
68
69
  pluginId: string,
@@ -120,8 +121,11 @@ export function unregisterDeviceTools(pluginId: string): void {
120
121
  }
121
122
  }
122
123
 
123
- export function hasDeviceTool(name: string): boolean {
124
- return registry.has(name);
124
+ /** Is `name` registered — and, given `pluginId`, registered BY that plugin?
125
+ * (A clean voice call only lets its owner's own tools run.) */
126
+ export function hasDeviceTool(name: string, pluginId?: string): boolean {
127
+ const entry = registry.get(name);
128
+ return !!entry && (pluginId === undefined || entry.pluginId === pluginId);
125
129
  }
126
130
 
127
131
  export function listDeviceTools(): DeviceToolSummary[] {
@@ -152,8 +156,11 @@ export async function callDeviceTool(
152
156
 
153
157
  try {
154
158
  const build = contextProvider || defaultContext;
159
+ const built = build(entry.pluginId, entry.capabilities, entry.stateDomains);
155
160
  const ctx: DeviceToolContext = {
156
- ...build(entry.pluginId, entry.capabilities, entry.stateDomains),
161
+ ...built,
162
+ // Whoever built the context, a call cannot own a subscription.
163
+ deviceState: withoutSubscribe(built.deviceState),
157
164
  signal: opts.signal,
158
165
  };
159
166
 
package/src/voiceHost.ts CHANGED
@@ -62,6 +62,8 @@ function deniedVoice(why: string): VoiceAPI {
62
62
  listening: false,
63
63
  userSpeaking: false,
64
64
  lastUserSpeechEndedAt: null,
65
+ lastDeviceTool: null,
66
+ authorizedSessionId: null,
65
67
  lastError: why,
66
68
  };
67
69
  },