arcane-os 0.39.1 → 0.40.1

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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.40.1
4
+
5
+ - Include complete selected local resource URL variants in generated static
6
+ PWA inventories. Reuse the source resource graph, resolve shared component
7
+ styles and modules against their runtime root, and retain speech worker mode
8
+ queries. Application selection, source content and output locations remain
9
+ unchanged; regenerate through the public import-map workflow to receive the
10
+ corrected offline inventory.
11
+ - Space DigitalOcean FAL inference request starts by one second while retaining
12
+ four concurrent jobs and ordered playback. Retry a readable rate-limit
13
+ rejection once, honoring longer readable Retry-After values, and retry a
14
+ same-job status network failure once. An ambiguous submission network failure
15
+ is surfaced without submitting another potentially paid job.
16
+ - Preserve the user's enabled speech intent after a synthesis, playback or
17
+ provider lifecycle failure. A failed job leaves unrelated queued jobs intact;
18
+ explicit Retry, Load, Mute and Cancel remain user-controlled.
19
+
20
+ ## 0.40.0
21
+
22
+ - Add optional `arcane-package.json` `outputDirectory` to select the final
23
+ workspace-relative package directory without an appended application ID.
24
+ For example, `"ai"` selects `<workspace>/ai`; omission preserves `dist/<id>`.
25
+ Descriptor refresh retains this package-local setting, and preview,
26
+ verification, bundles and native browser-content inputs use the selected
27
+ root. Source layout, app identity and internal release URLs are unchanged.
28
+ Packaging replaces only the selected destination through its existing staged
29
+ swap, preserving old destinations and siblings when that selection changes.
30
+ - Serve selected cached static HTML documents for navigation query variants
31
+ without redirecting or losing the requested URL. Exact selected query
32
+ variants retain precedence; a missing cached document fetches the original
33
+ request without replacing the canonical cached body. Assets, API requests,
34
+ non-navigation requests and update/refresh lifecycle remain unchanged.
35
+
3
36
  ## 0.39.1
4
37
 
5
38
  - Preserve the Markdown editor's current source viewport and selection during
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.39.1` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.40.1` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -113,6 +113,7 @@ export function createDigitalOceanFalTTSProvider({
113
113
  let generation = 0;
114
114
  let unloadOperation = null;
115
115
  let disposeOperation = null;
116
+ let nextInferenceStart = 0;
116
117
 
117
118
  function authority() {
118
119
  return {
@@ -214,12 +215,62 @@ export function createDigitalOceanFalTTSProvider({
214
215
  };
215
216
  }
216
217
 
218
+ async function fetchInference(url, options, operation) {
219
+ const signal = options.signal;
220
+ let retryError = null;
221
+ while (true) {
222
+ // Space request starts across jobs, not their completion. Remote jobs
223
+ // still run concurrently and the runtime retains ordered playback.
224
+ while (true) {
225
+ assertNotAborted(signal);
226
+ const delay = nextInferenceStart - Date.now();
227
+ if (delay <= 0) break;
228
+ await waitForStatus(delay, signal);
229
+ }
230
+ nextInferenceStart = Date.now() + POLL_INTERVAL_MS;
231
+ let response;
232
+ try {
233
+ response = await fetchImpl(url, options);
234
+ } catch (error) {
235
+ assertNotAborted(signal);
236
+ // A lost POST response may represent an accepted paid job. Only
237
+ // an identified job's status read can retry a network failure.
238
+ if (!retryError && options.method === 'GET' && error instanceof TypeError) {
239
+ nextInferenceStart = Math.max(nextInferenceStart, Date.now() + POLL_INTERVAL_MS);
240
+ retryError = error;
241
+ console.warn('Retrying the cloud speech status read once.', error);
242
+ continue;
243
+ }
244
+ throw error;
245
+ }
246
+ assertNotAborted(signal);
247
+ if (response.status === 429) {
248
+ // A readable rejection is safe to retry. Its cooldown also
249
+ // applies to waiting siblings, including when this retry ends.
250
+ const delay = Math.max(POLL_INTERVAL_MS, retryAfterDelay(response) ?? POLL_INTERVAL_MS);
251
+ nextInferenceStart = Math.max(nextInferenceStart, Date.now() + delay);
252
+ }
253
+ try {
254
+ await requireSuccessfulResponse(response, signal, operation);
255
+ } catch (error) {
256
+ assertNotAborted(signal);
257
+ if (!retryError && response.status === 429) {
258
+ retryError = error;
259
+ console.warn(`Retrying the cloud speech ${operation} once.`, error);
260
+ continue;
261
+ }
262
+ throw error;
263
+ }
264
+ return response;
265
+ }
266
+ }
267
+
217
268
  async function synthesize(payload, controller, requestGeneration) {
218
269
  const signal = controller.signal;
219
270
  const apiKey = await readApiKey(signal);
220
271
  assertNotAborted(signal);
221
272
  const headers = {Authorization: `Bearer ${apiKey}`};
222
- const response = await fetchImpl(
273
+ const response = await fetchInference(
223
274
  INVOKE_URL,
224
275
  {
225
276
  method: 'POST',
@@ -235,9 +286,9 @@ export function createDigitalOceanFalTTSProvider({
235
286
  }
236
287
  ),
237
288
  signal
238
- }
289
+ },
290
+ 'submission'
239
291
  );
240
- await requireSuccessfulResponse(response, signal, 'submission');
241
292
  let result = await response.json();
242
293
  assertNotAborted(signal);
243
294
  const requestId = result?.request_id;
@@ -249,22 +300,11 @@ export function createDigitalOceanFalTTSProvider({
249
300
  );
250
301
  }
251
302
  await waitForStatus(POLL_INTERVAL_MS, signal);
252
- let statusResponse;
253
- do {
254
- statusResponse = await fetchImpl(
255
- `${INVOKE_URL}/${encodeURIComponent(requestId)}`,
256
- {method: 'GET', headers, signal}
257
- );
258
- assertNotAborted(signal);
259
- const retryDelay = statusResponse.status === 429
260
- ? retryAfterDelay(statusResponse)
261
- : null;
262
- if (retryDelay === null) break;
263
- // Only status reads may be retried; submitting twice can create two paid jobs.
264
- await statusResponse.text();
265
- await waitForStatus(retryDelay, signal);
266
- } while (true);
267
- await requireSuccessfulResponse(statusResponse, signal, 'status read');
303
+ const statusResponse = await fetchInference(
304
+ `${INVOKE_URL}/${encodeURIComponent(requestId)}`,
305
+ {method: 'GET', headers, signal},
306
+ 'status read'
307
+ );
268
308
  result = await statusResponse.json();
269
309
  assertNotAborted(signal);
270
310
  }
@@ -844,11 +844,10 @@ function validateConfiguration(configuration, role) {
844
844
  function nestedWorkerUrl(role) {
845
845
  const url = new URL(
846
846
  role === "stt"
847
- ? "./browser-whisper-worker.mjs"
848
- : "./browser-kokoro-worker.mjs",
847
+ ? "./browser-whisper-worker.mjs?arcaneSpeechWorkerMode=artifact-module-worker"
848
+ : "./browser-kokoro-worker.mjs?arcaneSpeechWorkerMode=artifact-module-worker",
849
849
  import.meta.url,
850
850
  );
851
- url.searchParams.set("arcaneSpeechWorkerMode", "artifact-module-worker");
852
851
  return url;
853
852
  }
854
853
 
@@ -200,12 +200,13 @@ available until this wrapper and its explicit capability boundary are actually
200
200
  implemented.
201
201
 
202
202
  Packaging and release verification are separate explicit operations. Run
203
- `arcane package` to generate `dist/<id>`, then use
203
+ `arcane package` to generate the selected app's `outputDirectory`, defaulting
204
+ to `dist/<id>`, then use
204
205
  `arcane run --target browser` to serve that selected release. Packaging does
205
206
  not automatically run tests or checks; those run only when the user expressly
206
207
  requests them or when required for a separately selected release output. The browser
207
208
  run command does not substitute source files. If source changes after
208
- packaging, the prior `dist` remains intentionally unchanged until the next
209
+ packaging, the prior packaged output remains intentionally unchanged until the next
209
210
  explicit package operation. Never use packaged `dist` as the everyday
210
211
  development tree, and never treat source-serving behavior as evidence for the
211
212
  release artifact.
@@ -224,6 +225,17 @@ dist/<id>/apps/<id>/index.html
224
225
  dist/<id>/ARCANE_APP_RELEASE.json
225
226
  ```
226
227
 
228
+ These release paths show the default output location. An app's optional
229
+ schema-1 `arcane-package.json` field `outputDirectory` selects the final
230
+ workspace-relative directory instead: `"ai"` means `<workspace>/ai`, with no
231
+ app-ID suffix. The selected directory is wholly replaced through the existing
232
+ staged package swap; a changed destination preserves the old output and sibling
233
+ directories. Input/source conflicts fail before output writes. This changes
234
+ only package placement, not source layout, app identity, OPFS scope, or internal
235
+ release URLs. Preview, verification, bundling, and native browser-content input
236
+ consume that same selected release root; native executable output remains
237
+ separate. See the [complete output contract](reference/protocols.md#app-selected-package-output).
238
+
227
239
  Packaging places each selected app file beneath `apps/<id>/` in the release
228
240
  and preserves every shared route destination. When no selected shared payload
229
241
  supplies a root `index.html`, the SDK generates one that opens the selected app
@@ -56,8 +56,21 @@ punctuation boundary should submit promptly. This is incremental playback of
56
56
  completed synthesis jobs, not a live audio stream from DigitalOcean.
57
57
 
58
58
  The transport submits `POST https://inference.do-ai.run/v1/async-invoke`, then
59
- reads that job until its completed `output.audio.url` is available. Each
60
- segment is submitted once; only status reads retry a `429` with `Retry-After`.
59
+ reads that job until its completed `output.audio.url` is available. One provider
60
+ spaces inference request starts at least one second apart across its concurrent
61
+ jobs; it does not wait for one remote job to finish before starting another.
62
+ A readable `429` rejection permits one retry of the same submission or status
63
+ request after at least one second. A longer readable `Retry-After` postpones
64
+ all pending inference starts for that provider. A failed status Fetch may also
65
+ retry once for the same accepted job. Repeated failures surface their complete
66
+ diagnostics without an endless retry loop; a failed job does not cancel siblings.
67
+ Retry diagnostics remain in the developer console, outside conversation history.
68
+
69
+ An ambiguous submission network failure is not automatically resubmitted: a
70
+ browser cannot distinguish a rejected preflight from an accepted paid job whose
71
+ response was lost. This contract has no submission idempotency mechanism, and
72
+ unreadable response headers cannot supply a server cooldown. Pacing reduces local
73
+ request bursts; it does not claim to free upstream concurrency slots or fix CORS.
61
74
  The audio download never receives the inference Authorization header.
62
75
  The selected multilingual-v2 service supports speed `0.7` through `1.2`;
63
76
  unsupported speeds reject rather than being silently clamped. Complete
@@ -529,8 +529,9 @@ npm exec -- arcane check --app hello-world
529
529
 
530
530
  ### Overview
531
531
 
532
- Creates one complete browser release beneath `dist/<id>/`, preserving the prior
533
- output until the replacement is complete. It consumes saved source and managed
532
+ Creates one complete browser release in the app's selected output directory,
533
+ defaulting to `dist/<id>/`, and preserves the prior output until the replacement
534
+ is complete. It consumes saved source and managed
534
535
  import maps, keeps standalone app files at the output root, and retains the
535
536
  configured shared route destinations. Explicit multi-app workspaces retain
536
537
  their selected app beneath `apps/<id>/`. When selected shared content supplies no root
@@ -538,6 +539,14 @@ their selected app beneath `apps/<id>/`. When selected shared content supplies n
538
539
  Source document bases and resource URLs therefore retain their development
539
540
  layout. Packaging does not run tests or checks automatically.
540
541
 
542
+ Set `"outputDirectory": "ai"` in the selected app's `arcane-package.json`
543
+ to emit directly to `<workspace>/ai`; the SDK appends no app ID. Omit the field
544
+ to retain the default. There is no new CLI flag. The selected directory is
545
+ wholly replaced, not merged; choose a directory dedicated to the release.
546
+ Changing this setting preserves the old output and its siblings. Source/input
547
+ overlap fails before output writes. App identity, source layout, and saved
548
+ data remain unchanged. See the [output contract](protocols.md#app-selected-package-output).
549
+
541
550
  For `installed-v1`, the same four configured source routes supply the complete
542
551
  selected SDK content directly from `node_modules`. Only the portable output
543
552
  receives copies; no workspace `arcane/` projection is required. Its runtime URLs,
@@ -603,7 +612,10 @@ arcane bundle [--app <id>] [--artifact <file>.arcane-app.tar.gz] [--overwrite]
603
612
 
604
613
  ### Replacement behavior
605
614
 
606
- The default output is `dist/<id>-<version>.arcane-app.tar.gz`. An existing path
615
+ The default archive is `<id>-<version>.arcane-app.tar.gz` beside the app's
616
+ selected package directory. With the default package location, this remains
617
+ `dist/<id>-<version>.arcane-app.tar.gz`. Bundling reads the same selected release
618
+ root used by packaging and preview. An existing path
607
619
  is refused unless `--overwrite` is explicit. Even then, the prior artifact is
608
620
  retained until the replacement is complete. A conflicting or uncertain path is
609
621
  preserved rather than overwritten.
@@ -697,6 +709,8 @@ The command selects one workspace, app, target, architecture, format, signing
697
709
  profile, and output root. Current providers emit a portable directory,
698
710
  Windows x64 EXE bundle, Linux x64/ARM64 DEB, or development-signed Android APK.
699
711
  The output remains target-specific inside the common plan contract.
712
+ The app's `outputDirectory` selects its packaged browser-content input;
713
+ native `--output-root` independently selects native build output.
700
714
  `--dry-run` is implemented for the browser build path. Native builds reject it
701
715
  rather than returning a fictional native artifact plan.
702
716
 
@@ -713,7 +727,8 @@ npm exec -- arcane build \
713
727
 
714
728
  ### Overview
715
729
 
716
- For `--target browser`, starts the existing current `dist/<app>` release; it
730
+ For `--target browser`, starts the existing release in the app's configured
731
+ `outputDirectory`, or `dist/<app>` when omitted; it
717
732
  does not package, rebuild, test, check, or verify that release automatically.
718
733
  It opens the release manifest's `app.start` URL. Older flat releases without
719
734
  that field continue to open their `app.entry` path.
@@ -902,7 +902,7 @@
902
902
  ],
903
903
  "primaryImport": "arcane-os",
904
904
  "group": "Packaging and release bundles",
905
- "summary": "Loads one validated packager app plan, including the shared descriptor-admitted browser document inventory.",
905
+ "summary": "Loads one validated packager app plan, including the shared descriptor-admitted browser document inventory and final workspace-relative output selected by app outputDirectory or default dist/appId.",
906
906
  "availability": "Node",
907
907
  "protocol": "SDK packager and deterministic bundle contract",
908
908
  "normalization": "Normalized SDK validation with complete canonical archive and release content"
@@ -1197,7 +1197,7 @@
1197
1197
  ],
1198
1198
  "primaryImport": "arcane-os",
1199
1199
  "group": "Packaging and release bundles",
1200
- "summary": "Builds one selected browser application release through the low-level packager API; it never runs tests or checks automatically.",
1200
+ "summary": "Builds one selected browser application release into app outputDirectory or default dist/appId through staged whole-directory replacement, preserving source and unrelated prior destinations; it never runs tests or checks automatically.",
1201
1201
  "availability": "Node",
1202
1202
  "protocol": "SDK packager and deterministic bundle contract",
1203
1203
  "normalization": "Normalized SDK validation with complete canonical archive and release content"
@@ -1214,7 +1214,7 @@
1214
1214
  ],
1215
1215
  "primaryImport": "arcane-os",
1216
1216
  "group": "Headless toolchain operations",
1217
- "summary": "Runs the high-level package operation for one selected application.",
1217
+ "summary": "Runs the high-level package operation for one selected application using the same app-selected final outputDirectory, defaulting to dist/appId, with no separate destination override.",
1218
1218
  "availability": "Node; selected operation may produce browser or native output",
1219
1219
  "protocol": "Shared headless operation API + arcane-cli-events/1",
1220
1220
  "normalization": "SDK-normalized inputs, errors, events, and documented result"
@@ -2031,7 +2031,7 @@
2031
2031
  ],
2032
2032
  "primaryImport": "arcane-os/packager",
2033
2033
  "group": "Packaging and release bundles",
2034
- "summary": "Validates one schema-1 packager app configuration and its relationship to the root config.",
2034
+ "summary": "Validates one schema-1 packager app configuration and its relationship to the root config, including optional workspace-relative final outputDirectory without changing source layout or app identity.",
2035
2035
  "availability": "Node",
2036
2036
  "protocol": "SDK packager and deterministic bundle contract",
2037
2037
  "normalization": "Normalized SDK validation with complete canonical archive and release content"
@@ -96,6 +96,44 @@ materializes the complete required SDK runtime,
96
96
  browser-runtime, and managed-import-map closure inside that app's own artifact.
97
97
  No application polls for SDK changes.
98
98
 
99
+ ## App-selected package output
100
+
101
+ An app may set `"outputDirectory": "ai"` in its schema-1
102
+ `arcane-package.json`. This selects the final directory relative to the
103
+ workspace: `<workspace>/ai`, not `<workspace>/ai/<app-id>`. Omitting the
104
+ field retains `dist/<app-id>`. The root `arcane-packager.json` configuration
105
+ and simple scaffold defaults remain unchanged.
106
+ The destination is local to `arcane-package.json`, including apps with a
107
+ schema-2 `arcane-app.json`. Descriptor refresh preserves it; no matching
108
+ descriptor property or identity change is required.
109
+
110
+ This field controls the destination of the complete packaged release. It does
111
+ not relocate application source, rename the app, change its identity or OPFS
112
+ scope, or alter the package's internal file paths. Source development continues
113
+ to serve the same source tree. There is no migration of existing source,
114
+ saved data, or previously generated output.
115
+
116
+ The selected directory is wholly replaced through the packager's staged
117
+ replacement operation, so it must be dedicated to that release. The prior
118
+ contents remain until the replacement is ready; this is not an in-place merge.
119
+ Changing the configured destination leaves the former output directory and
120
+ unrelated siblings untouched. Output that would replace the app's source root,
121
+ selected inputs, or required control files fails with `ARCANE_PACKAGE_INVALID`
122
+ before output writes rather than overwriting those inputs.
123
+ If an included source directory contains the output directory, use the existing
124
+ `exclude` selection to keep that generated subtree outside the package inputs.
125
+
126
+ Inspection and dry runs report this selected destination. Packaging,
127
+ verification, and `arcane run --target browser` use the same release root.
128
+ The normal bundle destination is beside that release directory as
129
+ `<app-id>-<version>.arcane-app.tar.gz`; an explicit artifact path retains its
130
+ existing meaning. Native packaging supplies the selected release root to the
131
+ native provider, while native executable `outputRoot` remains a separate
132
+ target-output setting. Selected app package directories and native artifact
133
+ output must remain separate; a conflicting native assembly fails before it
134
+ packages any app. App launch paths, resource URLs, and enabled PWA
135
+ representations retain their package-relative layout.
136
+
99
137
  ## Installed-package browser routes
100
138
 
101
139
  An external application can serve and package its installed SDK directly,
@@ -340,7 +378,8 @@ catch-all `arcane/` prefix. Classic scripts, workers, stylesheets, and other
340
378
  non-ESM assets use their documented URL or host loading contract rather than
341
379
  invented package bindings. Development serves the selected app plus the
342
380
  complete selected tree.
343
- Packaging copies the same map, app entry, and physical content into `dist/<id>`;
381
+ Packaging copies the same map, app entry, and physical content into the
382
+ [selected package output](#app-selected-package-output), defaulting to `dist/<id>`;
344
383
  targets never resolve through the consumer workspace's root `node_modules/`.
345
384
  The two lowercase static runtime package specifiers above and the shared
346
385
  speech-text helper are exact npm package exports, so Node and managed-browser
@@ -93,6 +93,16 @@ The application entry and generated PWA shell records are retained. Select the
93
93
  resources needed by every offline page, including its shared modules and styles.
94
94
  The worker script itself is never an application cache entry.
95
95
 
96
+ For direct installed-package root applications, public `arcane import-map`
97
+ generation follows the same source resource graph as `arcane dev` before writing
98
+ the static offline inventory. Imports from unvisited pages, shared runtime
99
+ modules, component styles, and statically declared Worker URLs retain their
100
+ authored query values. SDK component `./arcane/` resource attributes resolve from
101
+ that component's actual runtime root, including installed package aliases and
102
+ nested component directories; ordinary application document bases keep their
103
+ normal meaning. The graph selects resources already present in the configured
104
+ routes. It does not invent resource paths or enumerate user-specific page URLs.
105
+
96
106
  These settings describe published application resources. They do not select
97
107
  user uploads, provider responses, live API requests, saved conversations,
98
108
  preferences, IndexedDB, OPFS, or a model owner's download/cache lifecycle.
@@ -180,7 +190,7 @@ without loading certificates; see [explicit HTTP development](cli.md#explicit-ht
180
190
  `--public` selects the IPv4 wildcard bind address; it does not enable PWA
181
191
  configuration, change manifest metadata, or determine browser installability.
182
192
 
183
- Source inventory work begins when the browser requests the worker or current
193
+ Live source inventory work begins when the browser requests the worker or current
184
194
  offline manifest, after the page can start. It traverses the selected route
185
195
  inventory once for that request, follows page and runtime resource references
186
196
  to retain selected query variants, and shares an in-flight traversal with
@@ -189,6 +199,11 @@ document corpus bodies remain under their existing owner. It does not rebuild
189
199
  the application. Installation metadata and bootstrap requests reuse the current
190
200
  generated bundle.
191
201
 
202
+ An explicit root import-map refresh also performs that traversal to produce the
203
+ committed static PWA files. It opens no server or listener. Its generated
204
+ inventory is available to ordinary static hosting without first visiting each
205
+ page through the development server.
206
+
192
207
  The SDK owns version information in `arcane-offline.json`:
193
208
 
194
209
  | Field | Owner and update rule |
@@ -262,6 +277,18 @@ consistent deployment of the selected output.
262
277
  An offline navigation alias redirects to its selected entry page, preserving the
263
278
  document URL used to resolve relative modules and styles.
264
279
 
280
+ For a same-origin navigation to a selected static `.html` or `.htm` document,
281
+ the worker can reuse that document's cached body while leaving the complete
282
+ query and fragment in the browser's navigation URL. Pages such as
283
+ `dashboard.html?id=person` therefore retain their application-owned parameters
284
+ without enumerating people or saving a cache entry for every query. An exact
285
+ query-specific resource already selected in the inventory takes precedence.
286
+ When the plain document has no retained cached body, the original navigation
287
+ request goes to the network unchanged; its response is not saved over the plain
288
+ document. This does not change query matching for assets, API calls, ordinary
289
+ fetch requests, unselected documents or other origins. Cache refresh and the
290
+ Update prompt keep their existing lifecycle.
291
+
265
292
  Browser storage eviction can remove an offline cache. A missing release cache
266
293
  entry falls back to the network, so offline availability still depends on the
267
294
  browser retaining the selected resources.
@@ -1566,8 +1566,10 @@ boundary applies to STT activation, transcription, TTS lifecycle, synthesis,
1566
1566
  and playback failures.
1567
1567
 
1568
1568
  `reportTTSError()` recognizes `synthesis`, `decode`, `playback-start`, and
1569
- `playback-resume`. It mutes and stops the failed operation, preserves sticky
1570
- provider readiness, and emits `speech-synthesis-error` with the exact boundary,
1569
+ `playback-resume`. It preserves the user's mute choice, sticky provider
1570
+ readiness, and accepted sibling speech jobs. The AI runtime owns settlement of
1571
+ the failed job; reporting an error neither stops the queue nor retries it. The
1572
+ component emits `speech-synthesis-error` with the exact boundary,
1571
1573
  stable reason/code, generic user-safe status, and complete Error in the local
1572
1574
  event detail.
1573
1575
 
@@ -1600,8 +1602,16 @@ same capture operation id rather than inventing a second correlation boundary.
1600
1602
  User Unmute calls the shared `AI.setSpeechMuted(false)` lifecycle owner before
1601
1603
  or with publishing the TTS load intent, so the runtime can legally load TTS.
1602
1604
  Configured `initialMuted:false` records that same unmute intent even when the
1603
- TTS route is still unselected or loading; the component remains publicly muted
1604
- until the selected role reaches `ready`, then applies the preserved intent.
1605
+ TTS route is still unselected or loading. Public `muted` expresses the user's
1606
+ choice; role availability and the AI runtime still prevent playback until the
1607
+ selected role reaches `ready`, when the waiting intent is applied. A failed
1608
+ load ends its activation attempt without changing that choice or starting
1609
+ another attempt. Retry voice explicitly starts a new attempt, including when
1610
+ the preserved choice is unmuted; an unloaded selected voice offers Load voice.
1611
+ When a role returns to ready, permitted queued playback resumes through the
1612
+ existing AI owner without repeating activation or replaying failed jobs.
1613
+ Synthesis, decode, and playback failures also
1614
+ preserve that choice and do not automatically replay failed speech.
1605
1615
  Mute calls `AI.setSpeechMuted(true)`, stops playback, cancels active TTS work,
1606
1616
  and unloads the selected TTS role. Lifecycle failures remain visible through
1607
1617
  `speech-tts-lifecycle-error` and sticky role state.
@@ -779,6 +779,10 @@ such as `apps/hello-world/index.html`. The complete `files` inventory uses
779
779
  package-relative paths and includes the root `index.html`. Shared
780
780
  files retain their configured route destinations.
781
781
 
782
+ `output` is the selected workspace-relative final package directory. It uses
783
+ the app's optional `arcane-package.json` `outputDirectory` directly, or
784
+ `dist/<app-id>` when omitted; the SDK appends no app ID to an explicit value.
785
+
782
786
  ### Availability and normalization
783
787
 
784
788
  **Node.** Normalized SDK validation with complete canonical archive and release content. Deep protocol: [SDK packager and deterministic bundle contract](protocols.md).
@@ -839,6 +843,15 @@ The package preserves the complete selected content, applying the documented
839
843
  asset-version and enabled browser-PWA transformations to resource references.
840
844
  Malformed configuration or descriptors fail while preserving the prior output.
841
845
 
846
+ The app's optional schema-1 `outputDirectory` selects the final
847
+ workspace-relative destination. For example, `"ai"` emits directly into
848
+ `<workspace>/ai`; omission retains `dist/<app-id>`. The selected directory is
849
+ wholly replaced through the existing staged swap, not merged. Changing the
850
+ destination preserves the former output and siblings. Conflicting source,
851
+ selected input, or required control paths fail before output writes. Source
852
+ layout, app identity, OPFS scope, and internal package paths remain unchanged.
853
+ See [app-selected package output](protocols.md#app-selected-package-output).
854
+
842
855
  Selected app files are emitted beneath `apps/<id>/`; shared files retain their
843
856
  configured route destinations. When selected shared content supplies no root
844
857
  `index.html`, the SDK generates one that opens the selected app page. The result
@@ -1030,6 +1043,13 @@ console.log(validateAppBundlePath('payload/apps/example/index.html'));
1030
1043
 
1031
1044
  Validates one schema-1 packager app configuration and its relationship to the root config.
1032
1045
 
1046
+ Optional `outputDirectory` is the final workspace-relative package directory;
1047
+ omission preserves `dist/<app-id>`. It is an app package field, not a change to
1048
+ the root configuration's `distRoot` or the schema-2 app identity. Packaging
1049
+ reports conflicting output/input selections with `ARCANE_PACKAGE_INVALID`
1050
+ before output writes. Authored-descriptor comparison treats the destination
1051
+ as package-local, and descriptor refresh preserves the existing setting.
1052
+
1033
1053
  ### Signature and result
1034
1054
 
1035
1055
  ```text
@@ -2650,6 +2670,11 @@ async function useresolveNativeBuildOutputRoot(...arguments_) {
2650
2670
 
2651
2671
  Invokes one target adapter run method and fails honestly when the artifact kind is not runnable.
2652
2672
 
2673
+ For the browser adapter, optional `releaseRoot` selects an existing packaged
2674
+ directory directly. Omission keeps the direct adapter's `dist/<app-id>`
2675
+ default, including callers with only a release workspace. The high-level
2676
+ `runApplication()` supplies the app-configured destination automatically.
2677
+
2653
2678
  ### Signature and result
2654
2679
 
2655
2680
  ```text
@@ -4024,6 +4049,10 @@ async function usebuildApplication(...arguments_) {
4024
4049
 
4025
4050
  Creates one deterministic external application bundle from one authenticated packaged release.
4026
4051
 
4052
+ The high-level operation reads the app's selected package directory. Its
4053
+ default archive is placed beside that directory; an explicit artifact path
4054
+ retains the existing output-path contract.
4055
+
4027
4056
  ### Signature and result
4028
4057
 
4029
4058
  ```text
@@ -4280,6 +4309,12 @@ import maps through `packageApp()`. Map generation remains the explicit
4280
4309
  Selected app files retain their `apps/<id>/` paths alongside the shared routes
4281
4310
  and root launcher described by `packageApp()`.
4282
4311
 
4312
+ The same low-level operation resolves the app's optional `outputDirectory`;
4313
+ there is no separate high-level destination override. `release.outputRoot`
4314
+ names the resulting complete release directory, with omitted configuration
4315
+ retaining `dist/<app-id>`. Browser preview, bundle creation, and native
4316
+ browser-content input use that selected root.
4317
+
4283
4318
  Packaging does not automatically run tests or checks. Verification occurs only
4284
4319
  when explicitly requested or when required for the selected release output.
4285
4320
  A failure leaves the prior distribution untouched. Success returns the selected
@@ -4375,6 +4410,10 @@ async function userepositoryApplication(...arguments_) {
4375
4410
 
4376
4411
  Runs a browser app or performs the selected native build/launch lifecycle for one target. It does not run tests or checks automatically; selected release-output verification remains explicit to that release operation.
4377
4412
 
4413
+ Browser runs resolve the app's current `outputDirectory`, defaulting to
4414
+ `dist/<app-id>`, and preview that existing release without repackaging it.
4415
+ Native artifact output remains separately selected by its target contract.
4416
+
4378
4417
  ### Signature and result
4379
4418
 
4380
4419
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.39.1",
3
+ "version": "0.40.1",
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",
@@ -518,7 +518,7 @@
518
518
  requestUserMute();
519
519
  } else {
520
520
  pendingUnmute = true;
521
- host.muted = true;
521
+ host.muted = false;
522
522
  if (!settleSuccessfulUnmute()) {
523
523
  synchronizeAIMutedState();
524
524
  continuePendingUnmute();
@@ -567,21 +567,24 @@
567
567
  if (sttBecameUnready) {
568
568
  cancelSTTOperation('runtime-unready');
569
569
  }
570
- if (ttsBecameUnready) {
570
+ if (ttsBecameUnready && ttsRole.state !== 'error') {
571
571
  pendingUnmute = false;
572
- host.muted = true;
573
572
  stopTTSPlayback();
574
573
  }
575
- if (ttsRole.state === 'error' && (pendingUnmute || !host.muted)) {
574
+ if (ttsRole.state === 'error') {
576
575
  pendingUnmute = false;
577
- host.muted = true;
578
- stopTTSPlayback();
579
576
  }
580
577
 
581
- settleSuccessfulUnmute();
578
+ const unmuteSettled = settleSuccessfulUnmute();
582
579
  continuePendingUnmute();
583
580
 
584
581
  synchronizeAIMutedState();
582
+ if (!unmuteSettled
583
+ && previousTTSRole.state !== 'ready'
584
+ && ttsRole.state === 'ready'
585
+ && !host.muted) {
586
+ resumeTTSPlayback();
587
+ }
585
588
  renderControls();
586
589
  renderStatus();
587
590
  }
@@ -720,6 +723,9 @@
720
723
  if (pendingTTSIntent === 'load') {
721
724
  label = 'Cancel voice load';
722
725
  description = 'Cancel loading the selected voice.';
726
+ } else if (!host.muted) {
727
+ label = 'Load voice';
728
+ description = 'Load the selected voice.';
723
729
  }
724
730
  } else if (ttsRole.state === 'error') {
725
731
  disabled = !providerSelected;
@@ -960,7 +966,7 @@
960
966
  return;
961
967
  }
962
968
 
963
- if (host.muted) {
969
+ if (host.muted || ['unloaded', 'error'].includes(ttsRole.state)) {
964
970
  void requestUserUnmute();
965
971
  } else {
966
972
  requestUserMute();
@@ -976,7 +982,7 @@
976
982
  }
977
983
  const wasMuted = host.muted;
978
984
  pendingUnmute = false;
979
- const shouldUnload = !wasMuted
985
+ const shouldUnload = (!wasMuted && is.function(globalThis.ai?.setSpeechMuted))
980
986
  || pendingTTSIntent === 'load'
981
987
  || ['loading', 'ready', 'error'].includes(ttsRole.state);
982
988
  host.muted = true;
@@ -1004,9 +1010,9 @@
1004
1010
  ) {
1005
1011
  return false;
1006
1012
  }
1013
+ host.muted = false;
1007
1014
  if (!is.function(globalThis.ai?.setSpeechMuted)) {
1008
1015
  pendingUnmute = true;
1009
- host.muted = true;
1010
1016
  renderControls();
1011
1017
  renderStatus();
1012
1018
  return false;
@@ -1021,8 +1027,7 @@
1021
1027
  && ['unloaded', 'loading', 'error'].includes(ttsRole.state)
1022
1028
  );
1023
1029
  if (!canRequestLoad) {
1024
- host.muted = true;
1025
- stopTTSPlayback();
1030
+ pendingUnmute = false;
1026
1031
  reportTTSLifecycleError(
1027
1032
  new Error('No selected speech provider is available to load.'),
1028
1033
  'load'
@@ -1038,8 +1043,6 @@
1038
1043
  }
1039
1044
  if (!requested) {
1040
1045
  pendingUnmute = false;
1041
- host.muted = true;
1042
- stopTTSPlayback();
1043
1046
  renderControls();
1044
1047
  renderStatus();
1045
1048
  return false;
@@ -1117,8 +1120,6 @@
1117
1120
  pendingTTSIntent = null;
1118
1121
  if (action === 'load') {
1119
1122
  pendingUnmute = false;
1120
- host.muted = true;
1121
- stopTTSPlayback();
1122
1123
  }
1123
1124
  reportTTSLifecycleError(error, action, operationId);
1124
1125
  return false;
@@ -1211,8 +1212,6 @@
1211
1212
  : 'synthesis';
1212
1213
  const failure = failures[normalizedBoundary];
1213
1214
  const message = visibleErrorMessage(error, failure.message);
1214
- host.muted = true;
1215
- stopTTSPlayback();
1216
1215
  localStatus = {
1217
1216
  message,
1218
1217
  tone: 'error'
@@ -34,6 +34,10 @@
34
34
  "entry": {
35
35
  "$ref": "#/$defs/relativePath"
36
36
  },
37
+ "outputDirectory": {
38
+ "$ref": "#/$defs/relativePath",
39
+ "description": "Final package directory relative to the workspace, with no appended app ID. Omission preserves dist/<id>. This directory is replaced when packaging succeeds; source layout and app identity remain unchanged."
40
+ },
37
41
  "strategy": {
38
42
  "enum": [
39
43
  "static",
@@ -448,6 +448,11 @@ export async function refreshAppPackageProjection({workspaceRoot, appId, signal,
448
448
  packagePath,
449
449
  `apps/${appId}/arcane-package.json`
450
450
  );
451
+ // The output destination belongs to this workspace's package configuration,
452
+ // not the portable application descriptor or its identity.
453
+ if (packageManifest.outputDirectory !== undefined) {
454
+ projection.outputDirectory = packageManifest.outputDirectory;
455
+ }
451
456
  const packageProjection = packageManifest.pwa === undefined ? packageManifest : {
452
457
  ...packageManifest,
453
458
  pwa: normalizePwaConfig(packageManifest.pwa)
@@ -474,10 +479,11 @@ export async function loadAppDescriptor({workspaceRoot,appRoot,appId,packageMani
474
479
  if(authored){
475
480
  const descriptor=validateAppDescriptor(authored,{appId});
476
481
  const projection=projectPackageManifest(authored);
477
- const packageProjection=packageManifest.pwa===undefined?packageManifest:{
482
+ const packageProjection={
478
483
  ...packageManifest,
479
- pwa:normalizePwaConfig(packageManifest.pwa)
484
+ ...(packageManifest.pwa===undefined?{}:{pwa:normalizePwaConfig(packageManifest.pwa)})
480
485
  };
486
+ delete packageProjection.outputDirectory;
481
487
  if(!isDeepStrictEqual(projection,packageProjection)){
482
488
  fail(`${APP_DESCRIPTOR_NAME} does not project exactly to arcane-package.json.`);
483
489
  }
@@ -9,6 +9,10 @@ export function resolveAppRoot(workspaceRoot, config, appId) {
9
9
  return path.resolve(workspaceRoot, appRelativeRoot(config, appId));
10
10
  }
11
11
 
12
+ export function resolvePackageOutputRoot(workspaceRoot, config, app) {
13
+ return path.resolve(workspaceRoot, app.outputDirectory ?? `${config.distRoot}/${app.id}`);
14
+ }
15
+
12
16
  export function appBaseHref(workspaceRoot, appRoot, document = 'index.html') {
13
17
  const directory = path.dirname(path.resolve(appRoot, document));
14
18
  const relative = path.relative(directory, workspaceRoot).split(path.sep).join('/');
@@ -13,7 +13,7 @@ import {APP_CONFIG_NAME, validateAppConfig} from './packager/core.mjs';
13
13
  import {createEventQueue} from './event-queue.mjs';
14
14
  import {
15
15
  applyPwaEntryReferences,inspectImportMapHtml,MANAGED_IMPORT_MAP_ATTRIBUTE,
16
- readWorkspaceAssetVersion,rewriteAssetReferences,versionAssetUrl
16
+ readWorkspaceAssetVersion,resolveAssetReference,rewriteAssetReferences,versionAssetUrl
17
17
  } from './import-map.mjs';
18
18
  import {createPwaArtifacts,selectPwaFiles} from './pwa.mjs';
19
19
 
@@ -658,14 +658,15 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
658
658
  }
659
659
  }
660
660
  }
661
- for (const {url, kind, baseHref, baseKind} of references) {
661
+ for (const reference of references) {
662
+ const {url, kind} = reference;
662
663
  if (kind === 'import' && !/^(?:\.{1,2}\/|\/)/u.test(url)) continue;
663
664
  let target;
664
- let base;
665
665
  try {
666
- base = baseHref ? new URL(baseHref, current.url)
667
- : managedMap || baseKind === 'document' ? documentUrl : current.url;
668
- target = new URL(versionAssetUrl(url, null), base);
666
+ target = resolveAssetReference(
667
+ {...reference, url: versionAssetUrl(url, null)},
668
+ {ownerUrl: current.url, documentUrl, managedMap}
669
+ );
669
670
  } catch {
670
671
  // Non-URL source values retain their authored behavior.
671
672
  continue;
@@ -695,6 +696,11 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
695
696
  return [...selectedUrls].sort();
696
697
  }
697
698
 
699
+ export async function collectSourcePwaAssets({workspaceRoot,appId,signal,onEvent}={}){
700
+ const routes=await sourceRoutes(workspaceRoot,appId,{signal,onEvent});
701
+ return sourcePwaAssets(routes,routes.mappings,signal,new Set(),new Set());
702
+ }
703
+
698
704
  function closeDevelopmentListeners(fileServer, tlsServer) {
699
705
  const closeTls = tlsServer ? new Promise(
700
706
  function closeRawTlsListener(resolve, reject) {
@@ -1236,13 +1242,16 @@ async function startOwnedDevServer({
1236
1242
  .includes(request.headers['sec-fetch-dest']);
1237
1243
  const rewrite=runtimeResource||entryDocument||browserResource||managedMap
1238
1244
  ||resourcePaths.has(target.path);
1239
- const onReference = function observeServedResource({url,kind,baseHref,baseKind}) {
1245
+ const onReference = function observeServedResource(reference) {
1246
+ const {url,kind,baseKind}=reference;
1240
1247
  if (pwaEnabled && kind !== 'fetch'
1241
1248
  && !(kind === 'import' && !/^(?:\.{1,2}\/|\/)/u.test(url))) {
1242
1249
  try {
1243
1250
  const documentUrl = new URL(target.path, 'http://arcane.invalid');
1244
- const base = baseHref ? new URL(baseHref, documentUrl) : documentUrl;
1245
- const resource = new URL(versionAssetUrl(url, null), base);
1251
+ const resource = resolveAssetReference(
1252
+ {...reference,url:versionAssetUrl(url,null)},
1253
+ {ownerUrl:documentUrl}
1254
+ );
1246
1255
  if (resource.origin === documentUrl.origin) {
1247
1256
  pwaResourceUrls.add(`${resource.pathname}${resource.search}`);
1248
1257
  }
@@ -1254,8 +1263,7 @@ async function startOwnedDevServer({
1254
1263
  ||(kind==='import'&&!/^(?:\.{1,2}\/|\/)/u.test(url)))return;
1255
1264
  try{
1256
1265
  const documentUrl=new URL(target.path,'http://arcane.invalid');
1257
- const base=baseHref?new URL(baseHref,documentUrl):documentUrl;
1258
- const resource=new URL(url,base);
1266
+ const resource=resolveAssetReference(reference,{ownerUrl:documentUrl});
1259
1267
  if(resource.origin===documentUrl.origin
1260
1268
  &&/\.(?:m?js|html?|css)$/iu.test(resource.pathname)){
1261
1269
  resourcePaths.add(decodeURIComponent(resource.pathname));
@@ -1281,6 +1281,23 @@ export function rewriteAssetReferences(source,{filePath,version=SDK_VERSION,onRe
1281
1281
  return source;
1282
1282
  }
1283
1283
 
1284
+ export function resolveAssetReference({url,baseHref,baseKind},{ownerUrl,documentUrl=ownerUrl,managedMap=false}){
1285
+ if(baseKind==='component-runtime'&&url.startsWith('./arcane/')){
1286
+ const owner=new URL(ownerUrl);
1287
+ const marker='/arcane/components/';
1288
+ const componentIndex=owner.pathname.lastIndexOf(marker);
1289
+ if(componentIndex>=0){
1290
+ // HTMLImport resolves these component attributes against their
1291
+ // installed runtime, independently of the host document's base.
1292
+ const runtimeRoot=new URL(owner.pathname.slice(0,componentIndex+'/arcane/'.length),owner.origin);
1293
+ return new URL(url.slice('./arcane/'.length),runtimeRoot);
1294
+ }
1295
+ }
1296
+ const base=baseHref?new URL(baseHref,ownerUrl)
1297
+ :managedMap||baseKind==='document'?documentUrl:ownerUrl;
1298
+ return new URL(url,base);
1299
+ }
1300
+
1284
1301
  function registerSpecifier(registry,specifier,target){
1285
1302
  registry.set(specifier,{specifier,target});
1286
1303
  }
@@ -2071,7 +2088,9 @@ function htmlReferenceEdits(source,version,onReference){
2071
2088
  :element.tag==='link'&&relationships.includes('preload')
2072
2089
  &&['script','worker','style'].includes(destination)?destination==='style'?'style':'script':'asset';
2073
2090
  const view=htmlAttributeView(original);
2074
- if(view)reportAssetReference(reportHtmlReference,view.decoded,kind);
2091
+ if(view)reportAssetReference(reportHtmlReference,view.decoded,kind,
2092
+ (name==='href'||name==='src')&&view.decoded.startsWith('./arcane/')
2093
+ ?'component-runtime':undefined);
2075
2094
  const value=versionHtmlAttribute(original,version);
2076
2095
  if(value===original)continue;
2077
2096
  const position=attributes.positions.get(name);
@@ -12,13 +12,14 @@ import {
12
12
  } from 'node:fs/promises';
13
13
  import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
- import {appRelativeRoot,resolveAppRoot} from '../app-layout.mjs';
15
+ import {appRelativeRoot,resolveAppRoot,resolvePackageOutputRoot} from '../app-layout.mjs';
16
16
  import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
17
17
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
18
18
  import {
19
19
  applyPwaEntryReferences,
20
20
  inspectImportMapHtml,
21
21
  readWorkspaceAssetVersion,
22
+ resolveAssetReference,
22
23
  rewriteAssetReferences,
23
24
  versionAssetUrl
24
25
  } from '../import-map.mjs';
@@ -307,7 +308,7 @@ function normalizeOptionalRecord(value,label){
307
308
  export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
308
309
  assertOnlyKeys(value,new Set([
309
310
  'schemaVersion','id','displayName','version','entry','strategy','security',
310
- 'localAIModelPolicy','include','exclude','shared','adapter','pwa'
311
+ 'localAIModelPolicy','include','exclude','shared','adapter','pwa','outputDirectory'
311
312
  ]),`${appId}/${APP_CONFIG_NAME}`);
312
313
  if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
313
314
  if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
@@ -316,6 +317,9 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
316
317
  const displayName=assertPresentationText(value.displayName,`${appId}/${APP_CONFIG_NAME}.displayName`);
317
318
  parseSemver(value.version);
318
319
  const entry=normalizeRelativePath(value.entry,`${appId}/${APP_CONFIG_NAME}.entry`);
320
+ const outputDirectory=value.outputDirectory===undefined?undefined:normalizeRelativePath(
321
+ value.outputDirectory,`${appId}/${APP_CONFIG_NAME}.outputDirectory`
322
+ );
319
323
  const include=validatePathList(value.include,`${appId}/${APP_CONFIG_NAME}.include`,{required:true});
320
324
  const exclude=validatePathList(value.exclude??[],`${appId}/${APP_CONFIG_NAME}.exclude`);
321
325
  if(include.some(allowed=>sameOrDescendant(APP_CONFIG_NAME,allowed))){
@@ -351,6 +355,7 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
351
355
  displayName,
352
356
  version:value.version,
353
357
  entry,
358
+ ...(outputDirectory===undefined?{}:{outputDirectory}),
354
359
  strategy:value.strategy,
355
360
  ...(value.pwa===undefined?{}:{pwa:normalizePwaConfig(value.pwa)}),
356
361
  ...(value.security===undefined?{}:{security:normalizeOptionalRecord(
@@ -421,11 +426,67 @@ async function loadContext(requestedWorkspaceRoot,appId){
421
426
  appRoot,
422
427
  appId,
423
428
  config,
424
- distRoot:path.join(workspaceRoot,rootConfig.distRoot),
425
- outputRoot:path.join(workspaceRoot,rootConfig.distRoot,appId)
429
+ outputRoot:resolvePackageOutputRoot(workspaceRoot,rootConfig,config)
426
430
  };
427
431
  }
428
432
 
433
+ async function assertPackageOutputLocation(context){
434
+ const output=context.outputRoot.split(path.sep).join('/');
435
+ const absolute=location=>path.resolve(location).split(path.sep).join('/');
436
+ const controls=[
437
+ context.appRoot,
438
+ path.join(context.workspaceRoot,ROOT_CONFIG_NAME),
439
+ path.join(context.appRoot,APP_CONFIG_NAME),
440
+ path.join(context.appRoot,APP_DESCRIPTOR_NAME),
441
+ ...(context.config.adapter?[path.join(context.appRoot,context.config.adapter)]:[])
442
+ ];
443
+ for(const control of controls){
444
+ if(sameOrDescendant(absolute(control),output)){
445
+ fail(`Package output would replace application source or configuration: ${control}.`);
446
+ }
447
+ }
448
+ for(const name of ['.git','.arcane','.agents','.codex','node_modules']){
449
+ const control=absolute(path.join(context.workspaceRoot,name));
450
+ if(sameOrDescendant(output,control)||sameOrDescendant(control,output)){
451
+ fail(`Package output overlaps the workspace control directory: ${name}.`);
452
+ }
453
+ }
454
+ if(context.rootConfig.appsRoot!=='.'){
455
+ const relative=path.relative(context.appsRoot,context.outputRoot);
456
+ if(relative&&!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative)){
457
+ const [otherAppId]=relative.split(path.sep);
458
+ if(pathKey(otherAppId)!==pathKey(context.appId)){
459
+ const otherAppRoot=path.join(context.appsRoot,otherAppId);
460
+ for(const manifest of [APP_CONFIG_NAME,APP_DESCRIPTOR_NAME]){
461
+ try{await lstat(path.join(otherAppRoot,manifest));}
462
+ catch(error){if(error?.code==='ENOENT')continue;throw error;}
463
+ fail(`Package output overlaps another application's source directory: ${otherAppRoot}.`);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ function assertSelectedInput(sourceRoot,selected,excludes,label){
469
+ if(isExcluded(selected,excludes))return;
470
+ const source=absolute(path.resolve(sourceRoot,selected));
471
+ const relativeOutput=path.relative(sourceRoot,context.outputRoot).split(path.sep).join('/');
472
+ if(sameOrDescendant(source,output)
473
+ ||(sameOrDescendant(output,source)&&!isExcluded(relativeOutput,excludes))){
474
+ fail(`Package output overlaps selected ${label} content: ${selected}.`);
475
+ }
476
+ }
477
+ for(const selected of context.config.include){
478
+ assertSelectedInput(context.appRoot,selected,context.config.exclude,context.appId);
479
+ }
480
+ for(const sharedId of context.config.shared){
481
+ for(const route of context.rootConfig.sharedPayloads[sharedId]){
482
+ const sourceRoot=path.resolve(context.workspaceRoot,route.source);
483
+ for(const selected of route.include){
484
+ assertSelectedInput(sourceRoot,selected,route.exclude,`sharedPayloads.${sharedId}`);
485
+ }
486
+ }
487
+ }
488
+ }
489
+
429
490
  function destinationJoin(root,relative){
430
491
  return relative==='.'?root:root==='.'?relative:`${root}/${relative}`;
431
492
  }
@@ -495,6 +556,7 @@ async function collectSelectedPath({
495
556
  }
496
557
 
497
558
  async function collectPackageRecords(context,{signal}={}){
559
+ await assertPackageOutputLocation(context);
498
560
  const records=[];
499
561
  const destinations=new Set();
500
562
  for(const selected of context.config.include){
@@ -574,14 +636,15 @@ async function optionalDescriptor(context){
574
636
  }
575
637
  }
576
638
 
577
- async function inspectContext(context,{signal}={}){
578
- const records=await collectPackageRecords(context,{signal});
579
- const documents=await browserDocuments(records,context.config.entry);
639
+ async function inspectContext(context,{signal,records}={}){
640
+ const selectedRecords=records??await collectPackageRecords(context,{signal});
641
+ const documents=await browserDocuments(selectedRecords,context.config.entry);
580
642
  return {
581
643
  appId:context.appId,
582
644
  displayName:context.config.displayName,
583
645
  version:context.config.version,
584
646
  entry:context.config.entry,
647
+ ...(context.config.outputDirectory===undefined?{}:{outputDirectory:context.config.outputDirectory}),
585
648
  strategy:context.config.strategy,
586
649
  ...(context.config.pwa===undefined?{}:{pwa:copyJson(context.config.pwa)}),
587
650
  include:[...context.config.include],
@@ -594,7 +657,7 @@ async function inspectContext(context,{signal}={}){
594
657
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
595
658
  descriptor:await optionalDescriptor(context),
596
659
  browserDocuments:documents,
597
- files:[...new Set(['index.html',...records.map(record=>record.destination)])].sort(compareText),
660
+ files:[...new Set(['index.html',...selectedRecords.map(record=>record.destination)])].sort(compareText),
598
661
  output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
599
662
  };
600
663
  }
@@ -752,7 +815,8 @@ async function packageWithContext(context,options={}){
752
815
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
753
816
  const appPath=appRelativeRoot(context.rootConfig,context.appId);
754
817
  const entryPath=appPackagePath(context,context.config.entry);
755
- const inspected=await inspectContext(context,{signal});
818
+ const records=await collectPackageRecords(context,{signal});
819
+ const inspected=await inspectContext(context,{signal,records});
756
820
  if(options.dryRun){
757
821
  return {
758
822
  appId:context.appId,
@@ -770,17 +834,29 @@ async function packageWithContext(context,options={}){
770
834
  ].sort(compareText)
771
835
  };
772
836
  }
773
- await mkdir(context.distRoot,{recursive:true});
774
- const distInfo=await lstat(context.distRoot);
775
- if(distInfo.isSymbolicLink()||!distInfo.isDirectory())fail('dist must be a real directory.');
837
+ // Directory replacement must stay on the requested physical path before
838
+ // mkdir or rename can affect an existing source tree through an ancestor.
839
+ let current=context.workspaceRoot;
840
+ for(const segment of path.relative(context.workspaceRoot,context.outputRoot).split(path.sep)){
841
+ current=path.join(current,segment);
842
+ let info;
843
+ try{info=await lstat(current);}
844
+ catch(error){if(error?.code==='ENOENT')break;throw error;}
845
+ if(info.isSymbolicLink()||!info.isDirectory()){
846
+ fail(`Package output must use real directories: ${current}.`);
847
+ }
848
+ }
849
+ const outputParent=path.dirname(context.outputRoot);
850
+ await mkdir(outputParent,{recursive:true});
851
+ const parentInfo=await lstat(outputParent);
852
+ if(parentInfo.isSymbolicLink()||!parentInfo.isDirectory())fail('Package output parent must be a real directory.');
776
853
  const stagingRoot=path.join(
777
- context.distRoot,
854
+ outputParent,
778
855
  `.${context.appId}-staging-${process.pid}-${Date.now()}`
779
856
  );
780
857
  await mkdir(stagingRoot);
781
858
  let promoted=false;
782
859
  try{
783
- const records=await collectPackageRecords(context,{signal});
784
860
  async function copyBase() {
785
861
  await copyRecords(records,stagingRoot,{signal,onEvent});
786
862
  if (!records.some(function selectedRootIndex(record) {
@@ -892,17 +968,19 @@ async function packageWithContext(context,options={}){
892
968
  resource={references,...(pwaDocumentSources.has(relative)?{content}:{})};
893
969
  resources.set(relative,resource);
894
970
  }
895
- for(const {url,kind,baseHref,baseKind} of resource.references){
971
+ for(const reference of resource.references){
972
+ const {url,kind,baseHref}=reference;
896
973
  const traversable=kind!=='fetch'&&(kind!=='asset'||/\.css(?:[?#]|$)/iu.test(url))
897
974
  &&(kind!=='import'||/^(?:\.{1,2}\/|\/)/u.test(url));
898
975
  if(!pwaEnabled&&!traversable)continue;
899
976
  if(kind==='import'&&!/^(?:\.{1,2}\/|\/)/u.test(url))continue;
900
977
  try{
901
978
  const ownerUrl=new URL(packageResourceUrl(relative),entryUrl.origin);
902
- const base=baseHref?new URL(baseHref,ownerUrl)
903
- :baseKind==='document'||path.posix.basename(relative)==='arcane.importmap.json'
904
- ?current.documentUrl:ownerUrl;
905
- const target=new URL(url,base);
979
+ const target=resolveAssetReference(reference,{
980
+ ownerUrl,
981
+ documentUrl:current.documentUrl,
982
+ managedMap:path.posix.basename(relative)==='arcane.importmap.json'
983
+ });
906
984
  if(target.origin===entryUrl.origin){
907
985
  const file=decodeURIComponent(target.pathname).replace(/^\//u,'');
908
986
  if(pwaEnabled&&offlineInventory.has(file)){
@@ -1009,7 +1087,7 @@ export async function packageApp(options={}){
1009
1087
  export async function verifyApp({workspaceRoot,appId,signal,onEvent}={}){
1010
1088
  throwIfAborted(signal);
1011
1089
  const context=await loadContext(workspaceRoot,appId);
1012
- const outputRoot=await realDirectory(context.outputRoot,`dist/${appId}`);
1090
+ const outputRoot=await realDirectory(context.outputRoot,'Package output');
1013
1091
  const manifest=await readJson(path.join(outputRoot,RELEASE_MANIFEST_NAME),RELEASE_MANIFEST_NAME);
1014
1092
  if(!isPlainObject(manifest)||manifest.schemaVersion!==1||manifest.kind!=='arcane-app-release'
1015
1093
  ||manifest.packagerVersion!==PACKAGER_VERSION||manifest.app?.id!==appId
@@ -494,13 +494,39 @@ function installPwaWorker(manifest, clientUrl) {
494
494
  return {location: destination.href, resource: cacheUrl(selected)};
495
495
  }
496
496
 
497
+ function staticNavigationResource(url) {
498
+ const document = new URL(url);
499
+ if (document.origin !== scopeOrigin || !document.search
500
+ || !/\.html?$/iu.test(document.pathname)) {
501
+ return null;
502
+ }
503
+ document.search = '';
504
+ return ownedUrls.has(document.href) ? document.href : null;
505
+ }
506
+
497
507
  async function requestedResource(request, url) {
498
508
  await restored;
499
- const redirect = request.mode === 'navigate' ? navigationRedirect(url) : null;
509
+ const selectedQuery = ownedUrls.has(url) && new URL(url).search;
510
+ const redirect = request.mode === 'navigate' && !selectedQuery ? navigationRedirect(url) : null;
500
511
  if (redirect && cacheUrl(redirect.location) !== url && ownedUrls.has(redirect.resource)) {
501
512
  return {response: Response.redirect(redirect.location, 302), done: Promise.resolve(null)};
502
513
  }
503
514
  if (!ownedUrls.has(url)) {
515
+ const document = request.mode === 'navigate' ? staticNavigationResource(url) : null;
516
+ if (document) {
517
+ // Reuse only the selected static document body. Keep the navigation URL
518
+ // intact; a query-specific response must never replace this shared entry.
519
+ const cached = await findCachedResource(document);
520
+ if (cached.response) {
521
+ return {
522
+ response: cached.response,
523
+ done: cached.current ? Promise.resolve(null)
524
+ : cached.cache.put(document, cached.response.clone()).then(function retainedDocument() {
525
+ return null;
526
+ })
527
+ };
528
+ }
529
+ }
504
530
  return {response: await fetch(request), done: Promise.resolve(null)};
505
531
  }
506
532
  const cache = await caches.open(cacheName);
@@ -522,7 +548,7 @@ function installPwaWorker(manifest, clientUrl) {
522
548
  return;
523
549
  }
524
550
  if (manifestRestored && !ownedUrls.has(url)
525
- && !(request.mode === 'navigate' && navigationRedirect(url))) {
551
+ && !(request.mode === 'navigate' && (navigationRedirect(url) || staticNavigationResource(url)))) {
526
552
  return;
527
553
  }
528
554
  const resource = requestedResource(request, url);
@@ -148,9 +148,8 @@ const browserAdapter={
148
148
  throwIfAborted(signal);
149
149
  return {target:'browser',release:await verifyApp({workspaceRoot,appId,signal,onEvent})};
150
150
  },
151
- async run({workspaceRoot,appId,host='127.0.0.1',port=0,httpPort=0,tls,certPath,keyPath,signal,onEvent}={}){
151
+ async run({workspaceRoot,appId,releaseRoot=path.join(workspaceRoot,'dist',appId),host='127.0.0.1',port=0,httpPort=0,tls,certPath,keyPath,signal,onEvent}={}){
152
152
  throwIfAborted(signal);
153
- const releaseRoot=path.join(workspaceRoot,'dist',appId);
154
153
  const server=await startDevServer({
155
154
  workspaceRoot,
156
155
  appId,
package/src/toolchain.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import Is from 'strong-type';
2
2
  import path from 'node:path';
3
+ import {resolvePackageOutputRoot} from './app-layout.mjs';
3
4
  import {readdir,lstat,realpath,mkdir,readFile,writeFile} from 'node:fs/promises';
4
5
  import {createWorkspace,initWorkspace} from './scaffold.mjs';
5
6
  import {
@@ -10,7 +11,7 @@ import {
10
11
  validateWorkspace
11
12
  } from './workspace.mjs';
12
13
  import {loadArcaneIntegratedProvider} from './integrated-provider-loader.mjs';
13
- import {startDevServer} from './dev-server.mjs';
14
+ import {collectSourcePwaAssets,startDevServer} from './dev-server.mjs';
14
15
  import {applyPwaEntryReferences,generateImportMap,readApplicationTestImportMapContext} from './import-map.mjs';
15
16
  import {createPwaArtifacts} from './pwa.mjs';
16
17
  import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
@@ -336,11 +337,21 @@ async function refreshRootApplicationFiles(prepared,inspected,importMap,{signal,
336
337
  ...inspected.files.filter(file=>file!=='index.html'||manifest.include.includes('index.html')),
337
338
  importMap.artifactRelativePath
338
339
  ])];
340
+ const sourceOrigin='http://arcane.invalid';
341
+ const selectedPaths=new Set(files.map(function selectedStaticPath(file){
342
+ return new URL(`/${file.split('/').map(encodeURIComponent).join('/')}`,sourceOrigin).pathname;
343
+ }));
344
+ const assets=installed?.direct&&manifest.pwa?.enabled
345
+ ?(await collectSourcePwaAssets({workspaceRoot,appId,signal,onEvent})).filter(function selectedStaticVariant(value){
346
+ const url=new URL(value,sourceOrigin);
347
+ return url.origin===sourceOrigin&&selectedPaths.has(url.pathname);
348
+ }):[];
339
349
  const pwa=installed?.direct&&manifest.pwa?.enabled?createPwaArtifacts({
340
350
  app:{id:appId,displayName:manifest.displayName,version:manifest.version,entry},
341
351
  sdkVersion:installed.version,
342
352
  pwa:manifest.pwa,
343
353
  files,
354
+ assets,
344
355
  basePath:'/',
345
356
  appBase:'/',
346
357
  installationId:`/apps/${appId}/`,
@@ -1022,6 +1033,20 @@ async function executePairedNativeBuild(options,adapter,{
1022
1033
  }
1023
1034
  const dependencyApps=await nativeDependencyClosure(initialPrepared,{target,signal,onEvent});
1024
1035
  const prepared=initialPrepared;
1036
+ const packageOutputs=[];
1037
+ for(const app of [prepared.validation.app,...dependencyApps]){
1038
+ const releaseRoot=resolvePackageOutputRoot(
1039
+ prepared.workspaceRoot,prepared.validation.config,app.manifest
1040
+ );
1041
+ if(pathsOverlap(releaseRoot,outputRoot)
1042
+ ||packageOutputs.some(existing=>pathsOverlap(existing,releaseRoot))){
1043
+ throw new ArcaneError(
1044
+ 'ARCANE_PACKAGE_INVALID',
1045
+ `Package output for ${app.appId} overlaps another selected package or native output directory.`
1046
+ );
1047
+ }
1048
+ packageOutputs.push(releaseRoot);
1049
+ }
1025
1050
  const selectedRelease=await packageNativeRelease(prepared,{
1026
1051
  appId:prepared.appId,
1027
1052
  appRoot:prepared.appRoot,
@@ -1180,6 +1205,9 @@ export async function runApplication(options={}){
1180
1205
  return runTarget({
1181
1206
  ...selectedOptions,
1182
1207
  ...prepared,
1208
+ releaseRoot:resolvePackageOutputRoot(
1209
+ prepared.workspaceRoot,prepared.validation.config,prepared.validation.app.manifest
1210
+ ),
1183
1211
  target
1184
1212
  });
1185
1213
  }