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

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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * props.voice — the live conversational SESSION a plugin may drive.
3
+ *
4
+ * Voice is a SESSION, not a device sensor. `props.device.*` is mechanical verbs
5
+ * that throw; `props.deviceState.*` is eight semantic domains describing the
6
+ * device. A Realtime conversation is neither — it is a long-lived loop with its
7
+ * own state (connecting/connected), its own transcript, and two ways to take a
8
+ * turn (the user speaks, or the agent speaks first). So it gets its own gated
9
+ * surface, mirroring the deviceState GRAMMAR (start / stop / getSnapshot /
10
+ * subscribe) because a reasoning loop reads it the same way — state with
11
+ * freshness, not bare values.
12
+ *
13
+ * TRUST BOUNDARY. This type is the WHOLE surface. There is deliberately NO way
14
+ * from here to inject fake user speech, read raw audio, reach the WebView, or
15
+ * hide the mic-active indicator — those are new doors, and the reason
16
+ * `CameraPreview` is a component rather than a module is that we keep closing
17
+ * doors of that shape. `prompt()` is an AGENT turn, not a forged user turn.
18
+ *
19
+ * See docs/handoff/SPEC_voice_session_without_the_voice_screen_2026-09-12.md.
20
+ */
21
+
22
+ /** The session lifecycle — identical vocabulary to the built-in `CallState`. */
23
+ export type VoiceState = 'idle' | 'connecting' | 'connected' | 'error';
24
+
25
+ /** One line of the conversation. `tool` entries are device/skill tool activity. */
26
+ export interface VoiceTranscriptEntry {
27
+ role: 'user' | 'ai' | 'tool';
28
+ text: string;
29
+ ts: number;
30
+ }
31
+
32
+ /** A point-in-time read of the session — what a reasoning loop polls. */
33
+ export interface VoiceSnapshot {
34
+ state: VoiceState;
35
+ sessionId: string | null;
36
+ muted: boolean;
37
+ /** The agent is currently producing audio. */
38
+ speaking: boolean;
39
+ /** The user is currently being heard (partial user transcript in flight). */
40
+ listening: boolean;
41
+ /** The user is currently speaking, per the session's own VAD (authoritative,
42
+ * distinct from `listening` which tracks a partial transcript). */
43
+ userSpeaking: boolean;
44
+ /**
45
+ * Epoch ms of the last moment the user STOPPED speaking, or `null` if they
46
+ * have not spoken yet this session. `null` is deliberate and load-bearing: a
47
+ * silence timer must arm on the first real `speaking:false`, never at session
48
+ * start — "not speaking" and "has never spoken" are different, and arming on
49
+ * the wrong one fires before anyone has said anything.
50
+ */
51
+ lastUserSpeechEndedAt: number | null;
52
+ /** The last error, if `state === 'error'`; also carries a refusal reason when
53
+ * this surface was handed to a plugin that did not declare `voice`. */
54
+ lastError: string | null;
55
+ }
56
+
57
+ export interface VoiceStartOptions {
58
+ /** Hand the mic to a specific skill on connect (voice /session → takeMic),
59
+ * exactly as `sys.focusUiPlugin("platform:voice", { skillSlug })` does. */
60
+ skillSlug?: string;
61
+ }
62
+
63
+ /**
64
+ * What changed. Coarse on purpose: `state` covers connect/mute/session/partials,
65
+ * `transcript` fires when a finalized line was appended. A subscriber re-reads
66
+ * `getSnapshot()` / `transcript()` — the event is a nudge, not a payload, so the
67
+ * surface never has to keep two shapes of the same fact in sync.
68
+ */
69
+ export type VoiceEvent = { type: 'state' } | { type: 'transcript' };
70
+
71
+ export interface VoiceSubscription {
72
+ unsubscribe(): void;
73
+ }
74
+
75
+ /**
76
+ * WHO is asking. The one physical session has an OWNER; access is scoped to it.
77
+ *
78
+ * A plugin's caller is its `pluginId` (a string), stamped by gateVoice so the
79
+ * plugin never supplies its own identity. The PLATFORM caller — the built-in
80
+ * voice screen, the user's own surface — is a Symbol, deliberately: a Symbol can
81
+ * never equal any pluginId string, so a plugin cannot forge platform access by
82
+ * naming itself `platform:voice`.
83
+ */
84
+ export type VoiceCaller = string | symbol;
85
+
86
+ /** The privileged caller — the platform's own surfaces (the built-in sheet, the
87
+ * mic indicator, the session host). Sees and controls any live session, because
88
+ * it is the USER's direct surface onto their own conversation. Unforgeable. */
89
+ export const PLATFORM_VOICE_CALLER: unique symbol = Symbol('adas.voice.platform');
90
+
91
+ /**
92
+ * The OWNER-AWARE session the HOST implements and hands to setVoiceProvider.
93
+ *
94
+ * This is NOT what a plugin receives — a plugin gets a `VoiceAPI` facade
95
+ * (gateVoice) that stamps its `pluginId` as the caller on every call, so the
96
+ * provider can enforce that a plugin only ever reads, controls, or attaches to a
97
+ * session IT owns. One physical session, isolated per authorized identity.
98
+ */
99
+ export interface VoiceSessionProvider {
100
+ start(opts: VoiceStartOptions | undefined, caller: VoiceCaller): Promise<VoiceSnapshot>;
101
+ stop(caller: VoiceCaller): void;
102
+ setMuted(muted: boolean, caller: VoiceCaller): void;
103
+ prompt(input: string, caller: VoiceCaller): void;
104
+ observe(text: string, caller: VoiceCaller): void;
105
+ getSnapshot(caller: VoiceCaller): VoiceSnapshot;
106
+ subscribe(cb: (event: VoiceEvent) => void, caller: VoiceCaller): VoiceSubscription;
107
+ transcript(caller: VoiceCaller): VoiceTranscriptEntry[];
108
+ }
109
+
110
+ /**
111
+ * The gated surface a plugin receives as `props.voice`. Present on EVERY plugin;
112
+ * a plugin that did not declare `capabilities: { voice: true }` gets a version
113
+ * whose reads say so and whose actions throw — never a silent no-op, never a
114
+ * fake `idle` (see gateVoice in voiceHost.ts).
115
+ */
116
+ export interface VoiceAPI {
117
+ /** Open the live loop. Resolves once connected (or rejects on connect error).
118
+ * If a session is ALREADY live, ATTACHES to it and resolves immediately —
119
+ * never a second Realtime loop (one phone, one mic, one bill). */
120
+ start(opts?: VoiceStartOptions): Promise<VoiceSnapshot>;
121
+ /** End the live loop. Safe to call when idle. */
122
+ stop(): void;
123
+ /** Mute/unmute the microphone. The session stays live; the mic indicator
124
+ * reflects it. */
125
+ setMuted(muted: boolean): void;
126
+ /**
127
+ * Make the agent take a turn WITHOUT the user speaking — the guide speaking
128
+ * first. `input` is the instruction for that turn. This is an agent-initiated
129
+ * turn into the SAME session, NOT fabricated user speech: one session, two
130
+ * trigger sources. Throws if the session is not connected.
131
+ */
132
+ prompt(input: string): void;
133
+ /**
134
+ * Stream a silent OBSERVATION into the session — tell the model something
135
+ * without asking it to speak (a `system` conversation item). Cheap and
136
+ * high-frequency (safe once or twice a second): this is the channel for what
137
+ * the camera sees while the user walks. The model answers with the
138
+ * observations in hand the next time it takes a turn (user VAD, or prompt()) —
139
+ * an observation never triggers a turn by itself. Throws if not connected.
140
+ */
141
+ observe(text: string): void;
142
+ /** Point-in-time session state. */
143
+ getSnapshot(): VoiceSnapshot;
144
+ /** Subscribe to state + transcript changes. Returns an unsubscribe handle. */
145
+ subscribe(cb: (event: VoiceEvent) => void): VoiceSubscription;
146
+ /** The conversation so far. */
147
+ transcript(): VoiceTranscriptEntry[];
148
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Device tool loading.
3
+ *
4
+ * A device tool is only callable once its plugin bundle has been evaluated —
5
+ * that is when the tools register (see `./registry`). Nothing else in the app
6
+ * guarantees that: bundle prewarm runs on the home screen and is best-effort, so
7
+ * a user who goes straight into a voice call can reach a tool the phone has
8
+ * never loaded. The model then hears "no such tool" and improvises, which looks
9
+ * exactly like a broken feature.
10
+ *
11
+ * So the voice path calls `ensureDeviceToolsLoaded()` before connecting.
12
+ */
13
+ import { listPlugins, getPlugin, getNativeBundleUrl, getNativeBundleHash } from '../api/plugins';
14
+ import { bundleManager } from '../bundles/bundleManager';
15
+ import { listDeviceTools } from './registry';
16
+ import { getBundleStorage, selectOwnedCacheEntries } from '../bundles/storage';
17
+ import { getTenant } from '@ateam-ai-mobile/core';
18
+
19
+ let inflight: Promise<string[]> | null = null;
20
+ let loadedOnce = false;
21
+
22
+ /**
23
+ * Load every plugin that declares `capabilities.device_tools`, so its tools are
24
+ * registered and callable. Best-effort and idempotent: safe to call on every
25
+ * call start. Returns the tool names currently registered.
26
+ *
27
+ * Pass `force` after a plugin catalog change (a newly deployed connector).
28
+ */
29
+ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<string[]> {
30
+ if (loadedOnce && !force) return listDeviceTools().map(t => t.name);
31
+ if (inflight) return inflight;
32
+
33
+ inflight = (async () => {
34
+ try {
35
+ const summaries = await listPlugins();
36
+
37
+ // The list response is slim, so the manifest is the authority on whether a
38
+ // plugin declares device tools. Fetched in parallel — a cold connector
39
+ // that answers slowly must not hold up a call.
40
+ const manifests = await Promise.all(
41
+ (summaries || []).map(s =>
42
+ getPlugin((s as any).id).catch(() => null),
43
+ ),
44
+ );
45
+
46
+ const targets = manifests.filter((m: any) => {
47
+ const declared = m?.capabilities?.device_tools;
48
+ return Array.isArray(declared) && declared.length > 0;
49
+ });
50
+
51
+ if (targets.length === 0) {
52
+ // The catalog can go blind: a solution connector is lazy, and while it
53
+ // is not running its plugins are absent from the list entirely. That is
54
+ // a fact about a server process, not about this phone — the bundle we
55
+ // already downloaded still has the tools in it.
56
+ //
57
+ // So fall back to what is on disk. Re-evaluating a cached bundle
58
+ // re-registers whatever it declares, and a bundle with no tools is a
59
+ // no-op, so this is safe to do blind.
60
+ const recovered = await loadFromDiskCache();
61
+ if (recovered.length > 0) {
62
+ console.log(`[deviceTools] catalog listed none — recovered from cache: ${recovered.join(', ')}`);
63
+ return recovered;
64
+ }
65
+ // Do NOT latch: "nothing yet" is usually just "not yet".
66
+ console.log('[deviceTools] no device tools available yet — will look again next call');
67
+ return [];
68
+ }
69
+
70
+ await Promise.all(
71
+ targets.map(async (m: any) => {
72
+ const bundleUrl = getNativeBundleUrl(m);
73
+ if (!bundleUrl) {
74
+ console.warn(`[deviceTools] ${m.id} declares device_tools but has no native bundle`);
75
+ return;
76
+ }
77
+ try {
78
+ await bundleManager.loadTools(
79
+ m.id,
80
+ bundleUrl,
81
+ m.version || '1.0.0',
82
+ getNativeBundleHash(m) || undefined,
83
+ );
84
+ } catch (err: any) {
85
+ console.warn(`[deviceTools] ${m.id} failed to load:`, err?.message || String(err));
86
+ }
87
+ }),
88
+ );
89
+
90
+ const names = listDeviceTools().map(t => t.name);
91
+ // Only latch on success. An empty result is a cold read, not a fact —
92
+ // same reasoning as the server-side discovery cache.
93
+ loadedOnce = names.length > 0;
94
+ console.log(`[deviceTools] ready: ${names.length > 0 ? names.join(', ') : '(none registered — will retry next call)'}`);
95
+ return names;
96
+ } finally {
97
+ inflight = null;
98
+ }
99
+ })();
100
+
101
+ return inflight;
102
+ }
103
+
104
+ /**
105
+ * Re-evaluate the CURRENT TENANT'S bundles already cached on disk.
106
+ *
107
+ * Used when the plugin catalog comes back empty — which happens whenever the
108
+ * owning connector is asleep. This is a local read, not a download.
109
+ *
110
+ * TENANT SCOPING IS LOAD-BEARING, NOT DEFENCE IN DEPTH.
111
+ *
112
+ * Every other load path is gated by the manifest hash: the expected hash comes
113
+ * from the current tenant's server, so another tenant's cached bytes simply
114
+ * fail to match and are re-downloaded. This path has no manifest — that is the
115
+ * whole reason it exists — so it passes each entry's OWN stored hash as the
116
+ * expected hash. That check compares an entry to itself and can never fail. It
117
+ * is an integrity check against disk corruption, and it is not, and cannot be,
118
+ * an authorisation check.
119
+ *
120
+ * Without the tenant filter below this loop re-evaluated EVERY cached bundle,
121
+ * so a plugin cached during a session on tenant A registered its device tools
122
+ * into a live session on tenant B, and subsequent tool calls ran tenant-A code
123
+ * holding tenant-B credentials. No id collision was required: bundle urls are
124
+ * identical across tenants and resolve per-tenant on the server, and the code
125
+ * cache deliberately survives logout.
126
+ *
127
+ * An entry with no recorded tenant predates the stamp. Its owner is unknown,
128
+ * which means untrusted — skip it and let the normal catalog path re-download
129
+ * it under a real hash gate.
130
+ */
131
+ async function loadFromDiskCache(): Promise<string[]> {
132
+ try {
133
+ const index = await getBundleStorage().loadIndex();
134
+ const entries = Object.values(index || {});
135
+ if (entries.length === 0) return [];
136
+
137
+ const tenant = getTenant();
138
+ if (!tenant) {
139
+ // No active tenant: nothing can be proven to be ours.
140
+ console.warn('[deviceTools] disk-cache recovery skipped — no active tenant');
141
+ return [];
142
+ }
143
+ const mine = selectOwnedCacheEntries(entries, tenant);
144
+ const skipped = entries.length - mine.length;
145
+ if (skipped > 0) {
146
+ console.log(`[deviceTools] disk-cache recovery: ${mine.length} for "${tenant}", skipped ${skipped} not owned by it`);
147
+ }
148
+ if (mine.length === 0) return [];
149
+
150
+ await Promise.all(mine.map(async (e: any) => {
151
+ try {
152
+ await bundleManager.loadTools('cache:' + e.bundleUrl, e.bundleUrl, e.version, e.bundleHash);
153
+ } catch {
154
+ // A cached bundle that no longer loads is not worth failing a call over.
155
+ }
156
+ }));
157
+ return listDeviceTools().map(t => t.name);
158
+ } catch (err: any) {
159
+ console.warn('[deviceTools] disk-cache recovery failed:', err?.message || String(err));
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /** Forget that we loaded, so the next call re-reads the catalog. */
165
+ export function resetDeviceToolLoader(): void {
166
+ loadedOnce = false;
167
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Platform device tools.
3
+ *
4
+ * These are not a solution's plugin tools — they ship with the SDK and every
5
+ * host has them, which is what makes a device tool testable without deploying
6
+ * anything to a tenant. Their names and contracts are declared once on the
7
+ * server (connectors/platform/device-tools-mcp.json); this is the device half.
8
+ *
9
+ * Registered at import, so a client answers them even before any plugin bundle
10
+ * has loaded — that is the point: proving the device path works should not
11
+ * depend on the thing being proved.
12
+ */
13
+ import { registerDeviceTools } from './registry';
14
+
15
+ const PLATFORM_PLUGIN_ID = 'platform:device-tools';
16
+
17
+ export function registerPlatformDeviceTools(): void {
18
+ registerDeviceTools(PLATFORM_PLUGIN_ID, [
19
+ {
20
+ name: 'device.echo',
21
+ description: "Echo a string back from the user's own device.",
22
+ input_schema: {
23
+ type: 'object',
24
+ properties: { text: { type: 'string' } },
25
+ required: ['text'],
26
+ },
27
+ run: (args: any) => ({
28
+ ok: true,
29
+ echoed: (args && args.text) || '',
30
+ // The proof: a server-side implementation could not say this truthfully.
31
+ ran_on: 'device',
32
+ }),
33
+ },
34
+ {
35
+ name: 'device.ping',
36
+ description: 'Check that the device is reachable.',
37
+ input_schema: { type: 'object', properties: {} },
38
+ run: () => ({ ok: true, ran_on: 'device', at: new Date().toISOString() }),
39
+ },
40
+ ]);
41
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Device tool registry.
3
+ *
4
+ * A solution's RN plugin may ship a `tools/` section alongside (or instead of)
5
+ * its UI. Those tools are registered HERE, when the bundle is evaluated — not
6
+ * when a screen mounts — so a caller can invoke one with nothing of the plugin
7
+ * on screen.
8
+ *
9
+ * Lookup is by the tool's own domain name (e.g. "guide.observe"). Callers never
10
+ * route on a name prefix: whether a tool runs locally is declared by the server
11
+ * in the session payload, and resolved against this registry. The full LOCAL
12
+ * reasoning path (discovery → call → result, and its independence from
13
+ * relay/MCP) is documented in docs/architecture/LOCAL_REASONING_PATH.md (R13).
14
+ */
15
+ import { createNativeBridge } from '../bridge/native';
16
+ import { getPluginNative, gatePluginNative } from '../host';
17
+ import { gateDeviceState } from '../deviceStateHost';
18
+ import type { DeviceToolContext, DeviceToolDefinition, NativeCapabilities } from '../sdk/types';
19
+ import type { DeviceBridgeDomain } from '../sdk/device-state';
20
+
21
+ export interface DeviceToolSummary {
22
+ name: string;
23
+ pluginId: string;
24
+ description?: string;
25
+ input_schema?: Record<string, unknown>;
26
+ }
27
+
28
+ export type DeviceToolOutcome =
29
+ | { ok: true; result: any }
30
+ | { ok: false; error: string; reason: 'not_found' | 'no_context' | 'timeout' | 'failed' };
31
+
32
+ interface Entry {
33
+ pluginId: string;
34
+ tool: DeviceToolDefinition;
35
+ capabilities?: NativeCapabilities;
36
+ /** L3 DeviceState domains this plugin declared — gates ctx.deviceState. */
37
+ stateDomains?: DeviceBridgeDomain[];
38
+ }
39
+
40
+ /** toolName → entry. One name, one implementation. */
41
+ const registry = new Map<string, Entry>();
42
+
43
+ /** Optional host override; the SDK supplies a working default (see below). */
44
+ let contextProvider:
45
+ | ((pluginId: string, capabilities?: NativeCapabilities, stateDomains?: DeviceBridgeDomain[]) => DeviceToolContext)
46
+ | null = null;
47
+
48
+ const DEFAULT_TIMEOUT_MS = 30_000;
49
+
50
+ /**
51
+ * Override how a device tool reaches the Device Bridge. Rarely needed — the
52
+ * default below builds the same bridge/native pair a plugin gets when it
53
+ * renders, gated by the capabilities the plugin declared.
54
+ */
55
+ export function setDeviceToolContextProvider(
56
+ fn: (pluginId: string, capabilities?: NativeCapabilities, stateDomains?: DeviceBridgeDomain[]) => DeviceToolContext,
57
+ ): void {
58
+ contextProvider = fn;
59
+ }
60
+
61
+ /**
62
+ * Default context: the plugin's own bridge + a capability-gated Device Bridge +
63
+ * the L3 DeviceState gated to the domains the plugin declared. All three are the
64
+ * SAME gated objects the plugin gets when it renders — a tool reaches nothing a
65
+ * UI plugin couldn't.
66
+ */
67
+ function defaultContext(
68
+ pluginId: string,
69
+ capabilities?: NativeCapabilities,
70
+ stateDomains?: DeviceBridgeDomain[],
71
+ ): DeviceToolContext {
72
+ return {
73
+ bridge: createNativeBridge(pluginId),
74
+ native: gatePluginNative(getPluginNative(), capabilities, pluginId),
75
+ deviceState: gateDeviceState(stateDomains),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Register a plugin's tools. Called from the bundle pipeline at load time.
81
+ *
82
+ * A name already taken by ANOTHER plugin is refused — a bundle loaded later
83
+ * must not be able to take over "guide.observe" from the plugin that owns it.
84
+ * Re-registering the same plugin (bundle reload) replaces its own entries.
85
+ */
86
+ export function registerDeviceTools(
87
+ pluginId: string,
88
+ tools: DeviceToolDefinition[],
89
+ capabilities?: NativeCapabilities,
90
+ stateDomains?: DeviceBridgeDomain[],
91
+ ): string[] {
92
+ const accepted: string[] = [];
93
+
94
+ for (const tool of tools || []) {
95
+ if (!tool || typeof tool.name !== 'string' || typeof tool.run !== 'function') {
96
+ console.warn(`[deviceTools] ${pluginId}: skipping malformed tool entry`);
97
+ continue;
98
+ }
99
+ const existing = registry.get(tool.name);
100
+ if (existing && existing.pluginId !== pluginId) {
101
+ console.warn(
102
+ `[deviceTools] ${pluginId}: "${tool.name}" already registered by ${existing.pluginId} — refusing to override`,
103
+ );
104
+ continue;
105
+ }
106
+ registry.set(tool.name, { pluginId, tool, capabilities, stateDomains });
107
+ accepted.push(tool.name);
108
+ }
109
+
110
+ if (accepted.length > 0) {
111
+ console.log(`[deviceTools] ${pluginId} registered: ${accepted.join(', ')}`);
112
+ }
113
+ return accepted;
114
+ }
115
+
116
+ /** Drop every tool owned by a plugin (bundle eviction / plugin revoked). */
117
+ export function unregisterDeviceTools(pluginId: string): void {
118
+ for (const [name, entry] of registry.entries()) {
119
+ if (entry.pluginId === pluginId) registry.delete(name);
120
+ }
121
+ }
122
+
123
+ export function hasDeviceTool(name: string): boolean {
124
+ return registry.has(name);
125
+ }
126
+
127
+ export function listDeviceTools(): DeviceToolSummary[] {
128
+ return Array.from(registry.entries()).map(([name, { pluginId, tool }]) => ({
129
+ name,
130
+ pluginId,
131
+ description: tool.description,
132
+ input_schema: tool.input_schema,
133
+ }));
134
+ }
135
+
136
+ /**
137
+ * Invoke a device tool. Never throws — every failure comes back as a typed
138
+ * outcome, because the caller is usually a reasoning loop that has to say
139
+ * something useful out loud rather than swallow an exception.
140
+ */
141
+ export async function callDeviceTool(
142
+ name: string,
143
+ args: any = {},
144
+ opts: { timeoutMs?: number; signal?: AbortSignal } = {},
145
+ ): Promise<DeviceToolOutcome> {
146
+ const entry = registry.get(name);
147
+ if (!entry) {
148
+ return { ok: false, reason: 'not_found', error: `No device tool registered as "${name}"` };
149
+ }
150
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
151
+ let timer: ReturnType<typeof setTimeout> | undefined;
152
+
153
+ try {
154
+ const build = contextProvider || defaultContext;
155
+ const ctx: DeviceToolContext = {
156
+ ...build(entry.pluginId, entry.capabilities, entry.stateDomains),
157
+ signal: opts.signal,
158
+ };
159
+
160
+ const result = await Promise.race([
161
+ Promise.resolve(entry.tool.run(args, ctx)),
162
+ new Promise<never>((_, reject) => {
163
+ timer = setTimeout(
164
+ () => reject(new Error(`Device tool "${name}" timed out after ${timeoutMs}ms`)),
165
+ timeoutMs,
166
+ );
167
+ }),
168
+ ]);
169
+
170
+ return { ok: true, result };
171
+ } catch (err: any) {
172
+ const message = err?.message || String(err);
173
+ const timedOut = message.includes('timed out after');
174
+ return { ok: false, reason: timedOut ? 'timeout' : 'failed', error: message };
175
+ } finally {
176
+ if (timer) clearTimeout(timer);
177
+ }
178
+ }
179
+
180
+ /** Test/host reset. */
181
+ export function clearDeviceTools(): void {
182
+ registry.clear();
183
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Device tools called BY THE SERVER.
3
+ *
4
+ * Two callers reach the same registered function on this phone:
5
+ *
6
+ * - the voice loop, which already runs here (no server hop at all), and
7
+ * - the server reasoning loop, which cannot execute a device tool itself and
8
+ * sends it down the client's stream instead.
9
+ *
10
+ * This is the second one. It rides the transport the `ui.*` plugin commands
11
+ * already use — a command arrives on the job/actor stream, we run it, and post
12
+ * the outcome back keyed by `correlationId`. Nothing new was invented; the
13
+ * payload just carries a tool call instead of a UI command.
14
+ *
15
+ * The host wires this once at startup:
16
+ *
17
+ * pluginCommandEmitter.on('plugin-command', handleServerDeviceToolCall);
18
+ */
19
+ import { callDeviceTool } from './registry';
20
+ import { ensureDeviceToolsLoaded } from './loader';
21
+
22
+ type PostResult = (correlationId: string, result: any, error?: string) => Promise<void>;
23
+
24
+ let postResult: PostResult | null = null;
25
+
26
+ /**
27
+ * Host DI: how to answer the server. `@ateam-ai-mobile/core` supplies
28
+ * postCommandResult; kept injectable so this package stays free of the REST
29
+ * client and so tests can observe the answer.
30
+ */
31
+ export function setDeviceToolResultPoster(fn: PostResult): void {
32
+ postResult = fn;
33
+ }
34
+
35
+ /** Is this stream command a device tool call? */
36
+ export function isDeviceToolCommand(cmd: any): boolean {
37
+ return !!cmd && cmd.kind === 'device_tool' && typeof cmd.tool === 'string' && !!cmd.correlationId;
38
+ }
39
+
40
+ /**
41
+ * Run a server-dispatched device tool and answer.
42
+ *
43
+ * Never throws — the server is blocked on the correlationId, so the one thing
44
+ * we must not do is stay silent. Every path posts something back.
45
+ */
46
+ export async function handleServerDeviceToolCall(cmd: any): Promise<void> {
47
+ if (!isDeviceToolCommand(cmd)) return;
48
+
49
+ const { tool, args, correlationId } = cmd;
50
+
51
+ if (!postResult) {
52
+ console.warn('[deviceTools] server called a tool but no result poster is installed');
53
+ return;
54
+ }
55
+
56
+ try {
57
+ // The server may reach us before anything opened a voice call, so the
58
+ // plugins may not be loaded yet. Cheap and idempotent once warm.
59
+ await ensureDeviceToolsLoaded();
60
+
61
+ const outcome = await callDeviceTool(tool, args || {});
62
+ console.log(`[deviceTools] server-called ${tool} → ${outcome.ok ? 'ok' : outcome.reason}`);
63
+
64
+ await postResult(
65
+ correlationId,
66
+ outcome.ok ? { ok: true, ...unwrap(outcome.result) } : undefined,
67
+ outcome.ok ? undefined : outcome.error,
68
+ );
69
+ } catch (err: any) {
70
+ await postResult(correlationId, undefined, err?.message || String(err)).catch(() => {});
71
+ }
72
+ }
73
+
74
+ /** Keep an object result flat for the reasoning loop; wrap anything else. */
75
+ function unwrap(result: any): Record<string, any> {
76
+ if (result && typeof result === 'object' && !Array.isArray(result)) return result;
77
+ return { result };
78
+ }