arcane-os 0.55.0 → 0.57.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +5 -3
  3. package/browser-runtime/ai/core-local.mjs +306 -0
  4. package/browser-runtime/core/client.mjs +75 -14
  5. package/browser-runtime/core/contracts.mjs +2 -1
  6. package/docs/consumer-updates.md +82 -0
  7. package/docs/publishing.md +41 -18
  8. package/docs/reference/README.md +31 -1
  9. package/docs/reference/availability-and-normalization.md +11 -3
  10. package/docs/reference/cli.md +9 -1
  11. package/docs/reference/core-client.md +45 -2
  12. package/docs/reference/core-native-packaging.md +195 -11
  13. package/docs/reference/core-runtime.md +10 -0
  14. package/docs/reference/inventory/package-api.json +160 -20
  15. package/docs/reference/local-ai.md +197 -0
  16. package/docs/reference/protocols.md +91 -14
  17. package/docs/reference/pwa.md +17 -0
  18. package/docs/reference/runtime-modules.md +6 -5
  19. package/docs/reference/sdk-api.md +165 -26
  20. package/package.json +5 -1
  21. package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
  22. package/schemas/arcane-app.schema.json +64 -0
  23. package/schemas/arcane-package.schema.json +54 -0
  24. package/src/app-descriptor.mjs +35 -2
  25. package/src/app-layout.mjs +6 -2
  26. package/src/cli/main.mjs +8 -2
  27. package/src/core/host.mjs +23 -0
  28. package/src/core/hosts/arcane-core-loader.cjs +2 -0
  29. package/src/core/hosts/linux/arcane_core_process.c +568 -0
  30. package/src/core/hosts/linux/arcane_core_process.h +127 -0
  31. package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
  32. package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
  33. package/src/core/hosts/sea-launcher.cjs +17 -0
  34. package/src/core/hosts/windows/ArcaneCoreProcess.cs +416 -0
  35. package/src/core/hosts/windows/ArcaneHost.cs +695 -0
  36. package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
  37. package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
  38. package/src/core/runtime.mjs +15 -3
  39. package/src/core/services/local-ai.mjs +543 -0
  40. package/src/core-classic-source.mjs +2 -2
  41. package/src/dev-server.mjs +7 -0
  42. package/src/import-map.mjs +1 -0
  43. package/src/local-ai/archive.mjs +52 -0
  44. package/src/local-ai/config.mjs +62 -0
  45. package/src/local-ai/dev.mjs +390 -0
  46. package/src/local-ai/discover.mjs +71 -0
  47. package/src/local-ai/http.mjs +106 -0
  48. package/src/local-ai/install.mjs +344 -0
  49. package/src/local-ai/server.mjs +262 -0
  50. package/src/native/portable-layout.mjs +29 -4
  51. package/src/native/portable-provider.mjs +39 -8
  52. package/src/native/windows-provider.mjs +262 -0
  53. package/src/native-provider-loader.mjs +8 -2
  54. package/src/packager/core.mjs +89 -15
  55. package/src/process.mjs +74 -12
  56. package/src/toolchain.mjs +38 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.57.0
4
+
5
+ - Add local model chat through explicit llama.cpp/Ollama runtime requirements,
6
+ reusable installation and bundling, a retained Core local-AI service, and an
7
+ optional browser-to-Core provider. Preserve complete requests and streamed
8
+ responses, model readiness, cancellation and owned process shutdown. Apps own
9
+ model selection and defaults; preparation does not block ordinary page startup.
10
+ - Add authored Core service selection and launch context. Factories receive
11
+ authored options and the separate app-root/runtime context; explicit launch
12
+ context overrides remain supported. Portable packages can bundle explicitly
13
+ selected local-AI runtimes without changing browser-only output.
14
+ - Add Windows x64 executable packaging through the public Windows native
15
+ provider and the matching release's retained WebView2/Node SEA host asset.
16
+ Client injection precedes app scripts, service requests retain complete JSON,
17
+ and accepted service work drains before the owned Core process exits.
18
+ - Add `runProcess` cancellation mode `close-input` for owned graceful shutdown;
19
+ the existing `terminate-tree` default remains unchanged. Includes separately
20
+ delivered macOS process source, without claiming a complete macOS host.
21
+ - Correct the documented Node document-library subpath and lexical-search
22
+ example. No preferences service, Nemotron diarization, native ONNX, native
23
+ Kokoro or local image generation is introduced in this release.
24
+
25
+ ## 0.56.0
26
+
27
+ - Add explicit `package.nativeResources` selection for native-only application
28
+ files and directories. Browser selection remains unchanged; non-browser
29
+ packaging keeps additional resources outside browser transformations and
30
+ stages them in a separate target output. Applications select their complete
31
+ required source closure, and target adapters receive the effective target
32
+ and browser-PWA selection.
33
+ - Add `getInstalledCoreClient()` to read the exact live SDK-installed client
34
+ without connecting or changing globals. Terminal `failTransport(error)` and
35
+ the owned native failure callback preserve complete errors, settle pending
36
+ requests, and release the failed installation's callbacks and subscriptions.
37
+ - Add opt-in `replayRuntimeState` for Core clients and classic injection. The
38
+ runtime replays its actual readiness and current service state for newly
39
+ connected documents without restarting services or delaying page startup.
40
+
41
+ - Preserve complete UTF-8 process output when a character spans stdout or stderr
42
+ chunks. Independent readable-stream decoders feed the same text to captured
43
+ results and existing event delivery; ordering, backpressure, cancellation,
44
+ graceful drain, nonzero error details and line-event behavior remain unchanged.
45
+ - Include committed Windows and Linux Core process-transport source and the
46
+ generic Windows SEA launcher source as native-host groundwork. These sources
47
+ do not activate a platform host or deliver a public native executable; app
48
+ host composition and the remaining native integration stay independently owned.
49
+ - Clarify development `latest` tracking, production-designated pins, matching
50
+ requester notices, and required documentation/site release follow-through.
51
+ - No new dependency, application service, automatic runtime installation,
52
+ application default change or saved-data migration is introduced.
53
+
3
54
  ## 0.55.0
4
55
 
5
56
  - Add public Core client, protocol contracts and classic-script source generation
package/README.md CHANGED
@@ -20,9 +20,11 @@ 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.55.0` SDK contract. Applications pin one exact npm
24
- version and lockfile; registry state is deliberately not baked into application
25
- artifacts.
23
+ This checkout defines the `0.57.0` SDK contract. Development applications declare
24
+ `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
25
+ designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
26
+ to refresh and compare the installed SDK and lockfile with the published channel;
27
+ registry state is deliberately not baked into application artifacts.
26
28
 
27
29
  Standalone browser apps use their repository root and installed npm package directly, without a
28
30
  generated workspace `arcane/` directory or `arcane.lock.json`. Select the
@@ -0,0 +1,306 @@
1
+ import Is from '../dependencies/strong-type/index.js';
2
+ import {getInstalledCoreClient} from '../core/client.mjs';
3
+
4
+ const is = new Is(false);
5
+ const PROVIDER_PROTOCOL = 'arcane-ai-provider/2';
6
+ const AUTHORITY_PROTOCOL = 'arcane-ai-model-authority/1';
7
+
8
+ function localAIError(code, message, cause) {
9
+ const error = cause === undefined ? new Error(message) : new Error(message, {cause});
10
+ error.code = code;
11
+ return error;
12
+ }
13
+
14
+ function cancelled(reason) {
15
+ const error = localAIError('ARCANE_AI_REQUEST_ABORTED', 'The local AI request was cancelled.', reason);
16
+ error.name = 'AbortError';
17
+ return error;
18
+ }
19
+
20
+ /**
21
+ * Browser-safe llama.cpp access through an existing Core connection. The app
22
+ * selects its model; Core owns the running engine and its loaded sessions.
23
+ * Creating this provider neither connects a transport nor installs a runtime.
24
+ */
25
+ export function createCoreLocalAIProvider({client = getInstalledCoreClient(), id = 'llama.cpp'} = {}) {
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))) {
28
+ throw new TypeError('The Core AI client must expose invoke and events.on.');
29
+ }
30
+
31
+ let runtime = null;
32
+ let models = [];
33
+ let selection = null;
34
+ let disposed = false;
35
+ let loading = false;
36
+ let sequence = 0;
37
+ let lifecycleRevision = 0;
38
+ const requests = new Set();
39
+
40
+ function requireClient() {
41
+ if (disposed) throw localAIError('ARCANE_AI_PROVIDER_DISPOSED', 'The Core AI provider is disposed.');
42
+ if (!client) throw localAIError('ARCANE_AI_CORE_UNAVAILABLE', 'Local llama.cpp requires an available Core connection.');
43
+ return client;
44
+ }
45
+
46
+ function requireSelection(value) {
47
+ if (value?.providerId !== id || !is.string(value?.modelId) || !value.modelId
48
+ || value.localOnly !== true) {
49
+ throw localAIError('ARCANE_AI_MODEL_AUTHORITY_REQUIRED', 'The local provider requires an explicit matching provider and model.');
50
+ }
51
+ return value;
52
+ }
53
+
54
+ function loadedModel() {
55
+ return selection && models.find(function selectedModel(model) {
56
+ return model.id === selection.modelId && model.loaded === true;
57
+ });
58
+ }
59
+
60
+ function modelReady() {
61
+ return runtime?.available === true && runtime.state === 'ready' && Boolean(loadedModel());
62
+ }
63
+
64
+ function status() {
65
+ const loaded = !disposed && modelReady();
66
+ return {
67
+ state: disposed ? 'disposed' : loading ? 'loading' : loaded ? 'ready'
68
+ : runtime?.error ? 'error' : runtime?.available === true ? 'unloaded' : 'unavailable',
69
+ loaded,
70
+ busy: loading || requests.size > 0,
71
+ modelId: selection?.modelId ?? null,
72
+ available: !disposed && runtime?.available === true,
73
+ installed: runtime?.installed === true,
74
+ error: runtime?.error ?? null
75
+ };
76
+ }
77
+
78
+ function acceptRuntime(value) {
79
+ runtime = value ?? null;
80
+ models = is.array(runtime?.models) ? runtime.models : [];
81
+ for (const request of requests) {
82
+ const model = models.find(function requestModel(record) { return record.id === request.modelId; });
83
+ if (model?.loaded === true) request.loaded = true;
84
+ if (runtime?.available !== true || (request.loaded && (runtime.state !== 'ready' || model?.loaded !== true))) {
85
+ request.controller.abort(localAIError('ARCANE_AI_MODEL_NOT_READY', 'The requested local model is no longer ready.', runtime?.error));
86
+ }
87
+ }
88
+ return status();
89
+ }
90
+
91
+ const unsubscribe = client?.events.on('localai.state', function localRuntimeChanged(snapshot) {
92
+ if (disposed || !is.array(snapshot?.runtimes)) return;
93
+ lifecycleRevision += 1;
94
+ acceptRuntime(snapshot.runtimes.find(function llamaRuntime(value) {
95
+ return value.id === 'llama.cpp';
96
+ }));
97
+ }) ?? function noCoreSubscription() {};
98
+
99
+ function authority(value) {
100
+ return {protocol: AUTHORITY_PROTOCOL, providerId: id, modelId: value.modelId};
101
+ }
102
+
103
+ async function inspect(value, {signal} = {}) {
104
+ requireSelection(value);
105
+ if (signal?.aborted) throw cancelled(signal.reason);
106
+ try {
107
+ const core = requireClient();
108
+ const statusRevision = lifecycleRevision;
109
+ const current = await core.invoke('llama.status', {}, {signal});
110
+ if (lifecycleRevision === statusRevision) acceptRuntime(current);
111
+ if (runtime?.available !== true) {
112
+ return {
113
+ available: false,
114
+ code: 'ARCANE_AI_PROVIDER_UNAVAILABLE',
115
+ message: 'The local llama.cpp runtime is unavailable.',
116
+ error: runtime?.error ?? null
117
+ };
118
+ }
119
+ if (!models.some(function matchesModel(model) { return model.id === value.modelId; })) {
120
+ return {
121
+ available: false,
122
+ code: 'ARCANE_AI_MODEL_UNAVAILABLE',
123
+ message: 'The selected model is unavailable in the local llama.cpp runtime.'
124
+ };
125
+ }
126
+ return {available: true, authority: authority(value)};
127
+ } catch (error) {
128
+ if (signal?.aborted || error?.name === 'AbortError') throw error;
129
+ acceptRuntime({...runtime, available: false, state: 'error', error});
130
+ return {available: false, code: error.code ?? 'ARCANE_AI_CORE_UNAVAILABLE', message: error.message, error};
131
+ }
132
+ }
133
+
134
+ async function load({selection: value, signal, progress} = {}) {
135
+ const core = requireClient();
136
+ requireSelection(value);
137
+ if (signal?.aborted) throw cancelled(signal.reason);
138
+ selection = value;
139
+ loading = true;
140
+ try {
141
+ progress?.({phase: 'loading', modelId: value.modelId});
142
+ const revision = lifecycleRevision;
143
+ const current = await core.invoke('llama.load', {model: value.modelId}, {signal});
144
+ if (lifecycleRevision === revision) acceptRuntime(current);
145
+ if (!modelReady()) throw localAIError('ARCANE_AI_MODEL_NOT_READY', 'Core did not report the selected model loaded and ready.', current?.error);
146
+ progress?.({phase: 'ready', modelId: value.modelId});
147
+ return {authority: authority(value), status: {...status(), state: 'ready', busy: false}};
148
+ } finally {
149
+ loading = false;
150
+ }
151
+ }
152
+
153
+ function ownRequest(signal, modelId) {
154
+ 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) {
159
+ return model.id === modelId && model.loaded === true;
160
+ })};
161
+ requests.add(request);
162
+ request.release = function releaseRequest() {
163
+ signal?.removeEventListener('abort', forwardAbort);
164
+ requests.delete(request);
165
+ };
166
+ return request;
167
+ }
168
+
169
+ function streamRequest(core, parameters, signal) {
170
+ const request = ownRequest(signal, parameters.model);
171
+ const chunks = [];
172
+ const readers = [];
173
+ let complete = false;
174
+ let failure = null;
175
+ sequence += 1;
176
+ const streamId = is.function(core.uuid) ? core.uuid()
177
+ : globalThis.crypto?.randomUUID?.() ?? `${id}:${Date.now()}:${sequence}:${Math.random()}`;
178
+
179
+ function flush() {
180
+ while (readers.length && chunks.length && !request.controller.signal.aborted) {
181
+ readers.shift().resolve({value: chunks.shift(), done: false});
182
+ }
183
+ if (!complete || (chunks.length && !request.controller.signal.aborted)) return;
184
+ while (readers.length) {
185
+ const reader = readers.shift();
186
+ if (failure) reader.reject(failure);
187
+ else reader.resolve({value: undefined, done: true});
188
+ }
189
+ }
190
+
191
+ const stopChunks = core.events.on('llama.chunk', function receiveChunk(event) {
192
+ if (event?.streamId !== streamId || complete || request.controller.signal.aborted) return;
193
+ chunks.push(event.chunk);
194
+ flush();
195
+ });
196
+
197
+ request.result = Promise.resolve().then(function invokeStream() {
198
+ return core.invoke('llama.chat', {...parameters, stream: true, streamId}, {signal: request.controller.signal});
199
+ }).then(function streamCompleted(result) {
200
+ if (request.controller.signal.aborted) throw cancelled(request.controller.signal.reason);
201
+ return result;
202
+ }).catch(function streamFailed(error) {
203
+ failure = error;
204
+ throw error;
205
+ }).finally(function releaseStream() {
206
+ complete = true;
207
+ stopChunks();
208
+ request.release();
209
+ flush();
210
+ });
211
+ request.result.catch(function observeRetainedStreamFailure() {});
212
+
213
+ function cancel(reason) {
214
+ if (!complete) request.controller.abort(reason);
215
+ return request.result.then(function cancelledAfterSuccess() {}, function cancelledAfterFailure() {});
216
+ }
217
+
218
+ return {
219
+ result: request.result,
220
+ cancel,
221
+ [Symbol.asyncIterator]() {
222
+ return {
223
+ next() {
224
+ if (request.controller.signal.aborted) return Promise.reject(failure ?? cancelled(request.controller.signal.reason));
225
+ if (chunks.length) return Promise.resolve({value: chunks.shift(), done: false});
226
+ if (complete) return failure ? Promise.reject(failure) : Promise.resolve({value: undefined, done: true});
227
+ return new Promise(function awaitChunk(resolve, reject) { readers.push({resolve, reject}); });
228
+ },
229
+ async return() {
230
+ await cancel();
231
+ return {value: undefined, done: true};
232
+ }
233
+ };
234
+ }
235
+ };
236
+ }
237
+
238
+ async function request({selection: value, operation, payload, signal} = {}) {
239
+ const core = requireClient();
240
+ requireSelection(value);
241
+ if (signal?.aborted) throw cancelled(signal.reason);
242
+ if (loading || selection?.modelId !== value.modelId) {
243
+ throw localAIError('ARCANE_AI_MODEL_NOT_READY', 'Load the selected local model before requesting inference.');
244
+ }
245
+ const model = payload?.model ?? value.modelId;
246
+ if (!models.some(function knownModel(record) { return record.id === model; })) {
247
+ const revision = lifecycleRevision;
248
+ const current = await core.invoke('llama.status', {}, {signal});
249
+ if (lifecycleRevision === revision) acceptRuntime(current);
250
+ }
251
+ if (!models.some(function knownModel(record) { return record.id === model; })) {
252
+ throw localAIError('ARCANE_AI_MODEL_UNAVAILABLE', 'The requested model is unavailable in the local llama.cpp runtime.');
253
+ }
254
+ if (runtime?.available !== true || runtime.state !== 'ready'
255
+ || (model === value.modelId && !modelReady())) {
256
+ throw localAIError('ARCANE_AI_MODEL_NOT_READY', 'The requested local runtime or selected model is not ready.');
257
+ }
258
+ const parameters = {model, payload};
259
+ if (operation === 'stream') return streamRequest(core, parameters, signal);
260
+ 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);
262
+ pending.result = Promise.resolve().then(function invokeChat() {
263
+ return core.invoke('llama.chat', {...parameters, stream: false}, {signal: pending.controller.signal});
264
+ }).then(function chatCompleted(result) {
265
+ if (pending.controller.signal.aborted) throw cancelled(pending.controller.signal.reason);
266
+ return result;
267
+ }).finally(pending.release);
268
+ return pending.result;
269
+ }
270
+
271
+ async function unload({selection: value = selection, signal} = {}) {
272
+ const core = requireClient();
273
+ if (signal?.aborted) throw cancelled(signal.reason);
274
+ 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();
283
+ }
284
+
285
+ async function dispose(options = {}) {
286
+ if (disposed) return status();
287
+ if (selection || requests.size) await unload(options);
288
+ disposed = true;
289
+ unsubscribe();
290
+ return status();
291
+ }
292
+
293
+ return {
294
+ protocol: PROVIDER_PROTOCOL,
295
+ role: 'llm',
296
+ id,
297
+ localOnly: true,
298
+ catalog: function catalog() { return [...models]; },
299
+ status,
300
+ inspect,
301
+ load,
302
+ request,
303
+ unload,
304
+ dispose
305
+ };
306
+ }
@@ -2,6 +2,7 @@ import {arcaneEvents} from '../event-manager.mjs';
2
2
  import {CORE_PROTOCOL,CORE_READY_EVENTS,CoreError,serializeCoreError} from './contracts.mjs';
3
3
 
4
4
  const CORE_CLIENT_KEY=Symbol.for('arcane-os.core.client');
5
+ const CORE_CLIENT_INSTALLATION=Symbol.for('arcane-os.core.client.installation');
5
6
  const CORE_EVENT='core.rpc.event';
6
7
  const LONG_OPERATION_TIMEOUT=50*60*1000;
7
8
 
@@ -12,6 +13,7 @@ export function createCoreClient({
12
13
  eventOwner=arcaneEvents,
13
14
  eventOwnerReady,
14
15
  autoConnect=true,
16
+ replayRuntimeState=false,
15
17
  onError=error=>global.console?.error('Arcane Core client failed.',error)
16
18
  }={}){
17
19
  const pending=new Map();
@@ -25,8 +27,14 @@ export function createCoreClient({
25
27
  let transport=null;
26
28
  let closed=false;
27
29
  let eventOwnerFailure=null;
30
+ let transportFailure=null;
31
+ let transportFailureReported=false;
28
32
 
29
33
  function report(error){
34
+ if(error===transportFailure){
35
+ if(transportFailureReported)return;
36
+ transportFailureReported=true;
37
+ }
30
38
  try{onError(error instanceof CoreError?error:new CoreError(error));}
31
39
  catch(reportError){global.console?.error('Arcane Core error listener failed.',reportError,error);}
32
40
  }
@@ -260,7 +268,7 @@ export function createCoreClient({
260
268
  throw new CoreError({code:'ARCANE_TRANSPORT_UNAVAILABLE',message:'This interface is not connected to an Arcane native host.'});
261
269
  }
262
270
  function connect(){
263
- if(closed)throw new CoreError({code:'ARCANE_CLIENT_CLOSED',message:'The Core client is closed.'});
271
+ if(closed)throw transportFailure??new CoreError({code:'ARCANE_CLIENT_CLOSED',message:'The Core client is closed.'});
264
272
  if(transport)return transport;
265
273
  const cleanupStart=cleanups.length;
266
274
  try{
@@ -271,7 +279,11 @@ export function createCoreClient({
271
279
  transport=selected;
272
280
  if(typeof transport.subscribe==='function'){
273
281
  const unsubscribe=transport.subscribe(receive);
274
- if(typeof unsubscribe==='function')cleanups.push(unsubscribe);
282
+ if(typeof unsubscribe==='function'){
283
+ if(closed){
284
+ try{unsubscribe();}catch(error){report(error);}
285
+ }else cleanups.push(unsubscribe);
286
+ }
275
287
  }
276
288
  }catch(error){
277
289
  transport=null;
@@ -280,7 +292,11 @@ export function createCoreClient({
280
292
  }
281
293
  throw error;
282
294
  }
295
+ if(closed)throw transportFailure??new CoreError({code:'ARCANE_CLIENT_CLOSED',message:'The Core client is closed.'});
283
296
  complete('transport.ready',{protocol:CORE_PROTOCOL,transport:transport.name});
297
+ if(replayRuntimeState&&!closed)sendControl({protocol:CORE_PROTOCOL,type:'control',control:'runtime.replay'});
298
+ // A native callback may close the client synchronously during replay.
299
+ if(closed)throw transportFailure??new CoreError({code:'ARCANE_CLIENT_CLOSED',message:'The Core client is closed.'});
284
300
  return transport;
285
301
  }
286
302
  function runtimeSnapshot(){
@@ -312,6 +328,7 @@ export function createCoreClient({
312
328
  return true;
313
329
  }
314
330
  function invoke(method,parameters={},options={}){
331
+ if(transportFailure)return Promise.reject(transportFailure);
315
332
  if(eventOwnerFailure)return Promise.reject(eventOwnerFailure);
316
333
  let selected;
317
334
  try{selected=connect();}catch(error){return Promise.reject(error);}
@@ -343,23 +360,37 @@ export function createCoreClient({
343
360
  }catch(error){sendFailed(error);}
344
361
  return promise;
345
362
  }
346
- function close(){
347
- if(closed)return false;
348
- closed=true;
349
- cancelAll();
363
+ function disposeTransport(error){
350
364
  for(const record of acknowledgements.values()){
351
365
  clearTimeout(record.timer);
352
- record.reject(new CoreError({code:'ARCANE_CLIENT_CLOSED',message:'The Core client is closed.'}));
366
+ record.reject(error);
353
367
  }
354
368
  acknowledgements.clear();
355
369
  for(const cleanup of cleanups.splice(0)){
356
370
  try{cleanup();}catch(error){report(error);}
357
371
  }
358
- source?.dispose();
372
+ try{source?.dispose();}catch(disposeError){report(disposeError);}
373
+ source=null;
374
+ transport=null;
359
375
  // Registration closures and already-delivered payloads belong to this
360
376
  // client lifetime and are released when the owner explicitly closes it.
361
377
  eventActions.length=0;
362
378
  completedEvents.clear();
379
+ }
380
+ function failTransport(error){
381
+ if(closed)return false;
382
+ transportFailure=error instanceof CoreError?error:new CoreError(error);
383
+ closed=true;
384
+ for(const id of pending.keys())settle(id,transportFailure);
385
+ disposeTransport(transportFailure);
386
+ report(transportFailure);
387
+ return true;
388
+ }
389
+ function close(){
390
+ if(closed)return false;
391
+ closed=true;
392
+ cancelAll();
393
+ disposeTransport(new CoreError({code:'ARCANE_CLIENT_CLOSED',message:'The Core client is closed.'}));
363
394
  return true;
364
395
  }
365
396
  if(typeof global.addEventListener==='function'){
@@ -369,7 +400,7 @@ export function createCoreClient({
369
400
  if(autoConnect){
370
401
  try{connect();}catch(error){if(error.code!=='ARCANE_TRANSPORT_UNAVAILABLE')report(error);}
371
402
  }
372
- return {protocol:CORE_PROTOCOL,Error:CoreError,invoke,receive,connect,close,cancelAll,events,eventsReady,runtime:{current:runtimeSnapshot},uuid};
403
+ return {protocol:CORE_PROTOCOL,Error:CoreError,invoke,receive,connect,close,failTransport,cancelAll,events,eventsReady,runtime:{current:runtimeSnapshot},uuid};
373
404
  }
374
405
 
375
406
  /** Existing namespace call shapes; method implementations remain host-owned. */
@@ -461,28 +492,58 @@ export function createCoreFacade(client){
461
492
  };
462
493
  }
463
494
 
495
+ /** Reads an installed SDK client without creating a transport or changing globals. */
496
+ export function getInstalledCoreClient(global=globalThis){
497
+ const client=global?.[CORE_CLIENT_KEY];
498
+ const installation=client?.[CORE_CLIENT_INSTALLATION];
499
+ return installation?.global===global&&installation.active?client:null;
500
+ }
501
+
464
502
  /** Installs the same synchronous facade used by classic native-host scripts. */
465
503
  export function installCoreClient(global=globalThis,options={}){
466
504
  if(global[CORE_CLIENT_KEY])return global[CORE_CLIENT_KEY];
467
505
  const client=createCoreClient({...options,global,autoConnect:false});
468
506
  const facade=createCoreFacade(client);
507
+ const installation={global,active:true};
508
+ client[CORE_CLIENT_INSTALLATION]=installation;
509
+ const previousFacade=global.Arcane;
469
510
  global.Arcane=facade;
470
511
  global[CORE_CLIENT_KEY]=client;
471
512
  const previous=global.__arcaneReceive;
472
513
  global.__arcaneReceive=client.receive;
514
+ const previousFailure=global.__arcaneTransportFailed;
515
+ function nativeTransportFailed(error){return client.failTransport(error);}
516
+ global.__arcaneTransportFailed=nativeTransportFailed;
473
517
  const close=client.close;
474
- client.close=function closeInstalledClient(){
475
- const changed=close();
518
+ const failTransport=client.failTransport;
519
+ let installedTransportFailure=null;
520
+ function releaseInstalledClient(){
521
+ installation.active=false;
476
522
  if(global[CORE_CLIENT_KEY]===client)delete global[CORE_CLIENT_KEY];
477
- if(global.Arcane===facade)delete global.Arcane;
523
+ if(global.Arcane===facade){
524
+ if(previousFacade===undefined)delete global.Arcane;
525
+ else global.Arcane=previousFacade;
526
+ }
478
527
  if(global.__arcaneReceive===client.receive){
479
528
  if(previous===undefined)delete global.__arcaneReceive;
480
529
  else global.__arcaneReceive=previous;
481
530
  }
482
- return changed;
531
+ if(global.__arcaneTransportFailed===nativeTransportFailed){
532
+ if(previousFailure===undefined)delete global.__arcaneTransportFailed;
533
+ else global.__arcaneTransportFailed=previousFailure;
534
+ }
535
+ }
536
+ client.close=function closeInstalledClient(){
537
+ installation.active=false;
538
+ try{return close();}finally{releaseInstalledClient();}
539
+ };
540
+ client.failTransport=function failInstalledTransport(error){
541
+ if(installation.active)installedTransportFailure=error instanceof CoreError?error:new CoreError(error);
542
+ releaseInstalledClient();
543
+ return failTransport(installedTransportFailure??error);
483
544
  };
484
545
  try{client.connect();}catch(error){
485
- if(error.code!=='ARCANE_TRANSPORT_UNAVAILABLE'){
546
+ if(error!==installedTransportFailure&&error.code!=='ARCANE_TRANSPORT_UNAVAILABLE'){
486
547
  if(options.onError)options.onError(error);
487
548
  else global.console?.error('Arcane Core transport initialization failed.',error);
488
549
  }
@@ -7,7 +7,8 @@ export const CORE_FRAME_CONTRACTS={
7
7
  response:{type:'response',fields:['protocol','type','id','ok'],success:['result'],failure:['error'],optional:['time']},
8
8
  event:{type:'event',fields:['protocol','type','event','data'],optional:['time']},
9
9
  cancel:{type:'control',control:'request.cancel',fields:['protocol','type','control','requestId']},
10
- cancelAll:{type:'control',control:'requests.cancelAll',fields:['protocol','type','control']}
10
+ cancelAll:{type:'control',control:'requests.cancelAll',fields:['protocol','type','control']},
11
+ replay:{type:'control',control:'runtime.replay',fields:['protocol','type','control']}
11
12
  };
12
13
 
13
14
  /** These names describe existing methods; installed services determine availability. */
@@ -0,0 +1,82 @@
1
+ # Consumer updates
2
+
3
+ Development applications track the published npm `latest` channel. Declare
4
+ `"arcane-os": "latest"` in the application's existing dependency section.
5
+ Preserve an existing package alias where the application needs it; Arcane OS,
6
+ for example, uses `"arcane-sdk": "npm:arcane-os@latest"` to avoid a name collision.
7
+ Pin an application's SDK version only when Roshi explicitly designates it as a
8
+ production build. An exact version recorded in `package-lock.json` records what
9
+ was installed; it does not turn a development application into a production pin.
10
+
11
+ ## Development refresh
12
+
13
+ Each application's existing owner performs its refresh through the public npm
14
+ package workflow in its canonical `main` checkout. Coordinate the dependency
15
+ and Git mutation window before cleanup, preserve active processes and other
16
+ owners' work, and establish applicable dependency permissions before removing
17
+ the installed tree. Respect explicit project stops, including Lifeline.
18
+
19
+ 1. Keep the SDK declaration at `latest` and preserve `package-lock.json`.
20
+ 2. Resolve the exact application path and remove only that application's
21
+ disposable `node_modules`, before pulling. Do not sweep sibling projects or
22
+ shared caches.
23
+ 3. Run `git pull` and observe the result. Preserve existing changes and stop the
24
+ affected operation if the pull fails or leaves conflicts.
25
+ 4. Run `npm i` through the application's supported install workflow, retaining
26
+ its applicable lifecycle-script options.
27
+ 5. Explicitly run `npm update arcane-os` to refresh only the SDK. For an existing
28
+ alias, target its dependency key, such as `npm update arcane-sdk`.
29
+ 6. Before materializing managed files or building, obtain npm's published
30
+ `latest` version with a fresh registry lookup, such as
31
+ `npm view arcane-os@latest version --prefer-online`. Read the actual installed
32
+ package version and the lockfile's resolved SDK version. Both must match that
33
+ published version. If the tag advances during the operation, refresh the SDK
34
+ again and report the actual selected version.
35
+ 7. Update managed SDK projections only through the application's supported
36
+ public package workflow. Commit the intended declaration, retained updated
37
+ lockfile, managed files, and relevant instructions through its normal owner.
38
+
39
+ A `latest` declaration and `npm i` alone are insufficient: an existing lockfile
40
+ can preserve an older resolution. Keep and update the lockfile instead of
41
+ deleting it and re-resolving unrelated dependencies. Surface installation and
42
+ compatibility failures; do not claim current adoption from a manifest label.
43
+
44
+ This dependency-result comparison is part of the requested refresh. It does
45
+ not authorize application tests, unrelated checks or dependency updates,
46
+ recurring polling, broad cleanup, or production administration. A later release
47
+ can advance `latest`; record the exact version selected by each refresh.
48
+
49
+ ## Production builds
50
+
51
+ On Roshi's explicit production designation, record the selected exact SDK
52
+ version consistently in the dependency declaration, lockfile, managed
53
+ projections, and application instructions. Use the supported locked install
54
+ workflow, normally `npm ci`, to reproduce that selection. A production pin
55
+ records reproducibility, not a permanent ceiling on otherwise authorized
56
+ needed updates. Preserve an explicit update deferral or actual compatibility
57
+ constraint.
58
+
59
+ ## Feature requests and release notices
60
+
61
+ Inspect the published SDK's public API and documentation before implementing a
62
+ request. If it already supports the requested behavior, tell the requesting
63
+ application owner which API and usage provide it and link directly to the
64
+ relevant documentation. Do not present unpublished source as available support.
65
+
66
+ After a release's npm version and channel are confirmed, notify only existing
67
+ application owners whose requests it addresses. Notify every requester covered
68
+ by matching, similar, or bundled requests when one shared feature serves them.
69
+ Do not send routine release broadcasts to unrelated consumers.
70
+
71
+ Each notice includes the exact published version and channel, requested
72
+ functionality delivered, its relevance, release notes or a changelog link,
73
+ direct API and usage documentation references, and required compatibility or
74
+ adoption steps. Keep publication evidence distinct from application behavior.
75
+ Deduplicate by version and recipient and retain send outcomes in the release
76
+ handoff. Report unreachable owners and resume missing deliveries.
77
+
78
+ After every release and applicable requester notices, update the documentation
79
+ and existing SDK website for all affected public contracts, including releases
80
+ with no requesters. Record those delivery outcomes separately. The SDK owner
81
+ retains this follow-through; application upgrades and tests remain with their
82
+ owners and never gate SDK publication. See [npm publication](publishing.md).