@ateam-ai-mobile/plugin-runtime 0.1.20 → 0.1.22

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.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  import {
2
+ AI_LIVE_IDLE,
2
3
  ActionButton,
3
4
  ActivityIndicator,
4
5
  AdaOrb,
5
6
  Animated,
6
7
  Badge,
8
+ CONSENT_TAKEN_BACK,
7
9
  CameraPreview,
8
10
  Card,
9
11
  EmptyState,
@@ -33,6 +35,7 @@ import {
33
35
  TextInput,
34
36
  TouchableOpacity,
35
37
  View,
38
+ aiLiveIndicator,
36
39
  bundleManager,
37
40
  callDeviceTool,
38
41
  clearDeviceTools,
@@ -41,6 +44,7 @@ import {
41
44
  device_state_exports,
42
45
  ensureDeviceToolsLoaded,
43
46
  explainCapability,
47
+ gateAiLive,
44
48
  gateDeviceState,
45
49
  gatePluginNative,
46
50
  gateVoice,
@@ -59,8 +63,11 @@ import {
59
63
  isMountSurface,
60
64
  listDeviceTools,
61
65
  listPlugins,
66
+ mayPicturesLeavePhone,
62
67
  nativePluginRegistry,
63
68
  neverObserved,
69
+ notifyPicturesToAiConsentChanged,
70
+ onPicturesToAiConsentChange,
64
71
  pluginBundleUrl,
65
72
  register,
66
73
  registerBundleModules,
@@ -68,17 +75,20 @@ import {
68
75
  registerNativePlugin,
69
76
  registerService,
70
77
  registerTools,
78
+ releaseAiLive,
71
79
  releaseDeviceStateHolds,
72
80
  rendersNative,
73
81
  resetDeviceToolLoader,
74
82
  resolvePluginGrants,
75
83
  resolvePluginRuntimeProps,
76
84
  resolvePluginUrl,
85
+ setAiLiveProvider,
77
86
  setBundleStorage,
78
87
  setCameraPreviewProvider,
79
88
  setDeviceStateProvider,
80
89
  setDeviceToolContextProvider,
81
90
  setHapticsProvider,
91
+ setPicturesToAiConsentProvider,
82
92
  setPluginNativeProvider,
83
93
  setPluginThemeColors,
84
94
  setVoiceProvider,
@@ -90,7 +100,7 @@ import {
90
100
  useNativeBridge,
91
101
  usePluginTheme,
92
102
  useSurfaceStore
93
- } from "./chunk-56Z2X5ZB.mjs";
103
+ } from "./chunk-RNA6X65P.mjs";
94
104
 
95
105
  // src/tools/platformTools.ts
96
106
  var PLATFORM_PLUGIN_ID = "platform:device-tools";
@@ -133,10 +143,10 @@ var REAL_DEPS = {
133
143
  // `./loader` reaches react-native via the bundle manager; import it only when
134
144
  // an effect actually fires (real app), never at module load (tests).
135
145
  resetLoader: () => {
136
- void import("./loader-3PQLHEEW.mjs").then((m) => m.resetDeviceToolLoader());
146
+ void import("./loader-GGJ5YO25.mjs").then((m) => m.resetDeviceToolLoader());
137
147
  },
138
148
  load: () => {
139
- void import("./loader-3PQLHEEW.mjs").then((m) => {
149
+ void import("./loader-GGJ5YO25.mjs").then((m) => {
140
150
  m.resetDeviceToolLoader();
141
151
  return m.ensureDeviceToolsLoaded({ force: true });
142
152
  });
@@ -311,6 +321,9 @@ function usePluginRuntimeProps(src, deps) {
311
321
  useEffect2(() => () => {
312
322
  void releaseDeviceStateHolds(holder);
313
323
  }, [holder]);
324
+ useEffect2(() => () => {
325
+ void releaseAiLive(holder);
326
+ }, [holder]);
314
327
  const grantedKey = granted.join(",");
315
328
  useEffect2(() => {
316
329
  void releaseDeviceStateHolds(holder, granted);
@@ -388,11 +401,13 @@ function useRemotePlugins(solutionId) {
388
401
  };
389
402
  }
390
403
  export {
404
+ AI_LIVE_IDLE,
391
405
  ActionButton,
392
406
  ActivityIndicator,
393
407
  AdaOrb,
394
408
  Animated,
395
409
  Badge,
410
+ CONSENT_TAKEN_BACK,
396
411
  CameraPreview,
397
412
  Card,
398
413
  EmptyState,
@@ -423,6 +438,7 @@ export {
423
438
  TouchableOpacity,
424
439
  View,
425
440
  __resetSessionSyncForTest,
441
+ aiLiveIndicator,
426
442
  bundleManager,
427
443
  callDeviceTool,
428
444
  clearDeviceTools,
@@ -432,6 +448,7 @@ export {
432
448
  deviceToolsActionForSession,
433
449
  ensureDeviceToolsLoaded,
434
450
  explainCapability,
451
+ gateAiLive,
435
452
  gateDeviceState,
436
453
  gatePluginNative,
437
454
  gateVoice,
@@ -452,8 +469,11 @@ export {
452
469
  isMountSurface,
453
470
  listDeviceTools,
454
471
  listPlugins,
472
+ mayPicturesLeavePhone,
455
473
  nativePluginRegistry,
456
474
  neverObserved,
475
+ notifyPicturesToAiConsentChanged,
476
+ onPicturesToAiConsentChange,
457
477
  pluginBundleUrl,
458
478
  register,
459
479
  registerBundleModules,
@@ -462,18 +482,21 @@ export {
462
482
  registerPlatformDeviceTools,
463
483
  registerService,
464
484
  registerTools,
485
+ releaseAiLive,
465
486
  releaseDeviceStateHolds,
466
487
  rendersNative,
467
488
  resetDeviceToolLoader,
468
489
  resolvePluginGrants,
469
490
  resolvePluginRuntimeProps,
470
491
  resolvePluginUrl,
492
+ setAiLiveProvider,
471
493
  setBundleStorage,
472
494
  setCameraPreviewProvider,
473
495
  setDeviceStateProvider,
474
496
  setDeviceToolContextProvider,
475
497
  setDeviceToolResultPoster,
476
498
  setHapticsProvider,
499
+ setPicturesToAiConsentProvider,
477
500
  setPluginNativeProvider,
478
501
  setPluginThemeColors,
479
502
  setVoiceProvider,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/tools/platformTools.ts","../src/tools/sessionSync.ts","../src/tools/serverCalls.ts","../src/hooks/usePlugin.ts","../src/hooks/usePluginRuntimeProps.ts","../src/hooks/useRemotePlugins.ts"],"sourcesContent":["/**\n * Platform device tools.\n *\n * These are not a solution's plugin tools — they ship with the SDK and every\n * host has them, which is what makes a device tool testable without deploying\n * anything to a tenant. Their names and contracts are declared once on the\n * server (connectors/platform/device-tools-mcp.json); this is the device half.\n *\n * Registered at import, so a client answers them even before any plugin bundle\n * has loaded — that is the point: proving the device path works should not\n * depend on the thing being proved.\n */\nimport { registerDeviceTools } from './registry';\n\nconst PLATFORM_PLUGIN_ID = 'platform:device-tools';\n\nexport function registerPlatformDeviceTools(): void {\n registerDeviceTools(PLATFORM_PLUGIN_ID, [\n {\n name: 'device.echo',\n description: \"Echo a string back from the user's own device.\",\n input_schema: {\n type: 'object',\n properties: { text: { type: 'string' } },\n required: ['text'],\n },\n run: (args: any) => ({\n ok: true,\n echoed: (args && args.text) || '',\n // The proof: a server-side implementation could not say this truthfully.\n ran_on: 'device',\n }),\n },\n {\n name: 'device.ping',\n description: 'Check that the device is reachable.',\n input_schema: { type: 'object', properties: {} },\n run: () => ({ ok: true, ran_on: 'device', at: new Date().toISOString() }),\n },\n ]);\n}\n","/**\n * Ambient device-tool registration, driven by the auth session (R14).\n *\n * The LOCAL reasoning path must be live BEFORE voice opens — otherwise the only\n * proof of a loadable external skill is a voice-triggered one. So the host wires\n * this to its EXISTING auth-session subscription (no new transport, no plugin\n * special-case): a tenant becoming authenticated loads that tenant's external\n * device-tool bundles (and re-ensures the static platform tools); a tenant\n * SWITCH is a change of tenant while still authenticated, so it clears the old\n * tenant's tools and reloads; sign-out resets the loader latch (the registry\n * itself is cleared by the host's logout teardown, which already calls\n * clearDeviceTools()).\n *\n * The decision is a PURE function so the lifecycle is testable without a network\n * or a device; the effects are injected (same DI shape as the host's logout\n * teardown) so a test drives them with fakes.\n */\nimport { registerPlatformDeviceTools } from './platformTools';\nimport { clearDeviceTools } from './registry';\n// NOTE: `./loader` pulls in the bundle manager (react-native, core), so it is\n// imported LAZILY inside the real effects below — keeping this module itself\n// node-testable (a test injects fake effects and never touches that graph).\n\nexport interface SessionSnapshot {\n authenticated: boolean;\n /** The tenant the session is scoped to (derive it; never a stored copy). */\n tenant: string | null;\n}\n\nexport type DeviceToolsAction = 'load' | 'reset' | 'none';\n\n/**\n * PURE: what should the device-tool registry do for this session transition?\n * - a new authenticated tenant (initial sign-in OR a switch) → 'load'\n * - the same authenticated tenant again (e.g. a silent token refresh) → 'none'\n * - leaving authenticated (sign-out) → 'reset'\n */\nexport function deviceToolsActionForSession(\n next: SessionSnapshot,\n prevTenant: string | null,\n): { action: DeviceToolsAction; tenant: string | null } {\n const tenant = next.authenticated ? (next.tenant ?? null) : null;\n if (tenant && tenant !== prevTenant) return { action: 'load', tenant };\n if (!tenant && prevTenant) return { action: 'reset', tenant: null };\n return { action: 'none', tenant: prevTenant };\n}\n\n/** The effects a session transition drives — injected so a test can fake them. */\nexport interface DeviceToolsSessionDeps {\n /** Drop the previous tenant's device tools (a switch does not run logout teardown). */\n clear: () => void;\n /** Ensure the static, host-owned platform device tools are present. */\n registerPlatform: () => void;\n /** Forget the \"already loaded\" latch so a later (non-forced) load runs — sign-out. */\n resetLoader: () => void;\n /** Fire-and-forget reload of the current tenant's external device-tool bundles. */\n load: () => void;\n}\n\nconst REAL_DEPS: DeviceToolsSessionDeps = {\n clear: clearDeviceTools,\n registerPlatform: registerPlatformDeviceTools,\n // `./loader` reaches react-native via the bundle manager; import it only when\n // an effect actually fires (real app), never at module load (tests).\n resetLoader: () => {\n void import('./loader').then((m) => m.resetDeviceToolLoader());\n },\n load: () => {\n void import('./loader').then((m) => {\n m.resetDeviceToolLoader();\n return m.ensureDeviceToolsLoaded({ force: true });\n });\n },\n};\n\nlet prevTenant: string | null = null;\n\n/**\n * Apply a session transition to the device-tool registry. Call from the host's\n * existing auth-session subscription with a derived snapshot. Idempotent per\n * tenant: repeated snapshots for the same authenticated tenant do nothing, so a\n * silent token refresh does not churn the registry. Returns the action taken.\n */\nexport function syncDeviceToolsForSession(\n next: SessionSnapshot,\n deps: DeviceToolsSessionDeps = REAL_DEPS,\n): DeviceToolsAction {\n const { action, tenant } = deviceToolsActionForSession(next, prevTenant);\n prevTenant = tenant;\n if (action === 'load') {\n // A new authenticated tenant: drop the prior tenant's tools, re-seat the\n // static platform tools, then reload this tenant's external bundles (forced).\n deps.clear();\n deps.registerPlatform();\n deps.load();\n } else if (action === 'reset') {\n // Sign-out: the host's logout teardown clears the registry; we only forget\n // the loader latch so a later sign-in reloads.\n deps.resetLoader();\n }\n return action;\n}\n\n/** Test seam: forget the last-seen tenant so a test starts from signed-out. */\nexport function __resetSessionSyncForTest(): void {\n prevTenant = null;\n}\n","/**\n * Device tools called BY THE SERVER.\n *\n * Two callers reach the same registered function on this phone:\n *\n * - the voice loop, which already runs here (no server hop at all), and\n * - the server reasoning loop, which cannot execute a device tool itself and\n * sends it down the client's stream instead.\n *\n * This is the second one. It rides the transport the `ui.*` plugin commands\n * already use — a command arrives on the job/actor stream, we run it, and post\n * the outcome back keyed by `correlationId`. Nothing new was invented; the\n * payload just carries a tool call instead of a UI command.\n *\n * The host wires this once at startup:\n *\n * pluginCommandEmitter.on('plugin-command', handleServerDeviceToolCall);\n */\nimport { callDeviceTool } from './registry';\nimport { ensureDeviceToolsLoaded } from './loader';\n\ntype PostResult = (correlationId: string, result: any, error?: string) => Promise<void>;\n\nlet postResult: PostResult | null = null;\n\n/**\n * Host DI: how to answer the server. `@ateam-ai-mobile/core` supplies\n * postCommandResult; kept injectable so this package stays free of the REST\n * client and so tests can observe the answer.\n */\nexport function setDeviceToolResultPoster(fn: PostResult): void {\n postResult = fn;\n}\n\n/** Is this stream command a device tool call? */\nexport function isDeviceToolCommand(cmd: any): boolean {\n return !!cmd && cmd.kind === 'device_tool' && typeof cmd.tool === 'string' && !!cmd.correlationId;\n}\n\n/**\n * Run a server-dispatched device tool and answer.\n *\n * Never throws — the server is blocked on the correlationId, so the one thing\n * we must not do is stay silent. Every path posts something back.\n */\nexport async function handleServerDeviceToolCall(cmd: any): Promise<void> {\n if (!isDeviceToolCommand(cmd)) return;\n\n const { tool, args, correlationId } = cmd;\n\n if (!postResult) {\n console.warn('[deviceTools] server called a tool but no result poster is installed');\n return;\n }\n\n try {\n // The server may reach us before anything opened a voice call, so the\n // plugins may not be loaded yet. Cheap and idempotent once warm.\n await ensureDeviceToolsLoaded();\n\n const outcome = await callDeviceTool(tool, args || {});\n console.log(`[deviceTools] server-called ${tool} → ${outcome.ok ? 'ok' : outcome.reason}`);\n\n await postResult(\n correlationId,\n outcome.ok ? { ok: true, ...unwrap(outcome.result) } : undefined,\n outcome.ok ? undefined : outcome.error,\n );\n } catch (err: any) {\n await postResult(correlationId, undefined, err?.message || String(err)).catch(() => {});\n }\n}\n\n/** Keep an object result flat for the reasoning loop; wrap anything else. */\nfunction unwrap(result: any): Record<string, any> {\n if (result && typeof result === 'object' && !Array.isArray(result)) return result;\n return { result };\n}\n","/**\n * usePlugin — single hook for loading any plugin.\n *\n * Simple async load → done pattern over the global BundleManager singleton\n * (deduplication + caching) and the host's native plugin registry.\n *\n * Usage:\n * const { plugin, loading, error } = usePlugin(pluginId, manifest);\n * if (loading) return <Spinner />;\n * if (error) return <Error message={error} />;\n * return <plugin.Component bridge={bridge} device={device} theme={theme} context={context} />;\n */\nimport { useState, useEffect } from 'react';\nimport { bundleManager } from '../bundles/bundleManager';\nimport { nativePluginRegistry } from '../host';\nimport { resolvePluginGrants } from '../pluginRuntime';\nimport type { NativeCapabilities, RegisteredUIPlugin } from '../sdk/types';\nimport type { DeviceBridgeDomain } from '../sdk/device-state';\nimport { pluginBundleUrl } from '../bundles/bundleUrl';\n\ninterface PluginManifestLike {\n version?: string;\n name?: string;\n render?: any;\n _connectorId?: string;\n capabilities?: Record<string, unknown>;\n native?: NativeCapabilities;\n stateDomains?: DeviceBridgeDomain[];\n}\n\ninterface UsePluginResult {\n plugin: RegisteredUIPlugin | null;\n loading: boolean;\n error: string | null;\n bundleMissing: boolean;\n}\n\n/**\n * Compute the bundle URL.\n *\n * **Policy:** bundle URLs are ALWAYS derived from the pluginId via the\n * canonical platform route /api/ui-plugins/{pluginId}/bundle.js.\n * Connector-supplied bundleUrl fields are IGNORED — the Builder strips them\n * at introspection time, but we treat any survivors as bugs and ignore them\n * here too (defense in depth vs \"hand-coded URL with bad encoding causes\n * silent iframe fallback\" bugs).\n */\ntype BundleUrlSource = 'derived-default' | 'ignored-explicit' | 'none';\nfunction extractBundleUrl(pluginId: string, manifest: PluginManifestLike): { url: string | null; source: BundleUrlSource; ignored?: string } {\n const render = manifest.render;\n if (!render || typeof render !== 'object') return { url: null, source: 'none' };\n\n // Detect (and refuse) any explicit bundle URL the connector tried to inject.\n const injected =\n (manifest as any).bundleUrl ||\n (render as any).bundleUrl ||\n render.reactNative?.bundleUrl ||\n null;\n\n // Mobile decides native vs web INDEPENDENT of render.mode. The server's\n // cp.getContextPlugin flattens mode to \"iframe\" whenever an iframeUrl is\n // available (for web-frontend compatibility) — even when a reactNative\n // component is also declared. So we derive the bundle URL whenever a\n // reactNative component is present, regardless of what `mode` says.\n const hasNativeComponent = !!(render.reactNative?.component || render.component);\n if (hasNativeComponent) {\n return {\n url: pluginBundleUrl(pluginId),\n source: injected ? 'ignored-explicit' : 'derived-default',\n ...(injected && { ignored: injected }),\n };\n }\n\n return { url: null, source: 'none' };\n}\n\n/**\n * Extract the SHA-256 integrity hash from the manifest (server-provided).\n * BundleManager refuses to evaluate a downloaded bundle whose bytes don't\n * hash to this value — defense vs MITM/CDN tampering.\n */\nfunction extractBundleHash(manifest: PluginManifestLike): string | undefined {\n if ((manifest as any).bundleHash) return (manifest as any).bundleHash;\n const render = manifest.render;\n if (!render || typeof render !== 'object') return undefined;\n if (render.bundleHash) return render.bundleHash;\n if (render.reactNative?.bundleHash) return render.reactNative.bundleHash;\n return undefined;\n}\n\n/**\n * Extract component name from manifest render field.\n */\nfunction extractComponentId(manifest: PluginManifestLike): string | null {\n const render = manifest.render;\n if (!render || typeof render !== 'object') return null;\n return render.component || render.reactNative?.component || null;\n}\n\nexport function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePluginResult {\n const [plugin, setPlugin] = useState<RegisteredUIPlugin | null>(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [bundleMissing, setBundleMissing] = useState(false);\n\n const version = manifest.version || '1.0.0';\n const { url: bundleUrl, ignored: ignoredUrl } = extractBundleUrl(pluginId, manifest);\n const bundleHash = extractBundleHash(manifest);\n const componentId = extractComponentId(manifest);\n const lookupId = componentId || pluginId;\n // The manifest's grants — the authority for the bundle's device tools, the\n // same one the renderer gates the component with. Keyed by value so a fresh\n // manifest object with the same grants does not reload the plugin.\n const grants = resolvePluginGrants({ pluginId, manifest });\n const grantsKey = JSON.stringify(grants);\n\n if (ignoredUrl) {\n console.warn(`[usePlugin] ${pluginId}: IGNORED connector-injected bundleUrl=\"${ignoredUrl}\". The host derives bundle URLs from pluginId via the canonical route.`);\n }\n\n useEffect(() => {\n let cancelled = false;\n\n (async () => {\n try {\n // Path 1: Local registry (host-bundled / dev plugins)\n if (nativePluginRegistry.has(lookupId)) {\n const registered = await nativePluginRegistry.get(lookupId);\n if (cancelled) return;\n\n if (!registered) {\n throw new Error(`Local plugin not found: ${lookupId}`);\n }\n if (registered.type === 'service') {\n throw new Error(`Plugin ${lookupId} is a service plugin and cannot be rendered`);\n }\n\n setPlugin(registered as RegisteredUIPlugin);\n setLoading(false);\n return;\n }\n\n // Path 2: Remote bundle (via global BundleManager)\n if (bundleUrl) {\n try {\n const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash, grants);\n if (cancelled) return;\n setPlugin(component);\n setLoading(false);\n return;\n } catch (bundleErr) {\n const msg = bundleErr instanceof Error ? bundleErr.message : String(bundleErr);\n // warn (not error) — this is a handled path: we fall back to WebView.\n console.warn(`[usePlugin] ${pluginId}: Bundle load failed, falling back to WebView:`, msg);\n // RN bundle failed — connector declared a component but didn't\n // ship a usable bundle (404, eval error, integrity fail, etc).\n // Mark \"bundle missing\" so the parent renderer can fall back to\n // the WebView/iframe when the manifest also has an iframeUrl.\n if (!cancelled) {\n setBundleMissing(true);\n setLoading(false);\n }\n return;\n }\n }\n\n // No native source available. If the manifest also has an iframeUrl,\n // mark \"bundle missing\" so the parent falls back to the WebView.\n // Only throw when there's truly nothing renderable.\n const iframeUrl = manifest?.render?.iframeUrl\n || (manifest?.render?.mode === 'adaptive' && manifest?.render?.iframe?.iframeUrl);\n if (iframeUrl) {\n if (!cancelled) {\n setBundleMissing(true);\n setLoading(false);\n }\n return;\n }\n throw new Error(\n `Plugin \"${manifest.name || pluginId}\" has no bundle URL and is not in the local registry. ` +\n `Check that the solution definition includes a valid render.reactNative.bundleUrl.`\n );\n } catch (err) {\n if (cancelled) return;\n const msg = err instanceof Error ? err.message : String(err);\n console.error(`[usePlugin] Failed to load ${pluginId}:`, msg);\n setError(msg);\n setLoading(false);\n }\n })();\n\n return () => { cancelled = true; };\n }, [pluginId, lookupId, bundleUrl, bundleHash, version, grantsKey]);\n\n return { plugin, loading, error, bundleMissing };\n}\n","// usePluginRuntimeProps — the gated { device, native, deviceState, voice } for ONE\n// mounted surface, with the device-state sessions it starts released when it\n// unmounts (AI Models F2: a screen that closes leaves no camera running).\n//\n// Every surface that renders a plugin uses this instead of calling\n// resolvePluginRuntimeProps in a useMemo, so the release cannot be forgotten.\n// Each mount is its own holder: a drawer and an embedded view of one plugin hold\n// the same camera separately, and it ends only when both have closed. A mount\n// that switches plugin releases the old plugin's sessions, and one whose grants\n// shrink releases the domains it lost.\n\nimport { useEffect, useMemo, useRef } from 'react';\nimport { resolvePluginGrants, resolvePluginRuntimeProps, type PluginRuntimeSource } from '../pluginRuntime';\nimport { releaseDeviceStateHolds } from '../deviceStateHost';\nimport type { PluginRuntimeProps } from '../pluginRuntime';\n\nlet mounts = 0;\n\nexport function usePluginRuntimeProps(src: PluginRuntimeSource, deps: readonly unknown[]): PluginRuntimeProps {\n // One holder per plugin on this mount: a renderer that switches to ANOTHER plugin\n // gets a new holder, and the effect below releases what the old one held.\n const holderRef = useRef<{ pluginId: string; holder: string } | null>(null);\n if (holderRef.current?.pluginId !== src.pluginId) {\n holderRef.current = { pluginId: src.pluginId, holder: `${src.pluginId}#${++mounts}` };\n }\n const holder = holderRef.current.holder;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n const props = useMemo(() => resolvePluginRuntimeProps(src, holder), [...deps, holder]);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n const granted = useMemo(() => resolvePluginGrants(src).stateDomains ?? [], deps);\n\n // Unmount, or a switch to another plugin: release everything this holder holds.\n useEffect(() => () => { void releaseDeviceStateHolds(holder); }, [holder]);\n\n // Grants changed while mounted: a domain no longer granted is released now. The\n // new facade refuses it, so the plugin could not stop that session itself.\n const grantedKey = granted.join(',');\n // eslint-disable-next-line react-hooks/exhaustive-deps\n useEffect(() => { void releaseDeviceStateHolds(holder, granted); }, [holder, grantedKey]);\n\n return props;\n}\n","import { useEffect, useState, useCallback } from 'react';\nimport { apiFetch } from '@ateam-ai-mobile/core';\nimport type { PluginManifest } from '../sdk/types';\n\nexport interface RemotePlugin {\n id: string;\n name: string;\n version: string;\n manifest: PluginManifest;\n source: 'remote' | 'local'; // Where this plugin came from\n}\n\ninterface UseRemotePluginsResult {\n plugins: RemotePlugin[];\n loading: boolean;\n error: string | null;\n refreshing: boolean;\n refresh: () => Promise<void>;\n}\n\n/**\n * Fetches available UI plugins from A-Team Core for the current solution.\n * Uses apiFetch which auto-injects JWT + tenant headers.\n */\nexport function useRemotePlugins(solutionId?: string): UseRemotePluginsResult {\n const [plugins, setPlugins] = useState<RemotePlugin[]>([]);\n const [loading, setLoading] = useState(true);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const fetchRemotePlugins = useCallback(async () => {\n try {\n setError(null);\n\n const targetSolutionId = solutionId || 'current';\n\n let data: any;\n try {\n data = await apiFetch<any>(`/api/solutions/${targetSolutionId}/ui-plugins`);\n } catch (err: any) {\n if (err?.message?.includes('404')) {\n console.warn(`[useRemotePlugins] Solution ${targetSolutionId} not found or has no plugins`);\n setPlugins([]);\n return;\n }\n throw err;\n }\n const uiPlugins: RemotePlugin[] = (data.plugins || []).map((plugin: any) => ({\n id: plugin.id,\n name: plugin.name,\n version: plugin.version || '1.0.0',\n source: 'remote' as const,\n manifest: {\n id: plugin.id,\n name: plugin.name,\n version: plugin.version || '1.0.0',\n type: 'ui',\n description: plugin.description || '',\n capabilities: plugin.capabilities || {},\n channels: plugin.channels || ['default'],\n render: (plugin.render && typeof plugin.render === 'object') ? plugin.render : { mode: 'react-native' as const, component: plugin.name || plugin.id },\n entry_point: plugin.entry_point || plugin.name,\n sandbox: plugin.sandbox !== false,\n requires_auth: plugin.requires_auth !== false,\n } as PluginManifest,\n }));\n\n setPlugins(uiPlugins);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n console.error('[useRemotePlugins] Failed to fetch plugins:', message);\n setError(message);\n setPlugins([]); // Clear plugins on error\n } finally {\n setLoading(false);\n setRefreshing(false);\n }\n }, [solutionId]);\n\n const refresh = useCallback(async () => {\n setRefreshing(true);\n await fetchRemotePlugins();\n }, [fetchRemotePlugins]);\n\n useEffect(() => {\n setLoading(true);\n fetchRemotePlugins();\n }, [fetchRemotePlugins]);\n\n return {\n plugins,\n loading,\n error,\n refreshing,\n refresh,\n };\n}\n"],"mappings":"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","names":["prevTenant","useEffect","useEffect","useEffect","useState"]}
1
+ {"version":3,"sources":["../src/tools/platformTools.ts","../src/tools/sessionSync.ts","../src/tools/serverCalls.ts","../src/hooks/usePlugin.ts","../src/hooks/usePluginRuntimeProps.ts","../src/hooks/useRemotePlugins.ts"],"sourcesContent":["/**\n * Platform device tools.\n *\n * These are not a solution's plugin tools — they ship with the SDK and every\n * host has them, which is what makes a device tool testable without deploying\n * anything to a tenant. Their names and contracts are declared once on the\n * server (connectors/platform/device-tools-mcp.json); this is the device half.\n *\n * Registered at import, so a client answers them even before any plugin bundle\n * has loaded — that is the point: proving the device path works should not\n * depend on the thing being proved.\n */\nimport { registerDeviceTools } from './registry';\n\nconst PLATFORM_PLUGIN_ID = 'platform:device-tools';\n\nexport function registerPlatformDeviceTools(): void {\n registerDeviceTools(PLATFORM_PLUGIN_ID, [\n {\n name: 'device.echo',\n description: \"Echo a string back from the user's own device.\",\n input_schema: {\n type: 'object',\n properties: { text: { type: 'string' } },\n required: ['text'],\n },\n run: (args: any) => ({\n ok: true,\n echoed: (args && args.text) || '',\n // The proof: a server-side implementation could not say this truthfully.\n ran_on: 'device',\n }),\n },\n {\n name: 'device.ping',\n description: 'Check that the device is reachable.',\n input_schema: { type: 'object', properties: {} },\n run: () => ({ ok: true, ran_on: 'device', at: new Date().toISOString() }),\n },\n ]);\n}\n","/**\n * Ambient device-tool registration, driven by the auth session (R14).\n *\n * The LOCAL reasoning path must be live BEFORE voice opens — otherwise the only\n * proof of a loadable external skill is a voice-triggered one. So the host wires\n * this to its EXISTING auth-session subscription (no new transport, no plugin\n * special-case): a tenant becoming authenticated loads that tenant's external\n * device-tool bundles (and re-ensures the static platform tools); a tenant\n * SWITCH is a change of tenant while still authenticated, so it clears the old\n * tenant's tools and reloads; sign-out resets the loader latch (the registry\n * itself is cleared by the host's logout teardown, which already calls\n * clearDeviceTools()).\n *\n * The decision is a PURE function so the lifecycle is testable without a network\n * or a device; the effects are injected (same DI shape as the host's logout\n * teardown) so a test drives them with fakes.\n */\nimport { registerPlatformDeviceTools } from './platformTools';\nimport { clearDeviceTools } from './registry';\n// NOTE: `./loader` pulls in the bundle manager (react-native, core), so it is\n// imported LAZILY inside the real effects below — keeping this module itself\n// node-testable (a test injects fake effects and never touches that graph).\n\nexport interface SessionSnapshot {\n authenticated: boolean;\n /** The tenant the session is scoped to (derive it; never a stored copy). */\n tenant: string | null;\n}\n\nexport type DeviceToolsAction = 'load' | 'reset' | 'none';\n\n/**\n * PURE: what should the device-tool registry do for this session transition?\n * - a new authenticated tenant (initial sign-in OR a switch) → 'load'\n * - the same authenticated tenant again (e.g. a silent token refresh) → 'none'\n * - leaving authenticated (sign-out) → 'reset'\n */\nexport function deviceToolsActionForSession(\n next: SessionSnapshot,\n prevTenant: string | null,\n): { action: DeviceToolsAction; tenant: string | null } {\n const tenant = next.authenticated ? (next.tenant ?? null) : null;\n if (tenant && tenant !== prevTenant) return { action: 'load', tenant };\n if (!tenant && prevTenant) return { action: 'reset', tenant: null };\n return { action: 'none', tenant: prevTenant };\n}\n\n/** The effects a session transition drives — injected so a test can fake them. */\nexport interface DeviceToolsSessionDeps {\n /** Drop the previous tenant's device tools (a switch does not run logout teardown). */\n clear: () => void;\n /** Ensure the static, host-owned platform device tools are present. */\n registerPlatform: () => void;\n /** Forget the \"already loaded\" latch so a later (non-forced) load runs — sign-out. */\n resetLoader: () => void;\n /** Fire-and-forget reload of the current tenant's external device-tool bundles. */\n load: () => void;\n}\n\nconst REAL_DEPS: DeviceToolsSessionDeps = {\n clear: clearDeviceTools,\n registerPlatform: registerPlatformDeviceTools,\n // `./loader` reaches react-native via the bundle manager; import it only when\n // an effect actually fires (real app), never at module load (tests).\n resetLoader: () => {\n void import('./loader').then((m) => m.resetDeviceToolLoader());\n },\n load: () => {\n void import('./loader').then((m) => {\n m.resetDeviceToolLoader();\n return m.ensureDeviceToolsLoaded({ force: true });\n });\n },\n};\n\nlet prevTenant: string | null = null;\n\n/**\n * Apply a session transition to the device-tool registry. Call from the host's\n * existing auth-session subscription with a derived snapshot. Idempotent per\n * tenant: repeated snapshots for the same authenticated tenant do nothing, so a\n * silent token refresh does not churn the registry. Returns the action taken.\n */\nexport function syncDeviceToolsForSession(\n next: SessionSnapshot,\n deps: DeviceToolsSessionDeps = REAL_DEPS,\n): DeviceToolsAction {\n const { action, tenant } = deviceToolsActionForSession(next, prevTenant);\n prevTenant = tenant;\n if (action === 'load') {\n // A new authenticated tenant: drop the prior tenant's tools, re-seat the\n // static platform tools, then reload this tenant's external bundles (forced).\n deps.clear();\n deps.registerPlatform();\n deps.load();\n } else if (action === 'reset') {\n // Sign-out: the host's logout teardown clears the registry; we only forget\n // the loader latch so a later sign-in reloads.\n deps.resetLoader();\n }\n return action;\n}\n\n/** Test seam: forget the last-seen tenant so a test starts from signed-out. */\nexport function __resetSessionSyncForTest(): void {\n prevTenant = null;\n}\n","/**\n * Device tools called BY THE SERVER.\n *\n * Two callers reach the same registered function on this phone:\n *\n * - the voice loop, which already runs here (no server hop at all), and\n * - the server reasoning loop, which cannot execute a device tool itself and\n * sends it down the client's stream instead.\n *\n * This is the second one. It rides the transport the `ui.*` plugin commands\n * already use — a command arrives on the job/actor stream, we run it, and post\n * the outcome back keyed by `correlationId`. Nothing new was invented; the\n * payload just carries a tool call instead of a UI command.\n *\n * The host wires this once at startup:\n *\n * pluginCommandEmitter.on('plugin-command', handleServerDeviceToolCall);\n */\nimport { callDeviceTool } from './registry';\nimport { ensureDeviceToolsLoaded } from './loader';\n\ntype PostResult = (correlationId: string, result: any, error?: string) => Promise<void>;\n\nlet postResult: PostResult | null = null;\n\n/**\n * Host DI: how to answer the server. `@ateam-ai-mobile/core` supplies\n * postCommandResult; kept injectable so this package stays free of the REST\n * client and so tests can observe the answer.\n */\nexport function setDeviceToolResultPoster(fn: PostResult): void {\n postResult = fn;\n}\n\n/** Is this stream command a device tool call? */\nexport function isDeviceToolCommand(cmd: any): boolean {\n return !!cmd && cmd.kind === 'device_tool' && typeof cmd.tool === 'string' && !!cmd.correlationId;\n}\n\n/**\n * Run a server-dispatched device tool and answer.\n *\n * Never throws — the server is blocked on the correlationId, so the one thing\n * we must not do is stay silent. Every path posts something back.\n */\nexport async function handleServerDeviceToolCall(cmd: any): Promise<void> {\n if (!isDeviceToolCommand(cmd)) return;\n\n const { tool, args, correlationId } = cmd;\n\n if (!postResult) {\n console.warn('[deviceTools] server called a tool but no result poster is installed');\n return;\n }\n\n try {\n // The server may reach us before anything opened a voice call, so the\n // plugins may not be loaded yet. Cheap and idempotent once warm.\n await ensureDeviceToolsLoaded();\n\n const outcome = await callDeviceTool(tool, args || {});\n console.log(`[deviceTools] server-called ${tool} → ${outcome.ok ? 'ok' : outcome.reason}`);\n\n await postResult(\n correlationId,\n outcome.ok ? { ok: true, ...unwrap(outcome.result) } : undefined,\n outcome.ok ? undefined : outcome.error,\n );\n } catch (err: any) {\n await postResult(correlationId, undefined, err?.message || String(err)).catch(() => {});\n }\n}\n\n/** Keep an object result flat for the reasoning loop; wrap anything else. */\nfunction unwrap(result: any): Record<string, any> {\n if (result && typeof result === 'object' && !Array.isArray(result)) return result;\n return { result };\n}\n","/**\n * usePlugin — single hook for loading any plugin.\n *\n * Simple async load → done pattern over the global BundleManager singleton\n * (deduplication + caching) and the host's native plugin registry.\n *\n * Usage:\n * const { plugin, loading, error } = usePlugin(pluginId, manifest);\n * if (loading) return <Spinner />;\n * if (error) return <Error message={error} />;\n * return <plugin.Component bridge={bridge} device={device} theme={theme} context={context} />;\n */\nimport { useState, useEffect } from 'react';\nimport { bundleManager } from '../bundles/bundleManager';\nimport { nativePluginRegistry } from '../host';\nimport { resolvePluginGrants } from '../pluginRuntime';\nimport type { NativeCapabilities, RegisteredUIPlugin } from '../sdk/types';\nimport type { DeviceBridgeDomain } from '../sdk/device-state';\nimport { pluginBundleUrl } from '../bundles/bundleUrl';\n\ninterface PluginManifestLike {\n version?: string;\n name?: string;\n render?: any;\n _connectorId?: string;\n capabilities?: Record<string, unknown>;\n native?: NativeCapabilities;\n stateDomains?: DeviceBridgeDomain[];\n}\n\ninterface UsePluginResult {\n plugin: RegisteredUIPlugin | null;\n loading: boolean;\n error: string | null;\n bundleMissing: boolean;\n}\n\n/**\n * Compute the bundle URL.\n *\n * **Policy:** bundle URLs are ALWAYS derived from the pluginId via the\n * canonical platform route /api/ui-plugins/{pluginId}/bundle.js.\n * Connector-supplied bundleUrl fields are IGNORED — the Builder strips them\n * at introspection time, but we treat any survivors as bugs and ignore them\n * here too (defense in depth vs \"hand-coded URL with bad encoding causes\n * silent iframe fallback\" bugs).\n */\ntype BundleUrlSource = 'derived-default' | 'ignored-explicit' | 'none';\nfunction extractBundleUrl(pluginId: string, manifest: PluginManifestLike): { url: string | null; source: BundleUrlSource; ignored?: string } {\n const render = manifest.render;\n if (!render || typeof render !== 'object') return { url: null, source: 'none' };\n\n // Detect (and refuse) any explicit bundle URL the connector tried to inject.\n const injected =\n (manifest as any).bundleUrl ||\n (render as any).bundleUrl ||\n render.reactNative?.bundleUrl ||\n null;\n\n // Mobile decides native vs web INDEPENDENT of render.mode. The server's\n // cp.getContextPlugin flattens mode to \"iframe\" whenever an iframeUrl is\n // available (for web-frontend compatibility) — even when a reactNative\n // component is also declared. So we derive the bundle URL whenever a\n // reactNative component is present, regardless of what `mode` says.\n const hasNativeComponent = !!(render.reactNative?.component || render.component);\n if (hasNativeComponent) {\n return {\n url: pluginBundleUrl(pluginId),\n source: injected ? 'ignored-explicit' : 'derived-default',\n ...(injected && { ignored: injected }),\n };\n }\n\n return { url: null, source: 'none' };\n}\n\n/**\n * Extract the SHA-256 integrity hash from the manifest (server-provided).\n * BundleManager refuses to evaluate a downloaded bundle whose bytes don't\n * hash to this value — defense vs MITM/CDN tampering.\n */\nfunction extractBundleHash(manifest: PluginManifestLike): string | undefined {\n if ((manifest as any).bundleHash) return (manifest as any).bundleHash;\n const render = manifest.render;\n if (!render || typeof render !== 'object') return undefined;\n if (render.bundleHash) return render.bundleHash;\n if (render.reactNative?.bundleHash) return render.reactNative.bundleHash;\n return undefined;\n}\n\n/**\n * Extract component name from manifest render field.\n */\nfunction extractComponentId(manifest: PluginManifestLike): string | null {\n const render = manifest.render;\n if (!render || typeof render !== 'object') return null;\n return render.component || render.reactNative?.component || null;\n}\n\nexport function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePluginResult {\n const [plugin, setPlugin] = useState<RegisteredUIPlugin | null>(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [bundleMissing, setBundleMissing] = useState(false);\n\n const version = manifest.version || '1.0.0';\n const { url: bundleUrl, ignored: ignoredUrl } = extractBundleUrl(pluginId, manifest);\n const bundleHash = extractBundleHash(manifest);\n const componentId = extractComponentId(manifest);\n const lookupId = componentId || pluginId;\n // The manifest's grants — the authority for the bundle's device tools, the\n // same one the renderer gates the component with. Keyed by value so a fresh\n // manifest object with the same grants does not reload the plugin.\n const grants = resolvePluginGrants({ pluginId, manifest });\n const grantsKey = JSON.stringify(grants);\n\n if (ignoredUrl) {\n console.warn(`[usePlugin] ${pluginId}: IGNORED connector-injected bundleUrl=\"${ignoredUrl}\". The host derives bundle URLs from pluginId via the canonical route.`);\n }\n\n useEffect(() => {\n let cancelled = false;\n\n (async () => {\n try {\n // Path 1: Local registry (host-bundled / dev plugins)\n if (nativePluginRegistry.has(lookupId)) {\n const registered = await nativePluginRegistry.get(lookupId);\n if (cancelled) return;\n\n if (!registered) {\n throw new Error(`Local plugin not found: ${lookupId}`);\n }\n if (registered.type === 'service') {\n throw new Error(`Plugin ${lookupId} is a service plugin and cannot be rendered`);\n }\n\n setPlugin(registered as RegisteredUIPlugin);\n setLoading(false);\n return;\n }\n\n // Path 2: Remote bundle (via global BundleManager)\n if (bundleUrl) {\n try {\n const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash, grants);\n if (cancelled) return;\n setPlugin(component);\n setLoading(false);\n return;\n } catch (bundleErr) {\n const msg = bundleErr instanceof Error ? bundleErr.message : String(bundleErr);\n // warn (not error) — this is a handled path: we fall back to WebView.\n console.warn(`[usePlugin] ${pluginId}: Bundle load failed, falling back to WebView:`, msg);\n // RN bundle failed — connector declared a component but didn't\n // ship a usable bundle (404, eval error, integrity fail, etc).\n // Mark \"bundle missing\" so the parent renderer can fall back to\n // the WebView/iframe when the manifest also has an iframeUrl.\n if (!cancelled) {\n setBundleMissing(true);\n setLoading(false);\n }\n return;\n }\n }\n\n // No native source available. If the manifest also has an iframeUrl,\n // mark \"bundle missing\" so the parent falls back to the WebView.\n // Only throw when there's truly nothing renderable.\n const iframeUrl = manifest?.render?.iframeUrl\n || (manifest?.render?.mode === 'adaptive' && manifest?.render?.iframe?.iframeUrl);\n if (iframeUrl) {\n if (!cancelled) {\n setBundleMissing(true);\n setLoading(false);\n }\n return;\n }\n throw new Error(\n `Plugin \"${manifest.name || pluginId}\" has no bundle URL and is not in the local registry. ` +\n `Check that the solution definition includes a valid render.reactNative.bundleUrl.`\n );\n } catch (err) {\n if (cancelled) return;\n const msg = err instanceof Error ? err.message : String(err);\n console.error(`[usePlugin] Failed to load ${pluginId}:`, msg);\n setError(msg);\n setLoading(false);\n }\n })();\n\n return () => { cancelled = true; };\n }, [pluginId, lookupId, bundleUrl, bundleHash, version, grantsKey]);\n\n return { plugin, loading, error, bundleMissing };\n}\n","// usePluginRuntimeProps — the gated { device, native, deviceState, voice } for ONE\n// mounted surface, with the device-state sessions it starts released when it\n// unmounts (AI Models F2: a screen that closes leaves no camera running).\n//\n// Every surface that renders a plugin uses this instead of calling\n// resolvePluginRuntimeProps in a useMemo, so the release cannot be forgotten.\n// Each mount is its own holder: a drawer and an embedded view of one plugin hold\n// the same camera separately, and it ends only when both have closed. A mount\n// that switches plugin releases the old plugin's sessions, and one whose grants\n// shrink releases the domains it lost.\n\nimport { useEffect, useMemo, useRef } from 'react';\nimport { resolvePluginGrants, resolvePluginRuntimeProps, type PluginRuntimeSource } from '../pluginRuntime';\nimport { releaseDeviceStateHolds } from '../deviceStateHost';\nimport { releaseAiLive } from '../aiLiveHost';\nimport type { PluginRuntimeProps } from '../pluginRuntime';\n\nlet mounts = 0;\n\nexport function usePluginRuntimeProps(src: PluginRuntimeSource, deps: readonly unknown[]): PluginRuntimeProps {\n // One holder per plugin on this mount: a renderer that switches to ANOTHER plugin\n // gets a new holder, and the effect below releases what the old one held.\n const holderRef = useRef<{ pluginId: string; holder: string } | null>(null);\n if (holderRef.current?.pluginId !== src.pluginId) {\n holderRef.current = { pluginId: src.pluginId, holder: `${src.pluginId}#${++mounts}` };\n }\n const holder = holderRef.current.holder;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n const props = useMemo(() => resolvePluginRuntimeProps(src, holder), [...deps, holder]);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n const granted = useMemo(() => resolvePluginGrants(src).stateDomains ?? [], deps);\n\n // Unmount, or a switch to another plugin: release everything this holder holds.\n useEffect(() => () => { void releaseDeviceStateHolds(holder); }, [holder]);\n // ... and stop any pictures it was sending to the AI (closing the screen ends it).\n useEffect(() => () => { void releaseAiLive(holder); }, [holder]);\n\n // Grants changed while mounted: a domain no longer granted is released now. The\n // new facade refuses it, so the plugin could not stop that session itself.\n const grantedKey = granted.join(',');\n // eslint-disable-next-line react-hooks/exhaustive-deps\n useEffect(() => { void releaseDeviceStateHolds(holder, granted); }, [holder, grantedKey]);\n\n return props;\n}\n","import { useEffect, useState, useCallback } from 'react';\nimport { apiFetch } from '@ateam-ai-mobile/core';\nimport type { PluginManifest } from '../sdk/types';\n\nexport interface RemotePlugin {\n id: string;\n name: string;\n version: string;\n manifest: PluginManifest;\n source: 'remote' | 'local'; // Where this plugin came from\n}\n\ninterface UseRemotePluginsResult {\n plugins: RemotePlugin[];\n loading: boolean;\n error: string | null;\n refreshing: boolean;\n refresh: () => Promise<void>;\n}\n\n/**\n * Fetches available UI plugins from A-Team Core for the current solution.\n * Uses apiFetch which auto-injects JWT + tenant headers.\n */\nexport function useRemotePlugins(solutionId?: string): UseRemotePluginsResult {\n const [plugins, setPlugins] = useState<RemotePlugin[]>([]);\n const [loading, setLoading] = useState(true);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const fetchRemotePlugins = useCallback(async () => {\n try {\n setError(null);\n\n const targetSolutionId = solutionId || 'current';\n\n let data: any;\n try {\n data = await apiFetch<any>(`/api/solutions/${targetSolutionId}/ui-plugins`);\n } catch (err: any) {\n if (err?.message?.includes('404')) {\n console.warn(`[useRemotePlugins] Solution ${targetSolutionId} not found or has no plugins`);\n setPlugins([]);\n return;\n }\n throw err;\n }\n const uiPlugins: RemotePlugin[] = (data.plugins || []).map((plugin: any) => ({\n id: plugin.id,\n name: plugin.name,\n version: plugin.version || '1.0.0',\n source: 'remote' as const,\n manifest: {\n id: plugin.id,\n name: plugin.name,\n version: plugin.version || '1.0.0',\n type: 'ui',\n description: plugin.description || '',\n capabilities: plugin.capabilities || {},\n channels: plugin.channels || ['default'],\n render: (plugin.render && typeof plugin.render === 'object') ? plugin.render : { mode: 'react-native' as const, component: plugin.name || plugin.id },\n entry_point: plugin.entry_point || plugin.name,\n sandbox: plugin.sandbox !== false,\n requires_auth: plugin.requires_auth !== false,\n } as PluginManifest,\n }));\n\n setPlugins(uiPlugins);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n console.error('[useRemotePlugins] Failed to fetch plugins:', message);\n setError(message);\n setPlugins([]); // Clear plugins on error\n } finally {\n setLoading(false);\n setRefreshing(false);\n }\n }, [solutionId]);\n\n const refresh = useCallback(async () => {\n setRefreshing(true);\n await fetchRemotePlugins();\n }, [fetchRemotePlugins]);\n\n useEffect(() => {\n setLoading(true);\n fetchRemotePlugins();\n }, [fetchRemotePlugins]);\n\n return {\n plugins,\n loading,\n error,\n refreshing,\n refresh,\n };\n}\n"],"mappings":"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","names":["prevTenant","useEffect","useEffect","useEffect","useState"]}
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  ensureDeviceToolsLoaded,
3
3
  resetDeviceToolLoader
4
- } from "./chunk-56Z2X5ZB.mjs";
4
+ } from "./chunk-RNA6X65P.mjs";
5
5
  export {
6
6
  ensureDeviceToolsLoaded,
7
7
  resetDeviceToolLoader
8
8
  };
9
- //# sourceMappingURL=loader-3PQLHEEW.mjs.map
9
+ //# sourceMappingURL=loader-GGJ5YO25.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/plugin-runtime",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "ADAS plugin runtime for React Native hosts — plugin SDK, bundle pipeline with integrity verification, host bridge, surfaces. No Expo dependencies.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,7 +9,7 @@
9
9
  "build": "tsup"
10
10
  },
11
11
  "dependencies": {
12
- "@ateam-ai-mobile/core": "0.1.20",
12
+ "@ateam-ai-mobile/core": "0.1.22",
13
13
  "zustand": "^5.0.0"
14
14
  },
15
15
  "peerDependencies": {
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Host DI + gate for `props.aiLive` — the session counterpart of setVoiceProvider.
3
+ *
4
+ * The app hands the SDK's live provider here once at startup (setAiLiveProvider,
5
+ * @ateam-ai-mobile/expo createAiLiveProvider). gateAiLive then decides, per screen:
6
+ *
7
+ * - NOT declared (`native: { picturesToAi: true }`) → a refusal that names it.
8
+ * - declared, no provider in this build → a refusal that names it.
9
+ * - declared + provider → the real session, bound to the screen's holder. start()
10
+ * asks mayPicturesLeavePhone() at CALL time, so the person's switch is read when
11
+ * it matters, never cached; it refuses by name when they have not allowed it.
12
+ * - and FOR AS LONG AS IT SENDS (GPT review 2026-10-30): the gate re-asks whenever
13
+ * the host says the answer may have changed (onPicturesToAiConsentChange) and
14
+ * stops the session on a no; the provider re-asks right before every picture
15
+ * (ctx.allowed), so nothing leaves after the yes is taken back even if a change
16
+ * went unannounced.
17
+ *
18
+ * The camera the session reads is held for the screen (deviceStateHost holds), and
19
+ * releaseAiLive(holder) — run when the screen unmounts (usePluginRuntimeProps) —
20
+ * stops both: a closed screen sends nothing. A screen's start, stop and close run one
21
+ * at a time, in order (one queue per screen, below), so they never overlap.
22
+ *
23
+ * ABSENCE IS AN ANSWER, as for voice: a denied surface reads `state: 'error'` with
24
+ * the reason and its actions throw — never a silent no-op that looks like "idle".
25
+ */
26
+ import { mayPicturesLeavePhone, onPicturesToAiConsentChange } from './host';
27
+ import { releaseDeviceStateHolds } from './deviceStateHost';
28
+ import type { NativeCapabilities } from './sdk/types';
29
+ import type { DeviceState, Subscription } from './sdk/device-state';
30
+ import type { AiLiveAPI, AiLiveProvider, AiLiveSnapshot } from './sdk/aiLive';
31
+
32
+ let _provider: AiLiveProvider | null = null;
33
+
34
+ /**
35
+ * ONE QUEUE PER SCREEN. Each screen's start, stop and close run one at a time, in the order they were asked:
36
+ * a request waits until the one before it has completely finished, so two can never overlap (the source of
37
+ * every race Codex found on #53 while they could). Two things act at once, outside the queue, because
38
+ * they must not wait behind a start still on the network: the person taking the yes back, and stop/close
39
+ * telling the provider to stop now. The provider stops even a start in progress; the queued step then
40
+ * tidies up (the camera, the watch).
41
+ *
42
+ * The screen's record goes when the screen closes. Its aiLive objects keep the record itself, so a start()
43
+ * a plugin calls late (a timer after unmount) still reads "closed" and is refused.
44
+ */
45
+ interface Screen {
46
+ closed: boolean;
47
+ /** Bumped by every stop: a start asked before it never runs. */
48
+ stops: number;
49
+ queue: Promise<unknown>;
50
+ watch: { unsubscribe(): void } | null;
51
+ }
52
+ const _screens = new Map<string, Screen>();
53
+ function screenOf(holder: string): Screen {
54
+ let sc = _screens.get(holder);
55
+ if (!sc) { sc = { closed: false, stops: 0, queue: Promise.resolve(), watch: null }; _screens.set(holder, sc); }
56
+ return sc;
57
+ }
58
+ /** Run `step` after everything this screen was asked before it. */
59
+ function inTurn<T>(sc: Screen, step: () => Promise<T>): Promise<T> {
60
+ const run = sc.queue.then(step, step);
61
+ sc.queue = run.then(() => {}, () => {});
62
+ return run;
63
+ }
64
+ const unwatch = (sc: Screen) => { sc.watch?.unsubscribe(); sc.watch = null; };
65
+ const STOPPED_BEFORE_START = 'aiLive: stopped before it started: the screen stopped or closed while its start was waiting.';
66
+ const SCREEN_CLOSED = 'aiLive: this screen is closed; it cannot start sending.';
67
+ const stopped = (msg: string) => Object.assign(new Error(msg), { code: 'STOPPED' });
68
+
69
+ /** The person took the yes back: what the screen reads, and why it stopped. */
70
+ export const CONSENT_TAKEN_BACK = 'Camera pictures to AI was turned off on this phone, so sending stopped.';
71
+
72
+ /** Host DI: the app supplies the live provider once at startup; null detaches it. */
73
+ export function setAiLiveProvider(provider: AiLiveProvider | null): void {
74
+ _provider = provider || null;
75
+ }
76
+
77
+ function denial(why: string): Error {
78
+ return Object.assign(new Error(`aiLive: ${why}`), { code: 'RESTRICTED' });
79
+ }
80
+
81
+ function deniedAiLive(why: string): AiLiveAPI {
82
+ const fail = (): never => { throw denial(why); };
83
+ return {
84
+ async start() { return fail(); },
85
+ async stop() { /* nothing of this screen's can be running */ },
86
+ getSnapshot(): AiLiveSnapshot {
87
+ return { state: 'error', camera: null, sent: 0, skipped: 0, lastEpisode: null, lastError: why };
88
+ },
89
+ onEpisode() { return fail(); },
90
+ subscribe() { return fail(); },
91
+ };
92
+ }
93
+
94
+ /** The camera hold of a screen's live session, apart from the screen's own holds. */
95
+ export const aiLiveCameraHolder = (holder: string) => `${holder}#aiLive`;
96
+
97
+ /**
98
+ * The `aiLive` a plugin receives. `capabilities` is its TRUSTED declaration
99
+ * (pluginRuntime.ts); `holder` is the mounted screen it is bound to; `camera()` gives
100
+ * the visual session the SDK's sender reads, held as aiLiveCameraHolder(holder). It
101
+ * is built by resolvePluginRuntimeProps (the one place device gates are called) and
102
+ * never reaches the plugin: the plugin gets episodes, the sender gets the camera.
103
+ */
104
+ export function gateAiLive(
105
+ capabilities: NativeCapabilities | undefined,
106
+ pluginId: string,
107
+ holder: string,
108
+ camera: () => DeviceState['visual'],
109
+ ): AiLiveAPI {
110
+ if (capabilities?.picturesToAi !== true) {
111
+ return deniedAiLive("not declared in this plugin's manifest. Add `native: { picturesToAi: true }` to send camera pictures to the workspace's chosen AI.");
112
+ }
113
+ if (!pluginId || !holder) return deniedAiLive('no plugin identity — it fails closed without a screen to bind the session to.');
114
+ if (!_provider) return deniedAiLive('no live AI in this build — the app has not called setAiLiveProvider.');
115
+ const provider = _provider;
116
+ const sc = screenOf(holder);
117
+ const allowed = () => mayPicturesLeavePhone(capabilities);
118
+ const releaseCamera = () => releaseDeviceStateHolds(aiLiveCameraHolder(holder));
119
+ /** The queued half of a stop: the session stopped (again, harmlessly) and the camera released. */
120
+ const tidy = async (why?: string) => {
121
+ unwatch(sc);
122
+ try { await provider.stop(holder, why); } finally { await releaseCamera(); }
123
+ };
124
+ /** Stop now, without waiting for a start still on the network; in turn, once that stop has finished,
125
+ * tidy up. So the next step in the queue never starts while a stop is still under way. */
126
+ const stopNowAndInTurn = (why?: string) => {
127
+ sc.stops++;
128
+ const now = provider.stop(holder, why).catch(() => {});
129
+ return inTurn(sc, async () => { await now; await tidy(why); });
130
+ };
131
+ const onConsentChange = () => { if (!allowed()) void stopNowAndInTurn(CONSENT_TAKEN_BACK); };
132
+
133
+ return {
134
+ start(opts) {
135
+ if (sc.closed) return Promise.reject(stopped(SCREEN_CLOSED));
136
+ const stopsWhenAsked = sc.stops;
137
+ return inTurn(sc, async () => {
138
+ if (sc.closed) throw stopped(SCREEN_CLOSED);
139
+ if (sc.stops !== stopsWhenAsked) throw stopped(STOPPED_BEFORE_START);
140
+ if (!allowed()) {
141
+ throw denial('the person on this phone has not allowed camera pictures to go to AI. They can turn on Settings > Camera > "Camera pictures to AI" in the app.');
142
+ }
143
+ // Watched from before the start: a yes taken back while it is on the network stops it too.
144
+ unwatch(sc);
145
+ sc.watch = onPicturesToAiConsentChange(onConsentChange);
146
+ let snap: AiLiveSnapshot;
147
+ try {
148
+ snap = await provider.start(opts || {}, holder, { visual: camera(), allowed });
149
+ } catch (e) {
150
+ unwatch(sc);
151
+ await releaseCamera();
152
+ if (!allowed()) throw denial(CONSENT_TAKEN_BACK);
153
+ throw e;
154
+ }
155
+ if (!allowed()) { await tidy(CONSENT_TAKEN_BACK); throw denial(CONSENT_TAKEN_BACK); }
156
+ return snap;
157
+ });
158
+ },
159
+ stop: () => stopNowAndInTurn(),
160
+ getSnapshot: () => provider.getSnapshot(holder),
161
+ onEpisode: (cb) => provider.onEpisode(cb, holder),
162
+ subscribe: (cb) => provider.subscribe(cb, holder),
163
+ };
164
+ }
165
+
166
+ /** The screen closed: stop its live session and release its camera, and keep nothing of it. Closed for
167
+ * good first, so every later start() of this screen is refused; then, in turn after anything it asked
168
+ * before, the session stopped and the camera released. */
169
+ export async function releaseAiLive(holder: string): Promise<void> {
170
+ const sc = _screens.get(holder);
171
+ if (!sc) {
172
+ // A screen that never asked for anything: nothing queued, nothing to wait for.
173
+ try { await _provider?.stop(holder); } catch { /* already stopped */ }
174
+ try { _provider?.forget(holder); } catch { /* nothing kept */ }
175
+ await releaseDeviceStateHolds(aiLiveCameraHolder(holder));
176
+ return;
177
+ }
178
+ sc.closed = true;
179
+ sc.stops++;
180
+ const now = _provider ? _provider.stop(holder).catch(() => {}) : Promise.resolve();
181
+ await inTurn(sc, async () => {
182
+ await now;
183
+ unwatch(sc);
184
+ try { await _provider?.stop(holder); } catch { /* already stopped */ }
185
+ try { _provider?.forget(holder); } catch { /* nothing kept */ }
186
+ await releaseDeviceStateHolds(aiLiveCameraHolder(holder));
187
+ });
188
+ if (_screens.get(holder) === sc) _screens.delete(holder);
189
+ }
190
+
191
+ /** For tests: how many screens still have a record (0 once every screen closed). */
192
+ export const _aiLiveRecordsForTest = () => ({ screens: _screens.size });
193
+
194
+ const NOTHING: Subscription = { unsubscribe() {} };
195
+
196
+ /** For the platform's "Sending to AI" indicator: is any screen sending, and when that changes. */
197
+ export const aiLiveIndicator = {
198
+ isSending: (): boolean => !!_provider?.isSending(),
199
+ subscribe: (cb: () => void): Subscription => _provider?.subscribeSending(cb) ?? NOTHING,
200
+ };
@@ -12,6 +12,7 @@
12
12
  import { useEffect, useMemo, useRef } from 'react';
13
13
  import { resolvePluginGrants, resolvePluginRuntimeProps, type PluginRuntimeSource } from '../pluginRuntime';
14
14
  import { releaseDeviceStateHolds } from '../deviceStateHost';
15
+ import { releaseAiLive } from '../aiLiveHost';
15
16
  import type { PluginRuntimeProps } from '../pluginRuntime';
16
17
 
17
18
  let mounts = 0;
@@ -31,6 +32,8 @@ export function usePluginRuntimeProps(src: PluginRuntimeSource, deps: readonly u
31
32
 
32
33
  // Unmount, or a switch to another plugin: release everything this holder holds.
33
34
  useEffect(() => () => { void releaseDeviceStateHolds(holder); }, [holder]);
35
+ // ... and stop any pictures it was sending to the AI (closing the screen ends it).
36
+ useEffect(() => () => { void releaseAiLive(holder); }, [holder]);
34
37
 
35
38
  // Grants changed while mounted: a domain no longer granted is released now. The
36
39
  // new facade refuses it, so the plugin could not stop that session itself.
package/src/host.ts CHANGED
@@ -180,6 +180,57 @@ export function getCameraPreviewProvider(): ((props: CameraPreviewImplProps) =>
180
180
  return _cameraPreview;
181
181
  }
182
182
 
183
+ // ── Pictures to AI (two-sided consent) ────────────────────────────────────
184
+ //
185
+ // A camera picture may leave the phone for an AI service only when BOTH say yes
186
+ // (owner decision 2026-10-25, amending "frames never leave the phone"):
187
+ // 1. the solution's developer declared `picturesToAi` in its trusted manifest;
188
+ // 2. the person on the phone allowed it in the HOST's settings — the host
189
+ // installs a consent provider that answers from its own live state.
190
+ // The answer is read at call time, never cached (derive, never store), and every
191
+ // doubt answers no: no provider, a provider that throws, or anything but `true`.
192
+ // Any code path that sends a picture off the phone must ask this first — and keep
193
+ // asking: a yes can be taken back while pictures are going (GPT review 2026-10-30),
194
+ // so the host also says WHEN its answer may have changed, and a running sender
195
+ // re-asks then and before every picture.
196
+
197
+ let _picturesToAiConsent: (() => boolean) | null = null;
198
+ const _consentListeners = new Set<() => void>();
199
+
200
+ /** Install the host's answer to "did the person on this phone allow camera
201
+ * pictures to go to AI?". Pass null to remove it (consent then reads as no). */
202
+ export function setPicturesToAiConsentProvider(provider: (() => boolean) | null): void {
203
+ _picturesToAiConsent = provider;
204
+ notifyPicturesToAiConsentChanged();
205
+ }
206
+
207
+ /** The host calls this whenever anything its consent answer reads may have changed
208
+ * (the switch, the camera's own switch, the OS permission). Listeners re-ask
209
+ * mayPicturesLeavePhone(); nothing is passed, so nothing can be cached. */
210
+ export function notifyPicturesToAiConsentChanged(): void {
211
+ for (const cb of [..._consentListeners]) {
212
+ try { cb(); } catch { /* a listener's error is its own */ }
213
+ }
214
+ }
215
+
216
+ /** Hear when the consent answer may have changed (see notifyPicturesToAiConsentChanged). */
217
+ export function onPicturesToAiConsentChange(cb: () => void): { unsubscribe(): void } {
218
+ _consentListeners.add(cb);
219
+ return { unsubscribe: () => { _consentListeners.delete(cb); } };
220
+ }
221
+
222
+ /** The one check: true only when the plugin's trusted declaration includes
223
+ * `picturesToAi` AND the person on the phone allowed it right now. */
224
+ export function mayPicturesLeavePhone(declared?: NativeCapabilities | null): boolean {
225
+ if (declared?.picturesToAi !== true) return false;
226
+ if (!_picturesToAiConsent) return false;
227
+ try {
228
+ return _picturesToAiConsent() === true;
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
183
234
  // ── Native (host-bundled) plugin registry ─────────────────────────────────
184
235
  // Plugins compiled into the host binary (dev plugins, first-party panels).
185
236
  // usePlugin checks this registry before hitting the remote bundle pipeline.
package/src/index.ts CHANGED
@@ -49,6 +49,17 @@ export {
49
49
  implementedVoice,
50
50
  } from './voiceHost';
51
51
 
52
+ // AI live host DI — the app hands the SDK's live provider here; the gate (called
53
+ // only via resolvePluginRuntimeProps) binds it per screen, and the platform's
54
+ // "Sending to AI" indicator reads aiLiveIndicator.
55
+ export {
56
+ setAiLiveProvider,
57
+ gateAiLive,
58
+ releaseAiLive,
59
+ aiLiveIndicator,
60
+ CONSENT_TAKEN_BACK,
61
+ } from './aiLiveHost';
62
+
52
63
  // Host integration
53
64
  export {
54
65
  setHapticsProvider,
@@ -56,6 +67,10 @@ export {
56
67
  setPluginNativeProvider,
57
68
  setCameraPreviewProvider,
58
69
  getCameraPreviewProvider,
70
+ setPicturesToAiConsentProvider,
71
+ mayPicturesLeavePhone,
72
+ notifyPicturesToAiConsentChanged,
73
+ onPicturesToAiConsentChange,
59
74
  getPluginNative,
60
75
  gatePluginNative,
61
76
  registerNativePlugin,