arcane-os 0.88.0 → 0.89.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 (44) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/browser-decisions.mjs +31 -4
  4. package/browser-runtime/ai/core-local.mjs +105 -25
  5. package/browser-runtime/ai/decision-runtime.mjs +7 -0
  6. package/browser-runtime/ai/decision-worker.mjs +9 -1
  7. package/browser-runtime/core/client.mjs +6 -0
  8. package/browser-runtime/core/contracts.mjs +10 -0
  9. package/docs/reference/ai/browser-decisions.md +89 -7
  10. package/docs/reference/cli.md +10 -0
  11. package/docs/reference/core-client.md +59 -0
  12. package/docs/reference/core-native-packaging.md +57 -10
  13. package/docs/reference/core-repositories.md +47 -3
  14. package/docs/reference/inventory/package-api.json +15 -15
  15. package/docs/reference/inventory/runtime-components.json +1 -1
  16. package/docs/reference/inventory/runtime-modules.json +4 -4
  17. package/docs/reference/local-ai.md +86 -3
  18. package/docs/reference/mcp-stdio.md +76 -1
  19. package/docs/reference/native-app-control.md +17 -5
  20. package/docs/reference/protocols.md +14 -0
  21. package/docs/reference/runtime-modules.md +9 -3
  22. package/docs/reference/sdk-api.md +146 -23
  23. package/package.json +2 -2
  24. package/runtime/arcane/modules/RiskSignalAnalyzer.js +1 -5
  25. package/schemas/arcane-app.schema.json +13 -3
  26. package/schemas/arcane-package.schema.json +10 -1
  27. package/src/app-descriptor.mjs +6 -2
  28. package/src/cli/app-control.mjs +1 -1
  29. package/src/core/hosts/windows/ArcaneHost.cs +273 -5
  30. package/src/core/hosts/windows/ArcaneLauncher.cs +9 -0
  31. package/src/core/hosts/windows/ArcaneWindowControl.cs +7 -7
  32. package/src/core/repositories.mjs +11 -1
  33. package/src/core/services/local-ai.mjs +183 -22
  34. package/src/installed-sdk-runtime.mjs +2 -0
  35. package/src/local-ai/config.mjs +16 -0
  36. package/src/local-ai/dev.mjs +2 -2
  37. package/src/local-ai/discover.mjs +3 -0
  38. package/src/local-ai/install.mjs +130 -20
  39. package/src/local-ai/llama-execution-windows.ps1 +158 -0
  40. package/src/local-ai/llama-execution.mjs +169 -0
  41. package/src/local-ai/server.mjs +38 -8
  42. package/src/native/portable-layout.mjs +13 -3
  43. package/src/workspace-operation-lock.mjs +23 -10
  44. package/src/workspace-runtime.mjs +97 -1
@@ -0,0 +1,158 @@
1
+ # Invoked through stdin by the SDK's owned process; stdout contains one JSON record.
2
+ $ErrorActionPreference = 'Stop'
3
+ $ProgressPreference = 'SilentlyContinue'
4
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
5
+
6
+ try {
7
+ # Enumerate the installed driver's devices in this helper only. An empty
8
+ # CUDA_VISIBLE_DEVICES value hides every device, so remove the inherited value.
9
+ [Environment]::SetEnvironmentVariable('CUDA_VISIBLE_DEVICES', $null, [EnvironmentVariableTarget]::Process)
10
+
11
+ Add-Type -TypeDefinition @'
12
+ using System;
13
+ using System.Globalization;
14
+ using System.Runtime.InteropServices;
15
+ using System.Text;
16
+
17
+ namespace Arcane.LocalAI {
18
+ [StructLayout(LayoutKind.Sequential)]
19
+ public struct AdapterLuid {
20
+ public uint LowPart;
21
+ public int HighPart;
22
+ }
23
+
24
+ public sealed class CudaDeviceIdentity {
25
+ public string Uuid;
26
+ public uint NodeMask;
27
+ public AdapterLuid Luid;
28
+ public string UuidFunction;
29
+
30
+ // NVIDIA CU_DEVICE_ATTRIBUTE_TCC_DRIVER is 35. CUDA's Windows LUID
31
+ // contract is undefined in TCC mode, so only WDDM devices can be matched.
32
+ // https://nvidia.github.io/cuda-python/cuda-bindings/latest/module/driver.html
33
+ private const int TccDriverAttribute = 35;
34
+
35
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
36
+ private static extern int cuInit(uint flags);
37
+
38
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
39
+ private static extern int cuDeviceGetCount(out int count);
40
+
41
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
42
+ private static extern int cuDeviceGet(out int device, int ordinal);
43
+
44
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
45
+ private static extern int cuDeviceGetAttribute(out int value, int attribute, int device);
46
+
47
+ // CUDA writes the native Windows LUID layout: unsigned LowPart followed
48
+ // by signed HighPart. Marshaling the struct preserves that native layout.
49
+ // https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-luid
50
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
51
+ private static extern int cuDeviceGetLuid(out AdapterLuid luid, out uint nodeMask, int device);
52
+
53
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
54
+ private static extern int cuDeviceGetUuid_v2([Out] byte[] uuid, int device);
55
+
56
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
57
+ private static extern int cuDeviceGetUuid([Out] byte[] uuid, int device);
58
+
59
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
60
+ private static extern int cuGetErrorName(int error, out IntPtr name);
61
+
62
+ [DllImport("nvcuda.dll", ExactSpelling = true)]
63
+ private static extern int cuGetErrorString(int error, out IntPtr message);
64
+
65
+ private static void Check(int result, string operation) {
66
+ if (result == 0) return;
67
+ IntPtr name;
68
+ IntPtr message;
69
+ int nameResult = cuGetErrorName(result, out name);
70
+ int messageResult = cuGetErrorString(result, out message);
71
+ throw new InvalidOperationException(
72
+ operation + " returned CUDA error " + result + ": " +
73
+ (nameResult == 0 ? Marshal.PtrToStringAnsi(name) : "cuGetErrorName returned " + nameResult) + "; " +
74
+ (messageResult == 0 ? Marshal.PtrToStringAnsi(message) : "cuGetErrorString returned " + messageResult)
75
+ );
76
+ }
77
+
78
+ private static string ReadUuid(int device, out string function) {
79
+ // CUuuid's 16 native octets are unavoidable ABI storage. CUDA UUID
80
+ // text keeps their order; System.Guid would reverse several fields.
81
+ byte[] uuid = new byte[16];
82
+ int result;
83
+ try {
84
+ result = cuDeviceGetUuid_v2(uuid, device);
85
+ function = "cuDeviceGetUuid_v2";
86
+ } catch (EntryPointNotFoundException) {
87
+ // Older drivers expose the original function for this same
88
+ // device. No device or backend selection changes on this path.
89
+ result = cuDeviceGetUuid(uuid, device);
90
+ function = "cuDeviceGetUuid";
91
+ }
92
+ Check(result, function);
93
+ StringBuilder text = new StringBuilder("GPU-");
94
+ for (int index = 0; index < uuid.Length; index++) {
95
+ if (index == 4 || index == 6 || index == 8 || index == 10) text.Append('-');
96
+ text.Append(uuid[index].ToString("x2", CultureInfo.InvariantCulture));
97
+ }
98
+ return text.ToString();
99
+ }
100
+
101
+ public static CudaDeviceIdentity Resolve(uint lowPart, int highPart) {
102
+ Check(cuInit(0), "cuInit");
103
+ int count;
104
+ Check(cuDeviceGetCount(out count), "cuDeviceGetCount");
105
+ CudaDeviceIdentity matched = null;
106
+ int tccCount = 0;
107
+ for (int ordinal = 0; ordinal < count; ordinal++) {
108
+ int device;
109
+ Check(cuDeviceGet(out device, ordinal), "cuDeviceGet(" + ordinal + ")");
110
+ int tcc;
111
+ Check(cuDeviceGetAttribute(out tcc, TccDriverAttribute, device), "cuDeviceGetAttribute(TCC_DRIVER, " + ordinal + ")");
112
+ if (tcc != 0) {
113
+ tccCount++;
114
+ continue;
115
+ }
116
+ AdapterLuid luid;
117
+ uint nodeMask;
118
+ Check(cuDeviceGetLuid(out luid, out nodeMask, device), "cuDeviceGetLuid(" + ordinal + ")");
119
+ if (luid.LowPart != lowPart || luid.HighPart != highPart) continue;
120
+ if (matched != null) {
121
+ throw new InvalidOperationException("Multiple CUDA devices share the selected adapter LUID; the catalog selection cannot identify one CUDA device.");
122
+ }
123
+ string function;
124
+ string uuid = ReadUuid(device, out function);
125
+ matched = new CudaDeviceIdentity { Uuid = uuid, NodeMask = nodeMask, Luid = luid, UuidFunction = function };
126
+ }
127
+ if (matched == null) {
128
+ throw new InvalidOperationException(
129
+ "No WDDM CUDA device matches the selected adapter LUID. CUDA devices enumerated: " + count +
130
+ "; TCC devices without a defined LUID: " + tccCount + "."
131
+ );
132
+ }
133
+ return matched;
134
+ }
135
+ }
136
+ }
137
+ '@
138
+
139
+ $lowPart = [uint32]::Parse($env:ARCANE_LLAMA_LUID_LOW, [Globalization.CultureInfo]::InvariantCulture)
140
+ $highPart = [int32]::Parse($env:ARCANE_LLAMA_LUID_HIGH, [Globalization.CultureInfo]::InvariantCulture)
141
+ $device = [Arcane.LocalAI.CudaDeviceIdentity]::Resolve($lowPart, $highPart)
142
+ [ordered]@{
143
+ uuid = $device.Uuid
144
+ nodeMask = $device.NodeMask
145
+ adapterLuid = [ordered]@{
146
+ lowPart = $device.Luid.LowPart
147
+ highPart = $device.Luid.HighPart
148
+ }
149
+ driverMode = 'wddm'
150
+ uuidFunction = $device.UuidFunction
151
+ } | ConvertTo-Json -Depth 4 -Compress
152
+ } catch {
153
+ [Console]::Error.WriteLine($_.Exception.ToString())
154
+ if ($null -ne $_.ErrorDetails) { [Console]::Error.WriteLine($_.ErrorDetails.Message) }
155
+ if ($null -ne $_.InvocationInfo) { [Console]::Error.WriteLine($_.InvocationInfo.PositionMessage) }
156
+ if ($_.ScriptStackTrace) { [Console]::Error.WriteLine($_.ScriptStackTrace) }
157
+ exit 1
158
+ }
@@ -0,0 +1,169 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import Is from 'strong-type';
4
+ import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
5
+ import {runProcess} from '../process.mjs';
6
+
7
+ const is = new Is(false);
8
+
9
+ /**
10
+ * Resolve one explicit catalog selection for a new native llama server lifetime.
11
+ * Default selection performs no discovery. Hardware identity and launch options
12
+ * are configuration evidence; the server's observed execution target stays null.
13
+ */
14
+ export async function resolveLlamaExecution({
15
+ executionTarget, executionDevices, runtime, signal, onEvent
16
+ } = {}) {
17
+ throwIfAborted(signal);
18
+ if (executionTarget === undefined || executionTarget === null) {
19
+ const cpu = runtime?.requestedBackend === 'cpu';
20
+ return {
21
+ args: cpu ? ['--device', 'none', '--gpu-layers', '0'] : [],
22
+ env: {},
23
+ execution: {
24
+ requestedTarget: null,
25
+ resolvedDevice: null,
26
+ resolution: 'automatic',
27
+ reason: cpu ? 'runtime-backend-selected' : 'engine-default-required',
28
+ configuredTarget: cpu ? {backend: 'cpu', device: 'none', gpuLayers: 0} : null,
29
+ observedTarget: null,
30
+ fallback: false
31
+ }
32
+ };
33
+ }
34
+ if (!is.function(executionDevices?.resolveTarget)) {
35
+ throw new ArcaneError(
36
+ 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
37
+ 'Explicit llama.cpp device selection requires the host execution-device catalog.',
38
+ {details: {requestedTarget: executionTarget}}
39
+ );
40
+ }
41
+ // The persistent device ID is resolved again for this load because a Windows
42
+ // adapter LUID belongs to the current host session, not the saved selection.
43
+ const resolution = await executionDevices.resolveTarget(
44
+ {executionTarget, refresh: true, signal}
45
+ );
46
+ throwIfAborted(signal);
47
+ const execution = {
48
+ ...resolution,
49
+ configuredTarget: null,
50
+ observedTarget: null,
51
+ fallback: false
52
+ };
53
+ if (resolution.resolution !== 'matched' || !resolution.resolvedDevice) {
54
+ throw new ArcaneError(
55
+ resolution.resolution === 'unsupported'
56
+ ? 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED'
57
+ : 'LOCAL_AI_EXECUTION_TARGET_UNAVAILABLE',
58
+ `The selected llama.cpp execution device could not be resolved: ${resolution.reason}.`,
59
+ {details: execution}
60
+ );
61
+ }
62
+ const device = resolution.resolvedDevice;
63
+ if (device.kind === 'cpu') {
64
+ execution.configuredTarget = {
65
+ backend: 'cpu', deviceId: device.deviceId, device: 'none', gpuLayers: 0
66
+ };
67
+ return {args: ['--device', 'none', '--gpu-layers', '0'], env: {}, execution};
68
+ }
69
+ if (device.kind !== 'gpu' || process.platform !== 'win32') {
70
+ throw new ArcaneError(
71
+ 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
72
+ `Exact llama.cpp ${device.kind} selection is unavailable on ${process.platform}.`,
73
+ {details: execution}
74
+ );
75
+ }
76
+ if (runtime?.requestedBackend === 'cpu') {
77
+ throw new ArcaneError(
78
+ 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
79
+ 'The selected GPU is incompatible with the explicit llama.cpp CPU runtime configuration.',
80
+ {details: execution}
81
+ );
82
+ }
83
+ if (runtime?.backend !== 'cuda') {
84
+ throw new ArcaneError(
85
+ 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
86
+ 'Exact Windows GPU selection requires a llama.cpp CUDA runtime.',
87
+ {details: {...execution, runtimeBackend: runtime?.backend ?? null}}
88
+ );
89
+ }
90
+ const adapterLuid = device.addresses?.adapterLuid;
91
+ if (!is.integer(adapterLuid?.lowPart) || adapterLuid.lowPart < 0 || adapterLuid.lowPart > 0xffffffff
92
+ || !is.integer(adapterLuid?.highPart) || adapterLuid.highPart < -0x80000000 || adapterLuid.highPart > 0x7fffffff) {
93
+ throw new ArcaneError(
94
+ 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
95
+ 'The selected Windows GPU has no current adapter LUID for CUDA identity resolution.',
96
+ {details: execution}
97
+ );
98
+ }
99
+ const identity = await resolveWindowsCudaIdentity({adapterLuid, signal, onEvent});
100
+ throwIfAborted(signal);
101
+ execution.configuredTarget = {
102
+ backend: 'cuda',
103
+ deviceId: device.deviceId,
104
+ device: 'CUDA0',
105
+ uuid: identity.uuid,
106
+ nodeMask: identity.nodeMask,
107
+ adapterLuid: identity.adapterLuid,
108
+ driverMode: identity.driverMode,
109
+ identitySource: 'nvidia-cuda-driver',
110
+ uuidFunction: identity.uuidFunction,
111
+ splitMode: 'none',
112
+ mainGpu: 0,
113
+ gpuLayers: 'all'
114
+ };
115
+ if (identity.diagnostics !== '') {
116
+ execution.diagnostics = [{source: 'cuda-driver', message: identity.diagnostics}];
117
+ }
118
+ // A single full UUID makes CUDA0 identify the selected physical GPU in the
119
+ // fresh server process, independent of DXGI or inherited CUDA ordinal order.
120
+ return {
121
+ args: ['--device', 'CUDA0', '--split-mode', 'none', '--main-gpu', '0', '--gpu-layers', 'all'],
122
+ env: {CUDA_VISIBLE_DEVICES: identity.uuid},
123
+ execution
124
+ };
125
+ }
126
+
127
+ async function resolveWindowsCudaIdentity({adapterLuid, signal, onEvent}) {
128
+ const script = await readFile(
129
+ new URL('./llama-execution-windows.ps1', import.meta.url),
130
+ {encoding: 'utf8', signal}
131
+ );
132
+ throwIfAborted(signal);
133
+ const command = process.env.SystemRoot
134
+ ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
135
+ : 'powershell.exe';
136
+ async function* identityInput() {
137
+ yield `& {\n${script}\n}\n\n`;
138
+ }
139
+ const result = await runProcess(
140
+ command,
141
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'],
142
+ {
143
+ signal,
144
+ onEvent,
145
+ env: {
146
+ ARCANE_LLAMA_LUID_LOW: String(adapterLuid.lowPart),
147
+ ARCANE_LLAMA_LUID_HIGH: String(adapterLuid.highPart)
148
+ },
149
+ input: identityInput(),
150
+ captureOutput: true,
151
+ emitOutputEvents: false
152
+ }
153
+ );
154
+ throwIfAborted(signal);
155
+ let identity;
156
+ try {
157
+ identity = JSON.parse(result.stdout);
158
+ if (!is.string(identity?.uuid) || identity.uuid === '' || !is.integer(identity.nodeMask)) {
159
+ throw new TypeError('The CUDA driver identity helper returned no device identity.');
160
+ }
161
+ } catch (error) {
162
+ throw new ArcaneError(
163
+ ERROR_CODES.operationFailed,
164
+ 'Could not read the selected CUDA device identity.',
165
+ {cause: error, details: result}
166
+ );
167
+ }
168
+ return {...identity, diagnostics: result.stderr};
169
+ }
@@ -4,6 +4,7 @@ import Is from 'strong-type';
4
4
  import {ArcaneError,ERROR_CODES,errorRecord,normalizeError,throwIfAborted} from '../errors.mjs';
5
5
  import {createEventQueue} from '../event-queue.mjs';
6
6
  import {runProcess} from '../process.mjs';
7
+ import {resolveLlamaExecution} from './llama-execution.mjs';
7
8
 
8
9
  const is=new Is(false);
9
10
 
@@ -48,17 +49,21 @@ async function probeServer(id,url,signal){
48
49
  return observed;
49
50
  }
50
51
 
51
- export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,onEvent,onState}={}){
52
+ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,onEvent,onState,onExit,executionDevices,requireManaged=false}={}){
52
53
  if(!['llama.cpp','ollama'].includes(id)){
53
54
  throw new ArcaneError(ERROR_CODES.usage,`Unsupported local AI server: ${String(id)}.`);
54
55
  }
55
56
  const url=localAIServerURL(id,configuration);
56
57
  const endpoint=new URL(url);
58
+ const managedSelection=requireManaged||(id==='llama.cpp'&&(configuration.executionTarget!==undefined
59
+ ||(runtime?.requestedBackend&&runtime.requestedBackend!=='auto')));
57
60
  const controller=new AbortController();
58
61
  const operationSignal=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
59
62
  const events=createEventQueue(onEvent,{onFailure:fail});
60
63
  let current={id,url,state:'starting',available:false,owned:false};
61
64
  let owned=false;
65
+ let external=false;
66
+ let execution;
62
67
  let processTask=null;
63
68
  let processObservation=null;
64
69
  let startupTask=null;
@@ -70,6 +75,8 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
70
75
  let terminalFailure=null;
71
76
  let resolveReady;
72
77
  let rejectReady;
78
+ let resolveManagement;
79
+ const management=new Promise(function discoverManagement(resolve){resolveManagement=resolve;});
73
80
  const output={stdout:'',stderr:''};
74
81
  const ready=new Promise(function createReadiness(resolve,reject){
75
82
  resolveReady=resolve;
@@ -80,7 +87,9 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
80
87
  void ready.catch(function observeReadinessFailure(){return undefined;});
81
88
 
82
89
  async function publishState(state,error){
83
- current={id,url,state,available:state==='ready',owned,...(error?{error:errorRecord(error)}:{})};
90
+ current={id,url,state,available:state==='ready',owned,
91
+ managed:!external&&Boolean(runtime?.executable),
92
+ ...(id==='llama.cpp'?{execution:execution??null}:{}),...(error?{error:errorRecord(error)}:{})};
84
93
  await onState?.(current);
85
94
  await events.send({
86
95
  type:`local-ai.server.${state}`,
@@ -147,6 +156,8 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
147
156
  // endpoint at this actual launch boundary rather than presenting
148
157
  // that earlier installation-selection snapshot as current health.
149
158
  const observed=await probeServer(id,url,operationSignal);
159
+ external=true;
160
+ resolveManagement(false);
150
161
  if(runtime?.requestedVersion&&runtime.requestedVersion!=='latest'){
151
162
  const requested=runtime.requestedVersion.replace(/^v/u,'');
152
163
  if(!is.string(observed.version)||observed.version.replace(/^v/u,'')!==requested){
@@ -158,10 +169,18 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
158
169
  throwIfAborted(operationSignal);
159
170
  // An HTTP error is an existing service's state. Only a refused
160
171
  // connection allows starting the selected local executable.
161
- if(!localAIConnectionRefused(error))throw error;
172
+ if(!localAIConnectionRefused(error)){
173
+ external=true;
174
+ resolveManagement(false);
175
+ throw error;
176
+ }
162
177
  if(!localHostname(endpoint.hostname))throw error;
163
178
  }
164
179
  if(existing){
180
+ external=true;
181
+ if(managedSelection){
182
+ throw new ArcaneError('LOCAL_AI_EXTERNAL_SELECTION_UNSUPPORTED','The existing llama.cpp server owns its model and device selection. Use an available listener for SDK-managed model loading.');
183
+ }
165
184
  await publishState('ready');
166
185
  throwIfAborted(operationSignal);
167
186
  settleReady();
@@ -170,6 +189,7 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
170
189
  if(!runtime?.executable){
171
190
  throw new ArcaneError(ERROR_CODES.prerequisiteMissing,`No installed ${id} executable was supplied.`);
172
191
  }
192
+ resolveManagement(true);
173
193
  if(endpoint.protocol!=='http:'){
174
194
  throw new ArcaneError(ERROR_CODES.usage,`${id} managed startup requires its direct HTTP listener URL.`);
175
195
  }
@@ -187,12 +207,19 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
187
207
  throwIfAborted(operationSignal);
188
208
  const host=endpoint.hostname.replace(/^\[|\]$/gu,'');
189
209
  const port=endpoint.port||'80';
210
+ const selected=id==='llama.cpp'?await resolveLlamaExecution({
211
+ executionTarget:configuration.executionTarget,executionDevices,runtime,signal:operationSignal,onEvent:events.send
212
+ }):null;
213
+ if(selected)execution=selected.execution;
190
214
  const arguments_=id==='ollama'?['serve',...args]:[
191
215
  '--host',host,'--port',port,'--jinja',
216
+ ...(managedSelection?args:[]),
192
217
  ...(configuration.model?['--model',path.resolve(appRoot,configuration.model)]:['--models-dir',modelsDirectory]),
193
- ...args
218
+ ...(!managedSelection?args:[]),
219
+ ...(configuration.alias?['--alias',configuration.alias]:[]),
220
+ ...selected.args
194
221
  ];
195
- const env=id==='ollama'?{OLLAMA_HOST:endpoint.origin,OLLAMA_MODELS:modelsDirectory}:undefined;
222
+ const env=id==='ollama'?{OLLAMA_HOST:endpoint.origin,OLLAMA_MODELS:modelsDirectory}:selected.env;
196
223
  owned=true;
197
224
  await publishState('starting');
198
225
  throwIfAborted(operationSignal);
@@ -215,7 +242,7 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
215
242
  return;
216
243
  }
217
244
  await fail(error);
218
- });
245
+ }).finally(async function releaseProcessResources(){await onExit?.();});
219
246
  void processObservation.catch(function observeProcessCallbackFailure(error){
220
247
  current={...current,state:'error',available:false,error:errorRecord(error)};
221
248
  settleReady(error);
@@ -250,7 +277,10 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
250
277
  });
251
278
  }
252
279
 
253
- startupTask=start().catch(fail);
280
+ startupTask=start().catch(fail).finally(async function releaseUnstartedResources(){
281
+ resolveManagement(false);
282
+ if(!processTask)await onExit?.();
283
+ });
254
284
  void startupTask.catch(function observeStartupCallbackFailure(error){
255
285
  current={...current,state:'error',available:false,error:errorRecord(error)};
256
286
  settleReady(error);
@@ -258,5 +288,5 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
258
288
  operationSignal.addEventListener('abort',observeLifetimeAbort,{once:true});
259
289
  if(operationSignal.aborted)observeLifetimeAbort();
260
290
 
261
- return {ready,url,close,get owned(){return owned;},get current(){return current;}};
291
+ return {ready,management,url,close,get owned(){return owned;},get external(){return external;},get current(){return current;}};
262
292
  }
@@ -155,8 +155,8 @@ export function coreEntrySource(application, version, services, {localAI, runtim
155
155
  const imageSelected = localAI?.runtimes?.some(function selectedImage(requirement) {
156
156
  return (typeof requirement === 'string' ? requirement : requirement.id) === 'stable-diffusion.cpp';
157
157
  }) ?? false;
158
- const modelAssetsSelected = imageSelected || (localAI?.runtimes?.some(function selectedONNX(requirement) {
159
- return (typeof requirement === 'string' ? requirement : requirement.id) === 'onnx';
158
+ const modelAssetsSelected = imageSelected || (localAI?.runtimes?.some(function selectedProjectedRuntime(requirement) {
159
+ return ['onnx', 'llama.cpp'].includes(typeof requirement === 'string' ? requirement : requirement.id);
160
160
  }) ?? false);
161
161
  const whisperSelected = localAI?.runtimes?.some(function selectedWhisper(requirement) {
162
162
  return (typeof requirement === 'string' ? requirement : requirement.id) === 'whisper.cpp';
@@ -199,13 +199,21 @@ export function coreEntrySource(application, version, services, {localAI, runtim
199
199
  ' register(packagedWeb);');
200
200
  }
201
201
  return [
202
- "import {fileURLToPath} from 'node:url';",
202
+ "import {fileURLToPath, pathToFileURL} from 'node:url';",
203
203
  "import path from 'node:path';",
204
204
  "import {isSea} from 'node:sea';",
205
205
  ...(launchDefaults === undefined ? [] : ["import {mkdir} from 'node:fs/promises';"]),
206
206
  "import {readCoreLaunchContext, startCoreHost, startCoreListener, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
207
207
  "import {createModelObservationService} from 'arcane-os/core/model-observation';",
208
208
  '',
209
+ "const appEntryFlag = process.argv.indexOf('--arcane-app-entry', 2);",
210
+ 'if (appEntryFlag !== -1) {',
211
+ ' const appEntry = process.argv[appEntryFlag + 1];',
212
+ " if (appEntry === undefined || appEntry === '') throw new TypeError('--arcane-app-entry requires an app-relative module path.');",
213
+ " const appRoot = fileURLToPath(new URL('../app/', import.meta.url));",
214
+ ' await import(pathToFileURL(path.resolve(appRoot, appEntry)).href);',
215
+ '} else {',
216
+ ...[
209
217
  ...(localAI === undefined ? [] : [
210
218
  '// Bundled runtime paths are artifact-root-relative POSIX paths.',
211
219
  'function runtimePath(relative) {',
@@ -366,6 +374,8 @@ export function coreEntrySource(application, version, services, {localAI, runtim
366
374
  ' }',
367
375
  " process.off('SIGINT', closeCore);",
368
376
  " process.off('SIGTERM', closeCore);",
377
+ '}'
378
+ ].map(function indentOrdinaryCore(line) { return line ? ` ${line}` : line; }),
369
379
  '}',
370
380
  ''
371
381
  ].join('\n');
@@ -98,17 +98,20 @@ async function ensureLockDirectory(workspace){
98
98
  return physicalDirectory(directory,'Workspace operation-lock directory',{create:true});
99
99
  }
100
100
 
101
- function ownerIsAlive(pid){
101
+ function ownerIsAbsent(pid){
102
102
  if(!is.safeInteger(pid)||pid<1)return false;
103
103
  try{
104
104
  process.kill(pid,0);
105
- return true;
105
+ return false;
106
106
  }catch(error){
107
- return error?.code==='EPERM';
107
+ if(error?.code==='ESRCH')return true;
108
+ const busyError=workspaceBusy('The recorded Arcane workspace operation owner could not be determined.');
109
+ busyError.cause=error;
110
+ throw busyError;
108
111
  }
109
112
  }
110
113
 
111
- function lockDocument(value,{workspaceRoot,now=Date.now()}={}){
114
+ function lockDocument(value,{workspaceRoot}={}){
112
115
  if(!value||!is.object(value)||is.array(value)
113
116
  ||value.schemaVersion!==1
114
117
  ||value.kind!=='arcane-workspace-operation-lock'
@@ -124,7 +127,7 @@ function lockDocument(value,{workspaceRoot,now=Date.now()}={}){
124
127
  }
125
128
  const expiresAt=Date.parse(value.expiresAt);
126
129
  if(!is.finite(expiresAt))return null;
127
- return {...value,expired:expiresAt<=now};
130
+ return value;
128
131
  }
129
132
 
130
133
  async function readLock(lockPath,workspaceRoot){
@@ -145,16 +148,24 @@ async function readLock(lockPath,workspaceRoot){
145
148
  return lockDocument(value,{workspaceRoot});
146
149
  }
147
150
 
148
- async function recoverExpiredLock(lockPath,workspaceRoot,onEvent){
151
+ async function recoverAbsentOwnerLock(lockPath,workspaceRoot,onEvent,signal){
152
+ throwIfAborted(signal);
149
153
  const document=await readLock(lockPath,workspaceRoot);
150
- if(!document?.expired||ownerIsAlive(document.owner.pid))return false;
154
+ throwIfAborted(signal);
155
+ if(!document||!ownerIsAbsent(document.owner.pid))return false;
151
156
  await onEvent?.({
152
157
  type:'workspace.operation.stale-recovered',
153
158
  workspaceRoot,
154
159
  lockPath,
155
- previousOwner:document.owner,
160
+ previousOwner:{...document.owner},
156
161
  previousOperation:document.operation
157
162
  });
163
+ throwIfAborted(signal);
164
+ const current=await readLock(lockPath,workspaceRoot);
165
+ throwIfAborted(signal);
166
+ // Observers may replace the record; their completion does not retain ownership.
167
+ if(current?.nonce!==document.nonce||current?.owner.pid!==document.owner.pid
168
+ ||!ownerIsAbsent(current.owner.pid))return false;
158
169
  await rm(lockPath);
159
170
  return true;
160
171
  }
@@ -254,15 +265,17 @@ async function acquire({
254
265
  lockPath=path.join(directory.canonical,LOCK_NAME);
255
266
  let recovered=false;
256
267
  for(;;){
268
+ throwIfAborted(signal);
257
269
  try{
258
270
  handle=await open(lockPath,OPEN_EXCLUSIVE,0o600);
259
271
  break;
260
272
  }catch(error){
261
273
  if(error?.code!=='EEXIST')throw error;
262
- if(recovered||!(await recoverExpiredLock(
274
+ if(recovered||!(await recoverAbsentOwnerLock(
263
275
  lockPath,
264
276
  workspace.canonical,
265
- onEvent
277
+ onEvent,
278
+ signal
266
279
  ))){
267
280
  fail(
268
281
  `Workspace is locked by another Arcane operation: ${lockPath}. `