arcane-os 0.56.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 (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/core-local.mjs +306 -0
  4. package/docs/reference/README.md +20 -0
  5. package/docs/reference/cli.md +9 -1
  6. package/docs/reference/core-native-packaging.md +195 -11
  7. package/docs/reference/inventory/package-api.json +138 -12
  8. package/docs/reference/local-ai.md +197 -0
  9. package/docs/reference/protocols.md +69 -1
  10. package/docs/reference/runtime-modules.md +6 -5
  11. package/docs/reference/sdk-api.md +165 -26
  12. package/package.json +5 -1
  13. package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
  14. package/schemas/arcane-app.schema.json +54 -0
  15. package/schemas/arcane-package.schema.json +44 -0
  16. package/src/app-descriptor.mjs +17 -1
  17. package/src/cli/main.mjs +8 -2
  18. package/src/core/host.mjs +23 -0
  19. package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
  20. package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
  21. package/src/core/hosts/windows/ArcaneCoreProcess.cs +3 -3
  22. package/src/core/hosts/windows/ArcaneHost.cs +695 -0
  23. package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
  24. package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
  25. package/src/core/services/local-ai.mjs +543 -0
  26. package/src/dev-server.mjs +7 -0
  27. package/src/import-map.mjs +1 -0
  28. package/src/local-ai/archive.mjs +52 -0
  29. package/src/local-ai/config.mjs +62 -0
  30. package/src/local-ai/dev.mjs +390 -0
  31. package/src/local-ai/discover.mjs +71 -0
  32. package/src/local-ai/http.mjs +106 -0
  33. package/src/local-ai/install.mjs +344 -0
  34. package/src/local-ai/server.mjs +262 -0
  35. package/src/native/portable-layout.mjs +29 -4
  36. package/src/native/portable-provider.mjs +39 -8
  37. package/src/native/windows-provider.mjs +262 -0
  38. package/src/native-provider-loader.mjs +8 -2
  39. package/src/packager/core.mjs +6 -2
  40. package/src/process.mjs +66 -8
  41. package/src/toolchain.mjs +33 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## 0.56.0
4
26
 
5
27
  - Add explicit `package.nativeResources` selection for native-only application
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.56.0` SDK contract. Development applications declare
23
+ This checkout defines the `0.57.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;
@@ -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
+ }
@@ -111,6 +111,26 @@ The [changelog](../../CHANGELOG.md) records changes by version; the
111
111
  identify the corresponding published package source. A newer website does not
112
112
  change the version installed in your application.
113
113
 
114
+ ### Generate the selected reference site
115
+
116
+ The existing `node tools/build-reference-site.mjs --write` command reads the
117
+ live checkout. For release documentation while disjoint source work continues,
118
+ select the reviewed documentation commit explicitly:
119
+
120
+ ```sh
121
+ node tools/build-reference-site.mjs --write --source-ref <documentation-commit>
122
+ ```
123
+
124
+ The same option is accepted by `createReferenceSite({sourceRef})` and
125
+ `writeReferenceSite({sourceRef})`. It reads the selected commit's package
126
+ version, complete reference documents and inventories, inventory-selected
127
+ runtime sources, and authored site inputs directly from Git in memory. It
128
+ creates no checkout or export and leaves source drafts untouched. Rendering
129
+ uses the current canonical generator and its authored semantic contracts;
130
+ select a documentation commit whose contracts match the published package.
131
+ Generated output still belongs to the canonical `site/` tree and requires its
132
+ usual coordinated output ownership. Generation does not publish hosted Pages.
133
+
114
134
  ### Historical 0.3.4 publication record
115
135
 
116
136
  The following records describe that earlier release only. They do not identify
@@ -49,6 +49,7 @@ meaning and cardinality rules:
49
49
  | `--app` | app id or label | Workspace/app operations except shared scope and `verify-bundle`; optional diagnostic label for `mail serve`. |
50
50
  | `--apps-root` | `.` or `apps` | `new`, `init`; standalone setup defaults to the repository root. |
51
51
  | `--sdk-runtime-source` | SDK checkout directory | `dev` only; maps live SDK source through the selected browser destinations for that server process. |
52
+ | `--local-ai` | comma-separated runtime IDs | `dev` only; adds `llama.cpp` and/or `ollama` requirements to application configuration and prepares missing runtimes alongside serving. See [local AI through Core](local-ai.md). |
52
53
  | `--arcane-root` | directory | `doctor`, native `build`/`run`, `native-doctor`, `native-prepare` |
53
54
  | `--host` / `--port` | host / integer 0–65535 | Browser `dev`/`run` default to HTTPS at `127.0.0.1:8000`; `mail serve` defaults to HTTPS with HTTP/2 at `0.0.0.0:4433` and accepts an explicit bind host. |
54
55
  | `--http-port` | integer 0–65535 | Browser `dev`/`run` HTTP redirect listener; defaults to `0`, which selects an available port. |
@@ -366,9 +367,16 @@ An explicit live-source SDK mapping follows the selected browser destinations
366
367
  while reading canonical SDK source; it never replaces installed files.
367
368
 
368
369
  ```text
369
- arcane dev [--app <id>] [--public] [--http | --https] [--cert <file> --key <file>] [--host <address>] [--port 8000] [--http-port 0] [--sdk-runtime-source <sdk-root>]
370
+ arcane dev [--app <id>] [--public] [--http | --https] [--cert <file> --key <file>] [--host <address>] [--port 8000] [--http-port 0] [--sdk-runtime-source <sdk-root>] [--local-ai llama.cpp,ollama]
370
371
  ```
371
372
 
373
+ `--local-ai` saves the selected requirements in `native.localAI`, or in
374
+ `arcane-package.json.localAI` for a package-only app. Later development runs
375
+ reuse them. Available services and executables are reused; missing runtimes
376
+ are installed inside the application while serving starts independently.
377
+ Applications choose their model files and optional browser Core connection.
378
+ See [local AI through Core](local-ai.md) for configuration and native bundling.
379
+
372
380
  ### Lifecycle
373
381
 
374
382
  Startup refreshes the selected authored `arcane-app.json` projection into
@@ -1,22 +1,28 @@
1
1
  # Portable Core packaging
2
2
 
3
3
  `arcane-os/native/portable-provider` owns the portable native payload in the SDK
4
- package. It does not load Arcane OS source, choose models, download a runtime,
5
- or compile an executable. Windows, Linux and macOS hosts may compose this
4
+ package. It does not load Arcane OS source, choose models or compile an
5
+ executable. An application's explicit `native.localAI` selection can include
6
+ its selected upstream runtimes through the SDK local-AI installer. Without that
7
+ selection, assembly does no local-AI runtime preparation. Windows, Linux and macOS hosts may compose this
6
8
  Node-based payload; Android needs its separate native host adaptation. The
7
9
  current CLI's portable platform selections remain Windows and Linux. A portable
8
10
  artifact is not a Windows executable, Linux package or Android application.
9
11
 
10
12
  `loadArcaneNativeProvider({target:'portable'})` selects the installed SDK provider
11
13
  without an `arcaneRoot`. Supplying an explicit `arcaneRoot` retains the existing
12
- checkout-provider route. Other native targets remain with their actual platform
14
+ checkout-provider route. The SDK also owns the `windows-x64` executable provider
15
+ described below. Other native targets remain with their actual platform
13
16
  providers; the loader does not substitute this payload for an unavailable host.
14
17
 
15
18
  ## Provider and selected input
16
19
 
17
20
  The default export and `arcaneNativeBuilderProvider` are the same provider.
18
- `createPortableNativeProvider({services:[]})` creates one with application-owned
19
- service configuration. All implement `arcane-native-builder/1`:
21
+ `createPortableNativeProvider({services})` creates one with application-owned
22
+ service configuration. Without that override, the provider uses the selected
23
+ app descriptor's `native.services`, or an empty selection when omitted. An
24
+ explicit `services:[]` overrides the descriptor with no app services.
25
+ All implement `arcane-native-builder/1`:
20
26
 
21
27
  - `describe()` identifies the portable target and `executable:false`.
22
28
  - `doctor({targetRequest, signal, onEvent})` reports missing SDK Core source files.
@@ -42,6 +48,45 @@ receipts, hashes, content limits or security policy. The package-relative source
42
48
  root comes from the provider module's own location, not `toolchainRoot` or an
43
49
  Arcane OS machine bundle.
44
50
 
51
+ ## Native-only application resources
52
+
53
+ Select application service modules and their complete local source closure in
54
+ the authored schema-2 descriptor's `package.nativeResources`, separately from
55
+ its browser `include`/`exclude` selection:
56
+
57
+ ```json
58
+ {
59
+ "package": {
60
+ "nativeResources": {
61
+ "include": ["native", "src", "package.json"],
62
+ "exclude": ["native/drafts"]
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ This is a descriptor excerpt for the app-owned service example below. The
69
+ projected schema-1 field is top-level `nativeResources`. Paths are literal and
70
+ app-relative; directories include descendants. Omission or an empty include
71
+ keeps previous selection and output behavior. The app supplies every required
72
+ source file explicitly; packaging does not discover or execute services.
73
+
74
+ Paired `buildApplication()` passes the actual native target to the selected app
75
+ and every explicitly bundled app. With a nonempty native include list, their
76
+ input releases use `dist/.native/<target>/<app-id>` instead of the saved browser
77
+ output, preserving existing browser/PWA artifacts. Each app's own selection is
78
+ applied; complete releases and actual roots are forwarded to the provider.
79
+ The provider then copies them unchanged into its artifact layout described below.
80
+
81
+ At the low-level `packageApp()` boundary, `target` defaults to `'browser'` and
82
+ is independent of `moduleFormat`. Nonbrowser selection disables generated PWA
83
+ content. Native-only files bypass browser document/module/asset transformations;
84
+ a file also in the ordinary browser selection retains that existing processing.
85
+ An explicit low-level `outputDirectory` still wins. Inspect the emitted input
86
+ with `verifyApp({workspaceRoot,appId,outputDirectory:release.output})`; neither
87
+ that inventory verification nor packaging launches the service or platform host.
88
+ See [the complete selection contract](protocols.md#native-only-application-resources).
89
+
45
90
  ## Output and host contract
46
91
 
47
92
  Each build creates a fresh `arcane-portable-*` directory beneath `outputRoot`.
@@ -85,7 +130,9 @@ host must:
85
130
  1. Mount `app/` at the root of its local application URL origin, preserving the
86
131
  selected start path and all navigable documents.
87
132
  2. Inject `runtime/arcane-api.js` at document creation. It is produced by the
88
- canonical `createCoreClassicSource()` generator, not a copied OS client.
133
+ canonical `createCoreClassicSource()` generator with `replayRuntimeState:true`.
134
+ Once its transport listener is attached, each document requests the current
135
+ Core and service state without starting services again.
89
136
  3. Start `runtime/arcane-core.mjs` with the host's supported Node runtime and
90
137
  connect the existing framed stdin/stdout transport.
91
138
  4. Close input and await Core's drain before terminating the child process.
@@ -100,7 +147,21 @@ is not the native serving contract. No host is launched during packaging.
100
147
 
101
148
  ## Explicit application services
102
149
 
103
- Pass native service modules already included in the selected app release:
150
+ For the ordinary SDK build command, select native service modules in the
151
+ application's authored descriptor. Include their source closure through
152
+ `package.nativeResources` as described above:
153
+
154
+ ```json
155
+ {
156
+ "native": {
157
+ "services": [
158
+ {"module":"native/moon-ledger.mjs", "options":{"ledgerName":"Cheese debts"}}
159
+ ]
160
+ }
161
+ }
162
+ ```
163
+
164
+ Direct SDK compositions can instead pass that selection to the provider:
104
165
 
105
166
  ```js
106
167
  import {createPortableNativeProvider} from 'arcane-os/native/portable-provider';
@@ -111,7 +172,8 @@ const nativeBuilder = createPortableNativeProvider({
111
172
  ```
112
173
 
113
174
  Each module default-exports a synchronous factory returning the existing Core
114
- service definition. Its options must be JSON-serializable data. Module imports
175
+ service definition. Its first argument is the complete authored options record;
176
+ its second is the shared launch context described below. Options must be JSON-serializable data. Module imports
115
177
  and factories run only when the generated Core entry is launched, never during
116
178
  packaging. Put asynchronous initialization in `start()`, not a top-level await
117
179
  or asynchronous factory, so unrelated services and dispatcher readiness remain
@@ -142,8 +204,130 @@ observable through `onError` and `closed`; no process is killed or forcibly exit
142
204
  See [the Core runtime contract](core-runtime.md) for request and service lifetime
143
205
  details and [the client contract](core-client.md) for native bridge integration.
144
206
 
145
- This source increment supplies portable assembly and service composition.
146
- Desktop WebView executables, platform child-process ownership, Android dispatch
147
- and executable build toolchains require their own completed adapters. Published
207
+ ## Launch-time locations
208
+
209
+ The generated Core entry accepts `--arcane-launch-config <path>`. The public
210
+ `readCoreLaunchContext({argv=process.argv.slice(2)}={})` function in
211
+ `arcane-os/core/host` reads that explicitly selected JSON object. Without the
212
+ flag it returns `{}`. Missing files, missing flag values and malformed JSON
213
+ report their actual errors; the reader does not search for configuration,
214
+ rewrite it or change the process environment.
215
+
216
+ The generated entry supplies an artifact-derived `appRoot` and then applies the
217
+ complete explicit launch context. Every service factory receives that same
218
+ object as its second argument, preserving its authored first argument. A
219
+ launcher can supply the user's selected `workspaceRoot` and writable
220
+ `stateRoot` at launch instead of embedding this machine's paths in an app
221
+ package. The host owns those selections; the SDK does not choose a product's
222
+ workspace, namespace, saved preferences or models. JSON values and additional
223
+ application-owned fields remain unchanged.
224
+
225
+ ## Selected local-AI runtimes
226
+
227
+ When `appDescriptor.native.localAI` is present, portable assembly calls the
228
+ SDK's `bundleLocalAIRuntimes()` owner with its `runtimes` selection and the
229
+ requested target platform/architecture. Its reusable installation directory is
230
+ `<outputRoot>/local-ai-runtimes`; each new payload receives the complete selected
231
+ runtime trees beneath `runtime/local-ai/`. Runtime preparation and app copying
232
+ proceed independently and both settle before assembly can clean up an incomplete
233
+ output. No application service or model executes during packaging.
234
+
235
+ `manifest.core.localAIRuntimes` records the bundled runtime locations relative
236
+ to the artifact root. Only the explicitly configured entry imports
237
+ `createLocalAIService` from `arcane-os/core/local-ai`. It resolves those bundled
238
+ locations against the installed artifact and supplies them in the launch
239
+ context's `runtimes` array, alongside `appRoot`, before applying explicit launch
240
+ configuration. It passes the complete authored `native.localAI` record to the
241
+ factory. Applications without that selection have no generated local-AI import
242
+ or service. Availability of a particular engine belongs to the selected
243
+ local-AI installer and service; a generic ONNX runtime is not a speech model.
244
+
245
+ ## Windows executable
246
+
247
+ The ordinary application command is:
248
+
249
+ ```sh
250
+ npm exec -- arcane build --target windows-x64
251
+ ```
252
+
253
+ `loadArcaneNativeProvider({target:'windows-x64'})` selects the SDK's own
254
+ `arcane-os/native/windows-provider` without an OS checkout or `arcaneRoot`.
255
+ Its default export and `arcaneNativeBuilderProvider` are the same provider;
256
+ `createWindowsNativeProvider({services,hostDirectory})` permits explicit
257
+ same-project composition. Service selection follows the portable provider's
258
+ descriptor/override contract above.
259
+
260
+ The provider assembles the selected app with the precompiled host asset
261
+ `arcane-native-windows-x64.tar.gz` from the numeric GitHub release matching the
262
+ builder's installed SDK version. The asset contains the launcher, WebView2
263
+ support libraries, Node SEA Core executable and required license files. It does
264
+ not select the newest host independently of that package. Downloads occur only
265
+ during `build()`, use the caller's output directory, and are reused from
266
+ `<outputRoot>/.arcane-native-hosts/<sdk-version>/windows-x64`. The build machine
267
+ needs the standard `tar` command for extraction; application builds do not need
268
+ a C# compiler, system Node installation for the generated executable, or host
269
+ source scripts. The SDK build command itself still runs through the application's
270
+ normal Node/npm toolchain.
271
+
272
+ An explicit `hostDirectory` selects an already-built complete host tree instead
273
+ of downloading one. This is useful for SDK collaborators verifying the next
274
+ host output; downstream applications use the published package and matching
275
+ release asset. `doctor()` reports the selected prerequisites and missing files;
276
+ it does not download the host or probe the installed WebView runtime.
277
+
278
+ The assembled directory contains `Arcane.exe`, the unchanged `app/` payload,
279
+ Core runtime and semantic `arcane-native.json`. Launch `Arcane.exe` on Windows
280
+ x64 with the Microsoft Edge WebView2 Runtime installed. It opens the selected
281
+ app in WebView2 and owns its framed connection to the bundled Core process.
282
+ `verify()` observes the assembled files and manifest; it does not execute the
283
+ window or prove application behavior. `run()` launches the executable and
284
+ observes complete diagnostics and exit. Cancelling that operation closes its
285
+ input, allowing the window and accepted Core service work to drain without a
286
+ forced process termination.
287
+
288
+ For explicit user-selected workspace/state locations, launch:
289
+
290
+ ```sh
291
+ Arcane.exe --arcane-launch-config path/to/launch.json
292
+ ```
293
+
294
+ The complete JSON object reaches the service factories as their second
295
+ argument. A supplied `stateRoot` also owns the WebView2 profile and diagnostic
296
+ directory. Otherwise the launcher uses the current user's local application
297
+ data under `Arcane/<app-id>`. The launcher does not invent an application
298
+ workspace. Relative launch-file paths resolve from the invocation's working
299
+ directory. `--close-on-stdin-eof` is an explicit process-owner option used by the
300
+ SDK runner; an ordinary double-clicked window closes through its own UI.
301
+
302
+ ## Windows host source and selected output
303
+
304
+ The SDK's `src/core/hosts/windows/ArcaneCoreProcess.cs` owns an explicitly
305
+ selected executable, argument string and working directory. It reads complete
306
+ framed Core messages and diagnostics, acknowledges ordered asynchronous writes,
307
+ and closes stdin before awaiting accepted Core work, final output and process
308
+ exit. It does not kill the child on an ordinary close.
309
+
310
+ `ArcaneHost.cs` composes that process with a WebView2 window through
311
+ `ArcaneHost.Run(options,onDiagnostic,onError)` or `ArcaneHostForm`. Options
312
+ select the app root/start path, stable virtual HTTPS origin, profile directory,
313
+ title, canonical classic-client source and Core process inputs. The form exposes
314
+ `Ready`, `Completion` and `CloseAsync()`. Page navigation, transport connection,
315
+ Core readiness and service readiness are separate states. Accepted work drains
316
+ before the window closes; complete engineering errors go to the supplied
317
+ diagnostic/error callbacks. Callbacks must not synchronously wait for the
318
+ window's lifetime promises.
319
+
320
+ `ArcaneLauncher.cs` supplies the executable entry point for that composition.
321
+ `tools/build-core-windows-host.mjs` builds the selected SDK host output from the
322
+ approved WebView2 package, existing .NET Framework compiler and selected Node
323
+ SEA output. It retains complete compiler diagnostics and produces a fresh
324
+ directory rather than overwriting an earlier artifact. The host requires
325
+ .NET Framework 4.6.2 or later. This is SDK release tooling, not an application
326
+ build requirement. Source compilation, selected host execution and real
327
+ application behavior are separate evidence boundaries; an assembled app is not
328
+ an installer or proof of its domain behavior.
329
+
330
+ Linux, macOS, Android dispatch and each platform's output verification retain
331
+ their separate delivery boundaries. Published
148
332
  consumers must adopt the numeric package that includes this increment; a source
149
333
  checkout or a successful source push is not that public package authority.