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

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,186 @@
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
+ /** The most recent LOCAL device-tool call the model made through the session
56
+ * (name + epoch ms), or null if none yet. Diagnostic: proves whether the
57
+ * model's call_device_tool actually reached the phone vs was never made. */
58
+ lastDeviceTool: { name: string; at: number } | null;
59
+ /** The sessionId the Core allowset is bound to. Diagnostic: authorization
60
+ * requires it to equal `sessionId`; a mismatch is why tools refuse. */
61
+ authorizedSessionId: string | null;
62
+ }
63
+
64
+ export interface VoiceStartOptions {
65
+ /** Hand the mic to a specific skill on connect (voice /session → takeMic),
66
+ * exactly as `sys.focusUiPlugin("platform:voice", { skillSlug })` does. */
67
+ skillSlug?: string;
68
+ /**
69
+ * A PER-CALL persona override, same shape as the authored
70
+ * `solution.voice.persona` ({ name, style }). Declared on the voice
71
+ * session-create call (POST /session `persona`); the server sanitizes it
72
+ * (name/style only, length-capped) and layers it OVER the solution's authored
73
+ * persona for THIS call only — prompt-layer, so it can never touch caller
74
+ * verification or tool allow-lists. Set once at session start and constant
75
+ * for the call, so it adds no per-turn prompt pushes.
76
+ *
77
+ * Use it when a plugin needs its own voice persona without owning a skill
78
+ * (e.g. a guide HUD). A solution-wide persona belongs in solution.voice.
79
+ */
80
+ persona?: VoicePersona;
81
+ /**
82
+ * `'none'` = a CLEAN call: the voice gets none of the workspace's skills — no
83
+ * skill manifest, no skill tools, no platform welcome line, no mid-call
84
+ * skill-state prompt refresh — only the voice basics, this call's persona and
85
+ * the CALLING plugin's own device tools (other solutions' tools registered on
86
+ * the same phone are neither offered nor run). For a plugin that owns its conversation (e.g.
87
+ * a guide HUD). Reduce-only: it can remove what the voice gets, never add.
88
+ * Absent = the workspace's skills, as today. Not combinable with `skillSlug`.
89
+ */
90
+ skills?: 'none';
91
+ }
92
+
93
+ /** The authored persona shape (solution.voice.persona). */
94
+ export interface VoicePersona {
95
+ /** The name the voice uses for itself ("You are <name> …"). */
96
+ name?: string;
97
+ /** Free-text style/behaviour guidance rendered under the identity line. */
98
+ style?: string;
99
+ }
100
+
101
+ /**
102
+ * What changed. Coarse on purpose: `state` covers connect/mute/session/partials,
103
+ * `transcript` fires when a finalized line was appended. A subscriber re-reads
104
+ * `getSnapshot()` / `transcript()` — the event is a nudge, not a payload, so the
105
+ * surface never has to keep two shapes of the same fact in sync.
106
+ */
107
+ export type VoiceEvent = { type: 'state' } | { type: 'transcript' };
108
+
109
+ export interface VoiceSubscription {
110
+ unsubscribe(): void;
111
+ }
112
+
113
+ /**
114
+ * WHO is asking. The one physical session has an OWNER; access is scoped to it.
115
+ *
116
+ * A plugin's caller is its `pluginId` (a string), stamped by gateVoice so the
117
+ * plugin never supplies its own identity. The PLATFORM caller — the built-in
118
+ * voice screen, the user's own surface — is a Symbol, deliberately: a Symbol can
119
+ * never equal any pluginId string, so a plugin cannot forge platform access by
120
+ * naming itself `platform:voice`.
121
+ */
122
+ export type VoiceCaller = string | symbol;
123
+
124
+ /** The privileged caller — the platform's own surfaces (the built-in sheet, the
125
+ * mic indicator, the session host). Sees and controls any live session, because
126
+ * it is the USER's direct surface onto their own conversation. Unforgeable. */
127
+ export const PLATFORM_VOICE_CALLER: unique symbol = Symbol('adas.voice.platform');
128
+
129
+ /**
130
+ * The OWNER-AWARE session the HOST implements and hands to setVoiceProvider.
131
+ *
132
+ * This is NOT what a plugin receives — a plugin gets a `VoiceAPI` facade
133
+ * (gateVoice) that stamps its `pluginId` as the caller on every call, so the
134
+ * provider can enforce that a plugin only ever reads, controls, or attaches to a
135
+ * session IT owns. One physical session, isolated per authorized identity.
136
+ */
137
+ export interface VoiceSessionProvider {
138
+ start(opts: VoiceStartOptions | undefined, caller: VoiceCaller): Promise<VoiceSnapshot>;
139
+ stop(caller: VoiceCaller): void;
140
+ setMuted(muted: boolean, caller: VoiceCaller): void;
141
+ prompt(input: string, caller: VoiceCaller): void;
142
+ observe(text: string, caller: VoiceCaller): void;
143
+ getSnapshot(caller: VoiceCaller): VoiceSnapshot;
144
+ subscribe(cb: (event: VoiceEvent) => void, caller: VoiceCaller): VoiceSubscription;
145
+ transcript(caller: VoiceCaller): VoiceTranscriptEntry[];
146
+ }
147
+
148
+ /**
149
+ * The gated surface a plugin receives as `props.voice`. Present on EVERY plugin;
150
+ * a plugin that did not declare `capabilities: { voice: true }` gets a version
151
+ * whose reads say so and whose actions throw — never a silent no-op, never a
152
+ * fake `idle` (see gateVoice in voiceHost.ts).
153
+ */
154
+ export interface VoiceAPI {
155
+ /** Open the live loop. Resolves once connected (or rejects on connect error).
156
+ * If a session is ALREADY live, ATTACHES to it and resolves immediately —
157
+ * never a second Realtime loop (one phone, one mic, one bill). */
158
+ start(opts?: VoiceStartOptions): Promise<VoiceSnapshot>;
159
+ /** End the live loop. Safe to call when idle. */
160
+ stop(): void;
161
+ /** Mute/unmute the microphone. The session stays live; the mic indicator
162
+ * reflects it. */
163
+ setMuted(muted: boolean): void;
164
+ /**
165
+ * Make the agent take a turn WITHOUT the user speaking — the guide speaking
166
+ * first. `input` is the instruction for that turn. This is an agent-initiated
167
+ * turn into the SAME session, NOT fabricated user speech: one session, two
168
+ * trigger sources. Throws if the session is not connected.
169
+ */
170
+ prompt(input: string): void;
171
+ /**
172
+ * Stream a silent OBSERVATION into the session — tell the model something
173
+ * without asking it to speak (a `system` conversation item). Cheap and
174
+ * high-frequency (safe once or twice a second): this is the channel for what
175
+ * the camera sees while the user walks. The model answers with the
176
+ * observations in hand the next time it takes a turn (user VAD, or prompt()) —
177
+ * an observation never triggers a turn by itself. Throws if not connected.
178
+ */
179
+ observe(text: string): void;
180
+ /** Point-in-time session state. */
181
+ getSnapshot(): VoiceSnapshot;
182
+ /** Subscribe to state + transcript changes. Returns an unsubscribe handle. */
183
+ subscribe(cb: (event: VoiceEvent) => void): VoiceSubscription;
184
+ /** The conversation so far. */
185
+ transcript(): VoiceTranscriptEntry[];
186
+ }
@@ -0,0 +1,185 @@
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 { resolvePluginGrants } from '../pluginRuntime';
18
+ import { getTenant } from '@ateam-ai-mobile/core';
19
+
20
+ let inflight: Promise<string[]> | null = null;
21
+ let loadedOnce = false;
22
+
23
+ /**
24
+ * Load every plugin that declares `capabilities.device_tools`, so its tools are
25
+ * registered and callable. Best-effort and idempotent: safe to call on every
26
+ * call start. Returns the tool names currently registered.
27
+ *
28
+ * Pass `force` after a plugin catalog change (a newly deployed connector).
29
+ */
30
+ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<string[]> {
31
+ if (loadedOnce && !force) return listDeviceTools().map(t => t.name);
32
+ if (inflight) return inflight;
33
+
34
+ inflight = (async () => {
35
+ try {
36
+ const summaries = await listPlugins();
37
+
38
+ // The list response is slim, so the manifest is the authority on whether a
39
+ // plugin declares device tools. Fetched in parallel — a cold connector
40
+ // that answers slowly must not hold up a call.
41
+ const manifests = await Promise.all(
42
+ (summaries || []).map(s =>
43
+ getPlugin((s as any).id).catch(() => null),
44
+ ),
45
+ );
46
+
47
+ const targets = manifests.filter((m: any) => {
48
+ const declared = m?.capabilities?.device_tools;
49
+ return Array.isArray(declared) && declared.length > 0;
50
+ });
51
+
52
+ if (targets.length === 0) {
53
+ // The catalog can go blind: a solution connector is lazy, and while it
54
+ // is not running its plugins are absent from the list entirely. That is
55
+ // a fact about a server process, not about this phone — the bundle we
56
+ // already downloaded still has the tools in it.
57
+ //
58
+ // So fall back to what is on disk: re-evaluate this tenant's cached
59
+ // bundles under the trusted declaration recorded when a manifest last
60
+ // loaded them (see loadFromDiskCache).
61
+ const recovered = await loadFromDiskCache();
62
+ if (recovered.length > 0) {
63
+ console.log(`[deviceTools] catalog listed none — recovered from cache: ${recovered.join(', ')}`);
64
+ return recovered;
65
+ }
66
+ // Do NOT latch: "nothing yet" is usually just "not yet".
67
+ console.log('[deviceTools] no device tools available yet — will look again next call');
68
+ return [];
69
+ }
70
+
71
+ await Promise.all(
72
+ targets.map(async (m: any) => {
73
+ const bundleUrl = getNativeBundleUrl(m);
74
+ if (!bundleUrl) {
75
+ console.warn(`[deviceTools] ${m.id} declares device_tools but has no native bundle`);
76
+ return;
77
+ }
78
+ try {
79
+ // The manifest is the authority on what the tools may touch — the
80
+ // same grants its rendered surface gets, never the bundle's claim.
81
+ await bundleManager.loadTools(
82
+ m.id,
83
+ bundleUrl,
84
+ m.version || '1.0.0',
85
+ getNativeBundleHash(m) || undefined,
86
+ resolvePluginGrants({ pluginId: m.id, manifest: m }),
87
+ );
88
+ } catch (err: any) {
89
+ console.warn(`[deviceTools] ${m.id} failed to load:`, err?.message || String(err));
90
+ }
91
+ }),
92
+ );
93
+
94
+ const names = listDeviceTools().map(t => t.name);
95
+ // Only latch on success. An empty result is a cold read, not a fact —
96
+ // same reasoning as the server-side discovery cache.
97
+ loadedOnce = names.length > 0;
98
+ console.log(`[deviceTools] ready: ${names.length > 0 ? names.join(', ') : '(none registered — will retry next call)'}`);
99
+ return names;
100
+ } finally {
101
+ inflight = null;
102
+ }
103
+ })();
104
+
105
+ return inflight;
106
+ }
107
+
108
+ /**
109
+ * Re-evaluate the CURRENT TENANT'S bundles already cached on disk.
110
+ *
111
+ * Used when the plugin catalog comes back empty — which happens whenever the
112
+ * owning connector is asleep. This is a local read, not a download.
113
+ *
114
+ * TENANT SCOPING IS LOAD-BEARING, NOT DEFENCE IN DEPTH.
115
+ *
116
+ * Every other load path is gated by the manifest hash: the expected hash comes
117
+ * from the current tenant's server, so another tenant's cached bytes simply
118
+ * fail to match and are re-downloaded. This path has no manifest — that is the
119
+ * whole reason it exists — so it passes each entry's OWN stored hash as the
120
+ * expected hash. That check compares an entry to itself and can never fail. It
121
+ * is an integrity check against disk corruption, and it is not, and cannot be,
122
+ * an authorisation check.
123
+ *
124
+ * Without the tenant filter below this loop re-evaluated EVERY cached bundle,
125
+ * so a plugin cached during a session on tenant A registered its device tools
126
+ * into a live session on tenant B, and subsequent tool calls ran tenant-A code
127
+ * holding tenant-B credentials. No id collision was required: bundle urls are
128
+ * identical across tenants and resolve per-tenant on the server, and the code
129
+ * cache deliberately survives logout.
130
+ *
131
+ * An entry with no recorded tenant predates the stamp. Its owner is unknown,
132
+ * which means untrusted — skip it and let the normal catalog path re-download
133
+ * it under a real hash gate.
134
+ *
135
+ * GRANTS COME FROM THE RECORDED MANIFEST, NEVER THE BUNDLE. With no manifest
136
+ * here, the tools register under the `trusted` declaration (pluginId +
137
+ * capabilities + stateDomains) the bundle manager recorded on the entry when a
138
+ * manifest last loaded these bytes. An entry without one — written before the
139
+ * record existed, or never loaded under a manifest — is skipped (fail closed):
140
+ * the only other source is the bundle's own claim, and the catalog path
141
+ * registers it properly once its connector answers.
142
+ */
143
+ async function loadFromDiskCache(): Promise<string[]> {
144
+ try {
145
+ const index = await getBundleStorage().loadIndex();
146
+ const entries = Object.values(index || {});
147
+ if (entries.length === 0) return [];
148
+
149
+ const tenant = getTenant();
150
+ if (!tenant) {
151
+ // No active tenant: nothing can be proven to be ours.
152
+ console.warn('[deviceTools] disk-cache recovery skipped — no active tenant');
153
+ return [];
154
+ }
155
+ const mine = selectOwnedCacheEntries(entries, tenant);
156
+ const skipped = entries.length - mine.length;
157
+ if (skipped > 0) {
158
+ console.log(`[deviceTools] disk-cache recovery: ${mine.length} for "${tenant}", skipped ${skipped} not owned by it`);
159
+ }
160
+ const declared = mine.filter((e) => !!e.trusted?.pluginId);
161
+ if (declared.length < mine.length) {
162
+ console.log(
163
+ `[deviceTools] disk-cache recovery: skipped ${mine.length - declared.length} with no recorded manifest grant (fail closed)`,
164
+ );
165
+ }
166
+ if (declared.length === 0) return [];
167
+
168
+ await Promise.all(declared.map(async (e) => {
169
+ try {
170
+ await bundleManager.loadTools(e.trusted!.pluginId, e.bundleUrl, e.version, e.bundleHash, e.trusted!);
171
+ } catch {
172
+ // A cached bundle that no longer loads is not worth failing a call over.
173
+ }
174
+ }));
175
+ return listDeviceTools().map(t => t.name);
176
+ } catch (err: any) {
177
+ console.warn('[deviceTools] disk-cache recovery failed:', err?.message || String(err));
178
+ return [];
179
+ }
180
+ }
181
+
182
+ /** Forget that we loaded, so the next call re-reads the catalog. */
183
+ export function resetDeviceToolLoader(): void {
184
+ loadedOnce = false;
185
+ }
@@ -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,190 @@
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, withoutSubscribe } 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. (One thing less: `callDeviceTool` takes `subscribe` away,
66
+ * because a call has no lifetime to own one.)
67
+ */
68
+ function defaultContext(
69
+ pluginId: string,
70
+ capabilities?: NativeCapabilities,
71
+ stateDomains?: DeviceBridgeDomain[],
72
+ ): DeviceToolContext {
73
+ return {
74
+ bridge: createNativeBridge(pluginId),
75
+ native: gatePluginNative(getPluginNative(), capabilities, pluginId),
76
+ deviceState: gateDeviceState(stateDomains),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Register a plugin's tools. Called from the bundle pipeline at load time.
82
+ *
83
+ * A name already taken by ANOTHER plugin is refused — a bundle loaded later
84
+ * must not be able to take over "guide.observe" from the plugin that owns it.
85
+ * Re-registering the same plugin (bundle reload) replaces its own entries.
86
+ */
87
+ export function registerDeviceTools(
88
+ pluginId: string,
89
+ tools: DeviceToolDefinition[],
90
+ capabilities?: NativeCapabilities,
91
+ stateDomains?: DeviceBridgeDomain[],
92
+ ): string[] {
93
+ const accepted: string[] = [];
94
+
95
+ for (const tool of tools || []) {
96
+ if (!tool || typeof tool.name !== 'string' || typeof tool.run !== 'function') {
97
+ console.warn(`[deviceTools] ${pluginId}: skipping malformed tool entry`);
98
+ continue;
99
+ }
100
+ const existing = registry.get(tool.name);
101
+ if (existing && existing.pluginId !== pluginId) {
102
+ console.warn(
103
+ `[deviceTools] ${pluginId}: "${tool.name}" already registered by ${existing.pluginId} — refusing to override`,
104
+ );
105
+ continue;
106
+ }
107
+ registry.set(tool.name, { pluginId, tool, capabilities, stateDomains });
108
+ accepted.push(tool.name);
109
+ }
110
+
111
+ if (accepted.length > 0) {
112
+ console.log(`[deviceTools] ${pluginId} registered: ${accepted.join(', ')}`);
113
+ }
114
+ return accepted;
115
+ }
116
+
117
+ /** Drop every tool owned by a plugin (bundle eviction / plugin revoked). */
118
+ export function unregisterDeviceTools(pluginId: string): void {
119
+ for (const [name, entry] of registry.entries()) {
120
+ if (entry.pluginId === pluginId) registry.delete(name);
121
+ }
122
+ }
123
+
124
+ /** Is `name` registered — and, given `pluginId`, registered BY that plugin?
125
+ * (A clean voice call only lets its owner's own tools run.) */
126
+ export function hasDeviceTool(name: string, pluginId?: string): boolean {
127
+ const entry = registry.get(name);
128
+ return !!entry && (pluginId === undefined || entry.pluginId === pluginId);
129
+ }
130
+
131
+ export function listDeviceTools(): DeviceToolSummary[] {
132
+ return Array.from(registry.entries()).map(([name, { pluginId, tool }]) => ({
133
+ name,
134
+ pluginId,
135
+ description: tool.description,
136
+ input_schema: tool.input_schema,
137
+ }));
138
+ }
139
+
140
+ /**
141
+ * Invoke a device tool. Never throws — every failure comes back as a typed
142
+ * outcome, because the caller is usually a reasoning loop that has to say
143
+ * something useful out loud rather than swallow an exception.
144
+ */
145
+ export async function callDeviceTool(
146
+ name: string,
147
+ args: any = {},
148
+ opts: { timeoutMs?: number; signal?: AbortSignal } = {},
149
+ ): Promise<DeviceToolOutcome> {
150
+ const entry = registry.get(name);
151
+ if (!entry) {
152
+ return { ok: false, reason: 'not_found', error: `No device tool registered as "${name}"` };
153
+ }
154
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
155
+ let timer: ReturnType<typeof setTimeout> | undefined;
156
+
157
+ try {
158
+ const build = contextProvider || defaultContext;
159
+ const built = build(entry.pluginId, entry.capabilities, entry.stateDomains);
160
+ const ctx: DeviceToolContext = {
161
+ ...built,
162
+ // Whoever built the context, a call cannot own a subscription.
163
+ deviceState: withoutSubscribe(built.deviceState),
164
+ signal: opts.signal,
165
+ };
166
+
167
+ const result = await Promise.race([
168
+ Promise.resolve(entry.tool.run(args, ctx)),
169
+ new Promise<never>((_, reject) => {
170
+ timer = setTimeout(
171
+ () => reject(new Error(`Device tool "${name}" timed out after ${timeoutMs}ms`)),
172
+ timeoutMs,
173
+ );
174
+ }),
175
+ ]);
176
+
177
+ return { ok: true, result };
178
+ } catch (err: any) {
179
+ const message = err?.message || String(err);
180
+ const timedOut = message.includes('timed out after');
181
+ return { ok: false, reason: timedOut ? 'timeout' : 'failed', error: message };
182
+ } finally {
183
+ if (timer) clearTimeout(timer);
184
+ }
185
+ }
186
+
187
+ /** Test/host reset. */
188
+ export function clearDeviceTools(): void {
189
+ registry.clear();
190
+ }
@@ -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
+ }