@ateam-ai-mobile/plugin-runtime 0.1.11 → 0.1.13
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/{chunk-SICFDA5K.mjs → chunk-P43IKNZE.mjs} +65 -6
- package/dist/chunk-P43IKNZE.mjs.map +1 -0
- package/dist/index.d.mts +38 -12
- package/dist/index.d.ts +38 -12
- package/dist/index.js +100 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +31 -5
- package/dist/index.mjs.map +1 -1
- package/dist/{loader-MSZ4PVTO.mjs → loader-M6CS6D22.mjs} +2 -2
- package/package.json +2 -2
- package/src/deviceStateHost.ts +78 -3
- package/src/hooks/usePluginRuntimeProps.ts +42 -0
- package/src/index.ts +3 -0
- package/src/pluginRuntime.ts +4 -2
- package/src/sdk/device-state.ts +12 -1
- package/src/sdk/index.ts +1 -0
- package/src/sdk/voice.ts +7 -5
- package/src/tools/registry.ts +12 -3
- package/dist/chunk-SICFDA5K.mjs.map +0 -1
- /package/dist/{loader-MSZ4PVTO.mjs.map → loader-M6CS6D22.mjs.map} +0 -0
package/dist/index.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
callDeviceTool,
|
|
38
38
|
clearDeviceTools,
|
|
39
39
|
createNativeBridge,
|
|
40
|
+
deviceStateHolders,
|
|
40
41
|
device_state_exports,
|
|
41
42
|
ensureDeviceToolsLoaded,
|
|
42
43
|
explainCapability,
|
|
@@ -67,6 +68,7 @@ import {
|
|
|
67
68
|
registerNativePlugin,
|
|
68
69
|
registerService,
|
|
69
70
|
registerTools,
|
|
71
|
+
releaseDeviceStateHolds,
|
|
70
72
|
resetDeviceToolLoader,
|
|
71
73
|
resolvePluginGrants,
|
|
72
74
|
resolvePluginRuntimeProps,
|
|
@@ -87,7 +89,7 @@ import {
|
|
|
87
89
|
useNativeBridge,
|
|
88
90
|
usePluginTheme,
|
|
89
91
|
useSurfaceStore
|
|
90
|
-
} from "./chunk-
|
|
92
|
+
} from "./chunk-P43IKNZE.mjs";
|
|
91
93
|
|
|
92
94
|
// src/tools/platformTools.ts
|
|
93
95
|
var PLATFORM_PLUGIN_ID = "platform:device-tools";
|
|
@@ -130,10 +132,10 @@ var REAL_DEPS = {
|
|
|
130
132
|
// `./loader` reaches react-native via the bundle manager; import it only when
|
|
131
133
|
// an effect actually fires (real app), never at module load (tests).
|
|
132
134
|
resetLoader: () => {
|
|
133
|
-
void import("./loader-
|
|
135
|
+
void import("./loader-M6CS6D22.mjs").then((m) => m.resetDeviceToolLoader());
|
|
134
136
|
},
|
|
135
137
|
load: () => {
|
|
136
|
-
void import("./loader-
|
|
138
|
+
void import("./loader-M6CS6D22.mjs").then((m) => {
|
|
137
139
|
m.resetDeviceToolLoader();
|
|
138
140
|
return m.ensureDeviceToolsLoaded({ force: true });
|
|
139
141
|
});
|
|
@@ -294,8 +296,29 @@ function usePlugin(pluginId, manifest) {
|
|
|
294
296
|
return { plugin, loading, error, bundleMissing };
|
|
295
297
|
}
|
|
296
298
|
|
|
299
|
+
// src/hooks/usePluginRuntimeProps.ts
|
|
300
|
+
import { useEffect as useEffect2, useMemo, useRef } from "react";
|
|
301
|
+
var mounts = 0;
|
|
302
|
+
function usePluginRuntimeProps(src, deps) {
|
|
303
|
+
const holderRef = useRef(null);
|
|
304
|
+
if (holderRef.current?.pluginId !== src.pluginId) {
|
|
305
|
+
holderRef.current = { pluginId: src.pluginId, holder: `${src.pluginId}#${++mounts}` };
|
|
306
|
+
}
|
|
307
|
+
const holder = holderRef.current.holder;
|
|
308
|
+
const props = useMemo(() => resolvePluginRuntimeProps(src, holder), [...deps, holder]);
|
|
309
|
+
const granted = useMemo(() => resolvePluginGrants(src).stateDomains ?? [], deps);
|
|
310
|
+
useEffect2(() => () => {
|
|
311
|
+
void releaseDeviceStateHolds(holder);
|
|
312
|
+
}, [holder]);
|
|
313
|
+
const grantedKey = granted.join(",");
|
|
314
|
+
useEffect2(() => {
|
|
315
|
+
void releaseDeviceStateHolds(holder, granted);
|
|
316
|
+
}, [holder, grantedKey]);
|
|
317
|
+
return props;
|
|
318
|
+
}
|
|
319
|
+
|
|
297
320
|
// src/hooks/useRemotePlugins.ts
|
|
298
|
-
import { useEffect as
|
|
321
|
+
import { useEffect as useEffect3, useState as useState2, useCallback } from "react";
|
|
299
322
|
import { apiFetch } from "@ateam-ai-mobile/core";
|
|
300
323
|
function useRemotePlugins(solutionId) {
|
|
301
324
|
const [plugins, setPlugins] = useState2([]);
|
|
@@ -351,7 +374,7 @@ function useRemotePlugins(solutionId) {
|
|
|
351
374
|
setRefreshing(true);
|
|
352
375
|
await fetchRemotePlugins();
|
|
353
376
|
}, [fetchRemotePlugins]);
|
|
354
|
-
|
|
377
|
+
useEffect3(() => {
|
|
355
378
|
setLoading(true);
|
|
356
379
|
fetchRemotePlugins();
|
|
357
380
|
}, [fetchRemotePlugins]);
|
|
@@ -403,6 +426,7 @@ export {
|
|
|
403
426
|
callDeviceTool,
|
|
404
427
|
clearDeviceTools,
|
|
405
428
|
createNativeBridge,
|
|
429
|
+
deviceStateHolders,
|
|
406
430
|
device_state_exports as deviceStateV2,
|
|
407
431
|
deviceToolsActionForSession,
|
|
408
432
|
ensureDeviceToolsLoaded,
|
|
@@ -437,6 +461,7 @@ export {
|
|
|
437
461
|
registerPlatformDeviceTools,
|
|
438
462
|
registerService,
|
|
439
463
|
registerTools,
|
|
464
|
+
releaseDeviceStateHolds,
|
|
440
465
|
resetDeviceToolLoader,
|
|
441
466
|
resolvePluginGrants,
|
|
442
467
|
resolvePluginRuntimeProps,
|
|
@@ -458,6 +483,7 @@ export {
|
|
|
458
483
|
useApi,
|
|
459
484
|
useNativeBridge,
|
|
460
485
|
usePlugin,
|
|
486
|
+
usePluginRuntimeProps,
|
|
461
487
|
usePluginTheme,
|
|
462
488
|
useRemotePlugins,
|
|
463
489
|
useSurfaceStore
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tools/platformTools.ts","../src/tools/sessionSync.ts","../src/tools/serverCalls.ts","../src/hooks/usePlugin.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","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","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 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,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ensureDeviceToolsLoaded,
|
|
3
3
|
resetDeviceToolLoader
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-P43IKNZE.mjs";
|
|
5
5
|
export {
|
|
6
6
|
ensureDeviceToolsLoaded,
|
|
7
7
|
resetDeviceToolLoader
|
|
8
8
|
};
|
|
9
|
-
//# sourceMappingURL=loader-
|
|
9
|
+
//# sourceMappingURL=loader-M6CS6D22.mjs.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai-mobile/plugin-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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.
|
|
12
|
+
"@ateam-ai-mobile/core": "0.1.13",
|
|
13
13
|
"zustand": "^5.0.0"
|
|
14
14
|
},
|
|
15
15
|
"peerDependencies": {
|
package/src/deviceStateHost.ts
CHANGED
|
@@ -162,14 +162,89 @@ export function setDeviceStateProvider(state: Partial<DeviceState>): void {
|
|
|
162
162
|
_provider = state || {};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// ── Holds: who started a session, and when it really ends (AI Models F2/F3) ──
|
|
166
|
+
//
|
|
167
|
+
// The engines keep ONE session per domain for the whole app (one camera, one
|
|
168
|
+
// compass). Before this, any declared plugin's stop() ended it for everyone, and
|
|
169
|
+
// nothing ended it when the plugin that started it went away. Now each start()
|
|
170
|
+
// takes a HOLD for its holder (one per mounted surface, or the plugin's device
|
|
171
|
+
// tools), stop() releases only that hold, and the engine is stopped when the LAST
|
|
172
|
+
// hold goes. releaseDeviceStateHolds(holder) runs when a surface unmounts, so a
|
|
173
|
+
// screen that closes — navigation, a tenant switch, a grant change — leaves no
|
|
174
|
+
// camera running behind it.
|
|
175
|
+
const holds = new Map<DeviceBridgeDomain, Set<string>>();
|
|
176
|
+
|
|
177
|
+
function holdersOf(domain: DeviceBridgeDomain): Set<string> {
|
|
178
|
+
let s = holds.get(domain);
|
|
179
|
+
if (!s) { s = new Set(); holds.set(domain, s); }
|
|
180
|
+
return s;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function withHolds(domain: DeviceBridgeDomain, engine: any, holder: string): any {
|
|
184
|
+
if (!engine || typeof engine.start !== 'function' || typeof engine.stop !== 'function') return engine;
|
|
185
|
+
return Object.assign(Object.create(engine), {
|
|
186
|
+
async start(options?: unknown) {
|
|
187
|
+
// The hold is taken BEFORE the engine starts: a surface that closes while
|
|
188
|
+
// the start is still pending must find it, or the session it started would
|
|
189
|
+
// outlive it with nobody left to stop it.
|
|
190
|
+
const s = holdersOf(domain);
|
|
191
|
+
const fresh = !s.has(holder);
|
|
192
|
+
s.add(holder);
|
|
193
|
+
let result;
|
|
194
|
+
try {
|
|
195
|
+
result = await engine.start(options);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
// A failed start leaves no hold of ours behind.
|
|
198
|
+
if (fresh && s.delete(holder) && s.size === 0) await stopQuietly(engine);
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
// Released while the start was pending (the surface closed): undo it, unless
|
|
202
|
+
// someone else holds the session by now.
|
|
203
|
+
if (!s.has(holder) && s.size === 0) await stopQuietly(engine);
|
|
204
|
+
return result;
|
|
205
|
+
},
|
|
206
|
+
async stop() {
|
|
207
|
+
const s = holdersOf(domain);
|
|
208
|
+
if (!s.has(holder)) return; // nothing of ours to release; another holder's session stays up
|
|
209
|
+
s.delete(holder);
|
|
210
|
+
if (s.size === 0) await engine.stop();
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function stopQuietly(engine: any): Promise<void> {
|
|
216
|
+
try { await engine?.stop?.(); } catch { /* already stopped */ }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Release `holder`'s holds — in every domain, or in every domain NOT in `keep`
|
|
221
|
+
* (a surface whose grants shrank keeps only what it may still use) — and stop
|
|
222
|
+
* each engine that no one holds any more.
|
|
223
|
+
*/
|
|
224
|
+
export async function releaseDeviceStateHolds(holder: string, keep: readonly DeviceBridgeDomain[] = []): Promise<void> {
|
|
225
|
+
for (const [domain, s] of holds) {
|
|
226
|
+
if (keep.includes(domain)) continue;
|
|
227
|
+
if (!s.delete(holder) || s.size > 0) continue;
|
|
228
|
+
await stopQuietly((_provider as any)[domain]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Who holds what — for diagnostics and tests. */
|
|
233
|
+
export function deviceStateHolders(domain: DeviceBridgeDomain): string[] {
|
|
234
|
+
return [...(holds.get(domain) || [])];
|
|
235
|
+
}
|
|
236
|
+
|
|
165
237
|
/**
|
|
166
238
|
* The `deviceState` a plugin receives, gated by what it declared.
|
|
167
239
|
*
|
|
168
240
|
* Every domain is always present — a plugin can call any of the eight without
|
|
169
241
|
* an existence check. What differs is whether it answers with data, or with
|
|
170
|
-
* an explicit "I know nothing" and a reason.
|
|
242
|
+
* an explicit "I know nothing" and a reason. `holder` names who a start() holds
|
|
243
|
+
* the session for (see Holds above); a caller with no lifetime of its own passes
|
|
244
|
+
* the plugin id.
|
|
171
245
|
*/
|
|
172
|
-
export function gateDeviceState(declared
|
|
246
|
+
export function gateDeviceState(declared: readonly DeviceBridgeDomain[] | undefined, holder: string): DeviceState {
|
|
247
|
+
if (!holder) throw new Error('gateDeviceState: a holder is required — who would release a session it starts?');
|
|
173
248
|
const allowed = new Set(declared || []);
|
|
174
249
|
const out: any = {};
|
|
175
250
|
|
|
@@ -182,7 +257,7 @@ export function gateDeviceState(declared?: readonly DeviceBridgeDomain[]): Devic
|
|
|
182
257
|
continue;
|
|
183
258
|
}
|
|
184
259
|
const engine = (_provider as any)[domain];
|
|
185
|
-
out[domain] = engine
|
|
260
|
+
out[domain] = engine ? withHolds(domain, engine, holder) : deniedDomain(
|
|
186
261
|
domain,
|
|
187
262
|
'no engine in this build — the native side does not exist yet. See docs/DEVICE_API_STATUS.md.',
|
|
188
263
|
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// usePluginRuntimeProps — the gated { device, native, deviceState, voice } for ONE
|
|
2
|
+
// mounted surface, with the device-state sessions it starts released when it
|
|
3
|
+
// unmounts (AI Models F2: a screen that closes leaves no camera running).
|
|
4
|
+
//
|
|
5
|
+
// Every surface that renders a plugin uses this instead of calling
|
|
6
|
+
// resolvePluginRuntimeProps in a useMemo, so the release cannot be forgotten.
|
|
7
|
+
// Each mount is its own holder: a drawer and an embedded view of one plugin hold
|
|
8
|
+
// the same camera separately, and it ends only when both have closed. A mount
|
|
9
|
+
// that switches plugin releases the old plugin's sessions, and one whose grants
|
|
10
|
+
// shrink releases the domains it lost.
|
|
11
|
+
|
|
12
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
13
|
+
import { resolvePluginGrants, resolvePluginRuntimeProps, type PluginRuntimeSource } from '../pluginRuntime';
|
|
14
|
+
import { releaseDeviceStateHolds } from '../deviceStateHost';
|
|
15
|
+
import type { PluginRuntimeProps } from '../pluginRuntime';
|
|
16
|
+
|
|
17
|
+
let mounts = 0;
|
|
18
|
+
|
|
19
|
+
export function usePluginRuntimeProps(src: PluginRuntimeSource, deps: readonly unknown[]): PluginRuntimeProps {
|
|
20
|
+
// One holder per plugin on this mount: a renderer that switches to ANOTHER plugin
|
|
21
|
+
// gets a new holder, and the effect below releases what the old one held.
|
|
22
|
+
const holderRef = useRef<{ pluginId: string; holder: string } | null>(null);
|
|
23
|
+
if (holderRef.current?.pluginId !== src.pluginId) {
|
|
24
|
+
holderRef.current = { pluginId: src.pluginId, holder: `${src.pluginId}#${++mounts}` };
|
|
25
|
+
}
|
|
26
|
+
const holder = holderRef.current.holder;
|
|
27
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
28
|
+
const props = useMemo(() => resolvePluginRuntimeProps(src, holder), [...deps, holder]);
|
|
29
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
30
|
+
const granted = useMemo(() => resolvePluginGrants(src).stateDomains ?? [], deps);
|
|
31
|
+
|
|
32
|
+
// Unmount, or a switch to another plugin: release everything this holder holds.
|
|
33
|
+
useEffect(() => () => { void releaseDeviceStateHolds(holder); }, [holder]);
|
|
34
|
+
|
|
35
|
+
// Grants changed while mounted: a domain no longer granted is released now. The
|
|
36
|
+
// new facade refuses it, so the plugin could not stop that session itself.
|
|
37
|
+
const grantedKey = granted.join(',');
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
useEffect(() => { void releaseDeviceStateHolds(holder, granted); }, [holder, grantedKey]);
|
|
40
|
+
|
|
41
|
+
return props;
|
|
42
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ export {
|
|
|
35
35
|
setDeviceStateProvider,
|
|
36
36
|
gateDeviceState,
|
|
37
37
|
implementedStateDomains,
|
|
38
|
+
releaseDeviceStateHolds,
|
|
39
|
+
deviceStateHolders,
|
|
38
40
|
} from './deviceStateHost';
|
|
39
41
|
|
|
40
42
|
// Voice session host DI — the app hands its one live session here, and the gate
|
|
@@ -105,6 +107,7 @@ export type { Bridge } from './bridge/types';
|
|
|
105
107
|
|
|
106
108
|
// Plugin loading
|
|
107
109
|
export { usePlugin } from './hooks/usePlugin';
|
|
110
|
+
export { usePluginRuntimeProps } from './hooks/usePluginRuntimeProps';
|
|
108
111
|
export { useRemotePlugins } from './hooks/useRemotePlugins';
|
|
109
112
|
export type { RemotePlugin } from './hooks/useRemotePlugins';
|
|
110
113
|
|
package/src/pluginRuntime.ts
CHANGED
|
@@ -93,10 +93,12 @@ export function resolvePluginGrants(src: PluginRuntimeSource): PluginGrants {
|
|
|
93
93
|
|
|
94
94
|
/** Turn a plugin's grants into the gated { device, native, deviceState, voice } it
|
|
95
95
|
* receives on every rendered surface. */
|
|
96
|
-
export function resolvePluginRuntimeProps(src: PluginRuntimeSource): PluginRuntimeProps {
|
|
96
|
+
export function resolvePluginRuntimeProps(src: PluginRuntimeSource, holder: string = src.pluginId): PluginRuntimeProps {
|
|
97
97
|
const { capabilities, stateDomains } = resolvePluginGrants(src);
|
|
98
98
|
const native = gatePluginNative(getPluginNative(), capabilities, src.pluginId);
|
|
99
|
-
|
|
99
|
+
// Sessions this surface starts are held for `holder` and end with it
|
|
100
|
+
// (usePluginRuntimeProps releases them on unmount).
|
|
101
|
+
const deviceState = gateDeviceState(stateDomains, holder);
|
|
100
102
|
// Voice is a capability flag, gated the same way — the TRUSTED declaration
|
|
101
103
|
// decides, a downloaded bundle cannot grant itself the session (or the
|
|
102
104
|
// transcript it can read). The facade is bound to the TRUSTED pluginId, so the
|
package/src/sdk/device-state.ts
CHANGED
|
@@ -605,6 +605,17 @@ export interface VisualCaptureOptions {
|
|
|
605
605
|
timeoutMs?: number;
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
+
export interface VisualStartOptions extends StartOptions<VisualIntent> {
|
|
609
|
+
/**
|
|
610
|
+
* Which camera: 'back' (the default) or 'front', the selfie camera — its
|
|
611
|
+
* frames are mirrored like the view the user sees, so a box's left is the
|
|
612
|
+
* screen's left. A binary without the front camera REJECTS 'front' rather
|
|
613
|
+
* than reading the back camera; `camera.facing` in the snapshot says which
|
|
614
|
+
* one is actually running.
|
|
615
|
+
*/
|
|
616
|
+
facing?: 'front' | 'back';
|
|
617
|
+
}
|
|
618
|
+
|
|
608
619
|
export interface VisualDomain {
|
|
609
620
|
/**
|
|
610
621
|
* Cached read. Does NOT open the camera and does NOT take a picture.
|
|
@@ -618,7 +629,7 @@ export interface VisualDomain {
|
|
|
618
629
|
getSnapshot(): Promise<VisualSnapshot>;
|
|
619
630
|
|
|
620
631
|
/** Maintain visual state according to this intent. Not "open the camera". */
|
|
621
|
-
start(options:
|
|
632
|
+
start(options: VisualStartOptions): Promise<StateSession>;
|
|
622
633
|
|
|
623
634
|
/**
|
|
624
635
|
* An intentional, one-shot, high-quality-ish acquisition. THROWS
|
package/src/sdk/index.ts
CHANGED
package/src/sdk/voice.ts
CHANGED
|
@@ -214,11 +214,13 @@ export interface VoiceAPI {
|
|
|
214
214
|
prompt(input: string): void;
|
|
215
215
|
/**
|
|
216
216
|
* Stream a silent OBSERVATION into the session — tell the model something
|
|
217
|
-
* without asking it to speak (a `system` conversation item)
|
|
218
|
-
*
|
|
219
|
-
* the
|
|
220
|
-
*
|
|
221
|
-
*
|
|
217
|
+
* without asking it to speak (a `system` conversation item): the channel for
|
|
218
|
+
* what the camera sees while the user walks. Limited, because every item stays
|
|
219
|
+
* in the model's context for the call: 2 a second (bursts of 4) and 2000
|
|
220
|
+
* characters each; over the limit it THROWS, so send only material changes.
|
|
221
|
+
* The model answers with the observations in hand the next time it takes a
|
|
222
|
+
* turn (user VAD, or prompt()) — an observation never triggers a turn by
|
|
223
|
+
* itself. Throws if not connected.
|
|
222
224
|
*/
|
|
223
225
|
observe(text: string): void;
|
|
224
226
|
/** Point-in-time session state. */
|
package/src/tools/registry.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createNativeBridge } from '../bridge/native';
|
|
16
16
|
import { getPluginNative, gatePluginNative } from '../host';
|
|
17
|
-
import { gateDeviceState, withoutSubscribe } from '../deviceStateHost';
|
|
17
|
+
import { gateDeviceState, withoutSubscribe, releaseDeviceStateHolds } from '../deviceStateHost';
|
|
18
18
|
import type { DeviceToolContext, DeviceToolDefinition, DeviceToolFailureReason, NativeCapabilities } from '../sdk/types';
|
|
19
19
|
import type { DeviceBridgeDomain } from '../sdk/device-state';
|
|
20
20
|
|
|
@@ -73,7 +73,9 @@ function defaultContext(
|
|
|
73
73
|
return {
|
|
74
74
|
bridge: createNativeBridge(pluginId),
|
|
75
75
|
native: gatePluginNative(getPluginNative(), capabilities, pluginId),
|
|
76
|
-
|
|
76
|
+
// A tool call has no lifetime of its own: what it starts is held for the
|
|
77
|
+
// plugin's tools, and released when the plugin's tools are unregistered.
|
|
78
|
+
deviceState: gateDeviceState(stateDomains, toolHolder(pluginId)),
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -114,11 +116,18 @@ export function registerDeviceTools(
|
|
|
114
116
|
return accepted;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
/**
|
|
119
|
+
/** Who a plugin's device tools hold device-state sessions for (deviceStateHost Holds). */
|
|
120
|
+
export function toolHolder(pluginId: string): string {
|
|
121
|
+
return `tools:${pluginId}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Drop every tool owned by a plugin (bundle eviction / plugin revoked), and the
|
|
125
|
+
* sessions its tools started. */
|
|
118
126
|
export function unregisterDeviceTools(pluginId: string): void {
|
|
119
127
|
for (const [name, entry] of registry.entries()) {
|
|
120
128
|
if (entry.pluginId === pluginId) registry.delete(name);
|
|
121
129
|
}
|
|
130
|
+
void releaseDeviceStateHolds(toolHolder(pluginId));
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
/** Is `name` registered — and, given `pluginId`, registered BY that plugin?
|