arcane-os 0.66.0 → 0.67.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.67.0
4
+
5
+ - Add native Laya FP32 typed decisions through `arcane-os/local-ai/decisions`
6
+ and `arcane-os/core/decisions`, using the existing native ONNX and model-assets
7
+ owners plus the approved `@huggingface/tokenizers@0.2.0` Worker dependency.
8
+ Explicit activation preserves complete rows, graph outputs, ordered results,
9
+ current-state replay, cancellation and actual worker-exit file lifetime.
10
+ - Compose selected native services through one Core owner, prepare complete
11
+ model files through the existing asset owner, and expose actual GPU-provider
12
+ attempts with CPU fallback. Accepted configuration does not prove GPU execution.
13
+ - Follow late Core client installation and retirement in default AI accessors.
14
+ Keep explicit clients fixed, model observation non-loading and disposal tied
15
+ to actual retained ownership. Preserve recoverable native inference errors
16
+ without retiring a healthy session or cancelling sibling work; terminal
17
+ stopping revokes readiness while resources remain retained until actual exit.
18
+ - Add repository `configuration()` observations and captured `target:{remote,ref}`
19
+ options for ordinary fast-forward pull, non-force push and write. Preserve
20
+ complete configuration values, payloads and failures without switching branches,
21
+ rewriting Git configuration or changing omitted-target behavior.
22
+ - Keep theme controls, shared same-app host discovery and optional NPU asset
23
+ transport follow-through separate. No app data migration or automatic model
24
+ activation is introduced; applications own their selections and orchestration.
25
+
3
26
  ## 0.66.0
4
27
 
5
28
  - Add `readGitIdentity` from `arcane-os/core/repositories` for separate global,
package/README.md CHANGED
@@ -20,7 +20,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
20
20
  share the theme, packaging, event, cancellation,
21
21
  and browser run contracts while retaining their selected application layout.
22
22
 
23
- This checkout defines the `0.66.0` SDK contract. Development applications declare
23
+ This checkout defines the `0.67.0` SDK contract. Development applications declare
24
24
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
25
25
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
26
26
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -1,5 +1,5 @@
1
1
  import Is from '../dependencies/strong-type/index.js';
2
- import {getInstalledCoreClient} from '../core/client.mjs';
2
+ import {subscribeCoreClient} from '../core/client.mjs';
3
3
  import {CoreError, serializeCoreError} from '../core/contracts.mjs';
4
4
  import {createArcaneEventSource} from '../event-manager.mjs';
5
5
 
@@ -50,7 +50,7 @@ async function encodeImage(image, signal) {
50
50
 
51
51
  /** Browser access to the selected native image service; no result persistence. */
52
52
  export function createCoreImageRuntime(
53
- {client = getInstalledCoreClient(), signal, onEvent} = {}
53
+ {client: suppliedClient, signal, onEvent} = {}
54
54
  ) {
55
55
  const owner = {};
56
56
  const events = createArcaneEventSource(
@@ -60,26 +60,37 @@ export function createCoreImageRuntime(
60
60
  const lifetime = new AbortController();
61
61
  const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
62
62
  const pending = new Set();
63
- let state = {
64
- id: 'stable-diffusion.cpp',
65
- installed: false,
66
- available: false,
67
- selectedModel: null,
68
- loaded: false,
69
- state: client ? 'unknown' : 'unavailable',
70
- backend: null,
71
- devices: [],
72
- resources: {},
73
- progress: null,
74
- error: null,
75
- models: []
76
- };
63
+ let client = null;
64
+ let connection = null;
65
+ let stopInstallation;
66
+ let state = emptyState();
77
67
  let revision = 0;
78
68
  let closed = false;
79
69
  let closing;
80
70
 
71
+ function emptyState(error = null) {
72
+ return {
73
+ id: 'stable-diffusion.cpp',
74
+ installed: false,
75
+ available: false,
76
+ selectedModel: null,
77
+ loaded: false,
78
+ state: client ? 'unknown' : 'unavailable',
79
+ backend: null,
80
+ devices: [],
81
+ resources: {},
82
+ progress: null,
83
+ error: error ? serializeCoreError(error) : null,
84
+ models: []
85
+ };
86
+ }
87
+
81
88
  function current() {
82
- const requests = [...pending].map(
89
+ const requests = [...pending].filter(
90
+ function currentConnectionRequest(request) {
91
+ return request.connection === connection && !request.signal.aborted;
92
+ }
93
+ ).map(
83
94
  function requestState(request) {
84
95
  return {
85
96
  method: request.method,
@@ -97,7 +108,7 @@ export function createCoreImageRuntime(
97
108
  return request.status === 'Thinking';
98
109
  }
99
110
  ) ? 'Thinking' : null,
100
- busy: pending.size > 0,
111
+ busy: requests.length > 0,
101
112
  closed
102
113
  };
103
114
  }
@@ -146,8 +157,38 @@ export function createCoreImageRuntime(
146
157
  publish();
147
158
  }
148
159
 
149
- const unsubscribe = client?.events.on('image.state', accept)
150
- ?? function noCoreSubscription() {};
160
+ function bindClient({client: nextClient, error = null}) {
161
+ if (closed || (connection && nextClient === client)) {
162
+ return;
163
+ }
164
+ const previous = connection;
165
+ client = nextClient;
166
+ const currentConnection = {client, controller: new AbortController(), unsubscribe: null};
167
+ connection = currentConnection;
168
+ revision += 1;
169
+ state = emptyState(error);
170
+ previous?.unsubscribe?.();
171
+ previous?.controller.abort();
172
+ if (client) {
173
+ currentConnection.unsubscribe = client.events.on(
174
+ 'image.state',
175
+ function currentImageState(value) {
176
+ if (connection === currentConnection) accept(value);
177
+ }
178
+ );
179
+ }
180
+ publish();
181
+ if (!closed && connection === currentConnection && client) {
182
+ // Observe only; installation never loads an engine or model.
183
+ inspect().catch(
184
+ function initialImageStateUnavailable(error) {
185
+ if (!closed && connection === currentConnection && !currentConnection.controller.signal.aborted) {
186
+ reportObserverFailure(error);
187
+ }
188
+ }
189
+ );
190
+ }
191
+ }
151
192
 
152
193
  function invoke(method, parameters, {signal: requestSignal, onProgress} = {}) {
153
194
  if (closed) {
@@ -156,11 +197,15 @@ export function createCoreImageRuntime(
156
197
  if (!client) {
157
198
  throw unavailable('Local image generation requires an available Core connection.');
158
199
  }
159
- const operationSignal = requestSignal
160
- ? AbortSignal.any([requestSignal, lifetimeSignal]) : lifetimeSignal;
200
+ const currentConnection = connection;
201
+ const operationSignal = AbortSignal.any(
202
+ [lifetimeSignal, currentConnection.controller.signal, ...(requestSignal ? [requestSignal] : [])]
203
+ );
161
204
  operationSignal.throwIfAborted();
162
205
  const request = {
163
206
  method,
207
+ connection: currentConnection,
208
+ signal: operationSignal,
164
209
  revision,
165
210
  streamId: parameters.streamId,
166
211
  status: method === 'image.generate' || method === 'image.edit' ? 'Thinking' : null,
@@ -169,7 +214,7 @@ export function createCoreImageRuntime(
169
214
  };
170
215
  let stopProgress;
171
216
  if (request.streamId) {
172
- stopProgress = client.events.on(
217
+ stopProgress = currentConnection.client.events.on(
173
218
  'image.progress',
174
219
  function imageProgress(value) {
175
220
  if (closed || operationSignal.aborted || value.streamId !== request.streamId) {
@@ -202,7 +247,7 @@ export function createCoreImageRuntime(
202
247
  {type: 'image.request', data: {method, parameters: requestParameters}}
203
248
  );
204
249
  operationSignal.throwIfAborted();
205
- const result = await client.invoke(
250
+ const result = await currentConnection.client.invoke(
206
251
  method,
207
252
  requestParameters,
208
253
  {signal: operationSignal, timeoutMs: 0}
@@ -238,7 +283,7 @@ export function createCoreImageRuntime(
238
283
  function releaseImageRequest() {
239
284
  stopProgress?.();
240
285
  pending.delete(request);
241
- if (!closed) {
286
+ if (!closed && connection === currentConnection) {
242
287
  publish();
243
288
  }
244
289
  }
@@ -251,12 +296,14 @@ export function createCoreImageRuntime(
251
296
 
252
297
  async function inspect({signal: requestSignal} = {}) {
253
298
  const before = revision;
299
+ const currentConnection = connection;
254
300
  try {
255
301
  const result = await invoke(
256
302
  'image.status',
257
303
  {},
258
304
  {signal: requestSignal}
259
305
  );
306
+ currentConnection.controller.signal.throwIfAborted();
260
307
  if (!closed && revision === before) {
261
308
  accept(result);
262
309
  }
@@ -273,11 +320,13 @@ export function createCoreImageRuntime(
273
320
 
274
321
  async function load({model, context, assetProjectionId, resourcePaths, signal: requestSignal} = {}) {
275
322
  const before = revision;
323
+ const currentConnection = connection;
276
324
  const result = await invoke(
277
325
  'image.load',
278
326
  {model, context, assetProjectionId, resourcePaths},
279
327
  {signal: requestSignal}
280
328
  );
329
+ currentConnection.controller.signal.throwIfAborted();
281
330
  if (!closed && revision === before) {
282
331
  accept(result);
283
332
  }
@@ -308,11 +357,13 @@ export function createCoreImageRuntime(
308
357
 
309
358
  async function unload({signal: requestSignal} = {}) {
310
359
  const before = revision;
360
+ const currentConnection = connection;
311
361
  const result = await invoke(
312
362
  'image.unload',
313
363
  {},
314
364
  {signal: requestSignal}
315
365
  );
366
+ currentConnection.controller.signal.throwIfAborted();
316
367
  if (!closed && revision === before) {
317
368
  accept(result);
318
369
  }
@@ -365,8 +416,10 @@ export function createCoreImageRuntime(
365
416
  );
366
417
  lifetime.abort();
367
418
  lifetimeSignal.removeEventListener('abort', close);
368
- unsubscribe();
369
- state = {...state, available: false, state: 'closed'};
419
+ stopInstallation?.();
420
+ connection?.unsubscribe?.();
421
+ connection?.controller.abort();
422
+ state = {...state, available: false, loaded: false, state: 'closed'};
370
423
  publish();
371
424
  events.dispose();
372
425
  return closing;
@@ -379,13 +432,10 @@ export function createCoreImageRuntime(
379
432
  );
380
433
  if (lifetimeSignal.aborted) {
381
434
  close();
382
- } else if (client) {
383
- // Subscribe before the snapshot request so a later lifecycle event wins.
384
- inspect().catch(
385
- function initialImageStateUnavailable(error) {
386
- globalThis.console?.error('Core image initial state unavailable.', error);
387
- }
388
- );
435
+ } else if (suppliedClient === undefined) {
436
+ stopInstallation = subscribeCoreClient(bindClient, {signal: lifetimeSignal});
437
+ } else {
438
+ bindClient({client: suppliedClient});
389
439
  }
390
440
 
391
441
  return {load, generate, edit, unload, inspect, current, subscribe, close};
@@ -1,5 +1,5 @@
1
1
  import Is from '../dependencies/strong-type/index.js';
2
- import {getInstalledCoreClient} from '../core/client.mjs';
2
+ import {subscribeCoreClient} from '../core/client.mjs';
3
3
 
4
4
  const is = new Is(false);
5
5
  const PROVIDER_PROTOCOL = 'arcane-ai-provider/2';
@@ -22,19 +22,26 @@ function cancelled(reason) {
22
22
  * selects its model; Core owns the running engine and its loaded sessions.
23
23
  * Creating this provider neither connects a transport nor installs a runtime.
24
24
  */
25
- export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id = 'llama.cpp'} = {}) {
25
+ export function createCoreLocalAIProvider({client: suppliedClient, id = 'llama.cpp'} = {}) {
26
26
  if (!is.string(id) || !id.trim()) throw new TypeError('The Core AI provider id must be a nonempty string.');
27
- if (client !== null && (!is.function(client?.invoke) || !is.function(client?.events?.on))) {
27
+ if (suppliedClient !== undefined && suppliedClient !== null
28
+ && (!is.function(suppliedClient?.invoke) || !is.function(suppliedClient?.events?.on))) {
28
29
  throw new TypeError('The Core AI client must expose invoke and events.on.');
29
30
  }
30
31
 
31
32
  let runtime = null;
32
33
  let models = [];
33
34
  let selection = null;
35
+ let selectionConnection = null;
34
36
  let disposed = false;
35
37
  let loading = false;
38
+ let unloading = false;
36
39
  let sequence = 0;
37
40
  let lifecycleRevision = 0;
41
+ let client = null;
42
+ let connection = null;
43
+ let stopInstallation;
44
+ let disposing;
38
45
  const requests = new Set();
39
46
 
40
47
  function requireClient() {
@@ -58,7 +65,13 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
58
65
  }
59
66
 
60
67
  function modelReady() {
61
- return runtime?.available === true && runtime.state === 'ready' && Boolean(loadedModel());
68
+ return !unloading && selectionConnection === connection && runtime?.available === true
69
+ && runtime.state === 'ready' && Boolean(loadedModel());
70
+ }
71
+
72
+ function assertCurrentConnection(value) {
73
+ if (disposed || connection !== value) throw cancelled();
74
+ value.controller.signal.throwIfAborted();
62
75
  }
63
76
 
64
77
  function status() {
@@ -67,7 +80,9 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
67
80
  state: disposed ? 'disposed' : loading ? 'loading' : loaded ? 'ready'
68
81
  : runtime?.error ? 'error' : runtime?.available === true ? 'unloaded' : 'unavailable',
69
82
  loaded,
70
- busy: loading || requests.size > 0,
83
+ busy: Boolean(loading || unloading) || [...requests].some(function currentRequest(request) {
84
+ return request.connection === connection && !request.controller.signal.aborted;
85
+ }),
71
86
  modelId: selection?.modelId ?? null,
72
87
  available: !disposed && runtime?.available === true,
73
88
  installed: runtime?.installed === true,
@@ -79,6 +94,7 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
79
94
  runtime = value ?? null;
80
95
  models = is.array(runtime?.models) ? runtime.models : [];
81
96
  for (const request of requests) {
97
+ if (request.connection !== connection || request.modelId === null) continue;
82
98
  const model = models.find(function requestModel(record) { return record.id === request.modelId; });
83
99
  if (model?.loaded === true) request.loaded = true;
84
100
  if (runtime?.available !== true || (request.loaded && (runtime.state !== 'ready' || model?.loaded !== true))) {
@@ -88,13 +104,62 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
88
104
  return status();
89
105
  }
90
106
 
91
- const unsubscribe = client?.events.on('localai.state', function localRuntimeChanged(snapshot) {
92
- if (disposed || !is.array(snapshot?.runtimes)) return;
107
+ function bindClient({client: nextClient, error = null}) {
108
+ if (disposed || (connection && nextClient === client)) return;
109
+ const previous = connection;
110
+ client = nextClient;
111
+ const currentConnection = {client, controller: new AbortController(), unsubscribe: null};
112
+ connection = currentConnection;
93
113
  lifecycleRevision += 1;
94
- acceptRuntime(snapshot.runtimes.find(function llamaRuntime(value) {
95
- return value.id === 'llama.cpp';
96
- }));
97
- }) ?? function noCoreSubscription() {};
114
+ loading = false;
115
+ unloading = false;
116
+ runtime = {installed: false, available: false, state: client ? 'unknown' : 'unavailable', error};
117
+ models = [];
118
+ previous?.unsubscribe?.();
119
+ previous?.controller.abort();
120
+ if (client) {
121
+ currentConnection.unsubscribe = client.events.on(
122
+ 'localai.state',
123
+ function localRuntimeChanged(snapshot) {
124
+ if (disposed || connection !== currentConnection || !is.array(snapshot?.runtimes)) return;
125
+ lifecycleRevision += 1;
126
+ acceptRuntime(snapshot.runtimes.find(function llamaRuntime(value) {
127
+ return value.id === 'llama.cpp';
128
+ }));
129
+ }
130
+ );
131
+ }
132
+ if (client && suppliedClient === undefined) {
133
+ const revision = lifecycleRevision;
134
+ invokeOwned(client, 'llama.status', {}).then(
135
+ function installedRuntimeObserved(value) {
136
+ if (!disposed && connection === currentConnection && lifecycleRevision === revision) acceptRuntime(value);
137
+ },
138
+ function installedRuntimeFailed(error) {
139
+ if (!disposed && connection === currentConnection && lifecycleRevision === revision) {
140
+ acceptRuntime({...runtime, available: false, state: 'error', error});
141
+ }
142
+ }
143
+ );
144
+ }
145
+ }
146
+
147
+ async function invokeOwned(core, method, parameters, signal) {
148
+ const request = ownRequest(signal, null, core);
149
+ request.result = Promise.resolve().then(
150
+ function invokeLocalOperation() {
151
+ request.controller.signal.throwIfAborted();
152
+ return core.invoke(method, parameters, {signal: request.controller.signal});
153
+ }
154
+ );
155
+ try {
156
+ const result = await request.result;
157
+ request.controller.signal.throwIfAborted();
158
+ return result;
159
+ } finally {
160
+ request.release();
161
+ }
162
+ }
98
163
 
99
164
  function authority(value) {
100
165
  return {protocol: AUTHORITY_PROTOCOL, providerId: id, modelId: value.modelId};
@@ -103,12 +168,20 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
103
168
  async function inspect(value, {signal} = {}) {
104
169
  requireSelection(value);
105
170
  if (signal?.aborted) throw cancelled(signal.reason);
171
+ const currentConnection = connection;
172
+ const statusRevision = lifecycleRevision;
106
173
  try {
107
174
  const core = requireClient();
108
- const statusRevision = lifecycleRevision;
109
- const current = await core.invoke('llama.status', {}, {signal});
175
+ const current = await invokeOwned(core, 'llama.status', {}, signal);
176
+ assertCurrentConnection(currentConnection);
110
177
  if (lifecycleRevision === statusRevision) acceptRuntime(current);
111
- if (runtime?.available !== true) {
178
+ const knownModel = models.some(function matchesModel(model) { return model.id === value.modelId; });
179
+ // Inspection describes an explicit load capability, never readiness.
180
+ // Core still verifies the exact model at the actual load boundary.
181
+ const starting = !runtime?.error && ['starting', 'loading'].includes(runtime?.state);
182
+ const released = !runtime?.error && runtime?.released === true && runtime.owned === true
183
+ && runtime.state === 'stopped' && knownModel;
184
+ if (runtime?.error || (runtime?.available !== true && !starting && !released)) {
112
185
  return {
113
186
  available: false,
114
187
  code: 'ARCANE_AI_PROVIDER_UNAVAILABLE',
@@ -116,7 +189,7 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
116
189
  error: runtime?.error ?? null
117
190
  };
118
191
  }
119
- if (!models.some(function matchesModel(model) { return model.id === value.modelId; })) {
192
+ if (!starting && !knownModel) {
120
193
  return {
121
194
  available: false,
122
195
  code: 'ARCANE_AI_MODEL_UNAVAILABLE',
@@ -126,48 +199,63 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
126
199
  return {available: true, authority: authority(value)};
127
200
  } catch (error) {
128
201
  if (signal?.aborted || error?.name === 'AbortError') throw error;
129
- acceptRuntime({...runtime, available: false, state: 'error', error});
202
+ if (!disposed && connection === currentConnection && lifecycleRevision === statusRevision) {
203
+ acceptRuntime({...runtime, available: false, state: 'error', error});
204
+ }
130
205
  return {available: false, code: error.code ?? 'ARCANE_AI_CORE_UNAVAILABLE', message: error.message, error};
131
206
  }
132
207
  }
133
208
 
134
209
  async function load({selection: value, signal, progress} = {}) {
135
210
  const core = requireClient();
211
+ const currentConnection = connection;
136
212
  requireSelection(value);
137
213
  if (signal?.aborted) throw cancelled(signal.reason);
138
214
  selection = value;
139
- loading = true;
215
+ selectionConnection = currentConnection;
216
+ const operation = {};
217
+ loading = operation;
218
+ unloading = false;
219
+ lifecycleRevision += 1;
140
220
  try {
141
221
  progress?.({phase: 'loading', modelId: value.modelId});
142
222
  const revision = lifecycleRevision;
143
- const current = await core.invoke('llama.load', {model: value.modelId}, {signal});
223
+ const current = await invokeOwned(core, 'llama.load', {model: value.modelId}, signal);
224
+ assertCurrentConnection(currentConnection);
225
+ if (loading !== operation || selection !== value) throw cancelled();
144
226
  if (lifecycleRevision === revision) acceptRuntime(current);
145
227
  if (!modelReady()) throw localAIError('ARCANE_AI_MODEL_NOT_READY', 'Core did not report the selected model loaded and ready.', current?.error);
146
228
  progress?.({phase: 'ready', modelId: value.modelId});
229
+ assertCurrentConnection(currentConnection);
230
+ if (loading !== operation || selection !== value) throw cancelled();
147
231
  return {authority: authority(value), status: {...status(), state: 'ready', busy: false}};
148
232
  } finally {
149
- loading = false;
233
+ if (loading === operation) loading = false;
150
234
  }
151
235
  }
152
236
 
153
- function ownRequest(signal, modelId) {
237
+ function ownRequest(signal, modelId, core = requireClient()) {
238
+ const currentConnection = connection;
239
+ if (core !== currentConnection?.client) throw cancelled();
154
240
  const controller = new AbortController();
155
- function forwardAbort() { controller.abort(signal.reason); }
156
- signal?.addEventListener('abort', forwardAbort, {once: true});
157
- if (signal?.aborted) forwardAbort();
158
- const request = {controller, result: null, modelId, loaded: models.some(function loadedTarget(model) {
241
+ const operationSignal = signal
242
+ ? AbortSignal.any([signal, currentConnection.controller.signal]) : currentConnection.controller.signal;
243
+ function forwardAbort() { controller.abort(operationSignal.reason); }
244
+ operationSignal.addEventListener('abort', forwardAbort, {once: true});
245
+ if (operationSignal.aborted) forwardAbort();
246
+ const request = {controller, connection: currentConnection, result: null, modelId, loaded: models.some(function loadedTarget(model) {
159
247
  return model.id === modelId && model.loaded === true;
160
248
  })};
161
249
  requests.add(request);
162
250
  request.release = function releaseRequest() {
163
- signal?.removeEventListener('abort', forwardAbort);
251
+ operationSignal.removeEventListener('abort', forwardAbort);
164
252
  requests.delete(request);
165
253
  };
166
254
  return request;
167
255
  }
168
256
 
169
257
  function streamRequest(core, parameters, signal) {
170
- const request = ownRequest(signal, parameters.model);
258
+ const request = ownRequest(signal, parameters.model, core);
171
259
  const chunks = [];
172
260
  const readers = [];
173
261
  let complete = false;
@@ -195,6 +283,7 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
195
283
  });
196
284
 
197
285
  request.result = Promise.resolve().then(function invokeStream() {
286
+ request.controller.signal.throwIfAborted();
198
287
  return core.invoke('llama.chat', {...parameters, stream: true, streamId}, {signal: request.controller.signal});
199
288
  }).then(function streamCompleted(result) {
200
289
  if (request.controller.signal.aborted) throw cancelled(request.controller.signal.reason);
@@ -237,15 +326,17 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
237
326
 
238
327
  async function request({selection: value, operation, payload, signal} = {}) {
239
328
  const core = requireClient();
329
+ const currentConnection = connection;
240
330
  requireSelection(value);
241
331
  if (signal?.aborted) throw cancelled(signal.reason);
242
- if (loading || selection?.modelId !== value.modelId) {
332
+ if (loading || unloading || selectionConnection !== connection || selection?.modelId !== value.modelId) {
243
333
  throw localAIError('ARCANE_AI_MODEL_NOT_READY', 'Load the selected local model before requesting inference.');
244
334
  }
245
335
  const model = payload?.model ?? value.modelId;
246
336
  if (!models.some(function knownModel(record) { return record.id === model; })) {
247
337
  const revision = lifecycleRevision;
248
- const current = await core.invoke('llama.status', {}, {signal});
338
+ const current = await invokeOwned(core, 'llama.status', {}, signal);
339
+ assertCurrentConnection(currentConnection);
249
340
  if (lifecycleRevision === revision) acceptRuntime(current);
250
341
  }
251
342
  if (!models.some(function knownModel(record) { return record.id === model; })) {
@@ -258,8 +349,9 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
258
349
  const parameters = {model, payload};
259
350
  if (operation === 'stream') return streamRequest(core, parameters, signal);
260
351
  if (operation !== 'chat') throw localAIError('ARCANE_AI_PROVIDER_OPERATION_UNAVAILABLE', 'The local llama.cpp provider supports chat and stream.');
261
- const pending = ownRequest(signal, model);
352
+ const pending = ownRequest(signal, model, core);
262
353
  pending.result = Promise.resolve().then(function invokeChat() {
354
+ pending.controller.signal.throwIfAborted();
263
355
  return core.invoke('llama.chat', {...parameters, stream: false}, {signal: pending.controller.signal});
264
356
  }).then(function chatCompleted(result) {
265
357
  if (pending.controller.signal.aborted) throw cancelled(pending.controller.signal.reason);
@@ -268,26 +360,86 @@ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id
268
360
  return pending.result;
269
361
  }
270
362
 
271
- async function unload({selection: value = selection, signal} = {}) {
363
+ async function unload({signal} = {}) {
364
+ const value = selection;
272
365
  const core = requireClient();
366
+ const currentConnection = connection;
273
367
  if (signal?.aborted) throw cancelled(signal.reason);
368
+ if (value && value === selection && selectionConnection !== currentConnection) {
369
+ throw localAIError('ARCANE_AI_CORE_UNAVAILABLE', 'The selected model belongs to a retired Core connection.');
370
+ }
371
+ if (value) requireSelection(value);
372
+ const operation = {};
373
+ unloading = operation;
374
+ loading = false;
375
+ lifecycleRevision += 1;
274
376
  for (const request of requests) request.controller.abort();
275
- await Promise.allSettled([...requests].map(function activeResult(request) { return request.result; }));
276
- if (!value) return status();
277
- requireSelection(value);
278
- const revision = lifecycleRevision;
279
- const current = await core.invoke('llama.unload', {model: value.modelId}, {signal});
280
- if (lifecycleRevision === revision) acceptRuntime(current);
281
- selection = null;
282
- return status();
377
+ try {
378
+ await Promise.allSettled([...requests].map(function activeResult(request) { return request.result; }));
379
+ assertCurrentConnection(currentConnection);
380
+ if (unloading !== operation) throw cancelled();
381
+ if (value) {
382
+ const revision = lifecycleRevision;
383
+ const current = await invokeOwned(core, 'llama.unload', {model: value.modelId}, signal);
384
+ assertCurrentConnection(currentConnection);
385
+ if (unloading !== operation) throw cancelled();
386
+ if (lifecycleRevision === revision) acceptRuntime(current);
387
+ }
388
+ if (unloading !== operation) throw cancelled();
389
+ selection = null;
390
+ selectionConnection = null;
391
+ unloading = false;
392
+ return status();
393
+ } catch (error) {
394
+ if (!disposed && connection === currentConnection && unloading === operation) {
395
+ runtime = {...runtime, state: 'error', error};
396
+ }
397
+ throw error;
398
+ } finally {
399
+ if (unloading === operation) unloading = false;
400
+ }
283
401
  }
284
402
 
285
- async function dispose(options = {}) {
286
- if (disposed) return status();
287
- if (selection || requests.size) await unload(options);
403
+ function dispose({signal} = {}) {
404
+ if (disposing) return disposing;
405
+ // The runtime also supplies its configured selection after unload.
406
+ // Only this provider's retained selection owns a native release.
407
+ const value = selection;
408
+ const core = selectionConnection?.client;
288
409
  disposed = true;
289
- unsubscribe();
290
- return status();
410
+ loading = false;
411
+ unloading = false;
412
+ lifecycleRevision += 1;
413
+ stopInstallation?.();
414
+ connection?.unsubscribe?.();
415
+ connection?.controller.abort();
416
+ disposing = Promise.resolve().then(
417
+ async function disposeLocalProvider() {
418
+ await Promise.allSettled([...requests].map(function activeResult(request) { return request.result; }));
419
+ if (value) {
420
+ requireSelection(value);
421
+ if (!core) throw localAIError('ARCANE_AI_CORE_UNAVAILABLE', 'The retired Core connection cannot unload the selected model.');
422
+ // Disposal owns this final release on the captured client only.
423
+ // Subscription cleanup above remains complete if it rejects.
424
+ await core.invoke('llama.unload', {model: value.modelId}, {signal});
425
+ }
426
+ selection = null;
427
+ selectionConnection = null;
428
+ return status();
429
+ }
430
+ ).catch(
431
+ function nativeReleaseFailed(error) {
432
+ runtime = {...runtime, error};
433
+ throw error;
434
+ }
435
+ );
436
+ return disposing;
437
+ }
438
+
439
+ if (suppliedClient === undefined) {
440
+ stopInstallation = subscribeCoreClient(bindClient);
441
+ } else {
442
+ bindClient({client: suppliedClient});
291
443
  }
292
444
 
293
445
  return {