@aiworker/web 1.22.2 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-B1Gizi3a.js} +1 -1
  3. package/dist/assets/DiffView-DXjVsEOw.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-aig6zJjg.js} +1 -1
  5. package/dist/assets/FilesView-CJdG0-wk.js +17 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-BXsS3jG6.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-D_hpXBnF.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-BoUiLQG1.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-B_B_a9KS.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-xgyyLiBM.js +12 -0
  11. package/dist/assets/OnboardingScreen-DIp84OWt.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-8p6nLJaU.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DjC8nTk9.js +161 -0
  14. package/dist/assets/{PlanView-DyDt_nZ_.js → PlanView-CJTmOF6I.js} +2 -2
  15. package/dist/assets/SettingsView-DD-JqIJx.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Cpb4hM6q.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-DfaKZXNU.js} +6 -6
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-DtZLzrSK.js} +2 -2
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-C8i9HpTB.js} +1 -1
  21. package/dist/assets/WalkthroughView-DeKMvZlL.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BBI7ZwrC.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DCrsQ5zS.js +10 -0
  25. package/dist/assets/es-Dwv4YfH6.js +16 -0
  26. package/dist/assets/fr-BCWg4Cnx.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-B68h5S5B.css +1 -0
  29. package/dist/assets/index-DVwMarHI.js +39 -0
  30. package/dist/assets/{index-Bl3LSbjn.js → index-jXSK5RrB.js} +1 -1
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-B_x_rVon.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-DW_xMer1.js} +1 -1
  33. package/dist/assets/ja-Bruw3RYC.js +16 -0
  34. package/dist/assets/ko-idhXGF8j.js +16 -0
  35. package/dist/assets/main-BkyaQhJX.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-CzdPuL0s.js} +2 -2
  37. package/dist/assets/markdownCore-Cn4Tyejo.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-CCkqKOLp.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-mvxTo055.js} +2 -2
  40. package/dist/assets/patchFileDiff-Csk4iNUi.js +13 -0
  41. package/dist/assets/pl-CGZWUqZJ.js +16 -0
  42. package/dist/assets/pt-BR-B8V7u7tA.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +2 -0
  44. package/dist/assets/renderMobileApp-B48CsP4r.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +17 -0
  46. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-CRl0SUC9.js} +1 -1
  48. package/dist/assets/surface-oMvhsog3.js +1 -0
  49. package/dist/assets/tr-BK_KiO2V.js +7 -0
  50. package/dist/assets/uk-CG74xV8N.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-CPKeOpUo.js} +160 -157
  52. package/dist/assets/useGitComparison-DH220WmO.js +1 -0
  53. package/dist/assets/useInlineCommentController-BiWz4AVb.js +2 -0
  54. package/dist/assets/useRootScrollLock-BS4-kr7P.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Bg4AEeCr.js} +2 -2
  57. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  58. package/dist/assets/{web-components-_mNEMjFY.js → web-components-z7hfnS8n.js} +1 -1
  59. package/dist/assets/zh-CN-T4AXofZI.js +16 -0
  60. package/dist/assets/zh-TW-Dte40sxp.js +16 -0
  61. package/dist/index.html +5 -43
  62. package/dist/mini-chat.html +4 -4
  63. package/dist/mobile.html +4 -4
  64. package/package.json +2 -3
  65. package/server/index.d.ts +15 -0
  66. package/server/index.js +45 -9
  67. package/server/lib/data-dir-migration.js +40 -0
  68. package/server/lib/data-dir-migration.test.js +59 -0
  69. package/server/lib/event-stream/runtime.test.js +12 -12
  70. package/server/lib/fs/DOCUMENTATION.md +3 -0
  71. package/server/lib/fs/routes.js +31 -0
  72. package/server/lib/fs/routes.test.js +110 -1
  73. package/server/lib/git/DOCUMENTATION.md +29 -5
  74. package/server/lib/git/routes.js +24 -4
  75. package/server/lib/git/service.js +184 -198
  76. package/server/lib/git/service.test.js +422 -25
  77. package/server/lib/inherited-env.js +16 -9
  78. package/server/lib/inherited-env.test.js +8 -9
  79. package/server/lib/message-queue/DOCUMENTATION.md +4 -1
  80. package/server/lib/network-defaults.js +23 -0
  81. package/server/lib/network-defaults.test.js +52 -0
  82. package/server/lib/opencode/DOCUMENTATION.md +17 -1
  83. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  84. package/server/lib/opencode/feature-routes-runtime.js +2 -0
  85. package/server/lib/opencode/openchamber-routes.js +64 -4
  86. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  87. package/server/lib/opencode/routes.js +5 -3
  88. package/server/lib/opencode/settings-files.js +214 -0
  89. package/server/lib/opencode/settings-helpers.js +85 -41
  90. package/server/lib/opencode/settings-helpers.test.js +166 -1
  91. package/server/lib/opencode/settings-registry.json +747 -0
  92. package/server/lib/opencode/settings-runtime.js +139 -18
  93. package/server/lib/opencode/settings-runtime.test.js +163 -5
  94. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  95. package/server/lib/project-context/routes.http.test.js +18 -0
  96. package/server/lib/project-context/routes.js +15 -0
  97. package/server/lib/project-context/runtime.js +248 -23
  98. package/server/lib/project-context/runtime.test.js +106 -3
  99. package/server/lib/projects/DOCUMENTATION.md +76 -0
  100. package/server/lib/projects/project-config.js +142 -9
  101. package/server/lib/projects/project-id.js +17 -0
  102. package/server/lib/projects/project-setup.js +409 -0
  103. package/server/lib/projects/project-setup.test.js +557 -0
  104. package/server/lib/projects/routes.js +59 -0
  105. package/server/lib/quota/DOCUMENTATION.md +31 -1
  106. package/server/lib/quota/providers/cline-pass.js +125 -0
  107. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  108. package/server/lib/quota/providers/index.js +7 -0
  109. package/server/lib/quota/providers/neuralwatt.js +3 -6
  110. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  111. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  112. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  113. package/server/lib/quota/providers/opencode-go.js +1 -1
  114. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  115. package/server/lib/quota/providers/openrouter.js +119 -24
  116. package/server/lib/quota/providers/openrouter.test.js +330 -0
  117. package/server/lib/relay/DOCUMENTATION.md +55 -0
  118. package/server/lib/relay/cross-compat.test.js +26 -0
  119. package/server/lib/relay/downstream-scheduler.js +154 -0
  120. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  121. package/server/lib/relay/e2ee.js +10 -8
  122. package/server/lib/relay/flow-control.test.js +177 -0
  123. package/server/lib/relay/host-client.js +37 -8
  124. package/server/lib/relay/tunnel-codec.js +9 -0
  125. package/server/lib/relay/tunnel-host.js +66 -17
  126. package/server/lib/session-assist/runtime.js +6 -10
  127. package/server/lib/session-goal/runtime.js +4 -9
  128. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  129. package/server/lib/small-model/call.js +9 -4
  130. package/server/lib/small-model/call.test.js +29 -0
  131. package/server/lib/small-model/index.js +5 -10
  132. package/server/lib/small-model/runtime-providers.js +15 -7
  133. package/server/lib/small-model/runtime-providers.test.js +7 -1
  134. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  135. package/server/lib/terminal/runtime.js +11 -6
  136. package/server/lib/terminal/runtime.test.js +20 -13
  137. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  138. package/server/lib/walkthrough/model-settings.js +8 -12
  139. package/server/lib/walkthrough/prompt.js +3 -1
  140. package/server/lib/walkthrough/sources.js +23 -2
  141. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  142. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  143. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  144. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  145. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  146. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  147. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  148. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  149. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  150. package/dist/assets/de-B5JaA3SA.js +0 -10
  151. package/dist/assets/es-EPIH88ii.js +0 -16
  152. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  153. package/dist/assets/index-DF5kMP-i.js +0 -39
  154. package/dist/assets/index-Dl3xbAB_.css +0 -1
  155. package/dist/assets/ja-CifOE1VN.js +0 -16
  156. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  157. package/dist/assets/main-MhSaOZoB.js +0 -930
  158. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  159. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  160. package/dist/assets/pl-DgEHYfFM.js +0 -16
  161. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  162. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  163. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  164. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  165. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  166. package/dist/assets/tr-D5m2yIWL.js +0 -7
  167. package/dist/assets/uk-D_q60oog.js +0 -16
  168. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  169. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  170. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  171. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  172. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  173. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
@@ -0,0 +1,23 @@
1
+ import net from 'node:net';
2
+
3
+ // Node caps each happy-eyeballs connect attempt at 250ms by default
4
+ // (autoSelectFamilyAttemptTimeout). TCP handshakes to provider endpoints that are
5
+ // geographically distant routinely take 300-1500ms, so fetch() from a Node process
6
+ // aborts every attempt (ETIMEDOUT) and surfaces "fetch failed" even though the host
7
+ // is reachable — e.g. the z.ai quota endpoint from an IPv4-only egress (#3399).
8
+ //
9
+ // Every Node process entrypoint that performs provider fetches raises the
10
+ // per-attempt cap. Family autoselection itself stays enabled, so the IPv6→IPv4
11
+ // fallback and ::1/localhost servers keep working; disabling it instead breaks
12
+ // local MCP servers. Runtimes without the setter (Bun's fetch path, older Node)
13
+ // are a no-op.
14
+ export const CONNECT_ATTEMPT_TIMEOUT_MS = 5_000;
15
+
16
+ export const applyConnectAttemptTimeout = (netModule = net) => {
17
+ try {
18
+ netModule.setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_TIMEOUT_MS);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ };
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import net from 'node:net';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ import { applyConnectAttemptTimeout, CONNECT_ATTEMPT_TIMEOUT_MS } from './network-defaults.js';
6
+
7
+ describe('applyConnectAttemptTimeout', () => {
8
+ it('initializes the server entrypoint in a fresh Node process', () => {
9
+ const serverUrl = new URL('../index.js', import.meta.url).href;
10
+ const result = spawnSync('node', ['--input-type=module', '--eval', `
11
+ import net from 'node:net';
12
+ import assert from 'node:assert/strict';
13
+ const family = net.getDefaultAutoSelectFamily();
14
+ net.setDefaultAutoSelectFamilyAttemptTimeout(250);
15
+ await import(${JSON.stringify(serverUrl)});
16
+ assert.equal(net.getDefaultAutoSelectFamilyAttemptTimeout(), 5000);
17
+ assert.equal(net.getDefaultAutoSelectFamily(), family);
18
+ process.exit(0);
19
+ `], { encoding: 'utf8', timeout: 15_000, windowsHide: true });
20
+ expect(result.status, result.stderr).toBe(0);
21
+ });
22
+
23
+ it('raises the per-attempt connect timeout on runtimes that expose the setter', () => {
24
+ const previous = net.getDefaultAutoSelectFamilyAttemptTimeout();
25
+ try {
26
+ expect(applyConnectAttemptTimeout()).toBe(true);
27
+ expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(CONNECT_ATTEMPT_TIMEOUT_MS);
28
+ } finally {
29
+ net.setDefaultAutoSelectFamilyAttemptTimeout(previous);
30
+ }
31
+ });
32
+
33
+ it('is a no-op on runtimes without the setter', () => {
34
+ expect(applyConnectAttemptTimeout({})).toBe(false);
35
+ });
36
+
37
+ it('survives a throwing setter', () => {
38
+ const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn(() => {
39
+ throw new Error('not supported');
40
+ });
41
+ expect(applyConnectAttemptTimeout({ setDefaultAutoSelectFamilyAttemptTimeout })).toBe(false);
42
+ });
43
+
44
+ it('leaves family autoselection itself untouched', () => {
45
+ const setDefaultAutoSelectFamily = vi.fn();
46
+ const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn();
47
+ applyConnectAttemptTimeout({ setDefaultAutoSelectFamily, setDefaultAutoSelectFamilyAttemptTimeout });
48
+ expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledTimes(1);
49
+ expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledWith(CONNECT_ATTEMPT_TIMEOUT_MS);
50
+ expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled();
51
+ });
52
+ });
@@ -212,7 +212,12 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
212
212
  - `persistSettings(changes)`
213
213
  - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
214
214
  - Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
215
- - Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
215
+ - Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter either settings file.
216
+ - Two files (`settings-files.js`): `settings.json` holds instance facts and any legacy or unknown keys; `preferences.json` beside it holds every key the generated registry snapshot (`settings-registry.json`) marks `profile`, as `{ version: 1, fields: { key: { value, updatedAt, surfaces? } } }`. Keys the snapshot marks `perSurface` are stored per surface kind: `GET`/`PUT /api/config/settings` read the client's kind from the `surface` query parameter (`settingsSurfaceOf`; the legacy `x-openchamber-surface` header is still honoured, but a header forces a CORS preflight that cross-origin shells and older instances refuse, so clients must not send one) (`web`, `desktop`, `vscode`, `mobile`; anything else means base), `persistSettings(changes, { surface })` writes a changed per-surface key under `surfaces[surface]` and never touches its base, and `readSettingsFromDisk({ surface })` resolves that kind's value first, the base otherwise. Callers without a surface (migrations, the seed, server-side feature writers) read and write the base. `readSettingsFromDisk()` returns the merged document and seeds `preferences.json` once from an existing `settings.json` (which it leaves intact). An existing `preferences.json` that fails to parse is a failure, not an empty profile: it is never seeded or overwritten, the merged read serves the instance part, and `persistSettings` drops profile keys with a warning until the file is fixed or removed. `writeSettingsToDisk(document)` splits by scope and writes `settings.json` as the instance part plus a copy of the profile's base values (`legacySettingsDocumentOf`): a build from before the split reads only that file, so a rollback keeps the user's preferences, while current builds ignore the copy because `preferences.json` wins in the merge; device keys are dropped from writes. Modules that read one profile key off the disk on a hot path use `readMergedSettingsSync`.
217
+
218
+ ## Public exports (settings-files.js)
219
+ - `parsePreferencesDocument(raw)`, `serializePreferencesDocument(fields)`, `flattenPreferences(fields)`, `buildPreferencesFields(previousFields, document, now)`, `instancePartOf(document)`, `seedPreferencesFrom(document, now)`, `readMergedSettingsSync({ fs, path, settingsFilePath })`, `getSettingsScope(key)`, `isProfileSettingsKey(key)`, `isDeviceSettingsKey(key)`, `preferencesFilePathFor(settingsFilePath, path)`.
220
+ - The VS Code extension host writes the same two files with the same shape (`packages/vscode/src/settings-files.ts`); format changes go to both.
216
221
 
217
222
  ## Public exports (settings-helpers.js)
218
223
  - `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -363,9 +368,20 @@ before starting managed OpenCode. The managed custom tool therefore receives
363
368
  an authoritative loopback callback URL even when AiWorker binds port `0`.
364
369
 
365
370
  ## Public exports (openchamber-routes.js)
371
+ Browser completion checks use `appType=web&updateStatus=true` to stay on the
372
+ Desktop Host's native updater. A rejected native restart is retained in the
373
+ server process and returned to these polls as `DESKTOP_UPDATE_RESTART_FAILED`;
374
+ ordinary availability checks remain usable so a browser reload can offer a
375
+ retry. Starting another installation clears the previous restart error.
376
+ The shared UI's `lib/web-update.ts` parses install/check responses and waits
377
+ for the installed native target version, rather than treating absence of a
378
+ newer release as installation success. Poll requests have individual deadlines
379
+ within a ten-minute overall deadline.
380
+
366
381
  - `registerAiWorkerRoutes(app, dependencies)`: registers AiWorker endpoints:
367
382
  - `GET /api/openchamber/update-check`
368
383
  - `POST /api/openchamber/update-install`
384
+ - Desktop-managed hosts delegate authenticated Web update requests to the Electron main process, which checks, downloads, and applies the update through `electron-updater` before restarting the host.
369
385
  - Foreground servers running under a systemd user unit queue installation in
370
386
  a separate transient unit and restart the configured service afterwards.
371
387
  `AIWORKER_SYSTEMD_UNIT` overrides the default `aiworker.service`.
@@ -63,6 +63,7 @@ export const createBootstrapRuntime = (dependencies) => {
63
63
  getCachedZenModels,
64
64
  setAutoAcceptSession,
65
65
  agentToolRuntime,
66
+ desktopUpdater,
66
67
  } = options;
67
68
 
68
69
  const uiAuthController = createUiAuth({
@@ -153,6 +154,7 @@ export const createBootstrapRuntime = (dependencies) => {
153
154
  readSettingsFromDiskMigrated,
154
155
  fetchFreeZenModels,
155
156
  getCachedZenModels,
157
+ desktopUpdater,
156
158
  });
157
159
 
158
160
  return {
@@ -10,6 +10,7 @@ import { registerDevServerRoutes } from '../dev-servers/routes.js';
10
10
  import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
11
11
  import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
12
12
  import { registerProjectContextRoutes } from '../project-context/routes.js';
13
+ import { registerProjectSetupRoutes } from '../projects/routes.js';
13
14
  import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
14
15
  import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
15
16
  import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
@@ -314,6 +315,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
314
315
  aiworkerDataDir,
315
316
  });
316
317
  registerProjectContextRoutes(app, { projectContextRuntime });
318
+ registerProjectSetupRoutes(app, { projectConfigRuntime });
317
319
  registerAgentMemoryRoutes(app, { agentMemoryRuntime, isAgentMemoryEnabled });
318
320
  registerSessionKnowledgeRoutes(app, { sessionKnowledgeRuntime });
319
321
 
@@ -29,11 +29,13 @@ export const registerAiWorkerRoutes = (app, dependencies) => {
29
29
  readSettingsFromDiskMigrated,
30
30
  fetchFreeZenModels,
31
31
  getCachedZenModels,
32
+ desktopUpdater,
32
33
  } = dependencies;
33
34
 
35
+ let desktopRestartError = null;
36
+
34
37
  app.get('/api/openchamber/update-check', async (req, res) => {
35
38
  try {
36
- const { checkForUpdates } = await import('../package-manager.js');
37
39
  const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
38
40
  const parseReportUsage = (value) => {
39
41
  if (typeof value !== 'string') return true;
@@ -49,8 +51,7 @@ export const registerAiWorkerRoutes = (app, dependencies) => {
49
51
  return 'desktop';
50
52
  };
51
53
  const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '';
52
-
53
- const updateInfo = await checkForUpdates({
54
+ const updateRequest = {
54
55
  appType: parseString(req.query.appType),
55
56
  deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent),
56
57
  platform: parseString(req.query.platform),
@@ -59,7 +60,31 @@ export const registerAiWorkerRoutes = (app, dependencies) => {
59
60
  currentVersion: parseString(req.query.currentVersion),
60
61
  installId: parseString(req.query.installId),
61
62
  reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
62
- });
63
+ };
64
+ let updateInfo;
65
+ if (process.env.AIWORKER_RUNTIME === 'desktop' && updateRequest.appType === 'web') {
66
+ if (desktopRestartError && req.query.updateStatus === 'true') {
67
+ return res.status(503).json({
68
+ code: 'DESKTOP_UPDATE_RESTART_FAILED',
69
+ error: desktopRestartError,
70
+ });
71
+ }
72
+ if (typeof desktopUpdater?.check !== 'function') {
73
+ return res.status(503).json({
74
+ available: false,
75
+ code: 'DESKTOP_UPDATER_UNAVAILABLE',
76
+ error: 'The desktop updater is not available.',
77
+ });
78
+ }
79
+ updateInfo = {
80
+ ...await desktopUpdater.check(),
81
+ packageManager: 'electron',
82
+ updateOwner: 'electron-updater',
83
+ };
84
+ } else {
85
+ const { checkForUpdates } = await import('../package-manager.js');
86
+ updateInfo = await checkForUpdates(updateRequest);
87
+ }
63
88
  res.json(updateInfo);
64
89
  } catch (error) {
65
90
  console.error('Failed to check for updates:', error);
@@ -72,6 +97,41 @@ export const registerAiWorkerRoutes = (app, dependencies) => {
72
97
 
73
98
  app.post('/api/openchamber/update-install', async (_req, res) => {
74
99
  try {
100
+ if (process.env.AIWORKER_RUNTIME === 'desktop') {
101
+ if (typeof desktopUpdater?.install !== 'function' || typeof desktopUpdater?.restart !== 'function') {
102
+ return res.status(503).json({
103
+ code: 'DESKTOP_UPDATER_UNAVAILABLE',
104
+ error: 'The desktop updater is not available.',
105
+ });
106
+ }
107
+
108
+ desktopRestartError = null;
109
+ const updateInfo = await desktopUpdater.install();
110
+ if (!updateInfo?.available) {
111
+ return res.status(400).json({ error: 'No update available' });
112
+ }
113
+
114
+ res.json({
115
+ success: true,
116
+ message: 'Desktop update downloaded, host will restart shortly',
117
+ version: updateInfo.version,
118
+ packageManager: 'electron',
119
+ updateOwner: 'electron-updater',
120
+ autoRestart: true,
121
+ restartManager: 'electron-updater',
122
+ });
123
+
124
+ setImmediate(() => {
125
+ Promise.resolve()
126
+ .then(() => desktopUpdater.restart())
127
+ .catch((error) => {
128
+ desktopRestartError = error instanceof Error ? error.message : 'Failed to restart after desktop update';
129
+ console.error('Failed to restart after desktop update:', error);
130
+ });
131
+ });
132
+ return;
133
+ }
134
+
75
135
  const { spawn: spawnChild, spawnSync } = await import('child_process');
76
136
  const {
77
137
  checkForUpdates,
@@ -18,7 +18,7 @@ const childProcess = await import('child_process');
18
18
  const packageManager = await import('../package-manager.js');
19
19
  const { registerAiWorkerRoutes } = await import('./openchamber-routes.js');
20
20
 
21
- const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
21
+ const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => {
22
22
  const app = express();
23
23
  const dependencies = {
24
24
  fs: {
@@ -47,6 +47,7 @@ const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
47
47
  readSettingsFromDiskMigrated: vi.fn(),
48
48
  fetchFreeZenModels: vi.fn(),
49
49
  getCachedZenModels: vi.fn(),
50
+ desktopUpdater,
50
51
  };
51
52
 
52
53
  registerAiWorkerRoutes(app, dependencies);
@@ -69,6 +70,132 @@ afterEach(() => {
69
70
  vi.clearAllMocks();
70
71
  });
71
72
 
73
+ describe('AiWorker desktop host update route', () => {
74
+ it('reports a restart rejection until the user retries installation', async () => {
75
+ const desktopUpdater = {
76
+ check: vi.fn(async () => ({ available: true, currentVersion: '1.17.0', version: '1.17.1' })),
77
+ install: vi.fn(async () => ({ available: true, version: '1.17.1' })),
78
+ restart: vi.fn().mockRejectedValueOnce(new Error('Signature rejected')).mockResolvedValue(undefined),
79
+ };
80
+ const { app } = createApp({ environment: { AIWORKER_RUNTIME: 'desktop' }, desktopUpdater });
81
+ const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
82
+
83
+ await request(app).post('/api/openchamber/update-install').expect(200);
84
+ await new Promise(resolve => setImmediate(resolve));
85
+ await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true').expect(503, {
86
+ code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
87
+ });
88
+ expect(desktopUpdater.check).not.toHaveBeenCalled();
89
+ expect(logError).toHaveBeenCalledOnce();
90
+ // Availability remains reachable after a browser reload, so users can retry.
91
+ await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
92
+
93
+ await request(app).post('/api/openchamber/update-install').expect(200);
94
+ await new Promise(resolve => setImmediate(resolve));
95
+ const response = await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
96
+ expect(response.body.currentVersion).toBe('1.17.0');
97
+ expect(response.body.updateOwner).toBe('electron-updater');
98
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('rejects native checks without a bridge and preserves explicit non-web checks', async () => {
102
+ const { app } = createApp({ environment: { AIWORKER_RUNTIME: 'desktop' } });
103
+ await request(app).get('/api/openchamber/update-check?appType=web').expect(503, {
104
+ available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.',
105
+ });
106
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
107
+ await request(app).get('/api/openchamber/update-check?appType=desktop-electron').expect(200);
108
+ expect(packageManager.checkForUpdates).toHaveBeenCalledOnce();
109
+ });
110
+
111
+ it('uses electron-updater to check for Web client updates', async () => {
112
+ const desktopUpdater = {
113
+ check: vi.fn(async () => ({
114
+ available: true,
115
+ currentVersion: '1.17.0',
116
+ version: '1.17.1',
117
+ })),
118
+ install: vi.fn(),
119
+ restart: vi.fn(),
120
+ };
121
+ const { app } = createApp({
122
+ environment: {
123
+ AIWORKER_RUNTIME: 'desktop',
124
+ },
125
+ desktopUpdater,
126
+ });
127
+
128
+ await request(app)
129
+ .get('/api/openchamber/update-check?appType=web&reportUsage=false')
130
+ .expect(200, {
131
+ available: true,
132
+ currentVersion: '1.17.0',
133
+ version: '1.17.1',
134
+ packageManager: 'electron',
135
+ updateOwner: 'electron-updater',
136
+ });
137
+
138
+ expect(desktopUpdater.check).toHaveBeenCalledOnce();
139
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('installs through electron-updater and restarts after responding', async () => {
143
+ const desktopUpdater = {
144
+ check: vi.fn(),
145
+ install: vi.fn(async () => ({
146
+ available: true,
147
+ version: '1.17.1',
148
+ })),
149
+ restart: vi.fn(),
150
+ };
151
+ const { app } = createApp({
152
+ environment: {
153
+ AIWORKER_RUNTIME: 'desktop',
154
+ },
155
+ desktopUpdater,
156
+ });
157
+
158
+ await request(app)
159
+ .post('/api/openchamber/update-install')
160
+ .expect(200, {
161
+ success: true,
162
+ message: 'Desktop update downloaded, host will restart shortly',
163
+ version: '1.17.1',
164
+ packageManager: 'electron',
165
+ updateOwner: 'electron-updater',
166
+ autoRestart: true,
167
+ restartManager: 'electron-updater',
168
+ });
169
+ await new Promise((resolve) => setImmediate(resolve));
170
+
171
+ expect(desktopUpdater.install).toHaveBeenCalledOnce();
172
+ expect(desktopUpdater.restart).toHaveBeenCalledOnce();
173
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
174
+ expect(packageManager.detectPackageManagerDetails).not.toHaveBeenCalled();
175
+ expect(packageManager.getUpdateCommand).not.toHaveBeenCalled();
176
+ expect(childProcess.spawn).not.toHaveBeenCalled();
177
+ expect(childProcess.spawnSync).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('fails safely when the Electron updater bridge is unavailable', async () => {
181
+ const { app } = createApp({
182
+ environment: {
183
+ AIWORKER_RUNTIME: 'desktop',
184
+ },
185
+ });
186
+
187
+ await request(app)
188
+ .post('/api/openchamber/update-install')
189
+ .expect(503, {
190
+ code: 'DESKTOP_UPDATER_UNAVAILABLE',
191
+ error: 'The desktop updater is not available.',
192
+ });
193
+
194
+ expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
195
+ expect(childProcess.spawn).not.toHaveBeenCalled();
196
+ });
197
+ });
198
+
72
199
  describe('AiWorker foreground update route', () => {
73
200
  it('rejects a foreground update when the server is not owned by systemd', async () => {
74
201
  const { app } = createApp();
@@ -7,6 +7,7 @@ import {
7
7
  } from './config-mutation-response.js';
8
8
  import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
9
9
  import { OPENCODE_CONFIG_DIR } from './shared.js';
10
+ import { settingsSurfaceOf } from './settings-files.js';
10
11
 
11
12
  export const registerOpenCodeRoutes = (app, dependencies) => {
12
13
  const {
@@ -208,9 +209,10 @@ ${desktopReturn ? `<a class="return" href="aiworker://focus/mcp-auth">Return to
208
209
  }
209
210
  };
210
211
 
211
- app.get('/api/config/settings', async (_req, res) => {
212
+ app.get('/api/config/settings', async (req, res) => {
212
213
  try {
213
- const settings = await readSettingsFromDiskMigrated();
214
+ // The surface kind resolves the per-surface profile keys; absent means base.
215
+ const settings = await readSettingsFromDiskMigrated({ surface: settingsSurfaceOf(req) });
214
216
  res.json(formatSettingsResponse(settings));
215
217
  } catch (error) {
216
218
  console.error('Failed to read settings:', error);
@@ -422,7 +424,7 @@ ${desktopReturn ? `<a class="return" href="aiworker://focus/mcp-auth">Return to
422
424
 
423
425
  app.put('/api/config/settings', async (req, res) => {
424
426
  try {
425
- const updated = await persistSettings(req.body ?? {});
427
+ const updated = await persistSettings(req.body ?? {}, { surface: settingsSurfaceOf(req) });
426
428
  res.json(updated);
427
429
  } catch (error) {
428
430
  console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
@@ -0,0 +1,214 @@
1
+ // The two settings files and how a merged document is split between them.
2
+ //
3
+ // `settings.json` holds instance facts (and, untouched, whatever legacy keys
4
+ // older builds left there). `preferences.json` holds the user's profile: the
5
+ // keys the settings registry marks `profile`, each with the time the store
6
+ // last accepted a new value for it. Device keys never reach either file.
7
+ //
8
+ // The VS Code extension host writes the same two files with the same shape
9
+ // (`packages/vscode/src/settings-files.ts`); keep the format changes in sync.
10
+ import { createRequire } from 'node:module';
11
+
12
+ const registry = createRequire(import.meta.url)('./settings-registry.json');
13
+
14
+ const PREFERENCES_FILE_NAME = 'preferences.json';
15
+ const PREFERENCES_DOCUMENT_VERSION = 1;
16
+
17
+ /** The registry scope for a key, or `null` when the registry does not know it. */
18
+ const getSettingsScope = (key) => registry.fields[key]?.scope ?? null;
19
+
20
+ export const isProfileSettingsKey = (key) => getSettingsScope(key) === 'profile';
21
+ export const isDeviceSettingsKey = (key) => getSettingsScope(key) === 'device';
22
+
23
+ /** Profile keys the owner chose to store per surface kind (a change on a phone stays on phones). */
24
+ const isPerSurfaceSettingsKey = (key) => registry.fields[key]?.perSurface === true;
25
+
26
+ const SETTINGS_SURFACES = Object.freeze(['web', 'desktop', 'vscode', 'mobile']);
27
+
28
+ export const normalizeSettingsSurface = (value) => (
29
+ typeof value === 'string' && SETTINGS_SURFACES.includes(value.trim()) ? value.trim() : null
30
+ );
31
+
32
+ /**
33
+ * Which surface kind a settings request comes from; `null` means "base".
34
+ * Clients send `?surface=<kind>` (a query parameter keeps the request
35
+ * CORS-simple for cross-origin shells and older instances); the
36
+ * `x-openchamber-surface` header is still honoured for clients that sent it.
37
+ */
38
+ export const settingsSurfaceOf = (req) => (
39
+ normalizeSettingsSurface(req.query?.surface) ?? normalizeSettingsSurface(req.get?.('x-openchamber-surface'))
40
+ );
41
+
42
+ export const preferencesFilePathFor = (settingsFilePath, path) => path.join(path.dirname(settingsFilePath), PREFERENCES_FILE_NAME);
43
+
44
+ const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
45
+
46
+ const sameValue = (left, right) => {
47
+ if (left === right) return true;
48
+ if (left === undefined || right === undefined) return false;
49
+ return JSON.stringify(left) === JSON.stringify(right);
50
+ };
51
+
52
+ const parseStamp = (value) => (Number.isFinite(value) ? value : 0);
53
+
54
+ /**
55
+ * Parse the text of a preferences file. A missing file is the caller's case
56
+ * (ENOENT); anything that is not a version-1 document with a `fields` object
57
+ * is a failure, never an empty profile.
58
+ *
59
+ * An entry is `{ value, updatedAt }` for the base value, optionally with
60
+ * `surfaces: { [surface]: { value, updatedAt } }` for per-surface keys; a
61
+ * per-surface key that was only ever set from one surface kind has no base.
62
+ */
63
+ export const parsePreferencesDocument = (raw) => {
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ } catch (error) {
68
+ return { ok: false, reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
69
+ }
70
+ if (!isPlainObject(parsed) || parsed.version !== PREFERENCES_DOCUMENT_VERSION || !isPlainObject(parsed.fields)) {
71
+ return { ok: false, reason: 'not a version-1 preferences document' };
72
+ }
73
+ const fields = {};
74
+ for (const [key, entry] of Object.entries(parsed.fields)) {
75
+ if (!isPlainObject(entry) || (!('value' in entry) && !isPlainObject(entry.surfaces))) {
76
+ return { ok: false, reason: `field "${key}" is not a { value, updatedAt } entry` };
77
+ }
78
+ const next = { updatedAt: parseStamp(entry.updatedAt) };
79
+ if ('value' in entry) next.value = entry.value;
80
+ if (isPlainObject(entry.surfaces)) {
81
+ next.surfaces = {};
82
+ for (const [surface, surfaceEntry] of Object.entries(entry.surfaces)) {
83
+ if (!SETTINGS_SURFACES.includes(surface) || !isPlainObject(surfaceEntry) || !('value' in surfaceEntry)) {
84
+ return { ok: false, reason: `field "${key}" has an invalid surface entry "${surface}"` };
85
+ }
86
+ next.surfaces[surface] = { value: surfaceEntry.value, updatedAt: parseStamp(surfaceEntry.updatedAt) };
87
+ }
88
+ }
89
+ fields[key] = next;
90
+ }
91
+ return { ok: true, fields };
92
+ };
93
+
94
+ export const serializePreferencesDocument = (fields) => JSON.stringify({ version: PREFERENCES_DOCUMENT_VERSION, fields }, null, 2);
95
+
96
+ /**
97
+ * The plain key → value view of preference fields as one surface kind sees it:
98
+ * that surface's own value first, the base value otherwise; a key with neither
99
+ * is absent (the client keeps what it holds, or its default).
100
+ */
101
+ export const flattenPreferences = (fields, surface = null) => {
102
+ const values = {};
103
+ for (const [key, entry] of Object.entries(fields)) {
104
+ const own = surface && entry.surfaces ? entry.surfaces[surface] : undefined;
105
+ if (own) {
106
+ values[key] = own.value;
107
+ } else if ('value' in entry) {
108
+ values[key] = entry.value;
109
+ }
110
+ }
111
+ return values;
112
+ };
113
+
114
+ /**
115
+ * The next preference fields for a merged document: every profile key it
116
+ * carries, stamped `now` when its value differs from what the file held and
117
+ * keeping the earlier stamp otherwise. Profile keys the document no longer
118
+ * carries are dropped (that is how a cleared key leaves the file).
119
+ *
120
+ * Per-surface keys: when the write comes from a surface kind (`surface`) and
121
+ * the key is among the keys that write changed (`changedKeys`), the value goes
122
+ * under `surfaces[surface]` and the base is left as it was; a per-surface key
123
+ * the write did not change keeps its whole entry (the document only carries
124
+ * that surface's resolved view of it). Without a surface (migrations, the
125
+ * one-time seed) the base is written.
126
+ */
127
+ export const buildPreferencesFields = (previousFields, document, now, { surface = null, changedKeys = null } = {}) => {
128
+ const fields = {};
129
+ const changed = changedKeys ? new Set(changedKeys) : null;
130
+ for (const [key, value] of Object.entries(document)) {
131
+ if (value === undefined || !isProfileSettingsKey(key)) continue;
132
+ const previous = previousFields[key];
133
+ if (isPerSurfaceSettingsKey(key) && surface) {
134
+ if (changed && !changed.has(key)) {
135
+ if (previous) fields[key] = previous;
136
+ continue;
137
+ }
138
+ const previousOwn = previous?.surfaces?.[surface];
139
+ const own = previousOwn && sameValue(previousOwn.value, value) ? previousOwn : { value, updatedAt: now };
140
+ fields[key] = {
141
+ ...(previous ?? { updatedAt: 0 }),
142
+ surfaces: { ...(previous?.surfaces ?? {}), [surface]: own },
143
+ };
144
+ continue;
145
+ }
146
+ if (previous && 'value' in previous && sameValue(previous.value, value)) {
147
+ fields[key] = previous;
148
+ } else {
149
+ fields[key] = { ...(previous ?? {}), value, updatedAt: now };
150
+ }
151
+ }
152
+ return fields;
153
+ };
154
+
155
+ /**
156
+ * The part of a merged document that belongs in `settings.json`: everything
157
+ * that is not a profile key. Device keys older builds persisted stay in place
158
+ * as a read-once seed for clients; the write path never adds new ones.
159
+ */
160
+ export const instancePartOf = (document) => {
161
+ const instance = {};
162
+ for (const [key, value] of Object.entries(document)) {
163
+ if (value === undefined || isProfileSettingsKey(key)) continue;
164
+ instance[key] = value;
165
+ }
166
+ return instance;
167
+ };
168
+
169
+ /** The profile keys of a document (the part `instancePartOf` leaves out). */
170
+ export const profilePartOf = (document) => {
171
+ const profile = {};
172
+ for (const [key, value] of Object.entries(document)) {
173
+ if (value !== undefined && isProfileSettingsKey(key)) profile[key] = value;
174
+ }
175
+ return profile;
176
+ };
177
+
178
+ /**
179
+ * What `settings.json` holds after a write: the instance part plus a copy of
180
+ * the profile's base values. The copy is for builds that predate the split —
181
+ * they read only this file, so a rollback still finds the user's preferences.
182
+ * Current builds ignore it: `preferences.json` wins in the merged read.
183
+ */
184
+ export const legacySettingsDocumentOf = (document, preferenceFields) => ({
185
+ ...instancePartOf(document),
186
+ ...flattenPreferences(preferenceFields),
187
+ });
188
+
189
+ /** The profile keys of a document, as they would seed a fresh preferences file. */
190
+ export const seedPreferencesFrom = (document, now) => buildPreferencesFields({}, document, now);
191
+
192
+ /**
193
+ * Synchronous merged read for server modules that consult one or two profile
194
+ * keys on a hot path (small-model resolution, goal/assist toggles). A missing
195
+ * or unreadable preferences file contributes nothing, and the caller's own
196
+ * default applies — the same "missing is not default" rule the clients use.
197
+ */
198
+ export const readMergedSettingsSync = ({ fs, path, settingsFilePath }) => {
199
+ let settings = {};
200
+ try {
201
+ const parsed = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8'));
202
+ if (isPlainObject(parsed)) settings = parsed;
203
+ } catch {
204
+ settings = {};
205
+ }
206
+ let preferences = {};
207
+ try {
208
+ const parsed = parsePreferencesDocument(fs.readFileSync(preferencesFilePathFor(settingsFilePath, path), 'utf8'));
209
+ if (parsed.ok) preferences = flattenPreferences(parsed.fields);
210
+ } catch {
211
+ preferences = {};
212
+ }
213
+ return { ...settings, ...preferences };
214
+ };