@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.10

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.
Files changed (33) hide show
  1. package/Package.swift +19 -2
  2. package/README.md +190 -13
  3. package/android/build.gradle.kts +12 -6
  4. package/android/settings.gradle +7 -0
  5. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +154 -0
  6. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +203 -1
  7. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
  8. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerCapabilitiesTest.kt +253 -0
  9. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
  10. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerTest.kt +79 -0
  11. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
  12. package/dist/definitions.d.ts +175 -1
  13. package/dist/definitions.d.ts.map +1 -1
  14. package/dist/errors.d.ts +19 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +36 -0
  17. package/dist/index.d.ts +17 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +44 -13
  20. package/dist/streaming.d.ts +17 -0
  21. package/dist/streaming.d.ts.map +1 -0
  22. package/dist/streaming.js +303 -0
  23. package/dist/web.d.ts +18 -2
  24. package/dist/web.d.ts.map +1 -1
  25. package/dist/web.js +136 -7
  26. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +181 -0
  27. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +93 -3
  28. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
  29. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +234 -0
  30. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
  31. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
  32. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
  33. package/package.json +21 -9
package/Package.swift CHANGED
@@ -9,18 +9,35 @@ import PackageDescription
9
9
  let package = Package(
10
10
  name: "IndeRunCapacitor",
11
11
  platforms: [
12
- .iOS(.v15),
12
+ .iOS(.v16),
13
13
  .macOS(.v14)
14
14
  ],
15
15
  products: [
16
16
  .library(
17
17
  name: "IndeRunCapacitor",
18
18
  targets: ["IndeRunCapacitorPlugin"]
19
+ ),
20
+ // The Capacitor CLI derives a product name from the npm package name
21
+ // (`@independo/capacitor-inderun` -> `IndependoCapacitorInderun`) and writes exactly
22
+ // that into the app's generated CapApp-SPM manifest. Without a product under this
23
+ // name, `cap add ios` produces a project that cannot resolve its dependencies at all.
24
+ // Kept alongside the original name rather than replacing it, so anything already
25
+ // depending on `IndeRunCapacitor` by URL keeps resolving.
26
+ .library(
27
+ name: "IndependoCapacitorInderun",
28
+ targets: ["IndeRunCapacitorPlugin"]
19
29
  )
20
30
  ],
21
31
  dependencies: [
22
32
  .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
23
- .package(url: "https://github.com/independo-gmbh/inderun.git", from: "0.2.2")
33
+ // Ranged rather than `exact:` so a consumer that also depends on `inderun`
34
+ // directly can unify the graph. `.upToNextMinor` rather than `from:` because
35
+ // `from:` on a 0.x means `<1.0.0` — it would let SwiftPM float across a minor
36
+ // (where an 0.x SDK's breaking changes live) while package.json and
37
+ // android/build.gradle.kts stay pinned, which is exactly the three-platform
38
+ // drift the versioning policy forbids. Both operators exclude prereleases:
39
+ // tracking a `-dev.N` again requires going back to `exact:`.
40
+ .package(url: "https://github.com/independo-gmbh/inderun.git", .upToNextMinor(from: "0.3.0"))
24
41
  ],
25
42
  targets: [
26
43
  .target(
package/README.md CHANGED
@@ -14,10 +14,15 @@
14
14
 
15
15
  <p align="center">Built and maintained by <a href="https://www.independo.app/">Independo</a>.</p>
16
16
 
17
- Thin Capacitor bridge for IndeRun Mode 1 `run()` execution.
17
+ **On-device AI with cloud fallback, for Capacitor apps.**
18
18
 
19
- The package delegates to the released [IndeRun](https://github.com/independo-gmbh/inderun)
20
- platform SDKs instead of re-implementing routing, provider logic, or normalized error handling:
19
+ Runs a prompt on the device when the device can — Apple Foundation Models on iOS, ML Kit GenAI
20
+ (Gemini Nano) on Android — and falls back to an OpenAI-compatible cloud endpoint when it cannot.
21
+ One API across iOS, Android and the web, with streaming and cancellation. MIT.
22
+
23
+ It is a thin bridge, not a second engine. The package delegates to the released
24
+ [IndeRun](https://github.com/independo-gmbh/inderun) platform SDKs instead of re-implementing
25
+ routing, provider logic, or normalized error handling:
21
26
 
22
27
  - web: [`@independo/inderun-web`](https://www.npmjs.com/package/@independo/inderun-web) (npm)
23
28
  - iOS: `IndeRun` Swift package (`github.com/independo-gmbh/inderun`, SwiftPM)
@@ -38,10 +43,72 @@ Then sync native projects with your normal Capacitor workflow.
38
43
 
39
44
  | Platform | Minimum version |
40
45
  |----------|------------------------|
41
- | iOS | 15.0 |
46
+ | iOS | 16.0 |
42
47
  | Android | API 26 (Android 8.0) |
43
48
  | Web | Any modern browser |
44
49
 
50
+ The iOS floor comes from the IndeRun Swift package this bridge depends on, not from the bridge
51
+ itself. On-device execution needs more than the floor: Apple Foundation Models requires an Apple
52
+ Intelligence–capable device on iOS 26+, and ML Kit GenAI requires AICore / Gemini Nano support.
53
+ Availability is checked at runtime and the cloud provider serves the request when it is missing.
54
+
55
+ ## Host Project Requirements
56
+
57
+ The IndeRun SDKs this bridge wraps are newer than a freshly generated Capacitor app's
58
+ defaults, so `cap add` alone is not enough. Each of these is a hard requirement — the
59
+ corresponding build failure is named so it is searchable:
60
+
61
+ **Android** (`android/variables.gradle` and `android/build.gradle` in your app):
62
+
63
+ | Setting | Value | Failure if unset |
64
+ |---|---|---|
65
+ | `compileSdkVersion` | `37` | *"requires libraries and applications that depend on it to compile against version 37 or later"* |
66
+ | `minSdkVersion` | `26` | manifest merger conflict |
67
+ | Android Gradle Plugin | `9.1.0`+ (Gradle 9.x) | *"requires Android Gradle plugin 9.1.0 or higher"* |
68
+ | `org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10` on the app's `buildscript` classpath | — | *"Module was compiled with an incompatible version of Kotlin … metadata is 2.4.0, expected version is 2.2.0"* |
69
+
70
+ The Kotlin one is the surprising entry: the `inderun-*` artifacts carry Kotlin 2.4.x
71
+ metadata, which the Kotlin plugin AGP brings by default cannot read. The plugin's own build
72
+ hoists a newer KGP for its standalone build, but a consuming app resolves KGP from its own
73
+ buildscript classpath, so the app has to add it too:
74
+
75
+ ```groovy
76
+ // android/build.gradle
77
+ buildscript {
78
+ dependencies {
79
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10'
80
+ }
81
+ }
82
+ ```
83
+
84
+ AGP 9 also rejects `getDefaultProguardFile('proguard-android.txt')`; switch to
85
+ `proguard-android-optimize.txt`.
86
+
87
+ **iOS**: set `IPHONEOS_DEPLOYMENT_TARGET` to `16.0` in your Xcode project **before**
88
+ `npx cap sync ios`. The Capacitor CLI reads that value to generate `CapApp-SPM/Package.swift`,
89
+ so syncing with the default leaves a manifest pinned below this package's floor and the build
90
+ fails with *"requires minimum platform version 16.0 for the iOS platform, but this target
91
+ supports 15.0"*.
92
+
93
+ ## Supported IndeRun Version
94
+
95
+ This release tracks **IndeRun 0.3.0** on all three platforms:
96
+
97
+ | Platform | Artifact | Constraint |
98
+ |----------|----------|------------|
99
+ | Web | `@independo/inderun-web`, `@independo/inderun-contracts` | `0.3.0` (exact) |
100
+ | iOS | `inderun` SwiftPM package | `>=0.3.0 <0.4.0` |
101
+ | Android | `app.independo.inderun:inderun-*` | `0.3.0` (exact) |
102
+
103
+ The npm and Gradle pins are exact and all three are bumped together — a partial bump is how the
104
+ platforms drift apart. The SwiftPM constraint is ranged rather than exact so an app that also depends
105
+ on `inderun` directly can still unify its package graph; it stops at the next minor because that is
106
+ where an 0.x SDK's breaking changes live.
107
+
108
+ This package versions **independently** of the IndeRun monorepo under plain semver — the numbers are
109
+ unrelated, and this bridge's own version says nothing about which IndeRun release it wraps. Read that
110
+ off the table above.
111
+
45
112
  ## Usage
46
113
 
47
114
  ```ts
@@ -63,31 +130,141 @@ const result = await inderun.run({
63
130
  });
64
131
  ```
65
132
 
133
+ ## Streaming (Mode 2)
134
+
135
+ `stream()` returns the same `StreamRun` shape the platform SDKs return directly: the
136
+ run's handle, its canonical event sequence, and a cancel hook.
137
+
138
+ ```ts
139
+ const run = await inderun.stream({
140
+ schemaVersion: "1.0",
141
+ task: { kind: "text_to_text" },
142
+ prompt: "Explain routing in two sentences."
143
+ });
144
+
145
+ console.log(run.handle.runId); // available before the first event
146
+
147
+ let text = "";
148
+ for await (const event of run.events) {
149
+ switch (event.type) {
150
+ case "content_delta":
151
+ text += event.payload.text; // append
152
+ break;
153
+ case "content_snapshot":
154
+ text = event.payload.text ?? ""; // replace — an empty one retracts
155
+ break;
156
+ case "terminal":
157
+ // exactly one of "completed" | "error" | "cancelled"
158
+ console.log(event.payload.outcome);
159
+ break;
160
+ }
161
+ }
162
+
163
+ run.cancel("user navigated away"); // idempotent; a no-op after the terminal
164
+ ```
165
+
166
+ Event semantics, ordering, the terminal guarantees, cancellation and fallback are the engines'
167
+ contract, identical on every platform, and documented once in
168
+ [Streaming (Mode 2)](https://github.com/independo-gmbh/inderun/blob/dev/docs/streaming.md). Two
169
+ things are specific to reaching them through a bridge:
170
+
171
+ - **The bridge hop is not order-preserving.** That is why `sequence` rather than arrival is the
172
+ contract's ordering authority. `events` already yields strictly by it; if you attach your own
173
+ listener to `STREAM_EVENT_NAME` instead, ordering is yours to enforce.
174
+ - **A failed run is not a rejected promise**, and a bridge transport fault is a third thing again.
175
+ See [Error Handling](#error-handling).
176
+
66
177
  ## API
67
178
 
68
- - `createIndeRunCapacitor(options)` — returns a handle that lazily `configure()`s on the first `run()` and memoizes it. Safe to call once at app startup.
69
- - The low-level plugin methods `configure(options)` and `run(request)` are also exported.
179
+ - `createIndeRunCapacitor(options)` — returns a handle that lazily `configure()`s on the first `run()` or `stream()` and memoizes it. Safe to call once at app startup.
180
+ - `run(request)` — Mode 1. Resolves with the canonical IndeRun `TaskResult`.
181
+ - `stream(request)` — Mode 2. Resolves with a `StreamRun` (`handle`, `events`, `cancel`). `events` is single-use.
182
+ - `checkCapabilities()` — every registered provider's static declaration and live availability, without executing a task. Resolves with `ProviderCapabilitySnapshot[]`. Use it for a provider or settings screen; availability changes between calls, so do not cache it across a `run()` or `stream()`.
183
+ - The low-level plugin methods `configure(options)`, `startStream(options)` and `cancelStream(options)` are also exported, along with the listener event names `STREAM_EVENT_NAME` (`"indeRunStreamEvent"`) and `STREAM_ERROR_NAME` (`"indeRunStreamError"`).
184
+
185
+ > The two listener event names are **public contract**. Native emits exactly these, and an
186
+ > app may attach its own listener to them; renaming one is a breaking change.
187
+
188
+ The `IndeRunCapacitorPlugin` and `ConfigureOptions` contracts — including the `openAI`,
189
+ `systemModel` and `onnx` bootstrap configs and the `allowDirectOpenAIEndpoint` flag — are
190
+ defined and documented in `src/definitions.ts`.
191
+
192
+ `systemModel`, `onnx` and `allowDirectOpenAIEndpoint` are **web-only** and ignored on iOS
193
+ and Android, which register their own on-device provider from `configure()` regardless.
194
+ `systemModel` registers the browser-managed on-device provider (Chrome's Prompt API) and is
195
+ what makes `constraints.privacy = "local_required"` routable in a browser. Both on-device
196
+ web providers are Mode 1 only.
197
+
198
+ `onnx` carries two caveats. It needs the consumer to install the optional
199
+ `@huggingface/transformers` peer dependency — this bridge does not declare it — and to
200
+ supply real model weights, because the web SDK's `runtime` injection seam is a function and
201
+ so cannot cross a JSON bridge hop: only the default Transformers.js runtime is reachable
202
+ through `configure()`, never the fixture runtime the upstream demos use offline. Register it
203
+ only when the weights are there; a provider that cannot load turns a clean routing refusal
204
+ into a provider error.
70
205
 
71
- The `IndeRunCapacitorPlugin`, `ConfigureOptions`, and `OpenAIProviderBootstrapOptions`
72
- contracts — including the `openAI` bootstrap config and the web-only
73
- `allowDirectOpenAIEndpoint` flag — are defined and documented in
74
- `src/definitions.ts`. `run()` returns the canonical IndeRun `TaskResult`.
206
+ Two asymmetries in the low-level surface, both hidden by the ergonomic API:
207
+
208
+ - `run(request)` passes the request at the options root, while
209
+ `startStream({ streamId, request })` nests it, because that envelope also carries the
210
+ bridge-local correlation id. `stream()` hides this.
211
+ - The plugin method `checkCapabilities()` resolves `{ providers: [...] }` rather than the
212
+ array itself, because a Capacitor plugin method cannot resolve a top-level array on
213
+ either native platform. `IndeRunCapacitor.checkCapabilities()` unwraps it, so app code
214
+ sees the same array the three platform SDKs return.
215
+
216
+ `ProviderCapabilitySnapshot` and the `ProviderDescriptor` /
217
+ `ProviderDynamicCapabilities` it contains are declared in `src/definitions.ts` rather than
218
+ imported, because — unlike `TaskRequest` or `StreamEvent` — they are not generated
219
+ contracts: each platform SDK declares its own copy and there is no schema or validator for
220
+ them upstream. `src/web.ts` returns the web SDK's snapshots into the bridge's type uncast,
221
+ so the shapes staying identical is a compile error rather than a convention. Note
222
+ `capabilities.streamingAvailable` and `cancellationAvailable` are **absent**, not `null`,
223
+ when the runtime has nothing to add: absence means *inherit the static declaration*.
75
224
 
76
225
  ## Platform Notes
77
226
 
78
- - Web requires `openAI` registration because the current web SDK only has the OpenAI-compatible provider.
227
+ - Web requires at least one provider to be registered from `configure()`. The web SDK ships an
228
+ OpenAI-compatible provider, a Web ONNX Runtime provider and a browser system-model provider, but
229
+ only the OpenAI-compatible one declares streaming support — so a `local_required` **stream** in a
230
+ browser is refused at routing time, while a `local_required` **run** can be served on-device.
79
231
  - iOS always registers the Apple on-device provider and optionally registers OpenAI when configured.
80
232
  - Android always registers the ML Kit on-device provider and optionally registers OpenAI when configured.
81
- - Keep credentials behind `authContextRef`. For browser apps, prefer a proxy endpoint with `auth: "none"`.
233
+ - Keep credentials behind `authContextRef`. That keeps a secret out of the request payload and out
234
+ of source; it does not make a key safe to ship, since anything an installed app or a browser can
235
+ read, someone with that app or browser can read. For a key you own, put it behind a backend you
236
+ control and point `endpointUrl` at that — for browser apps, a same-origin proxy with
237
+ `auth: "none"`.
82
238
 
83
239
  ## Current Limitations
84
240
 
85
- - Mode 1 `run()` only.
241
+ - Mode 1 `run()` and Mode 2 `stream()` are supported. Mode 3 sessions are not.
242
+ - **No backpressure across the bridge.** Native pushes events; a slow consumer buys memory,
243
+ not throttling, because events buffer in JS until they are read. Runs are finite and
244
+ terminal-bounded, and any drop or throttle policy would be behaviour — which belongs in
245
+ the engines, not in a bridge.
246
+ - Which providers can actually stream is decided upstream, not here; see the
247
+ [provider matrix](https://github.com/independo-gmbh/inderun/blob/main/docs/architecture/providers.md#provider-matrix).
248
+ - An unrecognized `StreamEvent.type` is passed through untouched rather than rejected, so a
249
+ consumer built against an older contract revision keeps working when a newer one adds an
250
+ event type. Ignore what you do not recognize.
86
251
  - No plugin-level credential management API is exposed in this first cut.
87
252
  - The bridge is intentionally thin; cloud provider bootstrap still has to come from the app.
88
253
 
89
254
  ## Error Handling
90
255
 
256
+ A streaming run has **three** distinct failure surfaces, and conflating them is the easiest
257
+ mistake to make:
258
+
259
+ | Surface | When | How it reaches you |
260
+ | --- | --- | --- |
261
+ | Rejection | Request validation, or no streaming-capable provider | `stream()` rejects with an `IndeRunError` |
262
+ | Terminal `error` event | A provider failed, or the whole planned chain did | **Not** a rejection — `for await` completes normally and the last event is `terminal` with `payload.outcome === "error"` |
263
+ | Bridge fault | The transport lost or could not encode events | `events` throws an `Internal` `IndeRunError` |
264
+
265
+ So a run that fails *after starting* ends your loop normally. Branch on
266
+ `event.payload.outcome` to tell completion from failure from cancellation.
267
+
91
268
  Errors thrown by `configure()` and `run()` conform to `IndeRunError` from
92
269
  `@independo/inderun-contracts`; branch on `error.errorClass` (the shared error
93
270
  taxonomy). The bridge unwraps the native error envelope before re-throwing, so
@@ -10,7 +10,13 @@ buildscript {
10
10
  }
11
11
 
12
12
  plugins {
13
- id("com.android.library") version "9.3.1"
13
+ // No version here on purpose. When a Capacitor app includes this module, AGP is already
14
+ // on the app's classpath and a versioned request fails with "the plugin is already on the
15
+ // classpath with an unknown version, so compatibility cannot be checked" — i.e. the plugin
16
+ // could not be consumed at all. The version for a standalone build of this directory is
17
+ // declared in settings.gradle's pluginManagement instead, which a consuming app's own
18
+ // settings file replaces.
19
+ id("com.android.library")
14
20
  }
15
21
 
16
22
  android {
@@ -37,11 +43,11 @@ dependencies {
37
43
  testImplementation("com.capacitorjs:core:8.0.0")
38
44
  implementation("androidx.appcompat:appcompat:1.7.1")
39
45
  implementation("androidx.core:core-ktx:1.17.0")
40
- implementation("app.independo.inderun:inderun-contracts:0.2.2")
41
- implementation("app.independo.inderun:inderun-core:0.2.2")
42
- implementation("app.independo.inderun:inderun-kotlin:0.2.2")
43
- implementation("app.independo.inderun:inderun-mlkit-providers:0.2.2")
44
- implementation("app.independo.inderun:inderun-openai-providers:0.2.2")
46
+ implementation("app.independo.inderun:inderun-contracts:0.3.0")
47
+ implementation("app.independo.inderun:inderun-core:0.3.0")
48
+ implementation("app.independo.inderun:inderun-kotlin:0.3.0")
49
+ implementation("app.independo.inderun:inderun-mlkit-providers:0.3.0")
50
+ implementation("app.independo.inderun:inderun-openai-providers:0.3.0")
45
51
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
46
52
 
47
53
  testImplementation("junit:junit:4.13.2")
@@ -4,6 +4,13 @@ pluginManagement {
4
4
  mavenCentral()
5
5
  gradlePluginPortal()
6
6
  }
7
+
8
+ // Supplies the version build.gradle.kts deliberately omits, for a standalone build of
9
+ // this directory (pnpm test:android / verify:android). A Capacitor app that includes the
10
+ // module uses its own settings file, so AGP comes from the app's classpath there.
11
+ plugins {
12
+ id("com.android.library") version "9.3.1"
13
+ }
7
14
  }
8
15
 
9
16
  dependencyResolutionManagement {
@@ -1,5 +1,6 @@
1
1
  package app.independo.inderun.capacitor
2
2
 
3
+ import app.independo.inderun.contracts.IndeRunError
3
4
  import app.independo.inderun.core.IndeRunException
4
5
  import app.independo.inderun.core.ProviderRegistry
5
6
  import app.independo.inderun.core.toIndeRunException
@@ -9,10 +10,12 @@ import app.independo.inderun.providers.openai.OpenAIAuthMode
9
10
  import app.independo.inderun.providers.openai.OpenAIProvider
10
11
  import app.independo.inderun.providers.openai.OpenAIProviderOptions
11
12
  import app.independo.inderun.sdk.IndeRun
13
+ import com.getcapacitor.JSObject
12
14
  import com.getcapacitor.Plugin
13
15
  import com.getcapacitor.PluginCall
14
16
  import com.getcapacitor.PluginMethod
15
17
  import com.getcapacitor.annotation.CapacitorPlugin
18
+ import kotlinx.coroutines.CancellationException
16
19
  import kotlinx.coroutines.CoroutineScope
17
20
  import kotlinx.coroutines.Dispatchers
18
21
  import kotlinx.coroutines.SupervisorJob
@@ -23,6 +26,7 @@ import kotlinx.coroutines.launch
23
26
  class IndeRunCapacitorPlugin : Plugin() {
24
27
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
25
28
  private var configuredRegistry: ProviderRegistry? = null
29
+ private val streams = IndeRunStreamRegistry()
26
30
 
27
31
  @PluginMethod
28
32
  fun configure(call: PluginCall) {
@@ -83,11 +87,161 @@ class IndeRunCapacitorPlugin : Plugin() {
83
87
  }
84
88
  }
85
89
 
90
+ /**
91
+ * Reports every registered provider's static declaration and live availability without
92
+ * executing a task. Availability changes between calls — a local model can unload,
93
+ * cloud credentials can expire — so callers must not cache it across a run.
94
+ */
95
+ @PluginMethod
96
+ fun checkCapabilities(call: PluginCall) {
97
+ scope.launch {
98
+ try {
99
+ val registry = configuredRegistry
100
+ ?: throw toIndeRunException(IllegalStateException("Capacitor IndeRun has not been configured. Configure providers before calling checkCapabilities()."))
101
+ // IndeRun is stateless; new per call is intentional — registry is cached after configure().
102
+ val snapshots = IndeRun.initialize(context.applicationContext, registry).checkCapabilities()
103
+ call.resolve(IndeRunSerializer.encodeCapabilitySnapshots(snapshots))
104
+ } catch (error: IndeRunException) {
105
+ val contractError = error.toContractError()
106
+ call.reject(
107
+ contractError.message,
108
+ contractError.errorClass.rawValue,
109
+ null,
110
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
111
+ )
112
+ } catch (error: Throwable) {
113
+ val normalized = toIndeRunException(error)
114
+ val contractError = normalized.toContractError()
115
+ call.reject(
116
+ contractError.message,
117
+ contractError.errorClass.rawValue,
118
+ null,
119
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
120
+ )
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Resolves with the run handle. Only validation and route-selection failures reject;
127
+ * a provider failure, a cancellation, or completion all arrive as the single terminal
128
+ * event on `indeRunStreamEvent` — which is why [resolved] is tracked: a PluginCall
129
+ * must settle exactly once, and a failure after the handle has gone back is an event,
130
+ * not a rejection.
131
+ *
132
+ * `retainUntilConsumed` closes the listener-registration race from the native side: an
133
+ * event emitted before the JS listener attaches is retained and replayed, not lost.
134
+ */
135
+ @PluginMethod
136
+ fun startStream(call: PluginCall) {
137
+ val streamId = call.getString("streamId")
138
+ if (streamId == null) {
139
+ rejectContractError(call, IllegalArgumentException("startStream requires a streamId."))
140
+ return
141
+ }
142
+ // Unlike run(request), which takes the request at the options root, startStream
143
+ // nests it under `request` so the envelope can also carry the bridge-local streamId.
144
+ val requestJson = call.getObject("request")
145
+ if (requestJson == null) {
146
+ rejectContractError(call, IllegalArgumentException("startStream requires a request."))
147
+ return
148
+ }
149
+
150
+ // Reserved before the engine is reached, so a cancel arriving during route
151
+ // selection is recorded rather than dropped as an unknown id.
152
+ streams.open(streamId)
153
+
154
+ val job = scope.launch {
155
+ var resolved = false
156
+ try {
157
+ val registry = configuredRegistry
158
+ ?: throw toIndeRunException(IllegalStateException("Capacitor IndeRun has not been configured. Configure providers before calling stream(request)."))
159
+ val request = IndeRunSerializer.parseTaskRequest(requestJson)
160
+ // IndeRun is stateless; new per call is intentional — registry is cached after configure().
161
+ val streamRun = IndeRun.initialize(context.applicationContext, registry).stream(request)
162
+
163
+ val outcome = streams.attach(streamId, streamRun)
164
+ if (outcome is AttachOutcome.CancelRequested) {
165
+ streamRun.cancel(outcome.reason)
166
+ }
167
+
168
+ call.resolve(IndeRunSerializer.encodeStreamRunHandle(streamRun.handle))
169
+ resolved = true
170
+
171
+ // The Flow is cold and single-use; collecting it exactly once here is what
172
+ // drives the run.
173
+ streamRun.events.collect { event ->
174
+ val payload = JSObject()
175
+ payload.put("streamId", streamId)
176
+ payload.put("event", IndeRunSerializer.encodeStreamEvent(event))
177
+ notifyListeners("indeRunStreamEvent", payload, true)
178
+ }
179
+ } catch (error: CancellationException) {
180
+ throw error
181
+ } catch (error: Throwable) {
182
+ val contractError = contractErrorFor(error)
183
+ if (resolved) {
184
+ val payload = JSObject()
185
+ payload.put("streamId", streamId)
186
+ payload.put(
187
+ "error",
188
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
189
+ )
190
+ notifyListeners("indeRunStreamError", payload, true)
191
+ } else {
192
+ call.reject(
193
+ contractError.message,
194
+ contractError.errorClass.rawValue,
195
+ null,
196
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
197
+ )
198
+ }
199
+ } finally {
200
+ streams.close(streamId)
201
+ }
202
+ }
203
+
204
+ streams.attachJob(streamId, job)
205
+ }
206
+
207
+ /**
208
+ * Resolves for an unknown or already-finished run: cancelling after the terminal is a
209
+ * no-op by contract, not an error.
210
+ */
211
+ @PluginMethod
212
+ fun cancelStream(call: PluginCall) {
213
+ val streamId = call.getString("streamId")
214
+ if (streamId == null) {
215
+ rejectContractError(call, IllegalArgumentException("cancelStream requires a streamId."))
216
+ return
217
+ }
218
+
219
+ streams.requestCancel(streamId, call.getString("reason"))
220
+ call.resolve()
221
+ }
222
+
86
223
  override fun handleOnDestroy() {
224
+ // Runs first, so providers unwind before the scope that collects them goes away.
225
+ streams.cancelAll("Capacitor plugin destroyed.")
87
226
  scope.cancel()
88
227
  super.handleOnDestroy()
89
228
  }
90
229
 
230
+ private fun contractErrorFor(error: Throwable): IndeRunError = when (error) {
231
+ is IndeRunException -> error.toContractError()
232
+ else -> toIndeRunException(error).toContractError()
233
+ }
234
+
235
+ private fun rejectContractError(call: PluginCall, error: Throwable) {
236
+ val contractError = contractErrorFor(error)
237
+ call.reject(
238
+ contractError.message,
239
+ contractError.errorClass.rawValue,
240
+ null,
241
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
242
+ )
243
+ }
244
+
91
245
  private fun createRegistry(openAI: OpenAIProviderBootstrapOptions?): ProviderRegistry {
92
246
  val registry = AndroidProviderRegistryFactory.makeDefaultRegistry(context.applicationContext)
93
247