@ateam-ai-mobile/plugin-runtime 0.1.19 → 0.1.21

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,4 +1,5 @@
1
1
  import {
2
+ AI_LIVE_IDLE,
2
3
  ActionButton,
3
4
  ActivityIndicator,
4
5
  AdaOrb,
@@ -33,6 +34,7 @@ import {
33
34
  TextInput,
34
35
  TouchableOpacity,
35
36
  View,
37
+ aiLiveIndicator,
36
38
  bundleManager,
37
39
  callDeviceTool,
38
40
  clearDeviceTools,
@@ -41,6 +43,7 @@ import {
41
43
  device_state_exports,
42
44
  ensureDeviceToolsLoaded,
43
45
  explainCapability,
46
+ gateAiLive,
44
47
  gateDeviceState,
45
48
  gatePluginNative,
46
49
  gateVoice,
@@ -59,6 +62,7 @@ import {
59
62
  isMountSurface,
60
63
  listDeviceTools,
61
64
  listPlugins,
65
+ mayPicturesLeavePhone,
62
66
  nativePluginRegistry,
63
67
  neverObserved,
64
68
  pluginBundleUrl,
@@ -68,17 +72,20 @@ import {
68
72
  registerNativePlugin,
69
73
  registerService,
70
74
  registerTools,
75
+ releaseAiLive,
71
76
  releaseDeviceStateHolds,
72
77
  rendersNative,
73
78
  resetDeviceToolLoader,
74
79
  resolvePluginGrants,
75
80
  resolvePluginRuntimeProps,
76
81
  resolvePluginUrl,
82
+ setAiLiveProvider,
77
83
  setBundleStorage,
78
84
  setCameraPreviewProvider,
79
85
  setDeviceStateProvider,
80
86
  setDeviceToolContextProvider,
81
87
  setHapticsProvider,
88
+ setPicturesToAiConsentProvider,
82
89
  setPluginNativeProvider,
83
90
  setPluginThemeColors,
84
91
  setVoiceProvider,
@@ -90,7 +97,7 @@ import {
90
97
  useNativeBridge,
91
98
  usePluginTheme,
92
99
  useSurfaceStore
93
- } from "./chunk-56Z2X5ZB.mjs";
100
+ } from "./chunk-32IN24XO.mjs";
94
101
 
95
102
  // src/tools/platformTools.ts
96
103
  var PLATFORM_PLUGIN_ID = "platform:device-tools";
@@ -133,10 +140,10 @@ var REAL_DEPS = {
133
140
  // `./loader` reaches react-native via the bundle manager; import it only when
134
141
  // an effect actually fires (real app), never at module load (tests).
135
142
  resetLoader: () => {
136
- void import("./loader-3PQLHEEW.mjs").then((m) => m.resetDeviceToolLoader());
143
+ void import("./loader-UH3GARV5.mjs").then((m) => m.resetDeviceToolLoader());
137
144
  },
138
145
  load: () => {
139
- void import("./loader-3PQLHEEW.mjs").then((m) => {
146
+ void import("./loader-UH3GARV5.mjs").then((m) => {
140
147
  m.resetDeviceToolLoader();
141
148
  return m.ensureDeviceToolsLoaded({ force: true });
142
149
  });
@@ -311,6 +318,9 @@ function usePluginRuntimeProps(src, deps) {
311
318
  useEffect2(() => () => {
312
319
  void releaseDeviceStateHolds(holder);
313
320
  }, [holder]);
321
+ useEffect2(() => () => {
322
+ void releaseAiLive(holder);
323
+ }, [holder]);
314
324
  const grantedKey = granted.join(",");
315
325
  useEffect2(() => {
316
326
  void releaseDeviceStateHolds(holder, granted);
@@ -388,6 +398,7 @@ function useRemotePlugins(solutionId) {
388
398
  };
389
399
  }
390
400
  export {
401
+ AI_LIVE_IDLE,
391
402
  ActionButton,
392
403
  ActivityIndicator,
393
404
  AdaOrb,
@@ -423,6 +434,7 @@ export {
423
434
  TouchableOpacity,
424
435
  View,
425
436
  __resetSessionSyncForTest,
437
+ aiLiveIndicator,
426
438
  bundleManager,
427
439
  callDeviceTool,
428
440
  clearDeviceTools,
@@ -432,6 +444,7 @@ export {
432
444
  deviceToolsActionForSession,
433
445
  ensureDeviceToolsLoaded,
434
446
  explainCapability,
447
+ gateAiLive,
435
448
  gateDeviceState,
436
449
  gatePluginNative,
437
450
  gateVoice,
@@ -452,6 +465,7 @@ export {
452
465
  isMountSurface,
453
466
  listDeviceTools,
454
467
  listPlugins,
468
+ mayPicturesLeavePhone,
455
469
  nativePluginRegistry,
456
470
  neverObserved,
457
471
  pluginBundleUrl,
@@ -462,18 +476,21 @@ export {
462
476
  registerPlatformDeviceTools,
463
477
  registerService,
464
478
  registerTools,
479
+ releaseAiLive,
465
480
  releaseDeviceStateHolds,
466
481
  rendersNative,
467
482
  resetDeviceToolLoader,
468
483
  resolvePluginGrants,
469
484
  resolvePluginRuntimeProps,
470
485
  resolvePluginUrl,
486
+ setAiLiveProvider,
471
487
  setBundleStorage,
472
488
  setCameraPreviewProvider,
473
489
  setDeviceStateProvider,
474
490
  setDeviceToolContextProvider,
475
491
  setDeviceToolResultPoster,
476
492
  setHapticsProvider,
493
+ setPicturesToAiConsentProvider,
477
494
  setPluginNativeProvider,
478
495
  setPluginThemeColors,
479
496
  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-32IN24XO.mjs";
5
5
  export {
6
6
  ensureDeviceToolsLoaded,
7
7
  resetDeviceToolLoader
8
8
  };
9
- //# sourceMappingURL=loader-3PQLHEEW.mjs.map
9
+ //# sourceMappingURL=loader-UH3GARV5.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/plugin-runtime",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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.19",
12
+ "@ateam-ai-mobile/core": "0.1.21",
13
13
  "zustand": "^5.0.0"
14
14
  },
15
15
  "peerDependencies": {
@@ -0,0 +1,106 @@
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 (`capabilities: { 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
+ *
13
+ * The camera the session reads is held for the screen (deviceStateHost holds), and
14
+ * releaseAiLive(holder) — run when the screen unmounts (usePluginRuntimeProps) —
15
+ * stops both: a closed screen sends nothing.
16
+ *
17
+ * ABSENCE IS AN ANSWER, as for voice: a denied surface reads `state: 'error'` with
18
+ * the reason and its actions throw — never a silent no-op that looks like "idle".
19
+ */
20
+ import { mayPicturesLeavePhone } from './host';
21
+ import { releaseDeviceStateHolds } from './deviceStateHost';
22
+ import type { NativeCapabilities } from './sdk/types';
23
+ import type { DeviceState, Subscription } from './sdk/device-state';
24
+ import type { AiLiveAPI, AiLiveProvider, AiLiveSnapshot } from './sdk/aiLive';
25
+
26
+ let _provider: AiLiveProvider | null = null;
27
+
28
+ /** Host DI: the app supplies the live provider once at startup; null detaches it. */
29
+ export function setAiLiveProvider(provider: AiLiveProvider | null): void {
30
+ _provider = provider || null;
31
+ }
32
+
33
+ function denial(why: string): Error {
34
+ return Object.assign(new Error(`aiLive: ${why}`), { code: 'RESTRICTED' });
35
+ }
36
+
37
+ function deniedAiLive(why: string): AiLiveAPI {
38
+ const fail = (): never => { throw denial(why); };
39
+ return {
40
+ async start() { return fail(); },
41
+ async stop() { /* nothing of this screen's can be running */ },
42
+ getSnapshot(): AiLiveSnapshot {
43
+ return { state: 'error', camera: null, sent: 0, skipped: 0, lastEpisode: null, lastError: why };
44
+ },
45
+ onEpisode() { return fail(); },
46
+ subscribe() { return fail(); },
47
+ };
48
+ }
49
+
50
+ /** The camera hold of a screen's live session, apart from the screen's own holds. */
51
+ export const aiLiveCameraHolder = (holder: string) => `${holder}#aiLive`;
52
+
53
+ /**
54
+ * The `aiLive` a plugin receives. `capabilities` is its TRUSTED declaration
55
+ * (pluginRuntime.ts); `holder` is the mounted screen it is bound to; `camera()` gives
56
+ * the visual session the SDK's sender reads, held as aiLiveCameraHolder(holder). It
57
+ * is built by resolvePluginRuntimeProps (the one place device gates are called) and
58
+ * never reaches the plugin: the plugin gets episodes, the sender gets the camera.
59
+ */
60
+ export function gateAiLive(
61
+ capabilities: NativeCapabilities | undefined,
62
+ pluginId: string,
63
+ holder: string,
64
+ camera: () => DeviceState['visual'],
65
+ ): AiLiveAPI {
66
+ if (capabilities?.picturesToAi !== true) {
67
+ return deniedAiLive("not declared in this plugin's capabilities. Add `capabilities: { picturesToAi: true }` to send camera pictures to the workspace's chosen AI.");
68
+ }
69
+ if (!pluginId || !holder) return deniedAiLive('no plugin identity — it fails closed without a screen to bind the session to.');
70
+ if (!_provider) return deniedAiLive('no live AI in this build — the app has not called setAiLiveProvider.');
71
+ const provider = _provider;
72
+ return {
73
+ async start(opts) {
74
+ if (!mayPicturesLeavePhone(capabilities)) {
75
+ throw denial('the person on this phone has not allowed camera pictures to go to AI. They can turn on "Pictures to AI" in the app\'s settings (What Ada can access).');
76
+ }
77
+ try {
78
+ return await provider.start(opts || {}, holder, { visual: camera() });
79
+ } catch (e) {
80
+ await releaseDeviceStateHolds(aiLiveCameraHolder(holder));
81
+ throw e;
82
+ }
83
+ },
84
+ async stop() {
85
+ try { await provider.stop(holder); } finally { await releaseDeviceStateHolds(aiLiveCameraHolder(holder)); }
86
+ },
87
+ getSnapshot: () => provider.getSnapshot(holder),
88
+ onEpisode: (cb) => provider.onEpisode(cb, holder),
89
+ subscribe: (cb) => provider.subscribe(cb, holder),
90
+ };
91
+ }
92
+
93
+ /** The screen closed: stop its live session and release its camera. */
94
+ export async function releaseAiLive(holder: string): Promise<void> {
95
+ try { await _provider?.stop(holder); } catch { /* already stopped */ }
96
+ try { _provider?.forget(holder); } catch { /* nothing kept */ }
97
+ await releaseDeviceStateHolds(aiLiveCameraHolder(holder));
98
+ }
99
+
100
+ const NOTHING: Subscription = { unsubscribe() {} };
101
+
102
+ /** For the platform's "Sending to AI" indicator: is any screen sending, and when that changes. */
103
+ export const aiLiveIndicator = {
104
+ isSending: (): boolean => !!_provider?.isSending(),
105
+ subscribe: (cb: () => void): Subscription => _provider?.subscribeSending(cb) ?? NOTHING,
106
+ };
@@ -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,37 @@ 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.
193
+
194
+ let _picturesToAiConsent: (() => boolean) | null = null;
195
+
196
+ /** Install the host's answer to "did the person on this phone allow camera
197
+ * pictures to go to AI?". Pass null to remove it (consent then reads as no). */
198
+ export function setPicturesToAiConsentProvider(provider: (() => boolean) | null): void {
199
+ _picturesToAiConsent = provider;
200
+ }
201
+
202
+ /** The one check: true only when the plugin's trusted declaration includes
203
+ * `picturesToAi` AND the person on the phone allowed it right now. */
204
+ export function mayPicturesLeavePhone(declared?: NativeCapabilities | null): boolean {
205
+ if (declared?.picturesToAi !== true) return false;
206
+ if (!_picturesToAiConsent) return false;
207
+ try {
208
+ return _picturesToAiConsent() === true;
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
183
214
  // ── Native (host-bundled) plugin registry ─────────────────────────────────
184
215
  // Plugins compiled into the host binary (dev plugins, first-party panels).
185
216
  // usePlugin checks this registry before hitting the remote bundle pipeline.
package/src/index.ts CHANGED
@@ -49,6 +49,16 @@ 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
+ } from './aiLiveHost';
61
+
52
62
  // Host integration
53
63
  export {
54
64
  setHapticsProvider,
@@ -56,6 +66,8 @@ export {
56
66
  setPluginNativeProvider,
57
67
  setCameraPreviewProvider,
58
68
  getCameraPreviewProvider,
69
+ setPicturesToAiConsentProvider,
70
+ mayPicturesLeavePhone,
59
71
  getPluginNative,
60
72
  gatePluginNative,
61
73
  registerNativePlugin,
@@ -16,9 +16,11 @@
16
16
  import { getPluginNative, gatePluginNative } from './host';
17
17
  import { gateDeviceState } from './deviceStateHost';
18
18
  import { gateVoice } from './voiceHost';
19
+ import { gateAiLive, aiLiveCameraHolder } from './aiLiveHost';
19
20
  import type { PluginNative, NativeCapabilities } from './sdk/types';
20
21
  import type { DeviceState, DeviceBridgeDomain } from './sdk/device-state';
21
22
  import type { VoiceAPI } from './sdk/voice';
23
+ import type { AiLiveAPI } from './sdk/aiLive';
22
24
 
23
25
  /** The gated surface a plugin Component receives. `device` and `native` are the
24
26
  * SAME object (native is the deprecated alias). */
@@ -27,6 +29,8 @@ export interface PluginRuntimeProps {
27
29
  native: PluginNative;
28
30
  deviceState: DeviceState;
29
31
  voice: VoiceAPI;
32
+ /** The workspace's chosen AI watching the phone camera, live (`picturesToAi`). */
33
+ aiLive: AiLiveAPI;
30
34
  }
31
35
 
32
36
  /** One declaration source — either the server manifest or the registry entry. */
@@ -105,6 +109,11 @@ export function resolvePluginRuntimeProps(src: PluginRuntimeSource, holder: stri
105
109
  // session is isolated per solution: declaring voice runs YOUR conversation, it
106
110
  // does not attach to another plugin's live one.
107
111
  const voice = gateVoice(!!(capabilities as any)?.voice, src.pluginId);
112
+ // Pictures to the chosen AI: gated by the TRUSTED `picturesToAi` declaration,
113
+ // bound to this surface's holder so closing the screen stops it. The camera its
114
+ // sender reads is the SDK's own hold (not a domain the plugin declared, and never
115
+ // handed to the plugin), built here because this is where device gates are called.
116
+ const aiLive = gateAiLive(capabilities, src.pluginId, holder, () => gateDeviceState(['visual'], aiLiveCameraHolder(holder)).visual);
108
117
  // device and native are intentionally the SAME gated object (R4 rename).
109
- return { device: native, native, deviceState, voice };
118
+ return { device: native, native, deviceState, voice, aiLive };
110
119
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * props.aiLive — the workspace's CHOSEN AI watching this phone's camera, live.
3
+ *
4
+ * The workspace admin picks and tests a vision setup in AI Models ("Drinking", a
5
+ * hosted model, the classes it must notice). A screen that wants that AI to watch
6
+ * the phone camera calls start(); the SDK then sends pictures to the workspace's
7
+ * live session at the rate and size the setup was TESTED at, and hands the screen
8
+ * what the AI saw through onEpisode ("drinking" started / ended). The screen never
9
+ * touches a picture: it gets events, not pixels.
10
+ *
11
+ * Two people must say yes before a picture leaves the phone (owner decision
12
+ * 2026-10-25): the solution declares `capabilities: { picturesToAi: true }`, AND
13
+ * the person on the phone allowed "Pictures to AI" in the app's settings. Owner
14
+ * decision 2026-10-29: only while the screen that asked is open, and with
15
+ * "Sending to AI" on screen — the indicator is the PLATFORM's (AiLiveIndicator in
16
+ * @ateam-ai-mobile/ui), a plugin cannot hide it, and closing the screen stops it.
17
+ */
18
+ import type { Subscription } from './device-state';
19
+
20
+ export type AiLiveCamera = 'front' | 'back';
21
+
22
+ /** What the AI saw over time, as the live session's episode events. */
23
+ export interface AiLiveEpisode {
24
+ /** episode.started / .interrupted / .recovered / .ongoing / .ended */
25
+ type: string;
26
+ /** The task class id from the chosen setup, e.g. "drinking". */
27
+ class: string;
28
+ episodeId: string;
29
+ /** When (ms since the epoch, the session's clock). */
30
+ at: number;
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ export type AiLiveState =
35
+ /** Nothing running for this screen. */
36
+ | 'idle'
37
+ /** Asking for the live session, opening the camera and the connection. */
38
+ | 'starting'
39
+ /** Pictures are going to the AI ("Sending to AI" is on screen). */
40
+ | 'sending'
41
+ /** Stopped by stop() or by the screen closing. */
42
+ | 'stopped'
43
+ /** It could not start or it broke; `lastError` says why in plain words. */
44
+ | 'error';
45
+
46
+ export interface AiLiveSnapshot {
47
+ state: AiLiveState;
48
+ camera: AiLiveCamera | null;
49
+ /** Pictures sent since start. */
50
+ sent: number;
51
+ /** Picture slots that sent nothing (no fresh camera frame, or the connection was busy). */
52
+ skipped: number;
53
+ lastEpisode: AiLiveEpisode | null;
54
+ lastError: string | null;
55
+ }
56
+
57
+ export interface AiLiveStartOptions {
58
+ /** Which camera: 'back' (default) or 'front', the selfie camera. */
59
+ camera?: AiLiveCamera;
60
+ }
61
+
62
+ export interface AiLiveAPI {
63
+ /** Start sending this phone's camera to the chosen AI. Refuses by name when either
64
+ * side has not said yes, or when another screen is sending already. */
65
+ start(opts?: AiLiveStartOptions): Promise<AiLiveSnapshot>;
66
+ /** Stop sending. Closing the screen does the same. */
67
+ stop(): Promise<void>;
68
+ getSnapshot(): AiLiveSnapshot;
69
+ /** Called with every episode event the AI makes while this screen sends. */
70
+ onEpisode(cb: (ev: AiLiveEpisode) => void): Subscription;
71
+ /** Called whenever the snapshot changes. */
72
+ subscribe(cb: (snap: AiLiveSnapshot) => void): Subscription;
73
+ }
74
+
75
+ /** What the host supplies (setAiLiveProvider). `owner` is the screen's holder id,
76
+ * captured by the gate — a plugin never supplies its own. */
77
+ export interface AiLiveProvider {
78
+ start(opts: AiLiveStartOptions, owner: string, ctx: { visual: import('./device-state').DeviceState['visual'] }): Promise<AiLiveSnapshot>;
79
+ stop(owner: string): Promise<void>;
80
+ getSnapshot(owner: string): AiLiveSnapshot;
81
+ onEpisode(cb: (ev: AiLiveEpisode) => void, owner: string): Subscription;
82
+ subscribe(cb: (snap: AiLiveSnapshot) => void, owner: string): Subscription;
83
+ /** The screen is gone: drop its callbacks and its last snapshot (after stop). */
84
+ forget(owner: string): void;
85
+ /** Whether ANY screen is sending right now — what the platform's indicator shows. */
86
+ isSending(): boolean;
87
+ /** Any change of isSending(). */
88
+ subscribeSending(cb: () => void): Subscription;
89
+ }
90
+
91
+ export const AI_LIVE_IDLE: AiLiveSnapshot = Object.freeze({
92
+ state: 'idle', camera: null, sent: 0, skipped: 0, lastEpisode: null, lastError: null,
93
+ }) as AiLiveSnapshot;
package/src/sdk/index.ts CHANGED
@@ -76,6 +76,18 @@ export type {
76
76
  // provider and its own surfaces call it as this caller. See VoiceSessionProvider.
77
77
  export { PLATFORM_VOICE_CALLER } from './voice';
78
78
 
79
+ // AI live — the workspace's chosen AI watching the phone camera (props.aiLive).
80
+ export type {
81
+ AiLiveAPI,
82
+ AiLiveCamera,
83
+ AiLiveEpisode,
84
+ AiLiveProvider,
85
+ AiLiveSnapshot,
86
+ AiLiveStartOptions,
87
+ AiLiveState,
88
+ } from './aiLive';
89
+ export { AI_LIVE_IDLE } from './aiLive';
90
+
79
91
  // Device state — the SEMANTIC layer: the types of what `props.deviceState`
80
92
  // returns, and the two pure helpers for reading a snapshot. They are here, in
81
93
  // the one module a bundle can require, because a bundle has no other SDK name