@aiworker/web 1.22.2 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -12,7 +12,9 @@
12
12
  import { createRequire } from 'node:module';
13
13
  import { existsSync } from 'node:fs';
14
14
 
15
- const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
15
+ const POSIX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
16
+ /** Variables a PTY shell must never inherit from the AiWorker host process. */
17
+ const PTY_HOST_PRIVATE_VARIABLES = Object.freeze(['ARGV0', 'NODE_CHANNEL_FD']);
16
18
 
17
19
  /**
18
20
  * Remove AppImage `ARGV0` from a mutable env object (or `process.env`).
@@ -49,26 +51,31 @@ export function clearAppImageArgv0FromProcessEnv() {
49
51
  }
50
52
 
51
53
  /**
52
- * Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts.
54
+ * Resolve a POSIX PTY launch that unsets host-private variables before the
55
+ * shell starts.
53
56
  *
54
- * `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the
55
- * JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it
56
- * before execing the real shell. No-op on non-Linux platforms.
57
+ * `bun-pty` merges the OS environ into the child, so deleting a variable from
58
+ * the JS env object alone is not enough: AppImage `ARGV0` came back on Linux,
59
+ * and the daemon's `NODE_CHANNEL_FD` came back everywhere (Node then warns
60
+ * "Failed to parse IPC channel number" when a CLI such as opencode exits).
61
+ * Wrapping with `env -u NAME ...` unsets them before execing the real shell.
62
+ * No-op on Windows and when no `env` binary is found.
57
63
  *
58
64
  * @param {string} executable
59
65
  * @param {string[]} args
66
+ * @param {readonly string[]} variables
60
67
  * @returns {{ executable: string, args: string[] }}
61
68
  */
62
- export function resolveLinuxPtyLaunch(executable, args = []) {
63
- if (process.platform !== 'linux') {
69
+ export function resolvePosixPtyLaunch(executable, args = [], variables = PTY_HOST_PRIVATE_VARIABLES) {
70
+ if (process.platform === 'win32' || variables.length === 0) {
64
71
  return { executable, args };
65
72
  }
66
- const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate));
73
+ const envBinary = POSIX_ENV_BINARIES.find((candidate) => existsSync(candidate));
67
74
  if (!envBinary) {
68
75
  return { executable, args };
69
76
  }
70
77
  return {
71
78
  executable: envBinary,
72
- args: ['-u', 'ARGV0', executable, ...args],
79
+ args: [...variables.flatMap((name) => ['-u', name]), executable, ...args],
73
80
  };
74
81
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  clearAppImageArgv0FromProcessEnv,
4
- resolveLinuxPtyLaunch,
4
+ resolvePosixPtyLaunch,
5
5
  stripAppImageArgv0Leak,
6
6
  } from './inherited-env.js';
7
7
 
@@ -46,18 +46,17 @@ describe('clearAppImageArgv0FromProcessEnv', () => {
46
46
  });
47
47
  });
48
48
 
49
- describe('resolveLinuxPtyLaunch', () => {
50
- it('wraps the shell with env -u ARGV0 on Linux', () => {
51
- if (process.platform !== 'linux') return;
52
- expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
49
+ describe('resolvePosixPtyLaunch', () => {
50
+ it('wraps the shell with env -u for every host-private variable on POSIX', () => {
51
+ if (process.platform === 'win32') return;
52
+ expect(resolvePosixPtyLaunch('/bin/zsh', ['-l'])).toEqual({
53
53
  executable: expect.stringMatching(/\/env$/),
54
- args: ['-u', 'ARGV0', '/bin/zsh', '-l'],
54
+ args: ['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/zsh', '-l'],
55
55
  });
56
56
  });
57
57
 
58
- it('leaves non-Linux launches unchanged', () => {
59
- if (process.platform === 'linux') return;
60
- expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
58
+ it('leaves the launch unchanged with nothing to unset', () => {
59
+ expect(resolvePosixPtyLaunch('/bin/zsh', ['-l'], [])).toEqual({
61
60
  executable: '/bin/zsh',
62
61
  args: ['-l'],
63
62
  });
@@ -31,6 +31,7 @@ send never re-resolves mutable UI state:
31
31
  {
32
32
  id, createdAt,
33
33
  content, // raw text for display and editing
34
+ contextPreview?, // bounded display-only summary captured by the UI
34
35
  text, // text to deliver (agent mention stripped, file mentions resolved); defaults to content
35
36
  agentMention?, // delivered as an `agent` part
36
37
  attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }],
@@ -56,6 +57,12 @@ context entry). Public snapshots and broadcasts strip the payloads —
56
57
  attachment `dataUrl` (megabytes of base64) and `context` (a PR diff, say) —
57
58
  so they do not ride every update; the only way to get them back is a `take`.
58
59
 
60
+ Snapshots retain `contextPreview`, capped at 100 characters plus an ellipsis.
61
+ It carries the attached comment or context label when `content` is empty and
62
+ never replaces editable text or delivered parts. Older items without a summary
63
+ derive one from the attached comment metadata or the first non-instruction
64
+ context text. This optional field needs no queue-file migration.
65
+
59
66
  ## Persistence
60
67
 
61
68
  `<data-dir>/message-queue.json` (`AIWORKER_DATA_DIR` or
@@ -140,7 +147,10 @@ allowlists.
140
147
 
141
148
  Every mutation broadcasts `aiworker:message-queue.updated` with
142
149
  `{ revision, session }` to all connected clients (SSE and WS), so several
143
- devices on one server see one queue. The session in that payload always names
150
+ devices on one server see one queue. SSE uses the shared control stream at
151
+ `/api/openchamber/events`; `/api/global/event` carries no AiWorker events.
152
+ The UI subscribes independently of its OpenCode transport and re-reads the
153
+ snapshot whenever either stream reconnects. The session in that payload always names
144
154
  its `directory`, including the broadcast that removes the last item: the UI
145
155
  keys its projection by directory, and a broadcast without one left the
146
156
  delivered message on screen (a session's directory is remembered until the
@@ -133,6 +133,8 @@ export const parseQueuedItemInput = (value) => {
133
133
  if (agentMention) item.agentMention = agentMention;
134
134
  item.attachments = attachments;
135
135
  item.context = context;
136
+ const contextPreview = asNonEmptyString(raw.contextPreview).slice(0, 103);
137
+ if (contextPreview) item.contextPreview = contextPreview;
136
138
  item.sendConfig = sendConfig;
137
139
  return item;
138
140
  };
@@ -157,6 +159,17 @@ const toPublicItem = (item) => {
157
159
  const publicItem = { id: item.id, createdAt: item.createdAt, content: item.content, text: item.text };
158
160
  if (item.agentMention) publicItem.agentMention = item.agentMention;
159
161
  publicItem.attachments = item.attachments.map(toPublicAttachment);
162
+ // Older persisted items have no UI summary. Prefer their attached comment
163
+ // before falling back to the model-facing context text.
164
+ const contextPreview = item.contextPreview || item.context
165
+ .filter((part) => part.kind !== 'instruction')
166
+ .map((part) => asNonEmptyString(asRecord(part.metadata?.aiworkerContext)?.text) || part.text.trim())
167
+ .find(Boolean);
168
+ if (contextPreview) {
169
+ const firstLine = contextPreview.split('\n', 1)[0];
170
+ publicItem.contextPreview = firstLine.slice(0, 100)
171
+ + (contextPreview.length > firstLine.length || firstLine.length > 100 ? '...' : '');
172
+ }
160
173
  publicItem.sendConfig = { ...item.sendConfig };
161
174
  return publicItem;
162
175
  };
@@ -251,7 +251,7 @@ describe('message queue runtime', () => {
251
251
  const first = createRuntime({ dataDir });
252
252
  first.runtime.start();
253
253
  first.openCode.state.statuses = { [SESSION]: { type: 'busy' } };
254
- await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted' }));
254
+ await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted', contextPreview: 'Saved context preview' }));
255
255
  await first.runtime.flush();
256
256
  first.runtime.stop();
257
257
 
@@ -259,6 +259,7 @@ describe('message queue runtime', () => {
259
259
  second.runtime.start();
260
260
  await second.runtime.load();
261
261
  expect(second.runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['persisted']);
262
+ expect(second.runtime.sessionSnapshot(SESSION).items[0].contextPreview).toBe('Saved context preview');
262
263
  second.connect();
263
264
  await settle();
264
265
  expect(second.openCode.state.sent).toHaveLength(1);
@@ -315,6 +316,35 @@ describe('message queue runtime', () => {
315
316
  expect(runtime.snapshot().sessions).toEqual([]);
316
317
  });
317
318
 
319
+ it('retains a bounded context preview in snapshots and broadcasts without exposing the full payload', async () => {
320
+ const { runtime, broadcasts } = createRuntime();
321
+ runtime.start();
322
+ const context = [{ kind: 'context', text: 'Full quoted content', metadata: { aiworkerContext: { kind: 'chat-quote', quote: 'Original answer', text: 'Explain this' } } }];
323
+ const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item({ content: '', text: '', context, contextPreview: 'Explain this' }));
324
+ const projected = runtime.sessionSnapshot(SESSION).items[0];
325
+ expect(projected.contextPreview).toBe('Explain this');
326
+ expect(projected.content).toBe('');
327
+ expect(projected.text).toBe('');
328
+ expect(projected).not.toHaveProperty('context');
329
+ expect(broadcasts.at(-1).properties.session.items[0].contextPreview).toBe('Explain this');
330
+ const taken = await runtime.take(SESSION, itemId);
331
+ expect(taken.item.context).toEqual(context);
332
+ expect(taken.item.content).toBe('');
333
+
334
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: '', text: '', context, contextPreview: 'a'.repeat(5000) }));
335
+ expect(runtime.sessionSnapshot(SESSION).items[0].contextPreview).toBe('a'.repeat(100) + '...');
336
+ });
337
+
338
+ it('derives a preview for older queued annotations without a saved summary', async () => {
339
+ const { runtime } = createRuntime();
340
+ runtime.start();
341
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: '', text: '', context: [
342
+ { kind: 'instruction', text: 'Use the skill' },
343
+ { kind: 'context', text: 'Model-facing wrapper', metadata: { aiworkerContext: { kind: 'browser-annotation', text: 'Fix the button\nMore detail' } } },
344
+ ] }));
345
+ expect(runtime.sessionSnapshot(SESSION).items[0].contextPreview).toBe('Fix the button...');
346
+ });
347
+
318
348
  it('names the directory in the broadcast that empties a queue', async () => {
319
349
  // The UI keys its projection by directory; without it the client cannot
320
350
  // tell which queue just delivered its last message and keeps showing it.
@@ -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
+ });
@@ -77,7 +77,7 @@ This module provides notification message preparation utilities for the web serv
77
77
  ### Emitter runtime API (emitter-runtime.js)
78
78
  - `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
79
79
  - Returned API:
80
- - `writeSseEvent(res, payload)`
80
+ - `writeSseEvent(res, payload, serializedPayload?)`, where broadcast callers may share one JSON encoding across recipients
81
81
  - `emitDesktopNotification(payload)`
82
82
  - `broadcastUiNotification(payload)`
83
83
 
@@ -22,8 +22,8 @@ export const createNotificationEmitterRuntime = (dependencies) => {
22
22
  onDesktopNotification = typeof cb === 'function' ? cb : null;
23
23
  };
24
24
 
25
- const writeSseEvent = (res, payload) => {
26
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
25
+ const writeSseEvent = (res, payload, serializedPayload = JSON.stringify(payload)) => {
26
+ res.write(`data: ${serializedPayload}\n\n`);
27
27
  };
28
28
 
29
29
  const emitDesktopNotification = (payload) => {
@@ -91,9 +91,10 @@ export const createNotificationEmitterRuntime = (dependencies) => {
91
91
  return;
92
92
  }
93
93
 
94
+ const serializedPayload = JSON.stringify(syntheticPayload);
94
95
  for (const res of clients) {
95
96
  try {
96
- writeSseEvent(res, syntheticPayload);
97
+ writeSseEvent(res, syntheticPayload, serializedPayload);
97
98
  } catch {
98
99
  // ignore
99
100
  }
@@ -34,8 +34,7 @@ This module provides OpenCode server integration utilities for the web server ru
34
34
  - `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
35
35
  - `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
36
36
  - `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
37
- - `packages/web/server/lib/mcp-reconnect/runtime.js`: always-on managed OpenCode plugin that reconnects MCP servers OpenCode marked `failed`, with per-server backoff.
38
- - `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer, MCP reconnect) appends itself through.
37
+ - `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer) appends itself through.
39
38
  - `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
40
39
  - `packages/web/server/lib/opencode/openchamber-routes.js`: AiWorker update and models metadata route registration.
41
40
  - `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
@@ -132,8 +131,12 @@ The runtime maintains active-session count incrementally from idempotent activit
132
131
  - `killProcessOnPort(port)`
133
132
 
134
133
  Managed OpenCode launch also merges the environment returned by the agent-tool
135
- runtime, the opt-in system prompt optimizer, and the always-on MCP reconnect
136
- plugin, each appending its `file://` entry to the previous one's config. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
134
+ runtime and the opt-in system prompt optimizer, each appending its `file://`
135
+ entry to the previous one's config. AiWorker adds no automatic MCP reconnect
136
+ loop; recovery after a failed connection is manual for both local and remote
137
+ servers. Previously generated reconnect plugin files are inert because managed
138
+ launch no longer registers them. User-configured plugins remain user-owned.
139
+ PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
137
140
  be replaced by injected values. External OpenCode processes receive no
138
141
  AiWorker tool injection. Managed launch env strips AppImage `ARGV0` before
139
142
  spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
@@ -156,6 +159,7 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
156
159
  ## Public exports (env-runtime.js)
157
160
  - `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state.
158
161
  - OpenCode CLI resolution order is persisted settings, environment overrides, bundled Desktop CLI when available, PATH, known install locations, then platform shell discovery.
162
+ - Automatic bundled resolution under `AIWORKER_RUNTIME=desktop` stays in runtime state and is returned to the managed launch function, including on OpenCode restart. It does not populate `process.env.OPENCODE_BINARY`: AppImage updater relaunch inherits that environment and would mistake the previous bundle path for an explicit override. Explicit settings/env selections and non-desktop or non-bundled resolution retain their existing environment behavior. This prevents future inheritance; it does not reinterpret overrides already inherited from older releases.
159
163
  - Returned API:
160
164
  - `applyLoginShellEnvSnapshot()`
161
165
  - `getLoginShellEnvSnapshot()`
@@ -212,7 +216,12 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
212
216
  - `persistSettings(changes)`
213
217
  - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
214
218
  - 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`.
219
+ - 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.
220
+ - 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`.
221
+
222
+ ## Public exports (settings-files.js)
223
+ - `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)`.
224
+ - 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
225
 
217
226
  ## Public exports (settings-helpers.js)
218
227
  - `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -363,9 +372,20 @@ before starting managed OpenCode. The managed custom tool therefore receives
363
372
  an authoritative loopback callback URL even when AiWorker binds port `0`.
364
373
 
365
374
  ## Public exports (openchamber-routes.js)
375
+ Browser completion checks use `appType=web&updateStatus=true` to stay on the
376
+ Desktop Host's native updater. A rejected native restart is retained in the
377
+ server process and returned to these polls as `DESKTOP_UPDATE_RESTART_FAILED`;
378
+ ordinary availability checks remain usable so a browser reload can offer a
379
+ retry. Starting another installation clears the previous restart error.
380
+ The shared UI's `lib/web-update.ts` parses install/check responses and waits
381
+ for the installed native target version, rather than treating absence of a
382
+ newer release as installation success. Poll requests have individual deadlines
383
+ within a ten-minute overall deadline.
384
+
366
385
  - `registerAiWorkerRoutes(app, dependencies)`: registers AiWorker endpoints:
367
386
  - `GET /api/openchamber/update-check`
368
387
  - `POST /api/openchamber/update-install`
388
+ - 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
389
  - Foreground servers running under a systemd user unit queue installation in
370
390
  a separate transient unit and restart the configured service afterwards.
371
391
  `AIWORKER_SYSTEMD_UNIT` overrides the default `aiworker.service`.
@@ -406,6 +426,16 @@ an authoritative loopback callback URL even when AiWorker binds port `0`.
406
426
  - Generic `/api/*` forwarding with hop-by-hop header filtering
407
427
  - Windows `/session` merge fallback path behavior
408
428
  - OpenCode readiness gate for proxied `/api` requests
429
+ - Worktree checkout gate before directory-scoped upstream reads and writes
430
+
431
+ Git bootstrap must reach `git-ready` before OpenCode can cache a new worktree's
432
+ project identity or config. Setup scripts may still be running; the optional UI
433
+ setup wait remains separate. Failed or timed-out checkout returns 503 without
434
+ forwarding. The shared draft creator keeps the project directory selected until
435
+ creation returns, because preview paths have no bootstrap state.
436
+
437
+ This server gate covers web, Electron, hosted mobile, and Capacitor connections.
438
+ The VS Code extension owns its separate Git and proxy implementation.
409
439
 
410
440
  ## Public exports (watcher.js)
411
441
  - `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime backed by the shared upstream SSE reader.
@@ -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 {
@@ -1110,7 +1110,11 @@ export const createOpenCodeEnvRuntime = (deps) => {
1110
1110
  return resolved;
1111
1111
  }
1112
1112
 
1113
- process.env.OPENCODE_BINARY = resolved;
1113
+ // AppImage updates inherit process.env. Keep automatic desktop bundle
1114
+ // selection local so the next app does not treat the old mount as an override.
1115
+ if (process.env.AIWORKER_RUNTIME !== 'desktop' || state.resolvedOpencodeBinarySource !== 'bundled') {
1116
+ process.env.OPENCODE_BINARY = resolved;
1117
+ }
1114
1118
  prependToPath(path.dirname(resolved));
1115
1119
  ensureOpencodeShimRuntime(resolved);
1116
1120
  state.resolvedOpencodeBinary = resolved;
@@ -14,6 +14,7 @@ const originalResourcesPath = process.resourcesPath;
14
14
  const originalWslBinary = process.env.WSL_BINARY;
15
15
  const originalAiWorkerWslBinary = process.env.AIWORKER_WSL_BINARY;
16
16
  const originalPlatform = process.platform;
17
+ const originalRuntime = process.env.AIWORKER_RUNTIME;
17
18
  const tempDirs = [];
18
19
  const itIf = (condition) => condition ? it : it.skip;
19
20
 
@@ -30,6 +31,8 @@ const setPlatform = (platform) => {
30
31
  };
31
32
 
32
33
  afterEach(() => {
34
+ if (originalRuntime === undefined) delete process.env.AIWORKER_RUNTIME;
35
+ else process.env.AIWORKER_RUNTIME = originalRuntime;
33
36
  Object.defineProperty(process, 'platform', {
34
37
  value: originalPlatform,
35
38
  });
@@ -117,7 +120,76 @@ const createRuntime = (settings, options = {}) => {
117
120
  return { runtime, state };
118
121
  };
119
122
 
123
+ const createBundledCli = () => {
124
+ const resourcesPath = createTempDir('openchamber-resources-');
125
+ const directory = path.join(resourcesPath, 'opencode-cli');
126
+ fs.mkdirSync(directory);
127
+ const binary = path.join(directory, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
128
+ fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
129
+ Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath });
130
+ return binary;
131
+ };
132
+
120
133
  describe('OpenCode env runtime', () => {
134
+ it.each(['linux', 'darwin', 'win32'])('keeps automatic desktop bundled resolution local across relaunch on %s', (platform) => {
135
+ setPlatform(platform);
136
+ process.env.AIWORKER_RUNTIME = 'desktop';
137
+ delete process.env.OPENCODE_BINARY;
138
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
139
+
140
+ const oldBinary = createBundledCli();
141
+ const oldRuntime = createRuntime({});
142
+ expect(oldRuntime.runtime.ensureOpencodeCliEnv()).toBe(oldBinary);
143
+ expect(oldRuntime.runtime.ensureOpencodeCliEnv()).toBe(oldBinary);
144
+ expect(oldRuntime.state.resolvedOpencodeBinarySource).toBe('bundled');
145
+ expect(process.env.OPENCODE_BINARY).toBeUndefined();
146
+ expect(process.env.PATH.split(path.delimiter)[0]).toBe(path.dirname(oldBinary));
147
+
148
+ // The old binary remains executable and its directory is still in PATH.
149
+ const newBinary = createBundledCli();
150
+ const newRuntime = createRuntime({});
151
+ expect(newRuntime.runtime.ensureOpencodeCliEnv()).toBe(newBinary);
152
+ expect(newRuntime.state.resolvedOpencodeBinarySource).toBe('bundled');
153
+ expect(newRuntime.runtime.isBundledOpenCodeCliPath(newBinary)).toBe(true);
154
+ expect(process.env.OPENCODE_BINARY).toBeUndefined();
155
+ });
156
+
157
+ it.each(['web', 'ssh-remote'])('preserves automatic binary export outside desktop in %s', (runtimeName) => {
158
+ process.env.AIWORKER_RUNTIME = runtimeName;
159
+ delete process.env.OPENCODE_BINARY;
160
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
161
+ const binary = createBundledCli();
162
+ const { runtime } = createRuntime({});
163
+ expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
164
+ expect(process.env.OPENCODE_BINARY).toBe(binary);
165
+ });
166
+
167
+ it.each(['env', 'settings'])('preserves explicit desktop %s selection even when it points at a bundled CLI', async (source) => {
168
+ process.env.AIWORKER_RUNTIME = 'desktop';
169
+ delete process.env.OPENCODE_BINARY;
170
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
171
+ const binary = createBundledCli();
172
+ if (source === 'env') process.env.OPENCODE_BINARY = binary;
173
+ const { runtime, state } = createRuntime(source === 'settings' ? { opencodeBinary: binary } : {});
174
+ await runtime.applyOpencodeBinaryFromSettings({ strict: true });
175
+ expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
176
+ expect(state.resolvedOpencodeBinarySource).toBe(source);
177
+ expect(process.env.OPENCODE_BINARY).toBe(binary);
178
+ });
179
+
180
+ it('preserves automatic PATH binary export for desktop without a bundled CLI', () => {
181
+ process.env.AIWORKER_RUNTIME = 'desktop';
182
+ delete process.env.OPENCODE_BINARY;
183
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
184
+ const binary = createBundledCli();
185
+ Object.defineProperty(process, 'resourcesPath', { configurable: true, value: undefined });
186
+ process.env.PATH = path.dirname(binary);
187
+ const { runtime, state } = createRuntime({});
188
+ expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
189
+ expect(state.resolvedOpencodeBinarySource).toBe('path');
190
+ expect(process.env.OPENCODE_BINARY).toBe(binary);
191
+ });
192
+
121
193
  it('searches an explicit PATH without mutating the process environment', () => {
122
194
  const defaultDir = createTempDir('openchamber-default-path-');
123
195
  const explicitDir = createTempDir('openchamber-explicit-path-');
@@ -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';
@@ -306,7 +307,21 @@ export const createFeatureRoutesRuntime = (dependencies) => {
306
307
  registerSessionGoalRoutes(app);
307
308
  registerGitHubRoutes(app);
308
309
  registerLinearRoutes(app);
309
- registerGitRoutes(app);
310
+ registerGitRoutes(app, {
311
+ emitWorktreeChanged: ({ directories, at }) => {
312
+ const clients = getAiWorkerEventClients();
313
+ for (const client of clients) {
314
+ try {
315
+ writeSseEvent(client, {
316
+ type: 'aiworker:worktree-changed',
317
+ properties: { directories, at },
318
+ });
319
+ } catch {
320
+ clients.delete(client);
321
+ }
322
+ }
323
+ },
324
+ });
310
325
  registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
311
326
  registerMagicPromptRoutes(app, {
312
327
  fsPromises,
@@ -314,6 +329,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
314
329
  aiworkerDataDir,
315
330
  });
316
331
  registerProjectContextRoutes(app, { projectContextRuntime });
332
+ registerProjectSetupRoutes(app, { projectConfigRuntime });
317
333
  registerAgentMemoryRoutes(app, { agentMemoryRuntime, isAgentMemoryEnabled });
318
334
  registerSessionKnowledgeRoutes(app, { sessionKnowledgeRuntime });
319
335
 
@@ -377,8 +377,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
377
377
  return parts.length > 0 ? parts.join('\n\n') : 'No stdout/stderr captured';
378
378
  };
379
379
 
380
- const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
381
- let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
380
+ const createManagedOpenCodeServerProcess = async ({ resolvedBinary, hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
381
+ let binary = (resolvedBinary || process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
382
382
  const sourceBinary = binary;
383
383
  let args = ['serve', '--hostname', hostname, '--port', String(port)];
384
384
  let launchWrapperType = null;
@@ -694,7 +694,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
694
694
  );
695
695
 
696
696
  await applyOpencodeBinaryFromSettings({ strict: true });
697
- ensureOpencodeCliEnv();
697
+ const resolvedBinary = ensureOpencodeCliEnv();
698
698
  recordStartupPerformance('opencode.binary.ready', {
699
699
  attempt,
700
700
  durationMs: performance.now() - phaseStartedAt,
@@ -721,6 +721,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
721
721
 
722
722
  try {
723
723
  const serverInstance = await createManagedOpenCodeServerProcess({
724
+ resolvedBinary,
724
725
  hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
725
726
  port: spawnPort,
726
727
  timeout: 30000,
@@ -126,6 +126,35 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
126
126
  };
127
127
 
128
128
  describe('OpenCode lifecycle', () => {
129
+ it('uses the resolved binary directly on startup and managed restart without an env override', async () => {
130
+ delete process.env.OPENCODE_BINARY;
131
+ const binaries = ['/bundle one/opencode-cli/opencode', '/bundle two/opencode-cli/opencode'];
132
+ const ensureOpencodeCliEnv = vi.fn()
133
+ .mockReturnValueOnce(binaries[0])
134
+ .mockReturnValueOnce(binaries[1]);
135
+ spawnMock.mockImplementation(() => {
136
+ const child = createMockChild();
137
+ queueMicrotask(() => child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'));
138
+ return child;
139
+ });
140
+ globalThis.fetch = vi.fn(async () => ({ ok: false }));
141
+ const runtime = createRuntime({ ensureOpencodeCliEnv });
142
+ const firstServer = await runtime.startOpenCode();
143
+ runtime.testState.openCodeProcess = firstServer;
144
+ try {
145
+ await runtime.restartOpenCode();
146
+ expect(firstServer.signalCode).toBe('SIGTERM');
147
+ expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual(binaries);
148
+ expect(runtime.testState.lastOpenCodeLaunchDiagnostics.sourceBinary).toBe(binaries[1]);
149
+ expect(process.env.OPENCODE_BINARY).toBeUndefined();
150
+ for (const [, , options] of spawnMock.mock.calls) {
151
+ expect(options.env).not.toHaveProperty('OPENCODE_BINARY');
152
+ }
153
+ } finally {
154
+ await runtime.testState.openCodeProcess.close();
155
+ }
156
+ });
157
+
129
158
  it('records an authoritative ready terminal event for external startup', async () => {
130
159
  globalThis.fetch = vi.fn(async () => ({
131
160
  ok: true,
@@ -8,7 +8,7 @@ const isJsonObject = (value) => value !== null && value !== undefined && Object.
8
8
  *
9
9
  * Existing entries are preserved; an earlier entry for the same URL is dropped
10
10
  * so a restart never registers the same plugin twice. Every managed plugin
11
- * (agent tools, system prompt optimizer, MCP reconnect) goes through this one
11
+ * (agent tools, system prompt optimizer) goes through this one
12
12
  * merge so they compose in any order.
13
13
  *
14
14
  * @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset