@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
@@ -1,4 +1,4 @@
1
- import { sendMessageStreamWsEvent, sendMessageStreamWsFrame } from './protocol.js';
1
+ import { sendMessageStreamWsEvent, sendMessageStreamWsFrame, sendSerializedMessageStreamWsFrame, serializeMessageStreamWsEvent } from './protocol.js';
2
2
 
3
3
  function shouldTriggerUpstreamHealthCheck(upstream) {
4
4
  if (!upstream) {
@@ -31,12 +31,9 @@ export function createGlobalMessageStreamWsBridge({
31
31
  wsClients.delete(socket);
32
32
  };
33
33
 
34
- const replayEvents = (socket, requestedLastEventId) => {
35
- for (const entry of globalHub.replayAfter(requestedLastEventId)) {
36
- const sent = sendMessageStreamWsEvent(socket, entry.payload, {
37
- directory: entry.directory,
38
- eventId: entry.eventId,
39
- });
34
+ const replayEvents = (socket, entries) => {
35
+ for (const entry of entries) {
36
+ const sent = sendSerializedMessageStreamWsFrame(socket, entry.serializedFrame);
40
37
  if (!sent) {
41
38
  removeClient(socket);
42
39
  return;
@@ -49,10 +46,10 @@ export function createGlobalMessageStreamWsBridge({
49
46
  return;
50
47
  }
51
48
 
52
- const sent = sendMessageStreamWsFrame(socket, {
53
- type: 'ready',
54
- scope: 'global',
55
- });
49
+ const replay = globalHub.replayAfter(requestedLastEventId);
50
+ const ready = { type: 'ready', scope: 'global' };
51
+ if (replay === null) ready.replayReset = true;
52
+ const sent = sendMessageStreamWsFrame(socket, ready);
56
53
  if (!sent) {
57
54
  removeClient(socket);
58
55
  return;
@@ -60,7 +57,7 @@ export function createGlobalMessageStreamWsBridge({
60
57
 
61
58
  readyClients.add(socket);
62
59
  wsClients.add(socket);
63
- replayEvents(socket, requestedLastEventId);
60
+ if (replay !== null) replayEvents(socket, replay);
64
61
  };
65
62
 
66
63
  const stopHubIfUnused = () => {
@@ -88,26 +85,26 @@ export function createGlobalMessageStreamWsBridge({
88
85
  }
89
86
  };
90
87
 
91
- const unsubscribeEvent = globalHub.subscribeEvent(({ payload, directory, eventId }) => {
88
+ const unsubscribeEvent = globalHub.subscribeEvent((event) => {
89
+ const { payload } = event;
92
90
  for (const socket of Array.from(clients)) {
93
91
  if (!readyClients.has(socket)) {
94
92
  continue;
95
93
  }
96
- const sent = sendMessageStreamWsEvent(socket, payload, {
97
- directory,
98
- eventId,
99
- });
94
+ const sent = sendSerializedMessageStreamWsFrame(socket, event.serialize());
100
95
  if (!sent) {
101
96
  removeClient(socket);
102
97
  }
103
98
  }
104
99
 
105
100
  processForwardedEventPayload(payload, (syntheticPayload) => {
101
+ if (readyClients.size === 0) return;
102
+ const serializedFrame = serializeMessageStreamWsEvent(syntheticPayload, { directory: 'global' });
106
103
  for (const socket of Array.from(clients)) {
107
104
  if (!readyClients.has(socket)) {
108
105
  continue;
109
106
  }
110
- const sent = sendMessageStreamWsEvent(socket, syntheticPayload, { directory: 'global' });
107
+ const sent = sendSerializedMessageStreamWsFrame(socket, serializedFrame);
111
108
  if (!sent) {
112
109
  removeClient(socket);
113
110
  }
@@ -71,6 +71,14 @@ export function parseSseEventEnvelope(block) {
71
71
  }
72
72
 
73
73
  export function sendMessageStreamWsFrame(socket, payload) {
74
+ try {
75
+ return sendSerializedMessageStreamWsFrame(socket, JSON.stringify(payload));
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ export function sendSerializedMessageStreamWsFrame(socket, serializedFrame) {
74
82
  if (!socket || socket.readyState !== 1) {
75
83
  return false;
76
84
  }
@@ -86,7 +94,7 @@ export function sendMessageStreamWsFrame(socket, payload) {
86
94
  }
87
95
 
88
96
  try {
89
- socket.send(JSON.stringify(payload));
97
+ socket.send(serializedFrame);
90
98
  const bufferedAfter = typeof socket.bufferedAmount === 'number' ? socket.bufferedAmount : 0;
91
99
  if (bufferedAfter > MESSAGE_STREAM_WS_MAX_BUFFERED_BYTES) {
92
100
  try {
@@ -124,10 +132,16 @@ export function sendMessageStreamWsFrame(socket, payload) {
124
132
  }
125
133
 
126
134
  export function sendMessageStreamWsEvent(socket, payload, options = {}) {
127
- return sendMessageStreamWsFrame(socket, {
128
- type: 'event',
129
- payload,
130
- ...(typeof options.eventId === 'string' && options.eventId.length > 0 ? { eventId: options.eventId } : {}),
131
- ...(typeof options.directory === 'string' && options.directory.length > 0 ? { directory: options.directory } : {}),
132
- });
135
+ try {
136
+ return sendSerializedMessageStreamWsFrame(socket, serializeMessageStreamWsEvent(payload, options));
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ export function serializeMessageStreamWsEvent(payload, options = {}) {
143
+ const frame = { type: 'event', payload };
144
+ if (typeof options.eventId === 'string' && options.eventId.length > 0) frame.eventId = options.eventId;
145
+ if (typeof options.directory === 'string' && options.directory.length > 0) frame.directory = options.directory;
146
+ return JSON.stringify(frame);
133
147
  }
@@ -5,7 +5,8 @@ import {
5
5
  MESSAGE_STREAM_DIRECTORY_WS_PATH,
6
6
  MESSAGE_STREAM_GLOBAL_WS_PATH,
7
7
  MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
8
- sendMessageStreamWsEvent,
8
+ serializeMessageStreamWsEvent,
9
+ sendSerializedMessageStreamWsFrame,
9
10
  } from './protocol.js';
10
11
  import { createGlobalMessageStreamHub } from './global-hub.js';
11
12
  import { createGlobalMessageStreamWsBridge } from './global-ws-bridge.js';
@@ -28,20 +29,22 @@ export function createGlobalUiEventBroadcaster({
28
29
  }
29
30
 
30
31
  if (hasSseClients) {
32
+ const serializedPayload = JSON.stringify(payload);
31
33
  for (const res of sseClients) {
32
34
  try {
33
- writeSseEvent(res, payload);
35
+ writeSseEvent(res, payload, serializedPayload);
34
36
  } catch {
35
37
  }
36
38
  }
37
39
  }
38
40
 
39
41
  if (hasWsClients) {
42
+ const serializedFrame = serializeMessageStreamWsEvent(payload, {
43
+ directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
44
+ eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
45
+ });
40
46
  for (const socket of Array.from(wsClients)) {
41
- const sent = sendMessageStreamWsEvent(socket, payload, {
42
- directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
43
- eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
44
- });
47
+ const sent = sendSerializedMessageStreamWsFrame(socket, serializedFrame);
45
48
  if (!sent) {
46
49
  wsClients.delete(socket);
47
50
  }
@@ -2,6 +2,66 @@ import { EventEmitter } from 'node:events';
2
2
  import { describe, expect, it } from 'vitest';
3
3
 
4
4
  import { createGlobalUiEventBroadcaster, createMessageStreamWsRuntime } from './runtime.js';
5
+ import { createGlobalMessageStreamHub } from './global-hub.js';
6
+ import { createNotificationEmitterRuntime } from '../notifications/emitter-runtime.js';
7
+
8
+ it('serializes a UI broadcast once per wire format and isolates failed clients', () => {
9
+ let serializations = 0;
10
+ const payload = { toJSON() { serializations++; return { type: 'aiworker:test', properties: { value: 'same' } }; } };
11
+ const writes = [];
12
+ const sseClients = new Set(Array.from({ length: 4 }, () => ({ write: text => writes.push(text) })));
13
+ const sockets = Array.from({ length: 4 }, () => ({ readyState: 1, bufferedAmount: 0, send: text => writes.push(text) }));
14
+ const failed = { readyState: 1, bufferedAmount: 0, send() { throw new Error('closed'); } };
15
+ const wsClients = new Set([...sockets, failed]);
16
+ const { writeSseEvent } = createNotificationEmitterRuntime({});
17
+ const broadcast = createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent });
18
+ broadcast(payload);
19
+ expect(serializations).toBe(2);
20
+ expect(writes).toHaveLength(8);
21
+ expect(wsClients.has(failed)).toBe(false);
22
+ expect(wsClients.size).toBe(4);
23
+ expect(writes[0]).toBe(writes[3]);
24
+ expect(writes[4]).toBe(writes[7]);
25
+ sseClients.clear(); wsClients.clear();
26
+ broadcast(payload);
27
+ expect(serializations).toBe(2);
28
+ });
29
+
30
+ it('delivers oversized events live, but reports a replay gap instead of replaying across it', async () => {
31
+ const hub = createGlobalMessageStreamHub({
32
+ buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
33
+ getOpenCodeAuthHeaders: () => ({}),
34
+ replayByteLimit: 512,
35
+ fetchImpl: async (_url, options) => createSseResponse({
36
+ signal: options.signal, holdOpen: true,
37
+ blocks: [
38
+ 'id: first\ndata: {"type":"first","properties":{}}\n\n',
39
+ `id: large\ndata: ${JSON.stringify({ type: 'large', properties: { text: '界'.repeat(1024) } })}\n\n`,
40
+ 'id: last\ndata: {"type":"last","properties":{}}\n\n',
41
+ ],
42
+ }),
43
+ });
44
+ const runtime = createMessageStreamWsRuntime({
45
+ server: new EventEmitter(), globalEventHub: hub, wsClients: new Set(),
46
+ uiAuthController: null, isRequestOriginAllowed: async () => true,
47
+ rejectWebSocketUpgrade() {}, buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
48
+ getOpenCodeAuthHeaders: () => ({}), processForwardedEventPayload() {},
49
+ });
50
+ const first = new FakeSocket();
51
+ const reconnect = new FakeSocket();
52
+ try {
53
+ runtime.wsServer.emit('connection', first, { url: '/api/global/event/ws' });
54
+ await expect.poll(() => first.sent.filter(frame => frame.type === 'event').length).toBe(3);
55
+ expect(first.sent.find(frame => frame.eventId === 'large').payload.properties.text).toHaveLength(1024);
56
+ expect(hub.replayAfter('first')).toBeNull();
57
+ expect(hub.replayAfter('last')).toEqual([]);
58
+ runtime.wsServer.emit('connection', reconnect, { url: '/api/global/event/ws?lastEventId=first' });
59
+ await expect.poll(() => reconnect.sent.length).toBeGreaterThan(0);
60
+ expect(reconnect.sent).toEqual([{ type: 'ready', scope: 'global', replayReset: true }]);
61
+ } finally {
62
+ first.close(); reconnect.close(); hub.stop(); await runtime.close();
63
+ }
64
+ });
5
65
 
6
66
  class FakeSocket extends EventEmitter {
7
67
  constructor() {
@@ -446,18 +506,18 @@ describe('message stream websocket runtime', () => {
446
506
  const socket = new FakeSocket();
447
507
  runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
448
508
 
449
- await new Promise((resolve) => setTimeout(resolve, 35));
450
-
451
- const readyFrames = socket.sent.filter((frame) => frame.type === 'ready');
452
- const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
453
-
454
- expect(readyFrames.length).toBeGreaterThanOrEqual(2);
455
- expect(eventFrames.length).toBeGreaterThanOrEqual(2);
456
- expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
457
- expect(triggerHealthCheckCalls).toBe(0);
458
-
459
- socket.close();
460
- await runtime.close();
509
+ try {
510
+ // Wait for the reconnect event itself; a 35ms sleep raced the event
511
+ // loop when the workspace build and test workers ran together.
512
+ await expect.poll(() => socket.sent.filter((frame) => frame.type === 'ready').length).toBeGreaterThanOrEqual(2);
513
+ const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
514
+ expect(eventFrames.length).toBeGreaterThanOrEqual(2);
515
+ expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
516
+ expect(triggerHealthCheckCalls).toBe(0);
517
+ } finally {
518
+ socket.close();
519
+ await runtime.close();
520
+ }
461
521
  });
462
522
 
463
523
  it('keeps synthetic event processing on forwarded upstream events', async () => {
@@ -14,6 +14,8 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
14
14
  - `POST /api/fs/mkdir`
15
15
  - `GET /api/fs/read`
16
16
  - `GET /api/fs/raw`
17
+ - `GET /api/fs/stat`
18
+ - `GET /api/fs/directory-stat`
17
19
  - `GET /api/fs/serve/:path(*)`
18
20
  - `POST /api/fs/write`
19
21
  - `POST /api/fs/upload`
@@ -43,6 +45,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
43
45
  - Workspace checks accept, besides the active workspace and its worktrees, the **managed roots**: the AiWorker config root and the managed chats root (`managedChatsRoot` dependency; `AIWORKER_CHATS_DIR` upstream, default `<config root>/chats`). Chat worktrees may legitimately live outside every project workspace.
44
46
  - `GET /api/fs/home` answers `{ home, chatsRoot }`. `chatsRoot` is the server-resolved managed chats root; clients must use it instead of joining `home` + the well-known segment (a relocated root does not contain that segment).
45
47
  - Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them.
48
+ - `GET /api/fs/directory-stat?path=...` uses one `stat` without listing contents or resolving project topology. It follows the same authenticated directory-discovery path policy as `/api/fs/list`, including targets outside the active workspace. A directory returns `{ isDirectory: true }`; `ENOENT` returns `not-found`, and a file or `ENOTDIR` returns `not-directory`. Permission and other failures remain distinct from a missing path. VS Code explicitly returns 501, so the shared client treats its probe as unknown.
46
49
  - Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks.
47
50
  - If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
48
51
  - `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
@@ -864,6 +864,37 @@ export const registerFsRoutes = (app, dependencies) => {
864
864
  }
865
865
  });
866
866
 
867
+ app.get('/api/fs/directory-stat', async (req, res) => {
868
+ res.setHeader('Cache-Control', 'no-store');
869
+ const paths = new URL(req.url, 'http://openchamber.local').searchParams.getAll('path');
870
+ const directoryPath = paths.length === 1 ? paths[0].trim() : '';
871
+ if (!directoryPath) {
872
+ return res.status(400).json({ error: 'Path is required' });
873
+ }
874
+
875
+ try {
876
+ // Directory discovery uses the same path policy as /api/fs/list, including
877
+ // paths outside the current workspace. stat follows symlinks without readdir.
878
+ const resolvedPath = path.resolve(normalizeDirectoryPath(directoryPath));
879
+ const stats = await fsPromises.stat(resolvedPath);
880
+ if (!stats.isDirectory()) {
881
+ return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
882
+ }
883
+ return res.json({ isDirectory: true });
884
+ } catch (error) {
885
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
886
+ return res.status(error.code === 'ENOENT' ? 404 : 400).json({
887
+ error: error.code === 'ENOENT' ? 'Directory not found' : 'Specified path is not a directory',
888
+ reason: error.code === 'ENOENT' ? 'not-found' : 'not-directory',
889
+ });
890
+ }
891
+ if (isOsPermissionError(error)) {
892
+ return sendOsPermissionDenied(res, 'Access to directory denied');
893
+ }
894
+ return res.status(500).json({ error: 'Failed to stat directory' });
895
+ }
896
+ });
897
+
867
898
  app.get('/api/fs/stat', async (req, res) => {
868
899
  const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
869
900
  const optional = req.query.optional === 'true';
@@ -1,5 +1,9 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import path from 'path';
3
+ import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { pathToFileURL } from 'node:url';
3
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
8
 
5
9
  import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
@@ -1385,7 +1389,11 @@ describe('fs stat directory scope (issue 3019)', () => {
1385
1389
  path: path.posix,
1386
1390
  fsPromises: {
1387
1391
  realpath: async (targetPath) => targetPath,
1388
- stat: async () => ({ isFile: () => true, size: 12 }),
1392
+ stat: async (targetPath) => (
1393
+ targetPath === '/repo-b'
1394
+ ? { isDirectory: () => true, mtimeMs: 123 }
1395
+ : { isFile: () => true, size: 12, mtimeMs: 456 }
1396
+ ),
1389
1397
  },
1390
1398
  spawn: vi.fn(),
1391
1399
  crypto: { randomUUID: () => 'job-0' },
@@ -1428,6 +1436,107 @@ describe('fs stat directory scope (issue 3019)', () => {
1428
1436
  expect(res.statusCode).toBe(200);
1429
1437
  expect(res.body.isFile).toBe(true);
1430
1438
  });
1439
+
1440
+ });
1441
+
1442
+ describe('fs stat directory error handling', () => {
1443
+ it('loads in Node without workspace node_modules, as packaged desktop does', async () => {
1444
+ const directory = await mkdtemp(path.join(tmpdir(), 'openchamber-fs-import-'));
1445
+ try {
1446
+ await mkdir(path.join(directory, 'fs'));
1447
+ await copyFile(new URL('./routes.js', import.meta.url), path.join(directory, 'fs/routes.mjs'));
1448
+ await copyFile(new URL('../path-realpath-cache.js', import.meta.url), path.join(directory, 'path-realpath-cache.js'));
1449
+ expect(() => execFileSync('node', [
1450
+ '--input-type=module',
1451
+ '--eval',
1452
+ 'await import(process.argv[1])',
1453
+ pathToFileURL(path.join(directory, 'fs/routes.mjs')).href,
1454
+ ], { cwd: directory, stdio: 'pipe' })).not.toThrow();
1455
+ } finally {
1456
+ await rm(directory, { recursive: true, force: true });
1457
+ }
1458
+ });
1459
+
1460
+ it('returns directory-missing reasons and permission errors for directory stat', async () => {
1461
+ const { app, getRoute } = createRouteRegistry();
1462
+ const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' });
1463
+ const enotdir = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' });
1464
+ const eacces = Object.assign(new Error('denied'), { code: 'EACCES' });
1465
+ const stat = vi.fn(async (targetPath) => {
1466
+ if (targetPath === '/repo-b') throw enoent;
1467
+ if (targetPath === '/repo-b/file.txt/child') throw enotdir;
1468
+ if (targetPath === '/repo-b/protected') throw eacces;
1469
+ if (targetPath === '/repo-b/file.txt') return { isDirectory: () => false };
1470
+ if (targetPath === '/repo-b/failure') throw new Error('unavailable');
1471
+ return { isDirectory: () => true, mtimeMs: 1 };
1472
+ });
1473
+ const readdir = vi.fn(async () => []);
1474
+ const callStat = async (handler, { headers = {}, query }) => {
1475
+ const res = createMockResponse();
1476
+ const req = {
1477
+ url: `/api/fs/directory-stat?${new URLSearchParams(query)}`,
1478
+ query,
1479
+ get: (name) => headers[name.toLowerCase()] ?? undefined,
1480
+ };
1481
+ await handler(req, res);
1482
+ return res;
1483
+ };
1484
+ registerFsRoutes(app, {
1485
+ os: { homedir: () => '/home/user' },
1486
+ path: path.posix,
1487
+ fsPromises: {
1488
+ realpath: async (targetPath) => targetPath,
1489
+ stat,
1490
+ readdir,
1491
+ },
1492
+ spawn: vi.fn(),
1493
+ crypto: { randomUUID: () => 'job-0' },
1494
+ normalizeDirectoryPath: (p) => p,
1495
+ resolveProjectDirectory: async () => ({ directory: '/repo' }),
1496
+ buildAugmentedPath: () => '/usr/bin',
1497
+ resolveGitBinaryForSpawn: () => 'git',
1498
+ aiworkerUserConfigRoot: '/home/user/.config',
1499
+ });
1500
+ const handler = getRoute('GET', '/api/fs/directory-stat');
1501
+
1502
+ const available = await callStat(handler, { query: { path: '/other-project' } });
1503
+ expect(available.statusCode).toBe(200);
1504
+ expect(available.body).toEqual({ isDirectory: true });
1505
+ expect(available.getHeader('Cache-Control')).toBe('no-store');
1506
+ expect(stat).toHaveBeenCalledTimes(1);
1507
+
1508
+ const invalid = await callStat(handler, { query: { path: ' ' } });
1509
+ expect(invalid.statusCode).toBe(400);
1510
+ expect(stat).toHaveBeenCalledTimes(1);
1511
+
1512
+ for (const query of ['path=/repo&path=/other', 'path[]=/repo', '']) {
1513
+ const malformed = createMockResponse();
1514
+ await handler({ url: `/api/fs/directory-stat?${query}` }, malformed);
1515
+ expect(malformed.statusCode).toBe(400);
1516
+ }
1517
+ expect(stat).toHaveBeenCalledTimes(1);
1518
+
1519
+ const missing = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b', directory: 'true' } });
1520
+ expect(missing.statusCode).toBe(404);
1521
+ expect(missing.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
1522
+
1523
+ const notDir = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/file.txt/child', directory: 'true' } });
1524
+ expect(notDir.statusCode).toBe(400);
1525
+ expect(notDir.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
1526
+
1527
+ const denied = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/protected', directory: 'true' } });
1528
+ expect(denied.statusCode).toBe(403);
1529
+ expect(denied.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
1530
+
1531
+ const file = await callStat(handler, { query: { path: '/repo-b/file.txt' } });
1532
+ expect(file.statusCode).toBe(400);
1533
+ expect(file.body.reason).toBe('not-directory');
1534
+
1535
+ const failure = await callStat(handler, { query: { path: '/repo-b/failure' } });
1536
+ expect(failure.statusCode).toBe(500);
1537
+ expect(failure.body).toEqual({ error: 'Failed to stat directory' });
1538
+ expect(readdir).not.toHaveBeenCalled();
1539
+ });
1431
1540
  });
1432
1541
 
1433
1542
  describe('fs managed chats root', () => {
@@ -25,9 +25,11 @@ The following functions are exported and used by the web server:
25
25
 
26
26
  ### Status and Diff Operations
27
27
  - `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
28
- - `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets.
29
- - `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. Uses three-dot `base...head` semantics, so work merged into `head` from `base` is excluded and only the branch's own changes are returned. Prefers `origin/<base>` when that remote-tracking ref exists, so a stale local base branch does not resurface already-merged commits. Exposed as `GET /api/git/range-diff` (`path` optional; omit it for the whole range).
30
- - `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
28
+ - `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree with full Git blob identities. Untracked symbolic links are represented as link entries without following their targets.
29
+ - `getRangeDiff(directory, { base, head, path, contextLines, includeWorkingTree })`: Compare the merge base of the exact selected refs with `head`. With `includeWorkingTree: true`, compare with the checked-out branch's current files instead, including committed, staged, unstaged, and untracked work in one net diff. This mode rejects a head that is not the checked-out branch. Exposed as `GET /api/git/range-diff`; omit `path` for the whole comparison.
30
+ - `getRangeFiles(directory, { base, head, includeWorkingTree })`: List changed paths using the same comparison as `getRangeDiff`. A successful empty list means the final files match the merge base, even if staging and working-tree changes cancel each other out.
31
+ - Both range operations honor refs literally. A local `main` is never replaced with `origin/main`, and an unavailable ref fails rather than choosing a different remote. The UI picker sends qualified refs to distinguish local and remote branches with matching display names.
32
+ - Working-tree comparisons use the real index read-only. When untracked paths exist, a temporary copy of the index receives intent-to-add entries so Git computes additions, deletions, recreations, and renames together. Current contents come from the working tree, symlinks remain links, ignored files stay excluded, and temporary files are removed on success or failure.
31
33
  - `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text).
32
34
  - `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory.
33
35
  - `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch.
@@ -35,9 +37,10 @@ The following functions are exported and used by the web server:
35
37
  - `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
36
38
  - `stageFile(directory, filePath)`: Add one file path to the index.
37
39
  - `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content.
38
- - `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file.
40
+ - `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). Inside the index mutation queue, the server verifies that the complete patch exactly matches one current three-context-line hunk for that file and scope, then runs `--check` before applying. Applicability alone cannot prove an unstaged change: old staged or committed hunks can reverse cleanly too. Stale, historical and multi-file patches fail with a refresh error. Temporary patch files are removed on success and failure; hunk content retains CRLF bytes.
39
41
 
40
42
  ### Branch Operations
43
+ - `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
41
44
  - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
42
45
  - `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
43
46
  - `createBranch(directory, branchName, options)`: Create and checkout a new branch.
@@ -47,12 +50,21 @@ The following functions are exported and used by the web server:
47
50
  - `getRemotes(directory)`: Get list of configured remotes.
48
51
 
49
52
  ### Worktree Operations
50
- - `getWorktrees(directory)`: List all git worktrees for a repository.
53
+ - `getWorktrees(directory)`: List all git worktrees for a repository. A directory outside any repository (or one that does not exist) is an authoritative empty list; any other git failure throws so callers keep their last known topology instead of clearing it. `GET /api/git/worktrees` answers such a failure with 500.
54
+ - `observeWorktreeTopology(directory)`: Compare the repository's registered linked-worktree set with the last one seen for it and notify `subscribeWorktreeTopologyChanges` listeners when it changed. The set is fingerprinted from the `worktrees` directory under the common Git directory (mtime plus entry names), so the check is a stat and a readdir; the common directory is resolved with `git rev-parse --git-common-dir` once per requested directory and cached. The first observation only records a baseline. Never throws.
55
+ - `subscribeWorktreeTopologyChanges(listener)`: Listener receives `{ directories, at }`, where `directories` are every directory of that repository the server has observed, so clients can map them onto registered projects. Returns an unsubscribe function.
51
56
  - `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
52
57
  - `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). When the current tracked branch has no unpublished commits, the UI supplies its remote-tracking ref and this operation fetches that branch once before creating the worktree. A failed fetch falls back to the local branch and reports `sourceFetchFailed`; other remote start refs still require an existing local ref when their fetch fails. After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
53
58
  - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
54
59
  - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
55
60
 
61
+ ### Worktree topology change tracking
62
+ There is no filesystem watcher and no polling. The server notices worktree changes in two ways, and both scale with what users are doing rather than with the number of registered projects:
63
+ - Its own `createWorktree` and `removeWorktree` publish a change right after `git worktree add` / `git worktree remove` succeed (creation notifies before background population and setup scripts run).
64
+ - `GET /api/git/status` for a repository and `GET /api/git/worktrees` with a non-empty listing call `observeWorktreeTopology` beside the response. Clients request status while they work in a repository, and a completed agent tool call already triggers a status refresh, so a worktree added by an agent or from a terminal is noticed on the next such request; nothing runs while the app is idle.
65
+
66
+ `feature-routes-runtime.js` forwards each change to connected control-event clients as `aiworker:worktree-changed` with `{ directories, at }`. A repository nobody sends status or listing requests for is not observed until the next ordinary listing. `git worktree move` rewrites files inside an entry without touching the `worktrees` directory and is not detected. Tracking state is bounded: 500 directory-to-repository entries, 200 repositories, 100 directories per repository, least recently used dropped first.
67
+
56
68
  ### Worktree creation from a GitHub pull request
57
69
  The UI provisions `pr-<owner>` via `ensureRemoteName`/`ensureRemoteUrl`
58
70
  (HTTPS clone URL preferred over SSH) and checks out
@@ -71,7 +83,8 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
71
83
 
72
84
  ### Log Operations
73
85
  - `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
74
- - `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit.
86
+ - `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit relative to its first parent, or the empty tree for a root commit. NUL-delimited paths preserve whitespace; renamed files return their destination in `path` and source in `previousPath`.
87
+ - `getCommitDiff(directory, { hash, path, previousPath, contextLines })`: Get the same commit's patch, with optional file filtering and context depth. `previousPath` keeps a rename's old and new paths in the per-file patch. Reads committed objects only, never the working tree. Exposed as `GET /api/git/commit-diff`; an unavailable hash fails rather than returning an empty diff.
75
88
  - `getCommitFileDiff(directory, hash, filePath, isBinary)`: Get before/after content for a specific file in a commit. Returns `{ original, modified, isBinary }`. Runs `git show <hash>^:<path>` and `git show <hash>:<path>` in parallel; returns empty strings on failure (added/deleted/root-commit edge cases).
76
89
 
77
90
  ### Merge and Rebase Operations
@@ -130,8 +143,28 @@ The following functions are internal helpers used by exported functions:
130
143
 
131
144
  ### Runtime availability of range diffs
132
145
  - `GET /api/git/range-diff` is served by the AiWorker web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
146
+ - Commit comparison uses the same server boundary through optional `GitAPI.getGitCommitDiff`. Desktop Changes, mobile Changes, and the existing walkthrough surface share branch/commit comparison semantics. Mobile Changes uses the same selectors and `useGitComparison` file-list owner, with a read-only list-to-detail flow. VS Code keeps its existing modes because its Git bridge does not provide these comparison operations. The HTTP operations are available to web, Electron, hosted mobile, and Capacitor clients.
133
147
 
134
148
  ### Staged and unstaged change handling
149
+ - Desktop Changes floats a compact action capsule after each hunk's last changed row,
150
+ including single-hunk files. Whole-file controls remain in the Git panel.
151
+ `getPatchHunkAnchors` uses the canonical patch's final changed row and side, so a hunk
152
+ ending in deletions is anchored after those deletions rather than above them.
153
+ Zero-height Pierre annotation slots anchor the capsule over following context
154
+ without a separate band. At EOF the capsule lifts inside the code column;
155
+ a one-line code column has a minimum hit-target height. React controls mount only
156
+ for currently rendered slots and only after their rendered diff and anchor
157
+ identities match the current props. Comment annotations remain independent.
158
+ - The canonical three-line-context action patch stays separate from the full-file
159
+ display patch. Their bytes must be identical, or their file headers and full
160
+ blob identities must match, including when reusing a cached action patch.
161
+ Mismatch leaves actions unavailable until Retry obtains a matching pair.
162
+ Successful hunk mutations invalidate
163
+ every mounted view of that path through `sessionEvents.requestGitRefresh`.
164
+ Actions remain unavailable until the refresh succeeds. Last turn, Branch and
165
+ Commit snapshots never expose hunk mutations. Mobile uses its separate Changes
166
+ surface and VS Code does not mount these controls.
167
+ - Untracked patches from `getDiff` and `getUntrackedDiffs` use `git diff --no-index` with separate stdout, stderr, and process exit status. Exit codes 0 and 1 return stdout only, so line-ending warnings never become patch text or request failures. Other exits and process failures reject the single-file request; the batch keeps an empty entry for the failed path and preserves the other results.
135
168
  - `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
136
169
  - A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs.
137
170
  - The shared Git panel exposes explicit staging actions. Unstaged rows use `stageFile`, staged rows use `unstageFile`, and commits operate on the current staged index.