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
@@ -61,7 +61,7 @@ function serviceSelection(services, release) {
61
61
  if (!is.string(service?.module) || !release.files.includes(service.module)) {
62
62
  throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The selected release does not contain service module ${String(service?.module)}.`);
63
63
  }
64
- return {module: service.module, options: service.options === undefined ? {} : service.options};
64
+ return {...service, options: service.options === undefined ? {} : service.options};
65
65
  });
66
66
  }
67
67
 
@@ -81,7 +81,7 @@ function eventOwnerPath(release) {
81
81
  }
82
82
 
83
83
  /** Package-owned assembly; never imports an OS checkout or application service. */
84
- export function createPortableNativeProvider({services = []} = {}) {
84
+ export function createPortableNativeProvider({services} = {}) {
85
85
  return {
86
86
  protocol: PROTOCOL,
87
87
  async describe() {
@@ -120,7 +120,8 @@ export function createPortableNativeProvider({services = []} = {}) {
120
120
  const {appDescriptor, release, signal, onEvent} = request;
121
121
  throwIfAborted(signal);
122
122
  const target = portableTarget(request.targetRequest);
123
- const selectedServices = serviceSelection(services, release);
123
+ const localAI = appDescriptor.native?.localAI;
124
+ const selectedServices = serviceSelection(services ?? appDescriptor.native?.services ?? [], release);
124
125
  const eventOwnerModuleURL = eventOwnerPath(release);
125
126
  const output = await outputDirectory(request);
126
127
  throwIfAborted(signal);
@@ -130,7 +131,33 @@ export function createPortableNativeProvider({services = []} = {}) {
130
131
  const files = [];
131
132
  try {
132
133
  await emit(onEvent, {type: 'native.payload.started', appId: appDescriptor.id, outputRoot: root});
133
- await copyRelease({...request, releaseRoot: request.appReleaseRoot}, 'app', root, files, signal);
134
+ async function bundleSelectedRuntimes() {
135
+ if (localAI === undefined) return {runtimes: [], files: []};
136
+ const {bundleLocalAIRuntimes} = await import('../local-ai/install.mjs');
137
+ return bundleLocalAIRuntimes({
138
+ runtimes: localAI.runtimes,
139
+ directory: path.join(output, 'local-ai-runtimes'),
140
+ outputRoot: root,
141
+ platform: target.platform === 'windows' ? 'win32' : target.platform,
142
+ architecture: target.architecture,
143
+ signal,
144
+ onEvent
145
+ });
146
+ }
147
+ // These outputs have separate directories. Join both owners
148
+ // before cleanup so neither can keep writing a removed assembly.
149
+ const preparation = await Promise.allSettled([
150
+ copyRelease({...request, releaseRoot: request.appReleaseRoot}, 'app', root, files, signal),
151
+ bundleSelectedRuntimes()
152
+ ]);
153
+ const failures = preparation.filter(function failedPreparation(result) { return result.status === 'rejected'; });
154
+ if (failures.length === 1) throw failures[0].reason;
155
+ if (failures.length > 1) {
156
+ throw new AggregateError(failures.map(function preparationError(result) { return result.reason; }),
157
+ 'Portable application and local runtime preparation failed.');
158
+ }
159
+ const bundled = preparation[1].value;
160
+ files.push(...bundled.files);
134
161
  const dependencies = [];
135
162
  for (const [index, dependency] of (request.dependencies ?? []).entries()) {
136
163
  const directory = `dependencies/${index}`;
@@ -155,8 +182,9 @@ export function createPortableNativeProvider({services = []} = {}) {
155
182
  appSdk = {name: metadata.name, version: metadata.version, root: destination};
156
183
  }
157
184
  await writeOutput(root, 'package.json', '{"type":"module"}\n', files, signal);
158
- await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices), files, signal);
159
- await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({eventOwnerModuleURL}), files, signal);
185
+ await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices,
186
+ {localAI, runtimes: bundled.runtimes}), files, signal);
187
+ await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({eventOwnerModuleURL, replayRuntimeState: true}), files, signal);
160
188
  const manifest = {
161
189
  schemaVersion: 1,
162
190
  kind: 'arcane-portable-native',
@@ -166,8 +194,11 @@ export function createPortableNativeProvider({services = []} = {}) {
166
194
  target,
167
195
  webRoot: 'app',
168
196
  start: release.manifest?.app?.start ?? './index.html',
169
- core: {entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices},
170
- client: {source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL},
197
+ core: {
198
+ entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
199
+ ...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})
200
+ },
201
+ client: {source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL, replayRuntimeState: true},
171
202
  dependencies,
172
203
  minimumCoreVersion: request.minimumCoreVersion ?? null,
173
204
  files: [...files, MANIFEST]
@@ -0,0 +1,262 @@
1
+ import {createWriteStream} from 'node:fs';
2
+ import {mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {Readable} from 'node:stream';
5
+ import {pipeline} from 'node:stream/promises';
6
+ import Is from 'strong-type';
7
+ import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
8
+ import {runProcess} from '../process.mjs';
9
+ import {createPortableNativeProvider} from './portable-provider.mjs';
10
+ import {writeOutput} from './portable-layout.mjs';
11
+
12
+ const is = new Is(false);
13
+ const PROTOCOL = 'arcane-native-builder/1';
14
+ const MANIFEST = 'arcane-native.json';
15
+ const HOST_ASSET = 'arcane-native-windows-x64.tar.gz';
16
+ const HOST_FILES = [
17
+ 'Arcane.exe',
18
+ 'Arcane.exe.config',
19
+ 'Microsoft.Web.WebView2.Core.dll',
20
+ 'Microsoft.Web.WebView2.WinForms.dll',
21
+ 'WebView2Loader.dll',
22
+ 'runtime/ArcaneCore.exe',
23
+ 'runtime/arcane-core-loader.cjs',
24
+ 'runtime/NODE-LICENSE',
25
+ 'WEBVIEW2-LICENSE',
26
+ 'LICENSE',
27
+ 'COMMERCIAL-LICENSE.md',
28
+ 'NOTICE'
29
+ ];
30
+
31
+ function windowsTarget(value) {
32
+ if (value?.target !== 'windows-x64' || value.platform !== 'windows'
33
+ || value.architecture !== 'x64' || value.format !== 'exe') {
34
+ throw new ArcaneError(ERROR_CODES.targetUnavailable, 'The Windows provider assembles windows-x64 executables.');
35
+ }
36
+ return {...value};
37
+ }
38
+
39
+ function portableRequest(target) {
40
+ return {...target, target: 'portable', format: 'portable'};
41
+ }
42
+
43
+ async function emit(onEvent, event) {
44
+ if (is.function(onEvent)) await onEvent(event);
45
+ }
46
+
47
+ async function hostFilesMissing(directory) {
48
+ const missing = [];
49
+ for (const relative of HOST_FILES) {
50
+ try {
51
+ if (!(await stat(path.join(directory, relative))).isFile()) missing.push(relative);
52
+ } catch (error) {
53
+ if (error.code !== 'ENOENT') throw error;
54
+ missing.push(relative);
55
+ }
56
+ }
57
+ return missing;
58
+ }
59
+
60
+ async function requireHostDirectory(directory) {
61
+ const resolved = await realpath(directory);
62
+ const missing = await hostFilesMissing(resolved);
63
+ if (missing.length) {
64
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, 'The selected Windows host assets are incomplete.', {
65
+ details: {hostDirectory: resolved, missing}
66
+ });
67
+ }
68
+ return resolved;
69
+ }
70
+
71
+ async function removeOwnedDirectory(directory, error) {
72
+ try { await rm(directory, {recursive: true, force: true}); }
73
+ catch (cleanupError) {
74
+ throw new AggregateError([error, cleanupError], `Windows preparation failed; incomplete output remains at ${directory}.`);
75
+ }
76
+ throw error;
77
+ }
78
+
79
+ async function releasedHostDirectory(outputRoot, version, {signal, onEvent}) {
80
+ const cache = path.join(outputRoot, '.arcane-native-hosts', version);
81
+ const destination = path.join(cache, 'windows-x64');
82
+ try { return await requireHostDirectory(destination); }
83
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
84
+ throwIfAborted(signal);
85
+ await mkdir(cache, {recursive: true});
86
+ const temporary = await mkdtemp(path.join(cache, 'preparing-windows-x64-'));
87
+ const archive = path.join(temporary, HOST_ASSET);
88
+ const extracted = path.join(temporary, 'host');
89
+ const url = `https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${version}/${HOST_ASSET}`;
90
+ try {
91
+ await emit(onEvent, {type: 'native.host.download.started', target: 'windows-x64', version, url});
92
+ throwIfAborted(signal);
93
+ const response = await fetch(url, {signal});
94
+ if (!response.ok) {
95
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The SDK Windows host download returned HTTP ${response.status}.`, {
96
+ details: {url, status: response.status, response: await response.text()}
97
+ });
98
+ }
99
+ if (!response.body) throw new ArcaneError(ERROR_CODES.operationFailed, 'The SDK Windows host download returned no content.', {details: {url}});
100
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(archive, {flags: 'wx'}), {signal});
101
+ throwIfAborted(signal);
102
+ await mkdir(extracted);
103
+ await runProcess('tar', ['-xzf', archive, '-C', extracted], {cwd: temporary, signal, onEvent});
104
+ await requireHostDirectory(extracted);
105
+ throwIfAborted(signal);
106
+ try { await rename(extracted, destination); }
107
+ catch (error) {
108
+ // Another preparation may have finished this exact version while
109
+ // this download was in flight. Preserve its complete asset tree.
110
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error;
111
+ try { await requireHostDirectory(destination); }
112
+ catch { throw error; }
113
+ }
114
+ await rm(temporary, {recursive: true, force: true});
115
+ await emit(onEvent, {type: 'native.host.download.completed', target: 'windows-x64', version, hostDirectory: destination});
116
+ throwIfAborted(signal);
117
+ return destination;
118
+ } catch (error) {
119
+ return removeOwnedDirectory(temporary, error);
120
+ }
121
+ }
122
+
123
+ function contains(root, candidate) {
124
+ const relative = path.relative(root, candidate);
125
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
126
+ }
127
+
128
+ async function copyHostAssets(source, root, files, signal, relative = '', ancestors = new Set()) {
129
+ throwIfAborted(signal);
130
+ const canonical = await realpath(source);
131
+ const output = await realpath(root);
132
+ if (contains(canonical, output) || contains(output, canonical) || ancestors.has(canonical)) {
133
+ throw new ArcaneError(ERROR_CODES.usage, 'Windows host assets overlap their output or contain a directory cycle.', {
134
+ details: {source, outputRoot: root}
135
+ });
136
+ }
137
+ const parents = new Set([...ancestors, canonical]);
138
+ for (const entry of await readdir(source, {withFileTypes: true})) {
139
+ throwIfAborted(signal);
140
+ const selected = path.join(source, entry.name);
141
+ const destination = relative ? `${relative}/${entry.name}` : entry.name;
142
+ const information = await stat(selected);
143
+ if (information.isDirectory()) {
144
+ await mkdir(path.join(root, destination), {recursive: true});
145
+ await copyHostAssets(selected, root, files, signal, destination, parents);
146
+ } else if (information.isFile()) {
147
+ await writeOutput(root, destination, await readFile(selected, {signal}), files, signal);
148
+ } else {
149
+ throw new ArcaneError(ERROR_CODES.operationFailed, `Cannot copy Windows host asset ${selected}.`);
150
+ }
151
+ }
152
+ }
153
+
154
+ /** Compose app-owned services with the SDK's precompiled Windows host. */
155
+ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
156
+ const portable = createPortableNativeProvider(services === undefined ? {} : {services});
157
+ return {
158
+ protocol: PROTOCOL,
159
+ async describe() {
160
+ return {
161
+ protocol: PROTOCOL,
162
+ id: 'arcane-sdk-windows-x64',
163
+ targets: ['windows-x64'],
164
+ executable: true,
165
+ serviceConfiguration: 'explicit release module factories'
166
+ };
167
+ },
168
+ async doctor({targetRequest, signal, onEvent} = {}) {
169
+ throwIfAborted(signal);
170
+ const target = windowsTarget(targetRequest);
171
+ const core = await portable.doctor({targetRequest: portableRequest(target), signal});
172
+ const missing = hostDirectory === undefined ? [] : await hostFilesMissing(path.resolve(hostDirectory));
173
+ const result = {
174
+ target: 'windows-x64', executable: true, ready: core.ready && missing.length === 0,
175
+ sdkRoot: core.sdkRoot, missing: [...core.missing, ...missing],
176
+ hostDirectory: hostDirectory === undefined ? null : path.resolve(hostDirectory),
177
+ hostSource: hostDirectory === undefined ? 'sdk-release' : 'selected-directory',
178
+ downloadRequired: hostDirectory === undefined,
179
+ runtimePrerequisite: 'Microsoft Edge WebView2 Runtime',
180
+ buildPrerequisite: hostDirectory === undefined ? 'tar' : null
181
+ };
182
+ await emit(onEvent, {type: 'native.doctor.completed', ...result});
183
+ throwIfAborted(signal);
184
+ return result;
185
+ },
186
+ async prepare({targetRequest, signal, onEvent} = {}) {
187
+ throwIfAborted(signal);
188
+ const target = windowsTarget(targetRequest);
189
+ const selection = await portable.prepare({targetRequest: portableRequest(target), signal});
190
+ const selected = hostDirectory === undefined ? undefined : await requireHostDirectory(hostDirectory);
191
+ // Downloads belong to build(), which has the caller's selected
192
+ // outputRoot. Preparation never invents a machine-wide cache.
193
+ const result = {...selection, target, executable: true, ...(selected ? {hostDirectory: selected} : {})};
194
+ await emit(onEvent, {type: 'native.prepared', target: 'windows-x64', sdkVersion: selection.sdkVersion});
195
+ throwIfAborted(signal);
196
+ return result;
197
+ },
198
+ async build(request = {}) {
199
+ const {signal, onEvent} = request;
200
+ throwIfAborted(signal);
201
+ const target = windowsTarget(request.targetRequest);
202
+ const selected = hostDirectory ?? request.toolchain?.hostDirectory;
203
+ const selectedHost = selected === undefined ? null : await requireHostDirectory(selected);
204
+ const artifact = await portable.build({
205
+ ...request,
206
+ targetRequest: portableRequest(target),
207
+ async onEvent(event) {
208
+ if (event.type !== 'native.payload.completed') await emit(onEvent, {...event, target: 'windows-x64'});
209
+ }
210
+ });
211
+ const root = artifact.target.rootDir;
212
+ try {
213
+ const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, {signal, onEvent});
214
+ const files = [...artifact.manifest.files];
215
+ await copyHostAssets(host, root, files, signal);
216
+ const manifest = {
217
+ ...artifact.manifest,
218
+ kind: 'arcane-windows-native',
219
+ target,
220
+ host: {
221
+ executable: 'Arcane.exe',
222
+ platform: 'windows', architecture: 'x64', runtime: 'webview2',
223
+ coreExecutable: 'runtime/ArcaneCore.exe',
224
+ coreLoader: 'runtime/arcane-core-loader.cjs'
225
+ },
226
+ files
227
+ };
228
+ await writeFile(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {signal});
229
+ throwIfAborted(signal);
230
+ await emit(onEvent, {type: 'native.payload.completed', target: 'windows-x64', appId: request.appDescriptor.id, outputRoot: root});
231
+ throwIfAborted(signal);
232
+ return {...artifact, target: {...target, rootDir: root}, manifest};
233
+ } catch (error) {
234
+ return removeOwnedDirectory(root, error);
235
+ }
236
+ },
237
+ async verify({artifact, targetRequest, signal, onEvent} = {}) {
238
+ throwIfAborted(signal);
239
+ const target = windowsTarget(targetRequest);
240
+ const result = await portable.verify({artifact, targetRequest: portableRequest(target), signal});
241
+ await requireHostDirectory(result.outputRoot);
242
+ const verified = {...result, target: 'windows-x64', executable: true};
243
+ await emit(onEvent, {type: 'native.verified', target: 'windows-x64', outputRoot: result.outputRoot});
244
+ throwIfAborted(signal);
245
+ return verified;
246
+ },
247
+ async run({artifact, targetRequest, signal, onEvent} = {}) {
248
+ throwIfAborted(signal);
249
+ windowsTarget(targetRequest);
250
+ if (process.platform !== 'win32' || process.arch !== 'x64') {
251
+ throw new ArcaneError(ERROR_CODES.nativeRunUnsupported, 'Running this Windows x64 artifact requires a Windows x64 host.');
252
+ }
253
+ const root = await requireHostDirectory(artifact?.target?.rootDir);
254
+ return runProcess(path.join(root, 'Arcane.exe'), ['--close-on-stdin-eof'], {
255
+ cwd: root, signal, onEvent, cancellationMode: 'close-input'
256
+ });
257
+ }
258
+ };
259
+ }
260
+
261
+ export const arcaneNativeBuilderProvider = createWindowsNativeProvider();
262
+ export default arcaneNativeBuilderProvider;
@@ -22,6 +22,11 @@ const FIXED_NATIVE_PROVIDER_PATHS={
22
22
  'android-arm64':providerPath('android-native-provider.mjs')
23
23
  };
24
24
 
25
+ const SDK_NATIVE_PROVIDER_PATHS={
26
+ portable:'./native/portable-provider.mjs',
27
+ 'windows-x64':'./native/windows-provider.mjs'
28
+ };
29
+
25
30
  export const ARCANE_NATIVE_PROVIDER_PATHS={
26
31
  portable:[...FIXED_NATIVE_PROVIDER_PATHS.portable],
27
32
  'windows-x64':[...FIXED_NATIVE_PROVIDER_PATHS['windows-x64']],
@@ -133,10 +138,11 @@ export async function loadArcaneNativeProvider(options={}){
133
138
  target,
134
139
  message:`Loading the Arcane ${String(target)} provider.`
135
140
  });
136
- const location=arcaneRoot===undefined&&target==='portable'
141
+ const sdkProvider=SDK_NATIVE_PROVIDER_PATHS[target];
142
+ const location=arcaneRoot===undefined&&sdkProvider
137
143
  ?{
138
144
  canonicalRoot:path.resolve(fileURLToPath(new URL('../',import.meta.url))),
139
- providerPath:fileURLToPath(new URL('./native/portable-provider.mjs',import.meta.url)),
145
+ providerPath:fileURLToPath(new URL(sdkProvider,import.meta.url)),
140
146
  providerSource:'sdk-package'
141
147
  }
142
148
  :await resolveProviderLocation({arcaneRoot,target,inspect,canonicalize});
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
15
  import {appRelativeRoot,resolveAppRoot,resolvePackageOutputRoot} from '../app-layout.mjs';
16
16
  import {TARGET_IDS} from '../constants.mjs';
17
+ import {normalizeLocalAIConfig} from '../local-ai/config.mjs';
17
18
  import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
18
19
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
19
20
  import {materializeNativeModules} from './native-modules.mjs';
@@ -321,7 +322,7 @@ function normalizeOptionalRecord(value,label){
321
322
  export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
322
323
  assertOnlyKeys(value,new Set([
323
324
  'schemaVersion','id','displayName','version','entry','strategy','security',
324
- 'localAIModelPolicy','include','exclude','shared','adapter','pwa','outputDirectory','documents','nativeResources'
325
+ 'localAIModelPolicy','localAI','include','exclude','shared','adapter','pwa','outputDirectory','documents','nativeResources'
325
326
  ]),`${appId}/${APP_CONFIG_NAME}`);
326
327
  if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
327
328
  if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
@@ -390,6 +391,7 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
390
391
  value.localAIModelPolicy,
391
392
  `${appId}/${APP_CONFIG_NAME}.localAIModelPolicy`
392
393
  )}),
394
+ ...(value.localAI===undefined?{}:{localAI:normalizeLocalAIConfig(value.localAI)}),
393
395
  include,
394
396
  exclude,
395
397
  ...(nativeResources===undefined?{}:{nativeResources}),
@@ -742,6 +744,7 @@ async function inspectContext(context,{signal,records}={}){
742
744
  ...(context.config.localAIModelPolicy===undefined?{}:{
743
745
  localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
744
746
  }),
747
+ ...(context.config.localAI===undefined?{}:{localAI:copyJson(context.config.localAI)}),
745
748
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
746
749
  descriptor:await optionalDescriptor(context),
747
750
  browserDocuments:documents,
@@ -871,7 +874,8 @@ function releaseManifest(context,files,pwaArtifacts,rootDocument){
871
874
  ...(context.config.security===undefined?{}:{security:copyJson(context.config.security)}),
872
875
  ...(context.config.localAIModelPolicy===undefined?{}:{
873
876
  localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
874
- })
877
+ }),
878
+ ...(context.config.localAI===undefined?{}:{localAI:copyJson(context.config.localAI)})
875
879
  },
876
880
  files:[...files]
877
881
  };
package/src/process.mjs CHANGED
@@ -124,12 +124,16 @@ export async function runProcess(command,args=[],{
124
124
  heartbeatMs=5000,
125
125
  terminationGraceMs=DEFAULT_TERMINATION_GRACE_MS,
126
126
  allowNonzero=false,
127
- input
127
+ input,
128
+ cancellationMode='terminate-tree'
128
129
  }={}){
129
130
  throwIfAborted(signal);
130
131
  if(!is.array(args)||args.some(argument=>!is.string(argument))){
131
132
  throw new ArcaneError(ERROR_CODES.usage,'Process arguments must be a fixed array of strings.');
132
133
  }
134
+ if(cancellationMode!=='terminate-tree'&&cancellationMode!=='close-input'){
135
+ throw new ArcaneError(ERROR_CODES.usage,'Unknown process cancellation mode.');
136
+ }
133
137
  if(!is.integer(terminationGraceMs)||terminationGraceMs<100||terminationGraceMs>30_000){
134
138
  throw new ArcaneError(
135
139
  ERROR_CODES.usage,
@@ -153,12 +157,15 @@ export async function runProcess(command,args=[],{
153
157
  return new Promise((resolve,reject)=>{
154
158
  let stdout='';
155
159
  let stderr='';
160
+ let childClosed=false;
161
+ let closedResult=null;
156
162
  let settlementStarted=false;
157
163
  let cancellationRequested=false;
158
164
  let cancellationError=null;
159
165
  let terminationRequested=false;
160
166
  let escalation=null;
161
167
  let spawnError=null;
168
+ let inputError=null;
162
169
  let child;
163
170
  let heartbeat=null;
164
171
  const deliveries=new Set();
@@ -184,9 +191,37 @@ export async function runProcess(command,args=[],{
184
191
  clearTimeout(escalation);
185
192
  signal?.removeEventListener('abort',abort);
186
193
  void (async()=>{
194
+ let callbackFailure=null;
187
195
  try{
188
196
  await events.drain();
189
- }catch(callbackFailure){
197
+ }catch(error){
198
+ callbackFailure=error;
199
+ }
200
+ if(cancellationMode==='close-input'&&closedResult){
201
+ const failures=[...new Set([
202
+ callback===reject?value:null,
203
+ callbackFailure,inputError,cancellationError,spawnError
204
+ ].filter(error=>error!==null))];
205
+ if(failures.length>0){
206
+ const primary=failures[0];
207
+ // Owned cancellation/exit errors already carry this exact
208
+ // result. Other errors retain their identity and cause in
209
+ // the complete failure record instead of being rewritten.
210
+ if(failures.length===1&&primary?.details===closedResult){
211
+ reject(primary);
212
+ return;
213
+ }
214
+ const failure=new ArcaneError(
215
+ primary?.code??ERROR_CODES.operationFailed,
216
+ primary?.message??`Could not complete ${command}.`,
217
+ {cause:primary,details:closedResult,exitCode:primary?.exitCode}
218
+ );
219
+ failure.errors=failures;
220
+ reject(failure);
221
+ return;
222
+ }
223
+ }
224
+ if(callbackFailure){
190
225
  reject(callbackFailure);
191
226
  return;
192
227
  }
@@ -195,10 +230,16 @@ export async function runProcess(command,args=[],{
195
230
  };
196
231
 
197
232
  const stopTree=()=>{
198
- if(terminationRequested||settlementStarted){
233
+ if(terminationRequested||settlementStarted||childClosed){
199
234
  return;
200
235
  }
201
236
  terminationRequested=true;
237
+ if(cancellationMode==='close-input'){
238
+ // The selected host owns shutdown after EOF. Keep observing its
239
+ // complete output and exit; accepted durable work may outlive UI.
240
+ child?.stdin.end();
241
+ return;
242
+ }
202
243
  terminateProcessTree(child);
203
244
  escalation=setTimeout(()=>{
204
245
  if(childIsRunning(child)){
@@ -214,7 +255,7 @@ export async function runProcess(command,args=[],{
214
255
  };
215
256
 
216
257
  const abort=()=>{
217
- if(cancellationRequested||settlementStarted){
258
+ if(cancellationRequested||settlementStarted||childClosed){
218
259
  return;
219
260
  }
220
261
  cancellationRequested=true;
@@ -225,7 +266,9 @@ export async function runProcess(command,args=[],{
225
266
  );
226
267
  void events.enqueue({
227
268
  type:'process.cancellation.requested',
228
- message:`Stopping ${command} and its child processes.`,
269
+ message:cancellationMode==='close-input'
270
+ ?`Closing ${command} input and waiting for its owned shutdown.`
271
+ :`Stopping ${command} and its child processes.`,
229
272
  data:{command,pid:child?.pid??null}
230
273
  });
231
274
  stopTree();
@@ -286,6 +329,10 @@ export async function runProcess(command,args=[],{
286
329
  }
287
330
  });
288
331
  child.on('close',(code,terminationSignal)=>{
332
+ // The child lifetime has ended even while its output/event callbacks
333
+ // are still draining. A later abort cannot cancel that completed work.
334
+ childClosed=true;
335
+ signal?.removeEventListener('abort',abort);
289
336
  clearInterval(heartbeat);
290
337
  void (async()=>{
291
338
  await drainDeliveries();
@@ -299,10 +346,11 @@ export async function runProcess(command,args=[],{
299
346
  stdout,
300
347
  stderr
301
348
  };
349
+ closedResult=result;
302
350
  if(terminationRequested){
303
351
  // The direct child has exited. Make one final tree-wide attempt so
304
352
  // a descendant that ignored the graceful request cannot outlive it.
305
- terminateProcessTree(child,{force:true});
353
+ if(cancellationMode!=='close-input')terminateProcessTree(child,{force:true});
306
354
  if(cancellationRequested){
307
355
  await events.enqueue({
308
356
  type:'process.cancelled',
@@ -310,7 +358,8 @@ export async function runProcess(command,args=[],{
310
358
  data:{command,code:result.code,signal:terminationSignal}
311
359
  });
312
360
  }
313
- finish(reject,events.error??cancellationError??new ArcaneError(
361
+ if(cancellationError)cancellationError.details=result;
362
+ finish(reject,events.error??inputError??cancellationError??new ArcaneError(
314
363
  ERROR_CODES.operationFailed,
315
364
  `Stopped ${command} after its event callback failed.`
316
365
  ));
@@ -324,6 +373,12 @@ export async function runProcess(command,args=[],{
324
373
  ));
325
374
  return;
326
375
  }
376
+ if(inputError){
377
+ finish(reject,new ArcaneError(ERROR_CODES.operationFailed,
378
+ `Could not write ${command} input: ${inputError.message}`,
379
+ {cause:inputError,details:result}));
380
+ return;
381
+ }
327
382
  await events.enqueue({
328
383
  type:'process.completed',
329
384
  message:`${command} exited with code ${String(result.code)}.`,
@@ -346,7 +401,10 @@ export async function runProcess(command,args=[],{
346
401
  abort();
347
402
  }
348
403
 
349
- if(input!==undefined){
404
+ if(cancellationMode==='close-input'){
405
+ child.stdin.on('error',error=>{inputError=error;stopTree();});
406
+ if(input!==undefined&&!terminationRequested)child.stdin.write(input);
407
+ }else if(input!==undefined){
350
408
  child.stdin.end(input);
351
409
  }else{
352
410
  child.stdin.end();
package/src/toolchain.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import Is from 'strong-type';
2
2
  import path from 'node:path';
3
- import {resolvePackageOutputRoot} from './app-layout.mjs';
3
+ import {resolveAppRoot,resolvePackageOutputRoot} from './app-layout.mjs';
4
4
  import {readdir,lstat,realpath,mkdir,readFile,writeFile} from 'node:fs/promises';
5
5
  import {createWorkspace,initWorkspace} from './scaffold.mjs';
6
6
  import {
@@ -17,6 +17,8 @@ import {createPwaArtifacts} from './pwa.mjs';
17
17
  import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
18
18
  import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
19
19
  import {refreshAppPackageProjection} from './app-descriptor.mjs';
20
+ import {configureDevelopmentLocalAI} from './local-ai/config.mjs';
21
+ import {createDevelopmentLocalAI} from './local-ai/dev.mjs';
20
22
  import {
21
23
  discoverApps as discoverPackagerApps,
22
24
  inspectApp as inspectPackagedApp,
@@ -566,6 +568,11 @@ export async function developApplication(options = {}) {
566
568
  onEvent: options.onEvent
567
569
  },
568
570
  async function refreshDevelopmentWorkspace(workspaceOperationLease) {
571
+ await configureDevelopmentLocalAI({
572
+ appRoot: resolveAppRoot(profile.workspaceRoot, profile.config, appId),
573
+ runtimes: options.localAI,
574
+ signal: options.signal
575
+ });
569
576
  await refreshAppPackageProjection(
570
577
  {
571
578
  workspaceRoot: profile.workspaceRoot,
@@ -589,7 +596,19 @@ export async function developApplication(options = {}) {
589
596
  return current;
590
597
  }
591
598
  );
592
- const server=await startDevServer({
599
+ const localAIConfig = prepared.descriptor?.native?.localAI;
600
+ const localAI = localAIConfig?.runtimes.length ? createDevelopmentLocalAI({
601
+ config: localAIConfig,
602
+ appRoot: prepared.appRoot,
603
+ directory: path.join(prepared.appRoot, '.arcane', 'local-ai', 'runtimes'),
604
+ application: prepared.descriptor,
605
+ version: prepared.descriptor.version,
606
+ signal: options.signal,
607
+ onEvent: options.onEvent
608
+ }) : undefined;
609
+ let server;
610
+ try {
611
+ server=await startDevServer({
593
612
  workspaceRoot:prepared.workspaceRoot,
594
613
  appId:prepared.appId,
595
614
  mode:'source',
@@ -605,10 +624,19 @@ export async function developApplication(options = {}) {
605
624
  tls:options.tls,
606
625
  certPath:options.certPath,
607
626
  keyPath:options.keyPath,
627
+ localAI,
608
628
  signal:options.signal,
609
629
  onEvent:options.onEvent
610
- });
611
- return {...server,appId:prepared.appId,mode:'source'};
630
+ });
631
+ } catch (error) {
632
+ try {
633
+ await localAI?.close(error);
634
+ } catch (closeError) {
635
+ throw new AggregateError([error, closeError], 'Development startup and local AI shutdown failed.');
636
+ }
637
+ throw error;
638
+ }
639
+ return {...server,appId:prepared.appId,mode:'source',...(localAI ? {localAI} : {})};
612
640
  }
613
641
 
614
642
  export async function packageApplication(options={}){
@@ -840,7 +868,7 @@ export function assertIntegratedNativeToolchain({
840
868
  workspaceMode,workspaceRoot,toolchainRoot,target,providerSource='arcane-checkout'
841
869
  }={}){
842
870
  if(workspaceMode!=='integrated')return;
843
- if(providerSource==='sdk-package'&&target==='portable')return;
871
+ if(providerSource==='sdk-package'&&(target==='portable'||target==='windows-x64'))return;
844
872
  if(!is.string(toolchainRoot)||!sameCanonicalPath(workspaceRoot,toolchainRoot)){
845
873
  throw new ArcaneError(
846
874
  ERROR_CODES.policyDenied,