arcane-os 0.39.0 → 0.40.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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.40.0
4
+
5
+ - Add optional `arcane-package.json` `outputDirectory` to select the final
6
+ workspace-relative package directory without an appended application ID.
7
+ For example, `"ai"` selects `<workspace>/ai`; omission preserves `dist/<id>`.
8
+ Descriptor refresh retains this package-local setting, and preview,
9
+ verification, bundles and native browser-content inputs use the selected
10
+ root. Source layout, app identity and internal release URLs are unchanged.
11
+ Packaging replaces only the selected destination through its existing staged
12
+ swap, preserving old destinations and siblings when that selection changes.
13
+ - Serve selected cached static HTML documents for navigation query variants
14
+ without redirecting or losing the requested URL. Exact selected query
15
+ variants retain precedence; a missing cached document fetches the original
16
+ request without replacing the canonical cached body. Assets, API requests,
17
+ non-navigation requests and update/refresh lifecycle remain unchanged.
18
+
19
+ ## 0.39.1
20
+
21
+ - Preserve the Markdown editor's current source viewport and selection during
22
+ configuration, programmatic value changes, preview rendering and toolbar
23
+ formatting. Keep the live textarea attached; only the preview follows edits.
24
+ Native typing, manual scrolling, complete content and save behavior remain
25
+ independent and unchanged.
26
+ - Generate default Chat filenames with a timestamp and an independent native
27
+ random identifier so sessions created in the same clock tick do not share a
28
+ storage name. Preserve explicit and saved filenames, existing stored content,
29
+ persistence format and request-only `persist:false` behavior.
30
+ - Detect continuous-capture activity using fixed 20 ms RMS audio windows so
31
+ brief background peaks do not indefinitely postpone quiet-gap segments.
32
+ Preserve complete original samples, pre-roll, periodic clip adjacency and
33
+ partial final flush. `activityThreshold` now measures sustained RMS level;
34
+ callers retain control of sensitivity for softer input.
35
+
3
36
  ## 0.39.0
4
37
 
5
38
  - Add application-owned assistant display names to new Chat turns through
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.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.40.0` 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
 
@@ -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
@@ -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"
@@ -535,7 +535,7 @@
535
535
  ],
536
536
  "availability": "Browser and supported native WebViews",
537
537
  "transport": "HTMLImport + DOM; injected Arcane/provider modules where listed",
538
- "normalization": "Editor values and optional presentation behavior normalized; injected save result mixed. fit and followPreview default false, preserve omitted prior configuration, and accept explicit false resets; data-fit and data-follow-preview select the initial options. Fit requires an application-bounded host and provides independent input/preview scrolling in roomy columns or narrow stacked panes without changing the application shell. Following responds to body typing/format edits, leaves manual preview scrolling independent until the next edit, and does not treat title edits or programmatic value replacement as follow requests. Public parts editor/title/toolbar/input/preview/actions/status/save-action allow application styling while the title stays native plain text. Existing controls, complete save payload, save cancellation, and destruction behavior remain."
538
+ "normalization": "Editor values and optional presentation behavior normalized; injected save result mixed. fit and followPreview default false, preserve omitted prior configuration, and accept explicit false resets; data-fit and data-follow-preview select the initial options. Fit requires an application-bounded host and provides independent input/preview scrolling in roomy columns or narrow stacked panes without changing the application shell. Following responds to body typing/format edits, leaves manual preview scrolling independent until the next edit, and does not treat title edits or programmatic value replacement as follow requests. Preview rendering preserves the current source viewport and selection; configuration/value replacement retain source offsets and applicable selection positions, and layout changes never reattach the textarea. Formatting selects the formatted text without panning the source or its ancestors. Native typing, keyboard selection, and manual source scrolling remain independent without deferred scroll restoration. Public parts editor/title/toolbar/input/preview/actions/status/save-action allow application styling while the title stays native plain text. Existing controls, complete save payload, save cancellation, and destruction behavior remain."
539
539
  },
540
540
  {
541
541
  "file": "runtime/arcane/components/media-embed.html",
@@ -9,7 +9,7 @@
9
9
  "modules": [
10
10
  {"file":"runtime/arcane/entities/ApiModelRecord.js","exports":["default"],"availability":"cross-host","normalization":"normalized"},
11
11
  {"file":"runtime/arcane/entities/Calculation.js","exports":["default"],"availability":"cross-host","normalization":"normalized"},
12
- {"file":"runtime/arcane/entities/Chat.js","exports":["default"],"availability":"browser/native-webview","normalization":"Provider-facing recurring messages preserve raw structural protocol only for the one active matching continuation, then replace settled exchanges with complete ordinary visible content; transcript records remain sanitized and readable, including ui_hidden rows. aiName defaults to empty; addAIMessage and addTurn capture an optional exact nonblank application-owned name on new assistant records only. Provider names do not establish display attribution; assistant display names stay out of messages and memory extraction, and existing records are never renamed or rewritten. ordered parallel structural calls require one exact nonblank result for every pending ID in one atomic turn, with rollback-safe persistence."},
12
+ {"file":"runtime/arcane/entities/Chat.js","exports":["default"],"availability":"browser/native-webview","normalization":"New default filenames combine the timestamp with an independent random identifier; explicit and saved filenames remain unchanged. Provider-facing recurring messages preserve raw structural protocol only for the one active matching continuation, then replace settled exchanges with complete ordinary visible content; transcript records remain sanitized and readable, including ui_hidden rows. aiName defaults to empty; addAIMessage and addTurn capture an optional exact nonblank application-owned name on new assistant records only. Provider names do not establish display attribution; assistant display names stay out of messages and memory extraction, and existing records are never renamed or rewritten. ordered parallel structural calls require one exact nonblank result for every pending ID in one atomic turn, with rollback-safe persistence."},
13
13
  {"file":"runtime/arcane/entities/CommunicationMessage.js","exports":["communicationChannels","default"],"availability":"cross-host","normalization":"normalized"},
14
14
  {"file":"runtime/arcane/entities/CommunicationThread.js","exports":["default"],"availability":"cross-host","normalization":"normalized"},
15
15
  {"file":"runtime/arcane/entities/Document.js","exports":["default"],"availability":"browser/native-webview","normalization":"mixed"},
@@ -1393,7 +1393,7 @@
1393
1393
  "summary": "Segments microphone samples on the audio rendering thread and transfers complete mono Float32 WAV clips to ContinuousVoiceCapture.",
1394
1394
  "availability": "AudioWorkletGlobalScope",
1395
1395
  "protocol": "AudioWorklet processor arcane-continuous-voice-capture and its owner-local MessagePort",
1396
- "normalization": "Rolling pre-roll, amplitude activity, quiet and periodic boundaries, sequence, complete WAV audio, and stop acknowledgement are owned here. Silence does not emit clips; adjacent periodic clips have no repeated samples.",
1396
+ "normalization": "Rolling pre-roll, fixed 20 ms RMS activity windows independent of callback length, audio-frame quiet duration, periodic boundaries, sequence, complete original WAV audio, and stop acknowledgement are owned here. activityThreshold is RMS amplitude, not peak amplitude. Stop classifies a partial final window using its actual samples and retains softer trailing audio in the active clip. Inactive capture emits no clips; adjacent periodic clips have no repeated samples.",
1397
1397
  "surface": "No ESM exports. ContinuousVoiceCapture loads this static worklet, supplies processorOptions, receives segment messages, and requests stop followed by the stopped acknowledgement."
1398
1398
  },
1399
1399
  {
@@ -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
@@ -262,6 +262,18 @@ consistent deployment of the selected output.
262
262
  An offline navigation alias redirects to its selected entry page, preserving the
263
263
  document URL used to resolve relative modules and styles.
264
264
 
265
+ For a same-origin navigation to a selected static `.html` or `.htm` document,
266
+ the worker can reuse that document's cached body while leaving the complete
267
+ query and fragment in the browser's navigation URL. Pages such as
268
+ `dashboard.html?id=person` therefore retain their application-owned parameters
269
+ without enumerating people or saving a cache entry for every query. An exact
270
+ query-specific resource already selected in the inventory takes precedence.
271
+ When the plain document has no retained cached body, the original navigation
272
+ request goes to the network unchanged; its response is not saved over the plain
273
+ document. This does not change query matching for assets, API calls, ordinary
274
+ fetch requests, unselected documents or other origins. Cache refresh and the
275
+ Update prompt keep their existing lifecycle.
276
+
265
277
  Browser storage eviction can remove an offline cache. A missing release cache
266
278
  entry falls back to the network, so offline availability still depends on the
267
279
  browser retaining the selected resources.
@@ -1067,6 +1067,14 @@ programmatic value replacement, and ordinary preview scrolling do not request
1067
1067
  edit-position following. Moving the selection alone does not trigger following,
1068
1068
  and following never scrolls the input pane or containing page. The title
1069
1069
  remains a native plain-text input.
1070
+ Preview rendering preserves the source pane's current horizontal and vertical
1071
+ scroll offsets and selection. Value replacement and reconfiguration retain
1072
+ those offsets and the selection positions that still fit the new text; layout
1073
+ changes move only the preview, keeping the live textarea attached. Toolbar
1074
+ formatting focuses the source without scrolling its ancestors, selects the
1075
+ formatted text, and preserves the source viewport. Native typing, keyboard
1076
+ selection, and manual source scrolling retain their ordinary behavior; there
1077
+ is no scroll lock or deferred restoration of an earlier source position.
1070
1078
  Following uses the shared [Markdown block mapping](runtime-modules.md#mdjs);
1071
1079
  lists, quotes, and tables follow their block, while invisible source positions
1072
1080
  use a nearby available rendered block rather than exact glyph geometry.
@@ -73,6 +73,11 @@ console.log(new Calculation({expression: '2 + 2', result: 4}).toJSON());
73
73
  and optional app-scoped DBOPFS persistence. It installs no independent native
74
74
  authority; AI and storage dependencies must be available to the host.
75
75
 
76
+ Each new chat receives a timestamp plus independently generated random identifier
77
+ in its default `.jsonl` filename, so chats created during the same clock tick do
78
+ not share a storage key. Explicit filenames and existing saved files remain
79
+ unchanged; no saved records are renamed or migrated.
80
+
76
81
  `messages` returns provider-facing recurring context. An unresolved structural
77
82
  call and its matching results remain raw only through their one active provider
78
83
  continuation. Once that continuation settles, `messages` replaces the protocol
@@ -4069,7 +4069,7 @@ Load with a stylesheet link before rendering uPlot charts.
4069
4069
  ### Overview
4070
4070
 
4071
4071
  Internal audio processor for [`ContinuousVoiceCapture.js`](#continuousvoicecapturejs).
4072
- It keeps rolling pre-roll, detects audio amplitude, preserves complete active
4072
+ It keeps rolling pre-roll, detects RMS audio level, preserves complete active
4073
4073
  audio through quiet and periodic cuts, and encodes mono Float32 WAV clips on
4074
4074
  the audio rendering thread. It performs no semantic speech detection or STT.
4075
4075
 
@@ -4079,6 +4079,22 @@ No ESM exports. The capture owner loads this static file with
4079
4079
  `audioContext.audioWorklet.addModule()` and creates the
4080
4080
  `arcane-continuous-voice-capture` processor with
4081
4081
  `{preRollMs,quietMs,chunkMs,activityThreshold}` in `processorOptions`.
4082
+ `activityThreshold` measures root-mean-square amplitude over fixed 20 ms
4083
+ windows counted from the audio sample rate, independently of browser callback
4084
+ lengths. Brief peaks do not reset the quiet duration when the window's RMS
4085
+ remains below that threshold. This is an RMS threshold, not a peak threshold:
4086
+ a sine wave with peak amplitude `0.025` can remain below the default `0.02`
4087
+ RMS threshold. Applications can select the existing threshold for their audio
4088
+ level; no relative-noise or semantic speech classifier is implied.
4089
+
4090
+ Classification selects segment boundaries without modifying the captured
4091
+ samples. Quiet duration advances by audio frames, pre-roll remains complete,
4092
+ and periodic clips remain adjacent. Stop classifies any partial final window
4093
+ using its actual samples and flushes the final active clip, including softer
4094
+ trailing audio before the quiet boundary. Analysis adds at most one 20 ms
4095
+ window of capture-to-segmentation delay; model processing is independently
4096
+ owned by the transcription queue.
4097
+
4082
4098
  The owner sends `{type:'stop'}`. The processor transfers each complete
4083
4099
  `{type:'segment',audio:ArrayBuffer,sampleRate,sequence,reason,durationMs}`
4084
4100
  and finally `{type:'stopped'}`. Each complete WAV includes its own format,
@@ -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.0",
3
+ "version": "0.40.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",
@@ -224,6 +224,7 @@
224
224
  if(destroyed){
225
225
  return false;
226
226
  }
227
+ const sourceView = captureSourceView();
227
228
  const setInitialValue=Object.prototype.hasOwnProperty.call(
228
229
  input||{},
229
230
  'initialValue'
@@ -240,9 +241,12 @@
240
241
  }
241
242
  if(setInitialValue){
242
243
  supersedeSave('markdown-content-replaced');
243
- textarea.value=options.initialValue;
244
+ if (textarea.value !== options.initialValue) {
245
+ textarea.value = options.initialValue;
246
+ }
244
247
  }
245
248
  updatePreview();
249
+ restoreSourceView(sourceView);
246
250
  return getOptions();
247
251
  }
248
252
 
@@ -250,13 +254,14 @@
250
254
  host.dataset.fit=String(options.fit);
251
255
  editor.classList.toggle('fit',options.fit);
252
256
  editor.classList.toggle('without-preview',!options.showPreview);
253
- // Keyboard order follows the displayed order in each layout.
254
- if(options.fit){
255
- editor.insertBefore(toolbar,preview);
256
- editor.insertBefore(textarea,preview);
257
- }else{
258
- editor.insertBefore(preview,toolbar);
259
- editor.insertBefore(textarea,toolbar.nextSibling);
257
+ // Move only the preview when the layout changes. Reattaching the live
258
+ // textarea during ordinary configuration would disturb focus/selection.
259
+ if (options.fit) {
260
+ if (textarea.nextSibling !== preview) {
261
+ editor.insertBefore(preview, textarea.nextSibling);
262
+ }
263
+ } else if (preview.nextSibling !== toolbar) {
264
+ editor.insertBefore(preview, toolbar);
260
265
  }
261
266
  titleInput.placeholder=options.labels.titlePlaceholder;
262
267
  titleInput.setAttribute('aria-label',options.labels.titlePlaceholder);
@@ -331,6 +336,36 @@
331
336
  previewFollowPending=false;
332
337
  }
333
338
 
339
+ function captureSourceView() {
340
+ return {
341
+ scrollTop: textarea.scrollTop,
342
+ scrollLeft: textarea.scrollLeft,
343
+ selectionStart: textarea.selectionStart,
344
+ selectionEnd: textarea.selectionEnd,
345
+ selectionDirection: textarea.selectionDirection
346
+ };
347
+ }
348
+
349
+ function restoreSourceView(view) {
350
+ if (textarea.selectionStart !== view.selectionStart ||
351
+ textarea.selectionEnd !== view.selectionEnd ||
352
+ textarea.selectionDirection !== view.selectionDirection) {
353
+ textarea.setSelectionRange(
354
+ view.selectionStart,
355
+ view.selectionEnd,
356
+ view.selectionDirection
357
+ );
358
+ }
359
+ // Native value/focus/selection updates may pan the textarea. Restore
360
+ // this operation's viewport, never a position retained across user input.
361
+ if (textarea.scrollTop !== view.scrollTop) {
362
+ textarea.scrollTop = view.scrollTop;
363
+ }
364
+ if (textarea.scrollLeft !== view.scrollLeft) {
365
+ textarea.scrollLeft = view.scrollLeft;
366
+ }
367
+ }
368
+
334
369
  function updatePreview(follow=false){
335
370
  if(destroyed){
336
371
  return;
@@ -340,6 +375,7 @@
340
375
  }
341
376
  previewFrame=0;
342
377
  previewFollowPending=false;
378
+ const sourceView = captureSourceView();
343
379
  const scrollTop=preview.scrollTop;
344
380
  const scrollLeft=preview.scrollLeft;
345
381
  const markdown=new MD(textarea.value,{sourceMap:options.followPreview});
@@ -355,6 +391,7 @@
355
391
  if(follow&&options.followPreview&&options.showPreview){
356
392
  followTypingPosition(markdown.sourceMap);
357
393
  }
394
+ restoreSourceView(sourceView);
358
395
  }
359
396
 
360
397
  function followTypingPosition(sourceMap){
@@ -434,6 +471,7 @@
434
471
  return;
435
472
  }
436
473
 
474
+ const sourceView = captureSourceView();
437
475
  const result=applyMarkdownFormat(
438
476
  textarea.value,
439
477
  textarea.selectionStart,
@@ -441,8 +479,12 @@
441
479
  format
442
480
  );
443
481
  textarea.value=result.value;
444
- textarea.focus();
445
- textarea.setSelectionRange(result.selectionStart,result.selectionEnd);
482
+ textarea.focus(
483
+ {preventScroll: true}
484
+ );
485
+ sourceView.selectionStart = result.selectionStart;
486
+ sourceView.selectionEnd = result.selectionEnd;
487
+ restoreSourceView(sourceView);
446
488
  contentChanged();
447
489
  }
448
490
 
@@ -524,8 +566,13 @@
524
566
  return false;
525
567
  }
526
568
  supersedeSave('markdown-content-replaced');
527
- textarea.value=`${value??''}`;
569
+ const sourceView = captureSourceView();
570
+ const nextValue = `${value ?? ''}`;
571
+ if (textarea.value !== nextValue) {
572
+ textarea.value = nextValue;
573
+ }
528
574
  updatePreview();
575
+ restoreSourceView(sourceView);
529
576
  return textarea.value;
530
577
  }
531
578
 
@@ -433,8 +433,8 @@ class ChatEntity{
433
433
  /**
434
434
  * Creates a new chat session.
435
435
  *
436
- * A unique file name is generated automatically using
437
- * the current timestamp.
436
+ * The default file name combines the current timestamp with an
437
+ * independent random identifier, including across concurrent tabs.
438
438
  *
439
439
  * Optionally accepts a system prompt which will be added
440
440
  * as the first message in the conversation.
@@ -444,7 +444,8 @@ class ChatEntity{
444
444
  */
445
445
  constructor(systemPrompt=''){
446
446
 
447
- this.fileName=`chat-${Date.now()}.jsonl`;
447
+ const identity=globalThis.crypto.getRandomValues(new Uint32Array(4)).join('-');
448
+ this.fileName=`chat-${Date.now()}-${identity}.jsonl`;
448
449
 
449
450
  if(systemPrompt){
450
451
  this.#messages.push(
@@ -7,6 +7,9 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
7
7
  #quietFrames;
8
8
  #chunkFrames;
9
9
  #activityThreshold;
10
+ #analysisSamples;
11
+ #analysisCount = 0;
12
+ #analysisEnergy = 0;
10
13
  #active = false;
11
14
  #hasActivity = false;
12
15
  #quietCount = 0;
@@ -25,6 +28,9 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
25
28
  this.#quietFrames = Math.max(1, Math.round(configuration.quietMs * sampleRate / 1000));
26
29
  this.#chunkFrames = Math.max(1, Math.round(configuration.chunkMs * sampleRate / 1000));
27
30
  this.#activityThreshold = configuration.activityThreshold;
31
+ // Measure sustained loudness, not individual peaks. The audio clock
32
+ // owns this fixed 20 ms window, independently of callback block sizes.
33
+ this.#analysisSamples = new Float32Array(Math.max(1, Math.round(sampleRate * 0.02)));
28
34
  this.port.onmessage = this.#receiveControl.bind(this);
29
35
  }
30
36
 
@@ -32,6 +38,7 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
32
38
  if (this.#stopped || event.data.type !== 'stop') return;
33
39
  this.#stopped = true;
34
40
  try {
41
+ this.#flushAnalysis();
35
42
  this.#emit('stop');
36
43
  this.#release();
37
44
  this.port.postMessage(
@@ -50,15 +57,33 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
50
57
  const samples = inputs[0]?.[0];
51
58
  if (!samples) return true;
52
59
  try {
53
- for (const sample of samples) this.#capture(sample);
60
+ for (const sample of samples) {
61
+ this.#analysisSamples[this.#analysisCount] = sample;
62
+ this.#analysisCount += 1;
63
+ this.#analysisEnergy += sample * sample;
64
+ if (this.#analysisCount === this.#analysisSamples.length) {
65
+ this.#flushAnalysis();
66
+ }
67
+ }
54
68
  } catch (error) {
55
69
  this.#fail(error);
56
70
  }
57
71
  return !this.#stopped;
58
72
  }
59
73
 
60
- #capture(sample) {
61
- const activity = Math.abs(sample) > this.#activityThreshold;
74
+ #flushAnalysis() {
75
+ if (!this.#analysisCount) return;
76
+ const activity = Math.sqrt(this.#analysisEnergy / this.#analysisCount) > this.#activityThreshold;
77
+ // Classification selects boundaries only. Every original sample still
78
+ // enters capture in order, including a partial final analysis window.
79
+ for (let index = 0; index < this.#analysisCount; index += 1) {
80
+ this.#capture(this.#analysisSamples[index], activity);
81
+ }
82
+ this.#analysisCount = 0;
83
+ this.#analysisEnergy = 0;
84
+ }
85
+
86
+ #capture(sample, activity) {
62
87
  if (!this.#active) {
63
88
  if (!activity) {
64
89
  this.#retainPreRoll(sample);
@@ -185,6 +210,9 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
185
210
  this.#view = null;
186
211
  this.#preRoll = new Float32Array(0);
187
212
  this.#preRollLength = 0;
213
+ this.#analysisSamples = new Float32Array(0);
214
+ this.#analysisCount = 0;
215
+ this.#analysisEnergy = 0;
188
216
  this.#sampleCount = 0;
189
217
  this.#hasActivity = false;
190
218
  this.#active = false;
@@ -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('/');
@@ -12,7 +12,7 @@ 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 {
@@ -307,7 +307,7 @@ function normalizeOptionalRecord(value,label){
307
307
  export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
308
308
  assertOnlyKeys(value,new Set([
309
309
  'schemaVersion','id','displayName','version','entry','strategy','security',
310
- 'localAIModelPolicy','include','exclude','shared','adapter','pwa'
310
+ 'localAIModelPolicy','include','exclude','shared','adapter','pwa','outputDirectory'
311
311
  ]),`${appId}/${APP_CONFIG_NAME}`);
312
312
  if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
313
313
  if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
@@ -316,6 +316,9 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
316
316
  const displayName=assertPresentationText(value.displayName,`${appId}/${APP_CONFIG_NAME}.displayName`);
317
317
  parseSemver(value.version);
318
318
  const entry=normalizeRelativePath(value.entry,`${appId}/${APP_CONFIG_NAME}.entry`);
319
+ const outputDirectory=value.outputDirectory===undefined?undefined:normalizeRelativePath(
320
+ value.outputDirectory,`${appId}/${APP_CONFIG_NAME}.outputDirectory`
321
+ );
319
322
  const include=validatePathList(value.include,`${appId}/${APP_CONFIG_NAME}.include`,{required:true});
320
323
  const exclude=validatePathList(value.exclude??[],`${appId}/${APP_CONFIG_NAME}.exclude`);
321
324
  if(include.some(allowed=>sameOrDescendant(APP_CONFIG_NAME,allowed))){
@@ -351,6 +354,7 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
351
354
  displayName,
352
355
  version:value.version,
353
356
  entry,
357
+ ...(outputDirectory===undefined?{}:{outputDirectory}),
354
358
  strategy:value.strategy,
355
359
  ...(value.pwa===undefined?{}:{pwa:normalizePwaConfig(value.pwa)}),
356
360
  ...(value.security===undefined?{}:{security:normalizeOptionalRecord(
@@ -421,11 +425,67 @@ async function loadContext(requestedWorkspaceRoot,appId){
421
425
  appRoot,
422
426
  appId,
423
427
  config,
424
- distRoot:path.join(workspaceRoot,rootConfig.distRoot),
425
- outputRoot:path.join(workspaceRoot,rootConfig.distRoot,appId)
428
+ outputRoot:resolvePackageOutputRoot(workspaceRoot,rootConfig,config)
426
429
  };
427
430
  }
428
431
 
432
+ async function assertPackageOutputLocation(context){
433
+ const output=context.outputRoot.split(path.sep).join('/');
434
+ const absolute=location=>path.resolve(location).split(path.sep).join('/');
435
+ const controls=[
436
+ context.appRoot,
437
+ path.join(context.workspaceRoot,ROOT_CONFIG_NAME),
438
+ path.join(context.appRoot,APP_CONFIG_NAME),
439
+ path.join(context.appRoot,APP_DESCRIPTOR_NAME),
440
+ ...(context.config.adapter?[path.join(context.appRoot,context.config.adapter)]:[])
441
+ ];
442
+ for(const control of controls){
443
+ if(sameOrDescendant(absolute(control),output)){
444
+ fail(`Package output would replace application source or configuration: ${control}.`);
445
+ }
446
+ }
447
+ for(const name of ['.git','.arcane','.agents','.codex','node_modules']){
448
+ const control=absolute(path.join(context.workspaceRoot,name));
449
+ if(sameOrDescendant(output,control)||sameOrDescendant(control,output)){
450
+ fail(`Package output overlaps the workspace control directory: ${name}.`);
451
+ }
452
+ }
453
+ if(context.rootConfig.appsRoot!=='.'){
454
+ const relative=path.relative(context.appsRoot,context.outputRoot);
455
+ if(relative&&!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative)){
456
+ const [otherAppId]=relative.split(path.sep);
457
+ if(pathKey(otherAppId)!==pathKey(context.appId)){
458
+ const otherAppRoot=path.join(context.appsRoot,otherAppId);
459
+ for(const manifest of [APP_CONFIG_NAME,APP_DESCRIPTOR_NAME]){
460
+ try{await lstat(path.join(otherAppRoot,manifest));}
461
+ catch(error){if(error?.code==='ENOENT')continue;throw error;}
462
+ fail(`Package output overlaps another application's source directory: ${otherAppRoot}.`);
463
+ }
464
+ }
465
+ }
466
+ }
467
+ function assertSelectedInput(sourceRoot,selected,excludes,label){
468
+ if(isExcluded(selected,excludes))return;
469
+ const source=absolute(path.resolve(sourceRoot,selected));
470
+ const relativeOutput=path.relative(sourceRoot,context.outputRoot).split(path.sep).join('/');
471
+ if(sameOrDescendant(source,output)
472
+ ||(sameOrDescendant(output,source)&&!isExcluded(relativeOutput,excludes))){
473
+ fail(`Package output overlaps selected ${label} content: ${selected}.`);
474
+ }
475
+ }
476
+ for(const selected of context.config.include){
477
+ assertSelectedInput(context.appRoot,selected,context.config.exclude,context.appId);
478
+ }
479
+ for(const sharedId of context.config.shared){
480
+ for(const route of context.rootConfig.sharedPayloads[sharedId]){
481
+ const sourceRoot=path.resolve(context.workspaceRoot,route.source);
482
+ for(const selected of route.include){
483
+ assertSelectedInput(sourceRoot,selected,route.exclude,`sharedPayloads.${sharedId}`);
484
+ }
485
+ }
486
+ }
487
+ }
488
+
429
489
  function destinationJoin(root,relative){
430
490
  return relative==='.'?root:root==='.'?relative:`${root}/${relative}`;
431
491
  }
@@ -495,6 +555,7 @@ async function collectSelectedPath({
495
555
  }
496
556
 
497
557
  async function collectPackageRecords(context,{signal}={}){
558
+ await assertPackageOutputLocation(context);
498
559
  const records=[];
499
560
  const destinations=new Set();
500
561
  for(const selected of context.config.include){
@@ -574,14 +635,15 @@ async function optionalDescriptor(context){
574
635
  }
575
636
  }
576
637
 
577
- async function inspectContext(context,{signal}={}){
578
- const records=await collectPackageRecords(context,{signal});
579
- const documents=await browserDocuments(records,context.config.entry);
638
+ async function inspectContext(context,{signal,records}={}){
639
+ const selectedRecords=records??await collectPackageRecords(context,{signal});
640
+ const documents=await browserDocuments(selectedRecords,context.config.entry);
580
641
  return {
581
642
  appId:context.appId,
582
643
  displayName:context.config.displayName,
583
644
  version:context.config.version,
584
645
  entry:context.config.entry,
646
+ ...(context.config.outputDirectory===undefined?{}:{outputDirectory:context.config.outputDirectory}),
585
647
  strategy:context.config.strategy,
586
648
  ...(context.config.pwa===undefined?{}:{pwa:copyJson(context.config.pwa)}),
587
649
  include:[...context.config.include],
@@ -594,7 +656,7 @@ async function inspectContext(context,{signal}={}){
594
656
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
595
657
  descriptor:await optionalDescriptor(context),
596
658
  browserDocuments:documents,
597
- files:[...new Set(['index.html',...records.map(record=>record.destination)])].sort(compareText),
659
+ files:[...new Set(['index.html',...selectedRecords.map(record=>record.destination)])].sort(compareText),
598
660
  output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
599
661
  };
600
662
  }
@@ -752,7 +814,8 @@ async function packageWithContext(context,options={}){
752
814
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
753
815
  const appPath=appRelativeRoot(context.rootConfig,context.appId);
754
816
  const entryPath=appPackagePath(context,context.config.entry);
755
- const inspected=await inspectContext(context,{signal});
817
+ const records=await collectPackageRecords(context,{signal});
818
+ const inspected=await inspectContext(context,{signal,records});
756
819
  if(options.dryRun){
757
820
  return {
758
821
  appId:context.appId,
@@ -770,17 +833,29 @@ async function packageWithContext(context,options={}){
770
833
  ].sort(compareText)
771
834
  };
772
835
  }
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.');
836
+ // Directory replacement must stay on the requested physical path before
837
+ // mkdir or rename can affect an existing source tree through an ancestor.
838
+ let current=context.workspaceRoot;
839
+ for(const segment of path.relative(context.workspaceRoot,context.outputRoot).split(path.sep)){
840
+ current=path.join(current,segment);
841
+ let info;
842
+ try{info=await lstat(current);}
843
+ catch(error){if(error?.code==='ENOENT')break;throw error;}
844
+ if(info.isSymbolicLink()||!info.isDirectory()){
845
+ fail(`Package output must use real directories: ${current}.`);
846
+ }
847
+ }
848
+ const outputParent=path.dirname(context.outputRoot);
849
+ await mkdir(outputParent,{recursive:true});
850
+ const parentInfo=await lstat(outputParent);
851
+ if(parentInfo.isSymbolicLink()||!parentInfo.isDirectory())fail('Package output parent must be a real directory.');
776
852
  const stagingRoot=path.join(
777
- context.distRoot,
853
+ outputParent,
778
854
  `.${context.appId}-staging-${process.pid}-${Date.now()}`
779
855
  );
780
856
  await mkdir(stagingRoot);
781
857
  let promoted=false;
782
858
  try{
783
- const records=await collectPackageRecords(context,{signal});
784
859
  async function copyBase() {
785
860
  await copyRecords(records,stagingRoot,{signal,onEvent});
786
861
  if (!records.some(function selectedRootIndex(record) {
@@ -1009,7 +1084,7 @@ export async function packageApp(options={}){
1009
1084
  export async function verifyApp({workspaceRoot,appId,signal,onEvent}={}){
1010
1085
  throwIfAborted(signal);
1011
1086
  const context=await loadContext(workspaceRoot,appId);
1012
- const outputRoot=await realDirectory(context.outputRoot,`dist/${appId}`);
1087
+ const outputRoot=await realDirectory(context.outputRoot,'Package output');
1013
1088
  const manifest=await readJson(path.join(outputRoot,RELEASE_MANIFEST_NAME),RELEASE_MANIFEST_NAME);
1014
1089
  if(!isPlainObject(manifest)||manifest.schemaVersion!==1||manifest.kind!=='arcane-app-release'
1015
1090
  ||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 {
@@ -1022,6 +1023,20 @@ async function executePairedNativeBuild(options,adapter,{
1022
1023
  }
1023
1024
  const dependencyApps=await nativeDependencyClosure(initialPrepared,{target,signal,onEvent});
1024
1025
  const prepared=initialPrepared;
1026
+ const packageOutputs=[];
1027
+ for(const app of [prepared.validation.app,...dependencyApps]){
1028
+ const releaseRoot=resolvePackageOutputRoot(
1029
+ prepared.workspaceRoot,prepared.validation.config,app.manifest
1030
+ );
1031
+ if(pathsOverlap(releaseRoot,outputRoot)
1032
+ ||packageOutputs.some(existing=>pathsOverlap(existing,releaseRoot))){
1033
+ throw new ArcaneError(
1034
+ 'ARCANE_PACKAGE_INVALID',
1035
+ `Package output for ${app.appId} overlaps another selected package or native output directory.`
1036
+ );
1037
+ }
1038
+ packageOutputs.push(releaseRoot);
1039
+ }
1025
1040
  const selectedRelease=await packageNativeRelease(prepared,{
1026
1041
  appId:prepared.appId,
1027
1042
  appRoot:prepared.appRoot,
@@ -1180,6 +1195,9 @@ export async function runApplication(options={}){
1180
1195
  return runTarget({
1181
1196
  ...selectedOptions,
1182
1197
  ...prepared,
1198
+ releaseRoot:resolvePackageOutputRoot(
1199
+ prepared.workspaceRoot,prepared.validation.config,prepared.validation.app.manifest
1200
+ ),
1183
1201
  target
1184
1202
  });
1185
1203
  }