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

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
@@ -5,6 +5,7 @@ import {
5
5
  AdaOrb,
6
6
  Animated,
7
7
  Badge,
8
+ CONSENT_TAKEN_BACK,
8
9
  CameraPreview,
9
10
  Card,
10
11
  EmptyState,
@@ -65,6 +66,8 @@ import {
65
66
  mayPicturesLeavePhone,
66
67
  nativePluginRegistry,
67
68
  neverObserved,
69
+ notifyPicturesToAiConsentChanged,
70
+ onPicturesToAiConsentChange,
68
71
  pluginBundleUrl,
69
72
  register,
70
73
  registerBundleModules,
@@ -97,7 +100,7 @@ import {
97
100
  useNativeBridge,
98
101
  usePluginTheme,
99
102
  useSurfaceStore
100
- } from "./chunk-32IN24XO.mjs";
103
+ } from "./chunk-DD3KH7OI.mjs";
101
104
 
102
105
  // src/tools/platformTools.ts
103
106
  var PLATFORM_PLUGIN_ID = "platform:device-tools";
@@ -140,10 +143,10 @@ var REAL_DEPS = {
140
143
  // `./loader` reaches react-native via the bundle manager; import it only when
141
144
  // an effect actually fires (real app), never at module load (tests).
142
145
  resetLoader: () => {
143
- void import("./loader-UH3GARV5.mjs").then((m) => m.resetDeviceToolLoader());
146
+ void import("./loader-QTQNSXGS.mjs").then((m) => m.resetDeviceToolLoader());
144
147
  },
145
148
  load: () => {
146
- void import("./loader-UH3GARV5.mjs").then((m) => {
149
+ void import("./loader-QTQNSXGS.mjs").then((m) => {
147
150
  m.resetDeviceToolLoader();
148
151
  return m.ensureDeviceToolsLoaded({ force: true });
149
152
  });
@@ -404,6 +407,7 @@ export {
404
407
  AdaOrb,
405
408
  Animated,
406
409
  Badge,
410
+ CONSENT_TAKEN_BACK,
407
411
  CameraPreview,
408
412
  Card,
409
413
  EmptyState,
@@ -468,6 +472,8 @@ export {
468
472
  mayPicturesLeavePhone,
469
473
  nativePluginRegistry,
470
474
  neverObserved,
475
+ notifyPicturesToAiConsentChanged,
476
+ onPicturesToAiConsentChange,
471
477
  pluginBundleUrl,
472
478
  register,
473
479
  registerBundleModules,
@@ -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 { 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
+ {"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-32IN24XO.mjs";
4
+ } from "./chunk-DD3KH7OI.mjs";
5
5
  export {
6
6
  ensureDeviceToolsLoaded,
7
7
  resetDeviceToolLoader
8
8
  };
9
- //# sourceMappingURL=loader-UH3GARV5.mjs.map
9
+ //# sourceMappingURL=loader-QTQNSXGS.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/plugin-runtime",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
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.21",
12
+ "@ateam-ai-mobile/core": "0.1.23",
13
13
  "zustand": "^5.0.0"
14
14
  },
15
15
  "peerDependencies": {
package/src/aiLiveHost.ts CHANGED
@@ -4,20 +4,26 @@
4
4
  * The app hands the SDK's live provider here once at startup (setAiLiveProvider,
5
5
  * @ateam-ai-mobile/expo createAiLiveProvider). gateAiLive then decides, per screen:
6
6
  *
7
- * - NOT declared (`capabilities: { picturesToAi: true }`) → a refusal that names it.
7
+ * - NOT declared (`native: { picturesToAi: true }`) → a refusal that names it.
8
8
  * - declared, no provider in this build → a refusal that names it.
9
9
  * - declared + provider → the real session, bound to the screen's holder. start()
10
10
  * asks mayPicturesLeavePhone() at CALL time, so the person's switch is read when
11
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.
12
17
  *
13
18
  * The camera the session reads is held for the screen (deviceStateHost holds), and
14
19
  * releaseAiLive(holder) — run when the screen unmounts (usePluginRuntimeProps) —
15
- * stops both: a closed screen sends nothing.
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.
16
22
  *
17
23
  * ABSENCE IS AN ANSWER, as for voice: a denied surface reads `state: 'error'` with
18
24
  * the reason and its actions throw — never a silent no-op that looks like "idle".
19
25
  */
20
- import { mayPicturesLeavePhone } from './host';
26
+ import { mayPicturesLeavePhone, onPicturesToAiConsentChange } from './host';
21
27
  import { releaseDeviceStateHolds } from './deviceStateHost';
22
28
  import type { NativeCapabilities } from './sdk/types';
23
29
  import type { DeviceState, Subscription } from './sdk/device-state';
@@ -25,6 +31,44 @@ import type { AiLiveAPI, AiLiveProvider, AiLiveSnapshot } from './sdk/aiLive';
25
31
 
26
32
  let _provider: AiLiveProvider | null = null;
27
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
+
28
72
  /** Host DI: the app supplies the live provider once at startup; null detaches it. */
29
73
  export function setAiLiveProvider(provider: AiLiveProvider | null): void {
30
74
  _provider = provider || null;
@@ -64,39 +108,89 @@ export function gateAiLive(
64
108
  camera: () => DeviceState['visual'],
65
109
  ): AiLiveAPI {
66
110
  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.");
111
+ return deniedAiLive("not declared in this plugin's manifest. Add `native: { picturesToAi: true }` to send camera pictures to the workspace's chosen AI.");
68
112
  }
69
113
  if (!pluginId || !holder) return deniedAiLive('no plugin identity — it fails closed without a screen to bind the session to.');
70
114
  if (!_provider) return deniedAiLive('no live AI in this build — the app has not called setAiLiveProvider.');
71
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
+
72
133
  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)); }
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
+ });
86
158
  },
159
+ stop: () => stopNowAndInTurn(),
87
160
  getSnapshot: () => provider.getSnapshot(holder),
88
161
  onEpisode: (cb) => provider.onEpisode(cb, holder),
89
162
  subscribe: (cb) => provider.subscribe(cb, holder),
90
163
  };
91
164
  }
92
165
 
93
- /** The screen closed: stop its live session and release its camera. */
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. */
94
169
  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));
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);
98
189
  }
99
190
 
191
+ /** For tests: how many screens still have a record (0 once every screen closed). */
192
+ export const _aiLiveRecordsForTest = () => ({ screens: _screens.size });
193
+
100
194
  const NOTHING: Subscription = { unsubscribe() {} };
101
195
 
102
196
  /** For the platform's "Sending to AI" indicator: is any screen sending, and when that changes. */
@@ -782,17 +782,23 @@ class BundleManager {
782
782
  assertBundleIntegrity(content, verifiedHash, bundleUrl);
783
783
 
784
784
  // Strip __toESM wrapping — our shim already provides correct ESM objects.
785
- // esbuild's __toESM + isNodeMode=1 double-wraps and breaks React 19 hooks.
785
+ // esbuild's __toESM + isNodeMode=1 double-wraps and breaks React 19 hooks:
786
+ // its getters close over a `let` loop variable, which Hermes compiles as one
787
+ // shared binding, so EVERY export reads the module's last key. For react that
788
+ // is `version`, and the plugin crashes with "'19.1.0' is not a function".
789
+ // esbuild's cjs format emits `require(...)`; its iife/esm formats emit
790
+ // `__require(...)` — both are the same call into this shim, so both unwrap.
786
791
  let cleaned = content.replace(
787
- /__toESM\(require\(("[^"]+")\)(?:,\s*\d+)?\)/g,
792
+ /__toESM\((?:__)?require\(("[^"]+")\)(?:,\s*\d+)?\)/g,
788
793
  'require($1)',
789
794
  );
790
795
 
791
796
  // Strip __toCommonJS wrapper — it creates a getter-based Proxy that
792
- // collapses all named exports to the same object in Hermes/JSC.
797
+ // collapses all named exports to the same object in Hermes/JSC. cjs assigns
798
+ // it to module.exports; iife returns it from the wrapper.
793
799
  cleaned = cleaned.replace(
794
- /module\.exports\s*=\s*__toCommonJS\((\w+)\)/g,
795
- 'module.exports = $1',
800
+ /(module\.exports\s*=|return)\s*__toCommonJS\((\w+)\)/g,
801
+ '$1 $2',
796
802
  );
797
803
 
798
804
  const mod: { exports: any } = { exports: {} };
package/src/host.ts CHANGED
@@ -189,14 +189,34 @@ export function getCameraPreviewProvider(): ((props: CameraPreviewImplProps) =>
189
189
  // installs a consent provider that answers from its own live state.
190
190
  // The answer is read at call time, never cached (derive, never store), and every
191
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.
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.
193
196
 
194
197
  let _picturesToAiConsent: (() => boolean) | null = null;
198
+ const _consentListeners = new Set<() => void>();
195
199
 
196
200
  /** Install the host's answer to "did the person on this phone allow camera
197
201
  * pictures to go to AI?". Pass null to remove it (consent then reads as no). */
198
202
  export function setPicturesToAiConsentProvider(provider: (() => boolean) | null): void {
199
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); } };
200
220
  }
201
221
 
202
222
  /** The one check: true only when the plugin's trusted declaration includes
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export {
57
57
  gateAiLive,
58
58
  releaseAiLive,
59
59
  aiLiveIndicator,
60
+ CONSENT_TAKEN_BACK,
60
61
  } from './aiLiveHost';
61
62
 
62
63
  // Host integration
@@ -68,6 +69,8 @@ export {
68
69
  getCameraPreviewProvider,
69
70
  setPicturesToAiConsentProvider,
70
71
  mayPicturesLeavePhone,
72
+ notifyPicturesToAiConsentChanged,
73
+ onPicturesToAiConsentChange,
71
74
  getPluginNative,
72
75
  gatePluginNative,
73
76
  registerNativePlugin,
package/src/sdk/aiLive.ts CHANGED
@@ -9,11 +9,21 @@
9
9
  * touches a picture: it gets events, not pixels.
10
10
  *
11
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.
12
+ * 2026-10-25): the solution declares `native: { picturesToAi: true }`, AND the
13
+ * person on the phone allowed "Camera pictures to AI" in the app's settings. Both
14
+ * are asked at start() AND for as long as it sends: taking the yes back (the
15
+ * switch, the camera's own switch, the OS permission) stops it, and no picture is
16
+ * sent after that (GPT review 2026-10-30). Owner decision 2026-10-29: only while
17
+ * the screen that asked is open, and with "Sending to AI" on screen — the
18
+ * indicator is the PLATFORM's (AiLiveIndicator in @ateam-ai-mobile/ui), a plugin
19
+ * cannot hide it, and closing the screen stops it.
20
+ *
21
+ * Each phone session is the phone's OWN live session, capped at US$0.50 by Core,
22
+ * and it is one connection: start() opening it starts the session, and its end
23
+ * (stop(), the screen closing, the app gone, the network lost) ends it. Live
24
+ * sessions are per person (workspace + actor): other people's never get in the
25
+ * way, and when the person already has one running (started on the AI Models
26
+ * page or on another device), start() refuses rather than join it.
17
27
  */
18
28
  import type { Subscription } from './device-state';
19
29
 
@@ -26,6 +36,9 @@ export interface AiLiveEpisode {
26
36
  /** The task class id from the chosen setup, e.g. "drinking". */
27
37
  class: string;
28
38
  episodeId: string;
39
+ /** Whose camera saw it: the signed-in person whose live session this is (live sessions are per
40
+ * person, workspace + actor). Always there: an event without it never reaches the screen. */
41
+ actorId: string;
29
42
  /** When (ms since the epoch, the session's clock). */
30
43
  at: number;
31
44
  [key: string]: unknown;
@@ -38,7 +51,7 @@ export type AiLiveState =
38
51
  | 'starting'
39
52
  /** Pictures are going to the AI ("Sending to AI" is on screen). */
40
53
  | 'sending'
41
- /** Stopped by stop() or by the screen closing. */
54
+ /** Stopped by stop(), by the screen closing, or by the person taking the yes back (lastError says so). */
42
55
  | 'stopped'
43
56
  /** It could not start or it broke; `lastError` says why in plain words. */
44
57
  | 'error';
@@ -60,8 +73,10 @@ export interface AiLiveStartOptions {
60
73
  }
61
74
 
62
75
  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. */
76
+ /** Start sending this phone's camera to the chosen AI, in a live session of the
77
+ * phone's own (capped at US$0.50). Refuses by name when either side has not said
78
+ * yes, when another screen is sending already, or when the person already has a
79
+ * live session running elsewhere. */
65
80
  start(opts?: AiLiveStartOptions): Promise<AiLiveSnapshot>;
66
81
  /** Stop sending. Closing the screen does the same. */
67
82
  stop(): Promise<void>;
@@ -75,8 +90,11 @@ export interface AiLiveAPI {
75
90
  /** What the host supplies (setAiLiveProvider). `owner` is the screen's holder id,
76
91
  * captured by the gate — a plugin never supplies its own. */
77
92
  export interface AiLiveProvider {
78
- start(opts: AiLiveStartOptions, owner: string, ctx: { visual: import('./device-state').DeviceState['visual'] }): Promise<AiLiveSnapshot>;
79
- stop(owner: string): Promise<void>;
93
+ /** `ctx.allowed` is the two-sided consent, asked again right before every picture:
94
+ * once it answers no, the provider sends nothing more and stops. */
95
+ start(opts: AiLiveStartOptions, owner: string, ctx: { visual: import('./device-state').DeviceState['visual']; allowed: () => boolean }): Promise<AiLiveSnapshot>;
96
+ /** `why` (optional) is what the screen then reads as lastError, e.g. the yes taken back. */
97
+ stop(owner: string, why?: string): Promise<void>;
80
98
  getSnapshot(owner: string): AiLiveSnapshot;
81
99
  onEpisode(cb: (ev: AiLiveEpisode) => void, owner: string): Subscription;
82
100
  subscribe(cb: (snap: AiLiveSnapshot) => void, owner: string): Subscription;