arcane-os 0.83.1 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.84.0
4
+
5
+ - Add `startCoreListener({runtime, endpoint, onError})` through
6
+ `arcane-os/core/host` so local clients can use an application's existing
7
+ window-owned Core without creating another runtime or changing its profile,
8
+ service instances or saved-data location. Native packages select it with
9
+ `native.launchContext.coreListener: {endpoint}`.
10
+ - Keep response correlation, cancellation and current-state replay scoped to
11
+ each connection. Preserve complete application payloads, expose the original
12
+ handler `requestId` and actual internal `coreRequestId`, and prevent retired
13
+ request events from attaching to a later reuse of a client ID.
14
+ - Closing a client or listener leaves accepted service-owned work with its
15
+ runtime. Window shutdown drains Core and then closes its listener. Existing
16
+ explicitly selected headless shared-host behavior remains available. This
17
+ capability does not claim to correct or verify model inference.
18
+
3
19
  ## 0.83.1
4
20
 
5
21
  - Preserve complete failure-only native ONNX diagnostics in `error.onnxRun`,
package/README.md CHANGED
@@ -25,7 +25,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
25
25
  share the theme, packaging, event, cancellation,
26
26
  and browser run contracts while retaining their selected application layout.
27
27
 
28
- This checkout defines the `0.83.1` SDK contract. Development applications declare
28
+ This checkout defines the `0.84.0` SDK contract. Development applications declare
29
29
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
30
30
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
31
31
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -234,6 +234,27 @@ Windows and macOS launchers use the same packaged defaults for native state
234
234
  selection, so an ordinary executable launch and external MCP can select the
235
235
  same app-owned Core.
236
236
 
237
+ To expose the actual window-owned Core to an external local client while
238
+ preserving that stdio lifetime, select
239
+ `native.launchContext:{coreListener:{endpoint:"APPLICATION-SELECTED-LOCAL-ENDPOINT"}}`
240
+ instead. The generated entry calls
241
+ `startCoreListener({runtime:host.runtime,endpoint})` after creating its ordinary
242
+ stdio host. It reuses that exact service set and launch context, including the
243
+ existing origin, profile and state root; no headless runtime is created. The
244
+ listener endpoint has no default and its parent directory must already exist
245
+ where required by the operating system. `coreListener` and `sharedHost` are
246
+ alternative selections; supplying both reports `TypeError`.
247
+
248
+ Clients use `connectSharedCoreHost({endpoint})` with `start` omitted and close
249
+ only their own connection. This listener provides no host-shutdown RPC. Closing
250
+ the window still drains its Core, including accepted service-owned operations,
251
+ and then closes the listener. Signal-driven host shutdown remains observed while
252
+ the listener is binding; a bind failure drains the newly created stdio host and
253
+ reports the failure. The generated entry joins listener closure without changing
254
+ the existing shared-host/headless branches. See
255
+ [the existing-runtime listener contract](core-shared-host.md#attach-to-an-existing-window-owned-core)
256
+ for direct composition, correlation, cancellation and complete error behavior.
257
+
237
258
  `resolveNativeLaunchContext({appId,context={}})` from `arcane-os/core/host` is
238
259
  the public, synchronous location owner for native applications and external MCP.
239
260
  It returns a new complete context and fills only omitted fields:
@@ -67,7 +67,7 @@ complete diagnostic serialization; the startup promise retains its original
67
67
  rejection value.
68
68
 
69
69
  Each lifecycle hook receives `{application, service, emit, getService}`. Each request handler
70
- receives `(parameters, {application, service, emit, getService, requestId, signal})`, with
70
+ receives `(parameters, {application, service, emit, getService, requestId, coreRequestId, signal})`, with
71
71
  `this` bound to its service definition. A hook or handler may publish a complete
72
72
  service-owned event through `emit(event, data)`.
73
73
 
@@ -132,6 +132,18 @@ The shared `arcane-os/core/contracts` module owns `CORE_PROTOCOL` (`arcane/1`),
132
132
  availability errors remain observable; unavailable services are not replaced
133
133
  with browser behavior.
134
134
 
135
+ A transport that assigns internal dispatch IDs may call
136
+ `runtime.handle(frame, {contextRequestId})`. This optional nonempty string sets
137
+ only the service handler's `context.requestId`. `context.coreRequestId` always
138
+ exposes the actual `frame.id`; it distinguishes simultaneous requests whose
139
+ clients selected equal IDs. The supplied `frame.id` still
140
+ owns active-request state, cancellation lookup, response correlation and the
141
+ request event's top-level envelope. Parameters and event data remain unchanged.
142
+ Ordinary `handle(frame)` keeps its existing behavior: `context.requestId` equals
143
+ `frame.id`, and `coreRequestId` equals that same ID. Stdio and
144
+ the borrowed IPC listener use this metadata to preserve the original client ID
145
+ in service-facing data while their transport owner routes unique dispatch IDs.
146
+
135
147
  A request with no registered handler retains code `METHOD_NOT_ALLOWED` and
136
148
  adds the exact `method`, its first dotted `namespace`, and a `reason`.
137
149
  `core-namespace-unavailable` means the dispatcher has neither a service with
@@ -222,6 +234,21 @@ reach `onError` and reject `closed`. The transport detaches
222
234
  its listeners and pauses its input; it does not end caller-owned output streams
223
235
  or take ownership of the host process.
224
236
 
237
+ Stdio routes responses and request-context events only for its own requests.
238
+ `request.cancel`, `requests.cancelAll` and `runtime.replay` are connection-scoped;
239
+ they do not cancel or replay another attached connection's work. The protocol
240
+ owner gives every accepted request a fresh internal ID and restores its client
241
+ ID on responses, event envelopes and stdio's own `core.state` entries. Handler
242
+ `context.requestId` and complete application payloads keep their original values.
243
+ Uncorrelated runtime/service events remain shared.
244
+
245
+ `startCoreListener({runtime,endpoint,onError})` from `arcane-os/core/host` can
246
+ attach additional local clients to this same runtime. Its explicit `close()`
247
+ closes only that listener; stdio EOF still closes the runtime and drains all
248
+ accepted service work, then the listener closes automatically. Observe the
249
+ listener's `closed` as well as the stdio owner's `closed` to join both transport
250
+ lifecycles. See [existing-runtime listeners](core-shared-host.md#attach-to-an-existing-window-owned-core).
251
+
225
252
  ## Platform and delivery boundary
226
253
 
227
254
  These modules use portable Node and JavaScript facilities for Windows, Linux
@@ -13,6 +13,69 @@ event history. Application methods and policy stay in the application services.
13
13
  Use the [MCP server](mcp-stdio.md) for external MCP framing; local Core continues
14
14
  to use the existing `arcane/1` protocol and Content-Length stream framing.
15
15
 
16
+ ## Attach to an existing window-owned Core
17
+
18
+ `startCoreListener({runtime,endpoint,onError})` adds a local IPC listener to an
19
+ existing Core runtime. It resolves `{endpoint,closed,close}` after binding the
20
+ selected endpoint. It neither constructs nor starts a runtime or service. The
21
+ existing host retains its application, services, origin, profile, state location
22
+ and shutdown ownership.
23
+
24
+ ```js
25
+ import {startCoreListener} from 'arcane-os/core/host';
26
+
27
+ // host is the application's existing startCoreHost/startCoreStdio handle.
28
+ const listener = await startCoreListener({runtime: host.runtime, endpoint: selectedEndpoint});
29
+ try {
30
+ await host.closed;
31
+ } finally {
32
+ await listener.close();
33
+ }
34
+ ```
35
+
36
+ An external client uses `connectSharedCoreHost({endpoint: selectedEndpoint})`
37
+ with `start` omitted, invokes the application's existing methods through
38
+ `connection.client.invoke(method, parameters)`, and calls `connection.close()`
39
+ when finished. This connection uses the existing SDK framing, correlation and
40
+ current-runtime replay; it does not launch another Core or replay requests.
41
+ The listener does not implement or intercept `core.host.shutdown`.
42
+ `connection.shutdown()` therefore has no listener-owned shutdown operation;
43
+ without an application method of that name it returns the ordinary
44
+ `METHOD_NOT_ALLOWED` error. The window's host remains the shutdown owner.
45
+
46
+ Disconnect and `listener.close()` cancel only the affected connections'
47
+ request-lifetime work. Explicit listener close drains queued output and releases
48
+ the endpoint without closing the runtime or waiting for accepted service-lifetime
49
+ work; that work remains tracked by its service/runtime owner. When the runtime
50
+ itself begins shutdown, the listener rejects new requests and retains its endpoint
51
+ through runtime drain/disposal, then closes its connections automatically.
52
+ Observe both the host's `closed` and the listener's `closed` for their respective
53
+ completion or failure. `close()` is idempotent. Transport errors reach `onError`
54
+ (complete console diagnostics by default); terminal listener failures reject
55
+ `closed`. Bind failures reject startup with their actual native error and leave
56
+ the supplied runtime alive. A draining/closed runtime rejects attachment with
57
+ `CORE_CLOSING`.
58
+
59
+ Stdio and each IPC connection have independent request correlation and
60
+ cancellation. Handlers reached through stdio or this listener receive the
61
+ original client ID in `context.requestId`; `context.coreRequestId` exposes the
62
+ fresh internal ID used by runtime dispatch and protocol routing. Responses and
63
+ top-level event correlation return to their originating connection. The new listener leaves all event data,
64
+ including any authored `data.requestId`, and all parameters/results unchanged.
65
+ Its runtime-state snapshot retains the runtime's internal active IDs. Late events
66
+ from a retired request never attach to a later reuse of that client's ID.
67
+ Uncorrelated service events remain shared. See the
68
+ [runtime metadata contract](core-runtime.md#state-and-frames).
69
+
70
+ For a generated native entry, select
71
+ `native.launchContext.coreListener:{endpoint}` and omit `sharedHost`. This adds
72
+ the listener to the ordinary window/stdio runtime; closing the native window
73
+ still drains Core. The endpoint is explicit, with no inferred headless startup
74
+ or new default location. Its parent directory must already exist on platforms
75
+ that require one. Selecting both `coreListener` and `sharedHost` reports
76
+ `TypeError` because they select different runtime lifetimes. See
77
+ [native launch locations](core-native-packaging.md#launch-time-locations).
78
+
16
79
  ## Host entry
17
80
 
18
81
  ```js
@@ -107,8 +170,8 @@ running. `closed` observes connection termination and rejects on a transport
107
170
  error. A subsequent connection uses the still-running owner. Reconnection is
108
171
  explicit; failed or uncertain requests are never resubmitted.
109
172
 
110
- `await connection.shutdown(invokeOptions)` explicitly invokes the host-owned
111
- `core.host.shutdown` operation. It drains the shared runtime, returns
173
+ For an owning shared host, `await connection.shutdown(invokeOptions)` explicitly
174
+ invokes the host-owned `core.host.shutdown` operation. It drains the shared runtime, returns
112
175
  `{state:'closed'}` after successful service cleanup, then closes the connection.
113
176
  A drain failure returns the full Core error. Shutdown affects **all** clients
114
177
  of that app endpoint and belongs to the application owner's explicit lifecycle,
@@ -118,8 +181,8 @@ after shutdown acceptance does not undo the shared shutdown.
118
181
 
119
182
  ## Correlation, events, and state
120
183
 
121
- Each connection has its own request-ID space. The transport substitutes an
122
- internal Core request ID and restores the originating ID on responses and
184
+ Each connection has its own request-ID space. The owning shared-host adapter
185
+ substitutes an internal Core request ID and restores the originating ID on responses and
123
186
  the optional top-level event `requestId`. Existing `data.requestId` values that
124
187
  carry that same Core correlation ID are restored too. `parameters`, returned results, schemas,
125
188
  documents, and content remain complete and unchanged. A handler's
@@ -137,10 +200,10 @@ connection's IDs; other connections' active requests retain Core-side IDs.
137
200
  requests. A disconnect performs the same scoped cancellation. The underlying
138
201
  runtime continues to preserve service-lifetime acceptance.
139
202
 
140
- `runtime.replay` sends only that connection the current `core.ready`,
141
- `core.state`, and `core.service.state` snapshot. Optional synchronous
142
- `getReplayEvents()` returns current `{event,data}` records from their actual
143
- service owners. It is for current domain state, not historical chunks or
203
+ Both listener forms handle `runtime.replay` by sending only that connection the
204
+ current `core.ready`, `core.state`, and `core.service.state` snapshot. Optional
205
+ synchronous `getReplayEvents()` on the owning shared host returns current
206
+ `{event,data}` records from their actual service owners. It is for current domain state, not historical chunks or
144
207
  retired requests. Subscribe before requesting a domain snapshot when its live
145
208
  events may change; applications own those domain methods and state.
146
209
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sdkVersion": "0.82.0",
3
+ "sdkVersion": "0.83.0",
4
4
  "environment": {
5
5
  "runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
7
7
  "moduleSystem": "ESM"
8
8
  },
9
- "memberCount": 302,
9
+ "memberCount": 303,
10
10
  "runtimeSubpathPatterns": {
11
11
  "arcane-os/modules/*": "./runtime/arcane/modules/*",
12
12
  "arcane-os/entities/*": "./runtime/arcane/entities/*"
@@ -4374,7 +4374,7 @@
4374
4374
  "summary": "Creates an app-neutral Core dispatcher with explicit services, request lifetimes, events and graceful drain.",
4375
4375
  "availability": "Node on Windows, Linux and macOS native hosts; Android requires its host adaptation",
4376
4376
  "protocol": "arcane/1 dispatcher and service lifecycle",
4377
- "normalization": "Starts services independently; runtime.getService(name) and context.getService(name) return the same registered object after only its shared startup, without a renderer or global barrier. Lookup is readiness, not a lifetime lease; new lookups reject during shutdown. Preserves complete results/errors, cancels request-lifetime work and drains accepted service work; runtime.replay publishes current dispatcher/service state without restart and preserves reentrant lifecycle publication order. Missing dispatch retains METHOD_NOT_ALLOWED with exact method/namespace and core-namespace-unavailable or core-method-unavailable; named services, registered methods and built-in namespaces establish presence. Handler/startup/permission errors remain unchanged."
4377
+ "normalization": "Starts services independently; runtime.getService(name) and context.getService(name) return the same registered object after only its shared startup, without a renderer or global barrier. Lookup is readiness, not a lifetime lease; new lookups reject during shutdown. runtime.handle(frame,{contextRequestId}) optionally sets handler requestId while coreRequestId remains frame.id; omitted metadata exposes both equal. Internal IDs continue to own cancellation/state/event envelopes and payloads remain unchanged. Preserves complete results/errors, cancels request-lifetime work and drains accepted service work; runtime.replay publishes current dispatcher/service state without restart and preserves reentrant lifecycle publication order. Missing dispatch retains METHOD_NOT_ALLOWED with exact method/namespace and core-namespace-unavailable or core-method-unavailable; named services, registered methods and built-in namespaces establish presence. Handler/startup/permission errors remain unchanged."
4378
4378
  },
4379
4379
  {
4380
4380
  "id": "core-stdio:encodeCoreFrame",
@@ -4416,7 +4416,7 @@
4416
4416
  "summary": "Attaches an existing Core runtime to framed input and output with concurrent dispatch and graceful shutdown.",
4417
4417
  "availability": "Node with readable input and writable output streams",
4418
4418
  "protocol": "Content-Length framed arcane/1 stdio",
4419
- "normalization": "Returns {runtime,closed,close}; preserves queued output order, reports parser and stream failures, drains accepted runtime work on EOF and never exits the host or ends caller-owned output"
4419
+ "normalization": "Returns {runtime,closed,close}; scopes responses/request events, cancellation and current-state replay to its connection, preserving original service-facing requestId and complete payloads while routing unique internal IDs. Preserves queued output order, reports parser and stream failures, drains accepted runtime work on EOF and never exits the host or ends caller-owned output"
4420
4420
  },
4421
4421
  {
4422
4422
  "id": "native-portable-provider:createPortableNativeProvider",
@@ -4598,7 +4598,7 @@
4598
4598
  "summary": "Resolves matching per-app native state, workspace and optional shared endpoint defaults for packaged UI and external MCP.",
4599
4599
  "availability": "Node native locations on Windows/Linux/macOS; Android and other hosts supply stateRoot and native adaptation",
4600
4600
  "protocol": "Pure native launch-context resolution and existing local IPC",
4601
- "normalization": "Returns a new complete context with only omitted stateRoot/workspaceRoot and selected sharedHost endpoint/log fields filled; preserves explicit values and additional payloads. Native launchContext presence opts generated entry into resolution; omitted sharedHost retains stdio. No I/O, saved-state discovery, process startup, migration or ArcaneData/Repos change. Malformed selected appId/stateRoot/sharedHost inputs report TypeError; platform IPC errors remain operation-owned."
4601
+ "normalization": "Returns a new complete context with only omitted stateRoot/workspaceRoot and selected sharedHost endpoint/log fields filled; preserves explicit values and additional payloads. Native launchContext presence opts generated entry into resolution; omitted sharedHost retains stdio. Optional coreListener:{endpoint} is preserved without endpoint defaults and attaches to that same window-owned runtime in the generated entry; selecting it together with sharedHost reports TypeError there. No I/O, saved-state discovery, process startup, migration or ArcaneData/Repos change. Malformed selected appId/stateRoot/sharedHost inputs report TypeError; platform IPC errors remain operation-owned."
4602
4602
  },
4603
4603
  {
4604
4604
  "id": "core-host:readCoreLaunchContext",
@@ -4628,6 +4628,20 @@
4628
4628
  "protocol": "arcane/1 over Content-Length framed stdio",
4629
4629
  "normalization": "Returns the existing {runtime,closed,close} transport owner; an optional abort signal invokes the same idempotent drain without killing the process, replacing service readiness or cancelling accepted service-lifetime work"
4630
4630
  },
4631
+ {
4632
+ "id": "core-host:startCoreListener",
4633
+ "name": "startCoreListener",
4634
+ "displayName": "startCoreListener()",
4635
+ "kind": "function",
4636
+ "signature": "startCoreListener({runtime,endpoint,onError}={})",
4637
+ "entrypoints": ["arcane-os/core/host"],
4638
+ "primaryImport": "arcane-os/core/host",
4639
+ "group": "Shared Core host",
4640
+ "summary": "Attaches local IPC to an existing window-owned Core runtime without changing its services or lifetime.",
4641
+ "availability": "Node named pipes on Windows and Unix-domain sockets on Linux/macOS; Android requires its host adaptation",
4642
+ "protocol": "arcane/1 over local IPC with per-connection correlation, cancellation and current-state replay",
4643
+ "normalization": "Returns {endpoint,closed,close}; never constructs, starts or closes the supplied runtime. Explicit close flushes queued output and disconnects its clients while accepted service work remains runtime-owned. Runtime shutdown retains the endpoint through service drain then closes the listener. Preserves parameters/results/event data, original handler requestId and internal coreRequestId; no host shutdown RPC. Bind errors reject startup; transport errors reach onError and terminal errors reject closed. Native launchContext.coreListener:{endpoint} selects the existing stdio branch; no sharedHost/headless selection or inferred endpoint."
4644
+ },
4631
4645
  {
4632
4646
  "id": "core-host:startSharedCoreHost",
4633
4647
  "name": "startSharedCoreHost",
@@ -4654,7 +4668,7 @@
4654
4668
  "summary": "Connects to one selected host and optionally starts an explicit headless entry when absent.",
4655
4669
  "availability": "Node local IPC and optional explicit child process with diagnostic file",
4656
4670
  "protocol": "arcane/1 Core client; optional Node startup IPC handshake",
4657
- "normalization": "Returns {client,endpoint,closed,close,shutdown}. Only ENOENT/ECONNREFUSED selects start {command,args,cwd,env,logFile}; no request retry. Close cancels this connection's request work while preserving the host and accepted service work. Explicit shutdown drains all clients' shared host; cancelling a launch wait does not kill the host"
4671
+ "normalization": "Returns {client,endpoint,closed,close,shutdown}. Only ENOENT/ECONNREFUSED selects start {command,args,cwd,env,logFile}; no request retry. Close cancels this connection's request work while preserving the host and accepted service work. Explicit shutdown drains an owning shared host for all clients; cancelling a launch wait does not kill the host. Connect to startCoreListener with start omitted; that borrowed listener implements no core.host.shutdown operation and its runtime owner retains shutdown."
4658
4672
  },
4659
4673
  {
4660
4674
  "id": "core-host:runSharedCoreHost",
@@ -427,6 +427,7 @@ The remaining data-only subpaths are eight JSON Schemas and package metadata.
427
427
  | `arcaneNativeBuilderProvider` | singleton | `arcane-os/native/portable-provider` | Portable native provider | Node; produces a portable payload rather than an executable host |
428
428
  | `arcaneNativeBuilderProvider default export` | singleton | `arcane-os/native/portable-provider` | Portable native provider | Node; produces a portable payload rather than an executable host |
429
429
  | `startCoreHost()` | function | `arcane-os/core/host` | Native Core host composition | Node with readable input and writable output streams |
430
+ | `startCoreListener()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC attached to an existing runtime |
430
431
  | `startSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC on Windows, Linux and macOS |
431
432
  | `connectSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC and optional explicit headless startup |
432
433
  | `connectAppControl()` | function | `arcane-os/core/app-control` | Native application control | Node local IPC; current window adapter is Windows WebView2 |
@@ -7394,6 +7395,14 @@ Import it from `arcane-os/core/runtime`. The runtime exposes `current()`,
7394
7395
  `subscribe()`, `onFrame()`, `emit()`, `registerService()`, `getService()`, `start()`, `handle()`
7395
7396
  and idempotent `close()`.
7396
7397
 
7398
+ `runtime.handle(frame, {contextRequestId})` optionally preserves a transport's
7399
+ original client ID in handler `context.requestId`, while `context.coreRequestId`
7400
+ always exposes the actual internal `frame.id`. Active requests, cancellation,
7401
+ response IDs and request-event envelopes retain that internal ID. Ordinary
7402
+ `handle(frame)` exposes both context IDs equal to `frame.id`. This metadata
7403
+ does not change parameters or event data; transports own client-facing protocol
7404
+ correlation. See [the runtime contract](core-runtime.md#state-and-frames).
7405
+
7397
7406
  `await runtime.getService(name)` and service `context.getService(name)` return
7398
7407
  the actual registered service after its shared startup. They wait for that
7399
7408
  dependency alone, may start it before `runtime.start()`, and add no renderer or
@@ -7551,6 +7560,13 @@ Import it from `arcane-os/core/stdio`. The result is
7551
7560
  `{runtime, closed, close}`. EOF or input close starts graceful shutdown;
7552
7561
  `closed` settles only after accepted runtime work and queued output settle.
7553
7562
 
7563
+ Responses, request events, cancellation controls and current-state replay are
7564
+ connection-scoped even when an additional IPC listener shares the runtime.
7565
+ Stdio preserves original handler `requestId` values while routing fresh internal
7566
+ IDs; uncorrelated service events remain shared. EOF retains its existing
7567
+ runtime-wide shutdown ownership. Additional clients attach through
7568
+ [`startCoreListener()`](#startcorelistener).
7569
+
7554
7570
  ### Availability and normalization
7555
7571
 
7556
7572
  **Node with readable input and writable output streams.** Parser, input, output
@@ -7995,6 +8011,14 @@ the caller's working-directory meaning. `appId` and the selected state path
7995
8011
  must be nonempty strings; a selected `sharedHost` must be an object. Malformed
7996
8012
  inputs report `TypeError`; native endpoint failures belong to the IPC operation.
7997
8013
 
8014
+ `native.launchContext.coreListener:{endpoint}` instead attaches local IPC to
8015
+ the existing window/stdio runtime. Its endpoint remains explicit and receives
8016
+ no default. The generated entry rejects simultaneous `coreListener` and
8017
+ `sharedHost` selections. Client disconnect affects that connection; window
8018
+ shutdown still drains Core and closes the additional listener. See
8019
+ [`startCoreListener()`](#startcorelistener) and
8020
+ [native launch locations](core-native-packaging.md#launch-time-locations).
8021
+
7998
8022
  ```javascript
7999
8023
  import {resolveNativeLaunchContext} from 'arcane-os/core/host';
8000
8024
 
@@ -8793,6 +8817,59 @@ async function bundleMoonCheeseRuntime(outputRoot){
8793
8817
  The ordinary provider invokes this owner only for an explicit native local-AI
8794
8818
  selection. See [selected local-AI runtimes](core-native-packaging.md#selected-local-ai-runtimes).
8795
8819
 
8820
+ ## startCoreListener()
8821
+
8822
+ ### Overview and signature
8823
+
8824
+ Attaches a local IPC listener to an existing Core runtime, without creating,
8825
+ starting or taking ownership of that runtime or its services.
8826
+
8827
+ ```text
8828
+ startCoreListener({runtime,endpoint,onError}={})
8829
+ ```
8830
+
8831
+ Import from `arcane-os/core/host`. Resolves `{endpoint,closed,close}` after the
8832
+ selected endpoint binds. `close()` is idempotent, cancels the listener's
8833
+ request-lifetime work, flushes queued output and closes its connections. It
8834
+ does not call `runtime.close()` or wait for accepted service-lifetime work;
8835
+ that work remains tracked by the existing runtime. Runtime shutdown rejects
8836
+ new listener requests, retains the endpoint through service drain/disposal,
8837
+ and closes the listener automatically. Observe both owners' `closed` promises.
8838
+ Bind errors reject startup without closing the supplied runtime. Transport
8839
+ errors reach `onError`, with complete console diagnostics by default; terminal
8840
+ listener errors reject `closed`. Attaching to a draining/closed runtime reports
8841
+ `CORE_CLOSING`.
8842
+
8843
+ Node supports named pipes on Windows and Unix-domain sockets on Linux/macOS;
8844
+ Android requires its host adaptation. Supply a nonempty endpoint and an existing
8845
+ parent directory where required. Connect with `connectSharedCoreHost({endpoint})`
8846
+ and omit `start`. Its current-runtime replay does not replay requests. The
8847
+ borrowed listener implements no `core.host.shutdown` operation; use client
8848
+ `close()` for disconnection and the existing runtime's owner for host shutdown.
8849
+
8850
+ Each connection owns response/event correlation and request cancellation.
8851
+ Handler `requestId` preserves the client ID; `coreRequestId` is the distinct
8852
+ internal dispatch ID. Parameters, results and event data, including
8853
+ `data.requestId`, remain unchanged. Runtime-state snapshots retain internal
8854
+ active IDs; no service event is invented or retained for later replay.
8855
+
8856
+ ```js
8857
+ import {startCoreListener} from 'arcane-os/core/host';
8858
+
8859
+ const listener = await startCoreListener({runtime: host.runtime, endpoint: selectedEndpoint});
8860
+ try {
8861
+ await host.closed;
8862
+ } finally {
8863
+ await listener.close();
8864
+ }
8865
+ ```
8866
+
8867
+ `host` is the application's existing stdio host. Generated native entries
8868
+ select this attachment with `native.launchContext.coreListener:{endpoint}`,
8869
+ preserving their actual runtime, service set, profile, origin and state root.
8870
+ Omit `sharedHost`; the two options describe alternative lifetimes. Full
8871
+ contract: [existing-runtime listener](core-shared-host.md#attach-to-an-existing-window-owned-core).
8872
+
8796
8873
  ## startSharedCoreHost()
8797
8874
 
8798
8875
  ### Overview
@@ -8871,6 +8948,12 @@ not kill a host another client may use. No startup timeout is imposed; supply
8871
8948
  before disconnecting. It affects every client; cancellation after acceptance
8872
8949
  does not reverse it. A drain failure returns the full Core error.
8873
8950
 
8951
+ The same connector can attach to `startCoreListener()` with `start` omitted.
8952
+ There, `close()` still disconnects only this client, and `shutdown()` has no
8953
+ listener-owned operation: absent an application method of that name, the
8954
+ ordinary dispatcher returns `METHOD_NOT_ALLOWED`. The existing window/runtime
8955
+ owner remains responsible for shutdown.
8956
+
8874
8957
  ```js
8875
8958
  import {connectSharedCoreHost} from 'arcane-os/core/host';
8876
8959
 
@@ -8967,9 +9050,10 @@ stdio-owned Core lifetime. See [native shared-host launch](core-shared-host.md#n
8967
9050
 
8968
9051
  ## startCoreHost()
8969
9052
 
8970
- For independent UI and MCP processes sharing one application runtime, use the
8971
- [shared host functions](#startsharedcorehost) above. Ordinary `startCoreHost()`
8972
- retains its existing stdio-owned lifetime.
9053
+ For an external client of the actual window-owned runtime, attach
9054
+ [`startCoreListener()`](#startcorelistener) to `host.runtime`. For an explicitly
9055
+ independent headless lifetime, use the [shared host functions](#startsharedcorehost).
9056
+ Ordinary `startCoreHost()` retains its existing stdio-owned lifetime.
8973
9057
 
8974
9058
  ### Overview
8975
9059
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.83.1",
3
+ "version": "0.84.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/core/host.mjs CHANGED
@@ -5,7 +5,7 @@ import Is from 'strong-type';
5
5
  import {createCoreRuntime} from './runtime.mjs';
6
6
  import {startCoreStdio} from './stdio.mjs';
7
7
 
8
- export {startSharedCoreHost, connectSharedCoreHost, runSharedCoreHost, startSharedCoreBridge} from './shared-host.mjs';
8
+ export {startCoreListener, startSharedCoreHost, connectSharedCoreHost, runSharedCoreHost, startSharedCoreBridge} from './shared-host.mjs';
9
9
 
10
10
  const is = new Is(false);
11
11
 
@@ -0,0 +1,165 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import Is from 'strong-type';
3
+ import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
4
+
5
+ const is = new Is(false);
6
+ const owners = new WeakMap();
7
+
8
+ function failure(code, message) {
9
+ return new CoreError({code, message});
10
+ }
11
+
12
+ function event(event, data) {
13
+ return {protocol: CORE_PROTOCOL, type: 'event', event, data, time: new Date().toISOString()};
14
+ }
15
+
16
+ function runtimeConnections(runtime) {
17
+ const existing = owners.get(runtime);
18
+ if (existing) return existing;
19
+ const connections = new Set();
20
+ const requests = new Map();
21
+ const prefix = `connection-${randomUUID()}:`;
22
+ let sequence = 0;
23
+
24
+ function route(frame) {
25
+ if (frame.type === 'response') {
26
+ const record = requests.get(frame.id);
27
+ if (record) {
28
+ record.release();
29
+ record.connection.send({...frame, id: record.id}, record);
30
+ }
31
+ return;
32
+ }
33
+ if (frame.type !== 'event') return;
34
+ if (Object.hasOwn(frame, 'requestId')) {
35
+ const record = requests.get(frame.requestId);
36
+ if (record) record.connection.send({...frame, requestId: record.id}, record);
37
+ return;
38
+ }
39
+ for (const connection of connections) connection.send(frame);
40
+ }
41
+
42
+ const unsubscribe = runtime.onFrame(route);
43
+ const owner = {
44
+ connections,
45
+ requests,
46
+ allocate(requestPrefix) {
47
+ // Every acceptance gets a fresh transport correlation. A retired
48
+ // service context must never target a later use of its client ID.
49
+ const active = runtime.current().activeRequests;
50
+ function inUse(candidate) {
51
+ return requests.has(candidate) || active.some(
52
+ function activeRequest(request) { return request.id === candidate; }
53
+ );
54
+ }
55
+ let internalId;
56
+ do {
57
+ internalId = `${requestPrefix ?? ''}${prefix}${++sequence}`;
58
+ } while (inUse(internalId));
59
+ return internalId;
60
+ },
61
+ release() {
62
+ if (connections.size || requests.size) return;
63
+ unsubscribe();
64
+ owners.delete(runtime);
65
+ }
66
+ };
67
+ owners.set(runtime, owner);
68
+ return owner;
69
+ }
70
+
71
+ /** One transport connection borrows dispatch; only its host owns runtime.close. */
72
+ export function createCoreRuntimeConnection({
73
+ runtime, send, requestPrefix, mapFrame, getReplayEvents, preserveContextRequestId = true
74
+ }) {
75
+ const owner = runtimeConnections(runtime);
76
+ const requests = new Map();
77
+ const internalRequests = new Map();
78
+ let disconnected = false;
79
+
80
+ function deliver(frame, record) {
81
+ if (disconnected) return;
82
+ const outgoing = mapFrame ? mapFrame(frame, record) : frame;
83
+ if (outgoing !== undefined) send(outgoing);
84
+ }
85
+
86
+ function cancel(record) {
87
+ return runtime.handle(
88
+ {protocol: CORE_PROTOCOL, type: 'control', control: 'request.cancel', requestId: record.internalId}
89
+ );
90
+ }
91
+
92
+ function cancelAll() {
93
+ return Promise.all([...requests.values()].map(cancel));
94
+ }
95
+
96
+ function replay() {
97
+ const state = runtime.current();
98
+ if (state.state === 'ready') deliver(event('core.ready', {version: state.version, app: state.application}));
99
+ deliver(event('core.state', state));
100
+ for (const service of state.services) deliver(event('core.service.state', service));
101
+ // Only the existing owner's current snapshots, never requests/history.
102
+ for (const snapshot of getReplayEvents?.() ?? []) deliver(event(snapshot.event, snapshot.data));
103
+ }
104
+
105
+ async function handle(frame) {
106
+ if (disconnected) throw failure('CORE_CONNECTION_CLOSED', 'The Core connection is closed.');
107
+ if (frame?.protocol !== CORE_PROTOCOL) throw failure('INVALID_RPC_REQUEST', 'Unknown Core protocol.');
108
+ if (frame.type === 'control') {
109
+ if (frame.control === 'runtime.replay') return replay();
110
+ if (frame.control === 'requests.cancelAll') return cancelAll();
111
+ if (frame.control === 'request.cancel') {
112
+ const record = requests.get(frame.requestId);
113
+ return record ? cancel(record) : undefined;
114
+ }
115
+ throw failure('INVALID_RPC_CONTROL', 'Unknown Core control.');
116
+ }
117
+ if (frame.type !== 'request' || !is.string(frame.id) || !frame.id || !is.string(frame.method)) {
118
+ throw failure('INVALID_RPC_REQUEST', 'A Core request requires an id and method.');
119
+ }
120
+ if (requests.has(frame.id)) {
121
+ const answer = {protocol: CORE_PROTOCOL, type: 'response', id: frame.id, ok: false,
122
+ error: serializeCoreError(failure('RPC_REQUEST_ID_ACTIVE', 'The request id is already active on this connection.')),
123
+ time: new Date().toISOString()};
124
+ deliver(answer);
125
+ return answer;
126
+ }
127
+ const record = {connection, id: frame.id, internalId: owner.allocate(requestPrefix), release: releaseRequest};
128
+ function releaseRequest() {
129
+ if (requests.get(record.id) === record) requests.delete(record.id);
130
+ internalRequests.delete(record.internalId);
131
+ owner.requests.delete(record.internalId);
132
+ owner.release();
133
+ }
134
+ requests.set(record.id, record);
135
+ internalRequests.set(record.internalId, record);
136
+ owner.requests.set(record.internalId, record);
137
+ try {
138
+ return await runtime.handle(
139
+ {...frame, id: record.internalId},
140
+ preserveContextRequestId ? {contextRequestId: record.id} : undefined
141
+ );
142
+ } finally {
143
+ releaseRequest();
144
+ }
145
+ }
146
+
147
+ function close() {
148
+ if (disconnected) return Promise.resolve();
149
+ disconnected = true;
150
+ owner.connections.delete(connection);
151
+ const cancellation = cancelAll();
152
+ owner.release();
153
+ return cancellation;
154
+ }
155
+
156
+ const connection = {
157
+ handle,
158
+ close,
159
+ send: deliver,
160
+ has(id) { return requests.has(id); },
161
+ request(internalId) { return internalRequests.get(internalId); }
162
+ };
163
+ owner.connections.add(connection);
164
+ return connection;
165
+ }
@@ -218,7 +218,7 @@ class CoreRuntime {
218
218
  return this.current();
219
219
  }
220
220
 
221
- async handle(frame) {
221
+ async handle(frame, {contextRequestId} = {}) {
222
222
  if (frame?.protocol !== CORE_PROTOCOL) throw failure('INVALID_RPC_REQUEST', 'Unknown Core protocol.');
223
223
  if (frame.type === 'control') {
224
224
  if (frame.control === 'runtime.replay') {
@@ -235,7 +235,10 @@ class CoreRuntime {
235
235
  if (frame.type !== 'request' || !is.string(frame.id) || !frame.id || !is.string(frame.method)) {
236
236
  throw failure('INVALID_RPC_REQUEST', 'A Core request requires an id and method.');
237
237
  }
238
- const task = this.#answer(frame);
238
+ if (contextRequestId !== undefined && (!is.string(contextRequestId) || !contextRequestId)) {
239
+ throw failure('INVALID_RPC_REQUEST', 'A transport context request id must be a nonempty string.');
240
+ }
241
+ const task = this.#answer(frame, contextRequestId);
239
242
  this.#responses.add(task);
240
243
  try {
241
244
  return await task;
@@ -244,7 +247,7 @@ class CoreRuntime {
244
247
  }
245
248
  }
246
249
 
247
- async #answer(frame) {
250
+ async #answer(frame, contextRequestId) {
248
251
  let result;
249
252
  let error;
250
253
  let ok = true;
@@ -254,7 +257,7 @@ class CoreRuntime {
254
257
  if (frame.method === 'system.ping') result = {ok: true};
255
258
  else if (frame.method === 'version.current') result = this.#version;
256
259
  else if (frame.method === 'app.current') result = this.#application;
257
- else result = await this.#request(frame);
260
+ else result = await this.#request(frame, contextRequestId);
258
261
  } catch (caught) {
259
262
  ok = false;
260
263
  error = caught;
@@ -270,7 +273,7 @@ class CoreRuntime {
270
273
  return response;
271
274
  }
272
275
 
273
- #request(frame) {
276
+ #request(frame, contextRequestId) {
274
277
  const registration = this.#methods.get(frame.method);
275
278
  if (!registration) {
276
279
  const namespace = frame.method.split('.')[0];
@@ -300,7 +303,8 @@ class CoreRuntime {
300
303
  const result = await handle.call(
301
304
  service.definition,
302
305
  frame.parameters,
303
- {...runtime.#serviceContext(service, frame.id), requestId: frame.id, signal: controller.signal}
306
+ {...runtime.#serviceContext(service, frame.id),
307
+ requestId: contextRequestId ?? frame.id, coreRequestId: frame.id, signal: controller.signal}
304
308
  );
305
309
  aborted(controller);
306
310
  return result;
@@ -2,10 +2,14 @@ import {spawn} from 'node:child_process';
2
2
  import {open} from 'node:fs/promises';
3
3
  import {createServer} from 'node:net';
4
4
  import {randomUUID} from 'node:crypto';
5
+ import Is from 'strong-type';
5
6
  import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
6
7
  import {createCoreRuntime} from './runtime.mjs';
7
8
  import {createCoreFrameDecoder, encodeCoreFrame} from './stdio.mjs';
8
9
  import {connectCoreSocket, createCoreSocketConnection, writeCoreSocket as write} from './socket-connection.mjs';
10
+ import {createCoreRuntimeConnection} from './runtime-connection.mjs';
11
+
12
+ const is = new Is(false);
9
13
 
10
14
  function failure(code, message) { return new CoreError({code, message}); }
11
15
  function reportError(error) { console.error('Shared Arcane Core failed:', error); }
@@ -23,6 +27,233 @@ function response(id, result, error) {
23
27
  time: new Date().toISOString()};
24
28
  }
25
29
 
30
+ /** Add local IPC to an existing runtime without acquiring its lifetime. */
31
+ export async function startCoreListener({runtime, endpoint, onError = reportError} = {}) {
32
+ if (!is.string(endpoint) || !endpoint) throw new TypeError('A Core listener endpoint must be a nonempty local pipe/socket path.');
33
+ if (!runtime || !is.function(runtime.handle) || !is.function(runtime.onFrame)
34
+ || !is.function(runtime.current) || !is.function(runtime.subscribe)) {
35
+ throw new TypeError('A Core listener requires an existing Core runtime.');
36
+ }
37
+ if (['draining', 'closed'].includes(runtime.current().state)) throw failure('CORE_CLOSING', 'Core is closing.');
38
+ const peers = new Set();
39
+ const prefix = `listener-${randomUUID()}:`;
40
+ let accepting = true;
41
+ let closing;
42
+ let terminalError;
43
+ let resolveClosed;
44
+ let rejectClosed;
45
+ let resolveRuntimeClosed;
46
+ const runtimeClosed = new Promise(
47
+ function observeRuntimeClosure(resolve) { resolveRuntimeClosed = resolve; }
48
+ );
49
+ const closed = new Promise(
50
+ function ownListener(resolve, reject) {
51
+ resolveClosed = resolve;
52
+ rejectClosed = reject;
53
+ }
54
+ );
55
+ closed.catch(function observeReportedListenerFailure() {});
56
+
57
+ function diagnostic(error) {
58
+ report(onError, error);
59
+ }
60
+
61
+ function disconnect(peer) {
62
+ if (peer.closed) return;
63
+ peer.closed = true;
64
+ peers.delete(peer);
65
+ peer.connection.close().catch(diagnostic);
66
+ }
67
+
68
+ function send(peer, frame) {
69
+ if (peer.closed) return;
70
+ let content;
71
+ try {
72
+ content = encodeCoreFrame(frame);
73
+ } catch (error) {
74
+ diagnostic(error);
75
+ peer.socket.destroy();
76
+ return;
77
+ }
78
+ peer.writes = peer.writes.then(
79
+ function writeNext() {
80
+ if (!peer.closed) return write(peer.socket, content);
81
+ }
82
+ ).catch(
83
+ function outputFailed(error) {
84
+ diagnostic(error);
85
+ peer.socket.destroy();
86
+ }
87
+ );
88
+ }
89
+
90
+ function accept(socket) {
91
+ const peer = {socket, closed: false, writes: Promise.resolve(), connection: null};
92
+ peers.add(peer);
93
+ peer.connection = createCoreRuntimeConnection(
94
+ {
95
+ runtime,
96
+ requestPrefix: prefix,
97
+ send(frame) { send(peer, frame); }
98
+ }
99
+ );
100
+ const decoder = createCoreFrameDecoder(
101
+ function receiveFrame(frame) {
102
+ if (peer.closed) return;
103
+ if (!accepting && frame?.type === 'request') {
104
+ send(peer, response(frame.id, undefined, failure('CORE_CLOSING', 'The Core listener is closing.')));
105
+ return;
106
+ }
107
+ // No owning-host shutdown command or application dispatcher.
108
+ peer.connection.handle(frame).catch(
109
+ function requestFailed(error) {
110
+ if (frame?.type === 'request') send(peer, response(frame.id, undefined, error));
111
+ else send(peer, event('core.error', serializeCoreError(error)));
112
+ }
113
+ );
114
+ }
115
+ );
116
+ socket.on(
117
+ 'data',
118
+ function receiveData(chunk) {
119
+ try {
120
+ decoder.push(chunk);
121
+ } catch (error) {
122
+ diagnostic(error);
123
+ socket.destroy();
124
+ }
125
+ }
126
+ );
127
+ socket.on('error', diagnostic);
128
+ socket.on(
129
+ 'end',
130
+ function inputEnded() {
131
+ try {
132
+ decoder.finish();
133
+ } catch (error) {
134
+ diagnostic(error);
135
+ }
136
+ disconnect(peer);
137
+ }
138
+ );
139
+ socket.on('close', function connectionClosed() { disconnect(peer); });
140
+ socket.resume();
141
+ }
142
+
143
+ const server = createServer({pauseOnConnect: true}, accept);
144
+ const binding = new Promise(
145
+ function claimEndpoint(resolve, reject) {
146
+ function failed(error) {
147
+ server.off('listening', listening);
148
+ reject(error);
149
+ }
150
+ function listening() {
151
+ server.off('error', failed);
152
+ resolve();
153
+ }
154
+ server.once('error', failed);
155
+ server.once('listening', listening);
156
+ server.listen({path: endpoint});
157
+ }
158
+ );
159
+
160
+ function close() {
161
+ if (closing) return closed;
162
+ accepting = false;
163
+ closing = Promise.resolve().then(
164
+ async function closeListener() {
165
+ try {
166
+ await binding;
167
+ } catch (error) {
168
+ terminalError ??= error;
169
+ }
170
+ // Window/Core shutdown retains the endpoint through service
171
+ // drain. Closing this listener never initiates runtime.close().
172
+ if (runtime.current().state === 'draining') await runtimeClosed;
173
+ const stopped = new Promise(
174
+ function stopListening(resolve, reject) {
175
+ server.close(
176
+ function listenerClosed(error) {
177
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
178
+ else resolve();
179
+ }
180
+ );
181
+ }
182
+ );
183
+ stopped.catch(function observeListenerStopFailure() {});
184
+ const connected = [...peers];
185
+ const cancellations = await Promise.allSettled(
186
+ connected.map(
187
+ function detachConnection(peer) {
188
+ peer.socket.pause();
189
+ return peer.connection.close();
190
+ }
191
+ )
192
+ );
193
+ for (const outcome of cancellations) {
194
+ if (outcome.status === 'rejected') {
195
+ terminalError = terminalError
196
+ ? new AggregateError([terminalError, outcome.reason], 'Core listener closure failed.')
197
+ : outcome.reason;
198
+ }
199
+ }
200
+ await Promise.all(connected.map(function flushConnection(peer) { return peer.writes; }));
201
+ for (const peer of connected) {
202
+ peer.socket.resume();
203
+ peer.socket.end();
204
+ }
205
+ await stopped;
206
+ if (terminalError) throw terminalError;
207
+ return {state: 'closed', endpoint};
208
+ }
209
+ );
210
+ closing.then(
211
+ function listenerClosed(result) {
212
+ unsubscribe();
213
+ resolveClosed(result);
214
+ },
215
+ function listenerFailed(error) {
216
+ unsubscribe();
217
+ diagnostic(error);
218
+ rejectClosed(error);
219
+ }
220
+ );
221
+ return closed;
222
+ }
223
+
224
+ const unsubscribe = runtime.subscribe(
225
+ function runtimeChanged(state) {
226
+ if (state.state === 'draining') accepting = false;
227
+ if (state.state === 'closed') {
228
+ resolveRuntimeClosed();
229
+ close();
230
+ }
231
+ }
232
+ );
233
+ server.on(
234
+ 'error',
235
+ function listenerFailed(error) {
236
+ terminalError = error;
237
+ close();
238
+ }
239
+ );
240
+ try {
241
+ await binding;
242
+ if (!accepting) {
243
+ await close();
244
+ throw failure('CORE_CLOSING', 'Core closed while its listener was starting.');
245
+ }
246
+ return {endpoint, closed, close};
247
+ } catch (error) {
248
+ try {
249
+ await close();
250
+ } catch (closeError) {
251
+ if (closeError !== error) throw new AggregateError([error, closeError], 'Core listener startup and cleanup failed.');
252
+ }
253
+ throw error;
254
+ }
255
+ }
256
+
26
257
  /** Claim one app-selected pipe/socket before importing or creating services. */
27
258
  export async function startSharedCoreHost({
28
259
  endpoint, application, version, configure, getReplayEvents, signal, onError = reportError
@@ -30,13 +261,10 @@ export async function startSharedCoreHost({
30
261
  signal?.throwIfAborted();
31
262
  if (typeof endpoint !== 'string' || !endpoint) throw new TypeError('A shared Core endpoint must be a nonempty local pipe/socket path.');
32
263
  const peers = new Set();
33
- const requests = new Map();
34
264
  const incoming = new Set();
35
265
  const lifetime = new AbortController();
36
266
  const prefix = `shared-${randomUUID()}:`;
37
- let sequence = 0;
38
267
  let runtime;
39
- let unsubscribe;
40
268
  let closing;
41
269
  let preparation = Promise.resolve();
42
270
  let ready = false;
@@ -65,89 +293,61 @@ export async function startSharedCoreHost({
65
293
  if (!peer.closed) return write(peer.socket, content);
66
294
  }).catch(function outputFailed(error) { diagnostic(error); peer.socket.destroy(); });
67
295
  }
68
- function cancel(record) {
69
- observe(runtime.handle({protocol: CORE_PROTOCOL, type: 'control',
70
- control: 'request.cancel', requestId: record.internalId}));
71
- }
72
296
  function disconnect(peer) {
73
297
  if (peer.closed) return;
74
298
  peer.closed = true;
75
299
  peers.delete(peer);
76
- for (const record of peer.requests.values()) cancel(record);
77
- }
78
- function stateFor(peer, state) {
79
- return {...state, activeRequests: state.activeRequests.map(function requestState(record) {
80
- const owned = requests.get(record.id);
81
- return owned?.peer === peer ? {...record, id: owned.id} : record;
82
- })};
83
- }
84
- function replay(peer) {
85
- const state = runtime.current();
86
- if (state.state === 'ready') send(peer, event('core.ready', {version: state.version, app: state.application}));
87
- send(peer, event('core.state', stateFor(peer, state)));
88
- for (const service of state.services) send(peer, event('core.service.state', service));
89
- // Domain snapshots belong to the app/service owner, never event history.
90
- for (const snapshot of getReplayEvents?.() ?? []) send(peer, event(snapshot.event, snapshot.data));
300
+ if (peer.connection) observe(peer.connection.close());
91
301
  }
92
- function route(frame) {
93
- if (frame.type === 'response') {
94
- const record = requests.get(frame.id);
95
- if (record) send(record.peer, {...frame, id: record.id});
96
- return;
97
- }
98
- if (frame.type !== 'event') return;
99
- if (Object.hasOwn(frame, 'requestId')) {
100
- const owned = requests.get(frame.requestId);
101
- if (owned) send(owned.peer, {...frame, requestId: owned.id,
102
- data: frame.data?.requestId === owned.internalId ? {...frame.data, requestId: owned.id} : frame.data});
103
- return;
104
- }
105
- const id = frame.data?.requestId;
106
- const record = requests.get(id);
107
- if (record) {
108
- send(record.peer, {...frame, data: {...frame.data, requestId: record.id}});
109
- return;
110
- }
111
- // A retired transport correlation is not a new app-wide event.
112
- if (typeof id === 'string' && id.startsWith(prefix)) return;
113
- for (const peer of peers) send(peer, frame.event === 'core.state'
114
- ? {...frame, data: stateFor(peer, frame.data)} : frame);
302
+ function attach(peer) {
303
+ peer.connection = createCoreRuntimeConnection(
304
+ {
305
+ runtime,
306
+ requestPrefix: prefix,
307
+ preserveContextRequestId: false,
308
+ getReplayEvents,
309
+ send(frame) { send(peer, frame); },
310
+ mapFrame(frame, record) {
311
+ // Preserve this owning adapter's established data correlation.
312
+ // The borrowed listener and stdio never apply this mapping.
313
+ if (record) {
314
+ return frame.type === 'event' && frame.data?.requestId === record.internalId
315
+ ? {...frame, data: {...frame.data, requestId: record.id}} : frame;
316
+ }
317
+ if (frame.event === 'core.state') {
318
+ return {...frame, data: {...frame.data, activeRequests: frame.data.activeRequests.map(
319
+ function requestState(request) {
320
+ const owned = peer.connection.request(request.id);
321
+ return owned ? {...request, id: owned.id} : request;
322
+ }
323
+ )}};
324
+ }
325
+ const id = frame.data?.requestId;
326
+ const owned = peer.connection.request(id);
327
+ if (owned) return {...frame, data: {...frame.data, requestId: owned.id}};
328
+ // A retired transport correlation is not a new app-wide event.
329
+ if (is.string(id) && id.startsWith(prefix)) return;
330
+ return frame;
331
+ }
332
+ }
333
+ );
115
334
  }
116
335
  async function dispatch(peer, frame) {
117
336
  if (frame?.protocol !== CORE_PROTOCOL) throw failure('INVALID_RPC_REQUEST', 'Unknown Core protocol.');
118
337
  if (frame.type === 'control') {
119
- if (frame.control === 'runtime.replay') { replay(peer); return; }
120
- if (frame.control === 'request.cancel') {
121
- const record = peer.requests.get(frame.requestId);
122
- if (record) cancel(record);
123
- return;
124
- }
125
- if (frame.control === 'requests.cancelAll') {
126
- for (const record of peer.requests.values()) cancel(record);
127
- return;
128
- }
129
- throw failure('INVALID_RPC_CONTROL', 'Unknown Core control.');
338
+ return peer.connection.handle(frame);
130
339
  }
131
340
  if (frame.type !== 'request' || typeof frame.id !== 'string' || !frame.id || typeof frame.method !== 'string') {
132
341
  throw failure('INVALID_RPC_REQUEST', 'A Core request requires an id and method.');
133
342
  }
134
- if (peer.requests.has(frame.id)) throw failure('RPC_REQUEST_ID_ACTIVE', 'The request id is already active on this connection.');
343
+ if (peer.connection.has(frame.id)) throw failure('RPC_REQUEST_ID_ACTIVE', 'The request id is already active on this connection.');
135
344
  if (frame.method === 'core.host.shutdown') {
136
345
  peer.shutdownIds.push(frame.id);
137
346
  close();
138
347
  return;
139
348
  }
140
349
  if (closing) throw failure('CORE_CLOSING', 'Core is closing.');
141
- const record = {peer, id: frame.id, internalId: `${prefix}${++sequence}`};
142
- peer.requests.set(record.id, record);
143
- requests.set(record.internalId, record);
144
- try {
145
- // Only protocol correlation changes; parameters remain untouched.
146
- await runtime.handle({...frame, id: record.internalId});
147
- } finally {
148
- peer.requests.delete(record.id);
149
- requests.delete(record.internalId);
150
- }
350
+ return peer.connection.handle(frame);
151
351
  }
152
352
  function receive(peer, frame) {
153
353
  if (peer.closed) return;
@@ -157,8 +357,9 @@ export async function startSharedCoreHost({
157
357
  }));
158
358
  }
159
359
  function accept(socket) {
160
- const peer = {socket, requests: new Map(), shutdownIds: [], closed: false, writes: Promise.resolve()};
360
+ const peer = {socket, connection: null, shutdownIds: [], closed: false, writes: Promise.resolve()};
161
361
  peers.add(peer);
362
+ if (runtime) attach(peer);
162
363
  const decoder = createCoreFrameDecoder(function receiveFrame(frame) { receive(peer, frame); });
163
364
  socket.on('data', function data(chunk) {
164
365
  try { decoder.push(chunk); } catch (error) { diagnostic(error); socket.destroy(); }
@@ -196,7 +397,6 @@ export async function startSharedCoreHost({
196
397
  await Promise.all([...peers].map(function drainWrites(peer) { return peer.writes; }));
197
398
  for (const peer of peers) { peer.socket.resume(); peer.socket.end(); }
198
399
  await stopped;
199
- unsubscribe?.();
200
400
  signal?.removeEventListener('abort', abortHost);
201
401
  if (error) throw error;
202
402
  return runtime?.current() ?? {state: 'closed'};
@@ -222,7 +422,7 @@ export async function startSharedCoreHost({
222
422
  // The endpoint is already owned. A losing launcher never calls configure.
223
423
  signal?.throwIfAborted();
224
424
  runtime = createCoreRuntime({application, version});
225
- unsubscribe = runtime.onFrame(route);
425
+ for (const peer of peers) attach(peer);
226
426
  signal?.addEventListener('abort', abortHost, {once: true});
227
427
  preparation = Promise.resolve().then(function configureClaimedHost() {
228
428
  lifetime.signal.throwIfAborted();
@@ -1,6 +1,7 @@
1
1
  import {finished} from 'node:stream';
2
2
  import Is from 'strong-type';
3
3
  import {CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
4
+ import {createCoreRuntimeConnection} from './runtime-connection.mjs';
4
5
 
5
6
  const is = new Is(false);
6
7
 
@@ -116,10 +117,24 @@ export function startCoreStdio({runtime, input = process.stdin, output = process
116
117
  ).catch(failOutput);
117
118
  }
118
119
 
119
- const unsubscribe = runtime.onFrame(queueFrame);
120
+ const connection = createCoreRuntimeConnection(
121
+ {
122
+ runtime,
123
+ send: queueFrame,
124
+ mapFrame(frame) {
125
+ if (frame.event !== 'core.state') return frame;
126
+ return {...frame, data: {...frame.data, activeRequests: frame.data.activeRequests.map(
127
+ function requestState(request) {
128
+ const owned = connection.request(request.id);
129
+ return owned ? {...request, id: owned.id} : request;
130
+ }
131
+ )}};
132
+ }
133
+ }
134
+ );
120
135
 
121
136
  function receiveFrame(frame) {
122
- const task = runtime.handle(frame);
137
+ const task = connection.handle(frame);
123
138
  pending.add(task);
124
139
  task.then(
125
140
  function requestSettled() {
@@ -157,7 +172,7 @@ export function startCoreStdio({runtime, input = process.stdin, output = process
157
172
  }
158
173
 
159
174
  async function releaseTransportListeners() {
160
- unsubscribe();
175
+ await connection.close();
161
176
  output.off('error', failOutput);
162
177
  // destroy(error) marks a stream destroyed before its scheduled error
163
178
  // and close events. The native completion owner observes their delivery;
@@ -203,7 +203,7 @@ export function coreEntrySource(application, version, services, {localAI, runtim
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
- "import {readCoreLaunchContext, startCoreHost, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
206
+ "import {readCoreLaunchContext, startCoreHost, startCoreListener, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
207
207
  '',
208
208
  ...(localAI === undefined ? [] : [
209
209
  '// Bundled runtime paths are artifact-root-relative POSIX paths.',
@@ -249,6 +249,10 @@ export function coreEntrySource(application, version, services, {localAI, runtim
249
249
  : ` ...await readCoreLaunchContext({appId: ${JSON.stringify(application.id)}, defaults: JSON.parse(${JSON.stringify(JSON.stringify(launchDefaults))})})`,
250
250
  '};',
251
251
  '',
252
+ 'if (context.coreListener !== undefined && context.sharedHost !== undefined) {',
253
+ " throw new TypeError('coreListener attaches to the window-owned Core; sharedHost selects its separate owning host.');",
254
+ '}',
255
+ '',
252
256
  ...(launchDefaults === undefined ? [] : [
253
257
  '// The shared endpoint and diagnostic log require their selected state directory.',
254
258
  'if (context.sharedHost !== undefined) await mkdir(context.stateRoot, {recursive: true});',
@@ -284,10 +288,29 @@ export function coreEntrySource(application, version, services, {localAI, runtim
284
288
  `const application = JSON.parse(${JSON.stringify(JSON.stringify(application))});`,
285
289
  `const version = ${JSON.stringify(version)};`,
286
290
  'let host;',
291
+ 'let listener;',
287
292
  'if (context.sharedHost === undefined) {',
288
293
  ' const services = [];',
289
294
  ' await createServices(function register(service) { services.push(service); });',
290
295
  ' host = startCoreHost({application, version, services});',
296
+ ' if (context.coreListener !== undefined) {',
297
+ " process.on('SIGINT', closeCore);",
298
+ " process.on('SIGTERM', closeCore);",
299
+ ' try {',
300
+ ' listener = await startCoreListener({runtime: host.runtime, endpoint: context.coreListener?.endpoint});',
301
+ ' listener.closed.catch(function listenerFailed() { process.exitCode = 1; });',
302
+ ' } catch (error) {',
303
+ ' try {',
304
+ ' await host.close();',
305
+ ' } catch (closeError) {',
306
+ " throw new AggregateError([error, closeError], 'Core listener startup and host drain failed.');",
307
+ ' }',
308
+ ' throw error;',
309
+ ' } finally {',
310
+ " process.off('SIGINT', closeCore);",
311
+ " process.off('SIGTERM', closeCore);",
312
+ ' }',
313
+ ' }',
291
314
  "} else if (process.argv.includes('--arcane-core-headless')) {",
292
315
  ' host = await runSharedCoreHost({',
293
316
  ' endpoint: context.sharedHost.endpoint, application, version,',
@@ -319,6 +342,12 @@ export function coreEntrySource(application, version, services, {localAI, runtim
319
342
  " console.error('Arcane Core host failed:', error);",
320
343
  ' process.exitCode = 1;',
321
344
  '} finally {',
345
+ ' try {',
346
+ ' await listener?.close();',
347
+ ' } catch {',
348
+ ' // The listener reports its complete failure through onError.',
349
+ ' process.exitCode = 1;',
350
+ ' }',
322
351
  " process.off('SIGINT', closeCore);",
323
352
  " process.off('SIGTERM', closeCore);",
324
353
  '}',