@aiworker/web 1.23.0 → 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 (152) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  2. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  3. package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  4. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  5. package/dist/assets/{GitView-BXsS3jG6.js → GitView-B_x52MHY.js} +4 -4
  6. package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-cmAzUTiw.js} +1 -1
  7. package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-Dehh5afm.js} +1 -1
  8. package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-DgNOqqhE.js} +1 -1
  9. package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DusmMZ_p.js} +2 -2
  10. package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-B8fm1H4m.js} +1 -1
  11. package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-BVr7gJU0.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-DzLt-nel.js} +1 -1
  13. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  14. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  15. package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  16. package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-cTl_eLIH.js} +6 -6
  17. package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-C_3iYNJQ.js} +2 -2
  18. package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  19. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  20. package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  21. package/dist/assets/de-DJsQr-p7.js +10 -0
  22. package/dist/assets/es-BuYMlA9g.js +16 -0
  23. package/dist/assets/fr-ePDay33C.js +14 -0
  24. package/dist/assets/index-BD8ILbxF.js +40 -0
  25. package/dist/assets/{index-jXSK5RrB.js → index-n6O3hklM.js} +1 -1
  26. package/dist/assets/index-zhgg-SDz.css +1 -0
  27. package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-GMdPz-71.js} +1 -1
  28. package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-BVuH1zH9.js} +1 -1
  29. package/dist/assets/ja-BzR9xOY_.js +16 -0
  30. package/dist/assets/{ko-idhXGF8j.js → ko-BEjHAm14.js} +3 -3
  31. package/dist/assets/main-BydQ1tr_.js +930 -0
  32. package/dist/assets/{main-CzdPuL0s.js → main-D3ZDQglc.js} +2 -2
  33. package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-Cb2MdZnV.js} +3 -3
  34. package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-d01k801U.js} +2 -2
  35. package/dist/assets/{mobile-mvxTo055.js → mobile-D7VV-Ufz.js} +2 -2
  36. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  37. package/dist/assets/{pl-CGZWUqZJ.js → pl-rQmpxNIF.js} +3 -3
  38. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  39. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  40. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  41. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  42. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  43. package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-BzXUjlby.js} +1 -1
  44. package/dist/assets/surface-COKb77Ua.js +1 -0
  45. package/dist/assets/tr-CoAMXPtI.js +7 -0
  46. package/dist/assets/uk-YlAX0rRU.js +16 -0
  47. package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-Cwyhjfl5.js} +153 -155
  48. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  49. package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-DDkpBEU8.js} +2 -2
  50. package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-CwjoWuC4.js} +1 -1
  51. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
  52. package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  53. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  54. package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
  55. package/dist/assets/{web-components-z7hfnS8n.js → web-components-LV3vjTxy.js} +1 -1
  56. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  57. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  58. package/dist/index.html +4 -4
  59. package/dist/mini-chat.html +4 -4
  60. package/dist/mobile.html +4 -4
  61. package/package.json +1 -1
  62. package/server/index.js +3 -13
  63. package/server/lib/agent-memory/runtime.js +7 -2
  64. package/server/lib/agent-memory/runtime.test.js +22 -0
  65. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  66. package/server/lib/event-stream/global-hub.js +29 -4
  67. package/server/lib/event-stream/global-hub.test.js +22 -0
  68. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  69. package/server/lib/event-stream/protocol.js +21 -7
  70. package/server/lib/event-stream/runtime.js +9 -6
  71. package/server/lib/event-stream/runtime.test.js +60 -0
  72. package/server/lib/git/DOCUMENTATION.md +10 -1
  73. package/server/lib/git/routes.js +21 -8
  74. package/server/lib/git/routes.test.js +78 -0
  75. package/server/lib/git/service.js +159 -4
  76. package/server/lib/git/service.test.js +74 -0
  77. package/server/lib/github/DOCUMENTATION.md +5 -0
  78. package/server/lib/github/routes.js +2 -2
  79. package/server/lib/message-queue/DOCUMENTATION.md +7 -0
  80. package/server/lib/message-queue/runtime.js +13 -0
  81. package/server/lib/message-queue/runtime.test.js +31 -1
  82. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  83. package/server/lib/notifications/emitter-runtime.js +4 -3
  84. package/server/lib/opencode/DOCUMENTATION.md +18 -4
  85. package/server/lib/opencode/env-runtime.js +5 -1
  86. package/server/lib/opencode/env-runtime.test.js +72 -0
  87. package/server/lib/opencode/feature-routes-runtime.js +15 -1
  88. package/server/lib/opencode/lifecycle.js +4 -3
  89. package/server/lib/opencode/lifecycle.test.js +29 -0
  90. package/server/lib/opencode/managed-plugin-config.js +1 -1
  91. package/server/lib/opencode/proxy.js +33 -0
  92. package/server/lib/opencode/settings-helpers.js +3 -0
  93. package/server/lib/opencode/settings-helpers.test.js +11 -1
  94. package/server/lib/opencode/settings-registry.json +3 -0
  95. package/server/lib/opencode/settings-runtime.js +36 -9
  96. package/server/lib/opencode/settings-runtime.test.js +158 -2
  97. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  98. package/server/lib/project-context/runtime.js +8 -2
  99. package/server/lib/project-context/runtime.test.js +54 -0
  100. package/server/lib/projects/DOCUMENTATION.md +2 -1
  101. package/server/lib/projects/project-config.js +38 -9
  102. package/server/lib/projects/project-config.test.js +97 -0
  103. package/server/lib/projects/project-id.js +26 -0
  104. package/server/lib/relay/DOCUMENTATION.md +20 -0
  105. package/server/lib/relay/host-client.js +7 -0
  106. package/server/lib/relay/host-client.test.js +14 -0
  107. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  108. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  109. package/server/lib/scheduled-tasks/runtime.js +12 -2
  110. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  111. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  112. package/server/lib/session-assist/context.js +127 -0
  113. package/server/lib/session-assist/context.test.js +132 -0
  114. package/server/lib/session-assist/prompt.js +54 -0
  115. package/server/lib/session-assist/runtime.js +111 -257
  116. package/server/lib/session-assist/runtime.test.js +203 -0
  117. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  118. package/server/lib/walkthrough/pr-source.test.js +21 -0
  119. package/server/lib/walkthrough/pull-request.js +7 -4
  120. package/server/lib/walkthrough/pull-request.test.js +16 -0
  121. package/server/lib/walkthrough/routes.js +19 -0
  122. package/server/lib/walkthrough/routes.test.js +26 -0
  123. package/server/lib/walkthrough/sources.js +13 -2
  124. package/server/opencode-worktree-readiness.test.js +130 -0
  125. package/dist/assets/DiffView-DXjVsEOw.js +0 -4
  126. package/dist/assets/FilesView-CJdG0-wk.js +0 -17
  127. package/dist/assets/PlanView-CJTmOF6I.js +0 -5
  128. package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
  129. package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
  130. package/dist/assets/de-DCrsQ5zS.js +0 -10
  131. package/dist/assets/es-Dwv4YfH6.js +0 -16
  132. package/dist/assets/fr-BCWg4Cnx.js +0 -14
  133. package/dist/assets/index-B68h5S5B.css +0 -1
  134. package/dist/assets/index-DVwMarHI.js +0 -39
  135. package/dist/assets/ja-Bruw3RYC.js +0 -16
  136. package/dist/assets/main-BkyaQhJX.js +0 -930
  137. package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
  138. package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
  139. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
  140. package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
  141. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
  142. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
  143. package/dist/assets/surface-oMvhsog3.js +0 -1
  144. package/dist/assets/tr-BK_KiO2V.js +0 -7
  145. package/dist/assets/uk-CG74xV8N.js +0 -16
  146. package/dist/assets/useGitComparison-DH220WmO.js +0 -1
  147. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  148. package/dist/assets/zh-CN-T4AXofZI.js +0 -16
  149. package/dist/assets/zh-TW-Dte40sxp.js +0 -16
  150. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  151. package/server/lib/mcp-reconnect/runtime.js +0 -122
  152. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -5,6 +5,9 @@ const gitLibraries = {
5
5
  unstageFiles: vi.fn(),
6
6
  isGitRepository: vi.fn(),
7
7
  getStatus: vi.fn(),
8
+ getWorktrees: vi.fn(),
9
+ observeWorktreeTopology: vi.fn(),
10
+ subscribeWorktreeTopologyChanges: vi.fn(),
8
11
  };
9
12
 
10
13
  vi.mock('./index.js', () => ({
@@ -12,6 +15,9 @@ vi.mock('./index.js', () => ({
12
15
  unstageFiles: gitLibraries.unstageFiles,
13
16
  isGitRepository: gitLibraries.isGitRepository,
14
17
  getStatus: gitLibraries.getStatus,
18
+ getWorktrees: gitLibraries.getWorktrees,
19
+ observeWorktreeTopology: gitLibraries.observeWorktreeTopology,
20
+ subscribeWorktreeTopologyChanges: gitLibraries.subscribeWorktreeTopologyChanges,
15
21
  }));
16
22
 
17
23
  const { registerGitRoutes } = await import('./routes.js');
@@ -141,6 +147,78 @@ describe('git routes index mutations', () => {
141
147
  });
142
148
  });
143
149
 
150
+ describe('git worktree topology routes', () => {
151
+ beforeEach(() => {
152
+ gitLibraries.isGitRepository.mockReset();
153
+ gitLibraries.getStatus.mockReset();
154
+ gitLibraries.getWorktrees.mockReset();
155
+ gitLibraries.observeWorktreeTopology.mockReset();
156
+ gitLibraries.subscribeWorktreeTopologyChanges.mockReset();
157
+ gitLibraries.observeWorktreeTopology.mockResolvedValue(undefined);
158
+ });
159
+
160
+ it('observes the repository topology while serving status, never for non-repositories', async () => {
161
+ gitLibraries.isGitRepository.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
162
+ gitLibraries.getStatus.mockResolvedValue({ current: 'main' });
163
+ const { app, getRoute } = createRouteRegistry();
164
+ registerGitRoutes(app);
165
+ const route = getRoute('GET', '/api/git/status');
166
+
167
+ const repoResponse = createMockResponse();
168
+ await route({ query: { directory: '/repo' } }, repoResponse);
169
+ expect(repoResponse.body).toEqual({ current: 'main' });
170
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledWith('/repo');
171
+
172
+ await route({ query: { directory: '/plain-folder' } }, createMockResponse());
173
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it('observes topology after a repository listing and reports listing failures', async () => {
177
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
178
+ gitLibraries.getWorktrees
179
+ .mockResolvedValueOnce([{ path: '/repo', branch: 'main' }])
180
+ .mockResolvedValueOnce([])
181
+ .mockRejectedValueOnce(new Error('git failed'));
182
+ const { app, getRoute } = createRouteRegistry();
183
+ registerGitRoutes(app);
184
+ const route = getRoute('GET', '/api/git/worktrees');
185
+
186
+ const listed = createMockResponse();
187
+ await route({ query: { directory: '/repo' } }, listed);
188
+ expect(listed.body).toEqual([{ path: '/repo', branch: 'main' }]);
189
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledWith('/repo');
190
+
191
+ await route({ query: { directory: '/plain-folder' } }, createMockResponse());
192
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledTimes(1);
193
+
194
+ const failed = createMockResponse();
195
+ await route({ query: { directory: '/repo' } }, failed);
196
+ expect(failed.statusCode).toBe(500);
197
+ expect(failed.body).toEqual({ error: 'git failed' });
198
+ errorSpy.mockRestore();
199
+ });
200
+
201
+ it('forwards topology changes to the control event emitter once', async () => {
202
+ let listener = null;
203
+ gitLibraries.subscribeWorktreeTopologyChanges.mockImplementation((next) => {
204
+ listener = next;
205
+ return () => undefined;
206
+ });
207
+ gitLibraries.getWorktrees.mockResolvedValue([]);
208
+ const emitWorktreeChanged = vi.fn();
209
+ const { app, getRoute } = createRouteRegistry();
210
+ registerGitRoutes(app, { emitWorktreeChanged });
211
+ const route = getRoute('GET', '/api/git/worktrees');
212
+
213
+ await route({ query: { directory: '/repo' } }, createMockResponse());
214
+ await route({ query: { directory: '/repo' } }, createMockResponse());
215
+ expect(gitLibraries.subscribeWorktreeTopologyChanges).toHaveBeenCalledTimes(1);
216
+
217
+ listener({ directories: ['/repo'], at: 123 });
218
+ expect(emitWorktreeChanged).toHaveBeenCalledWith({ directories: ['/repo'], at: 123 });
219
+ });
220
+ });
221
+
144
222
  describe('git routes status discovery', () => {
145
223
  beforeEach(() => {
146
224
  gitLibraries.isGitRepository.mockReset();
@@ -4064,14 +4064,167 @@ export async function getWorktrees(directory) {
4064
4064
  // OpenCode's working directory or an unconfigured project path), git
4065
4065
  // exits with "fatal: not a git repository ...". Treat that as an
4066
4066
  // authoritative empty result so the route handler can still respond
4067
- // 200 [] and the desktop main.log stays free of noise.
4068
- if (!isNotGitRepositoryError(error)) {
4069
- console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
4067
+ // 200 [] and the desktop main.log stays free of noise. Any other failure
4068
+ // is a failure: callers keep their last known topology instead of
4069
+ // treating "git could not answer" as "there are no worktrees".
4070
+ if (isNotGitRepositoryError(error)) return [];
4071
+ throw error;
4072
+ }
4073
+ }
4074
+
4075
+ // ---------------------------------------------------------------------------
4076
+ // Worktree topology change tracking
4077
+ //
4078
+ // Linked worktrees are registered under the repository's common Git directory
4079
+ // (`<common>/worktrees/<name>`). Instead of watching the filesystem, the server
4080
+ // fingerprints that directory while handling requests clients already make
4081
+ // (status, worktree listing) and after its own worktree create/remove, and
4082
+ // tells connected clients when the set of worktrees changed. Cost scales with
4083
+ // user activity, never with the number of registered projects.
4084
+ // ---------------------------------------------------------------------------
4085
+
4086
+ const MAX_TRACKED_WORKTREE_DIRECTORIES = 500;
4087
+ const MAX_TRACKED_WORKTREE_REPOSITORIES = 200;
4088
+ const MAX_DIRECTORIES_PER_WORKTREE_REPOSITORY = 100;
4089
+ const worktreeTopologyListeners = new Set();
4090
+ const worktreeRepositoryKeyByDirectory = new Map();
4091
+ const worktreeTopologyByRepository = new Map();
4092
+
4093
+ export function subscribeWorktreeTopologyChanges(listener) {
4094
+ worktreeTopologyListeners.add(listener);
4095
+ return () => {
4096
+ worktreeTopologyListeners.delete(listener);
4097
+ };
4098
+ }
4099
+
4100
+ const rememberWorktreeRepositoryKey = (directoryPath, key) => {
4101
+ worktreeRepositoryKeyByDirectory.delete(directoryPath);
4102
+ worktreeRepositoryKeyByDirectory.set(directoryPath, key);
4103
+ while (worktreeRepositoryKeyByDirectory.size > MAX_TRACKED_WORKTREE_DIRECTORIES) {
4104
+ const oldest = worktreeRepositoryKeyByDirectory.keys().next().value;
4105
+ if (oldest === undefined) break;
4106
+ worktreeRepositoryKeyByDirectory.delete(oldest);
4107
+ }
4108
+ };
4109
+
4110
+ /**
4111
+ * Canonical common Git directory for `directoryPath`, resolved with git once per
4112
+ * directory and cached. Returns null when git cannot answer (not a repository,
4113
+ * missing directory).
4114
+ */
4115
+ const resolveWorktreeRepositoryKey = async (directoryPath) => {
4116
+ const cached = worktreeRepositoryKeyByDirectory.get(directoryPath);
4117
+ if (cached) {
4118
+ rememberWorktreeRepositoryKey(directoryPath, cached);
4119
+ return cached;
4120
+ }
4121
+ const result = await runGitCommand(directoryPath, ['rev-parse', '--git-common-dir']);
4122
+ const rawCommonDir = String(result.stdout || '').trim();
4123
+ if (!result.success || !rawCommonDir) {
4124
+ return null;
4125
+ }
4126
+ const commonDir = path.resolve(directoryPath, rawCommonDir);
4127
+ let key = commonDir;
4128
+ try {
4129
+ key = fs.realpathSync(commonDir);
4130
+ } catch {
4131
+ // Keep the resolved path; a missing common dir cannot register worktrees.
4132
+ }
4133
+ rememberWorktreeRepositoryKey(directoryPath, key);
4134
+ return key;
4135
+ };
4136
+
4137
+ /**
4138
+ * Cheap identity of the registered linked-worktree set: the `worktrees`
4139
+ * directory's mtime plus its entry names. Adding, removing, or pruning a
4140
+ * worktree changes at least one of them; `git worktree move` rewrites files
4141
+ * inside an entry and is not detected.
4142
+ */
4143
+ const readWorktreeTopologyFingerprint = (repositoryKey) => {
4144
+ const worktreesDir = path.join(repositoryKey, 'worktrees');
4145
+ try {
4146
+ const stat = fs.statSync(worktreesDir);
4147
+ const names = fs.readdirSync(worktreesDir).sort();
4148
+ return `${stat.mtimeMs}:${names.join('\0')}`;
4149
+ } catch {
4150
+ return 'none';
4151
+ }
4152
+ };
4153
+
4154
+ const trackWorktreeTopologyDirectory = (repositoryKey, directoryPath) => {
4155
+ let entry = worktreeTopologyByRepository.get(repositoryKey);
4156
+ if (!entry) {
4157
+ entry = { directories: new Set(), fingerprint: null };
4158
+ }
4159
+ // Re-insert so the map stays ordered by last use; the least recently used
4160
+ // repository is dropped first once the bound is reached.
4161
+ worktreeTopologyByRepository.delete(repositoryKey);
4162
+ worktreeTopologyByRepository.set(repositoryKey, entry);
4163
+ while (worktreeTopologyByRepository.size > MAX_TRACKED_WORKTREE_REPOSITORIES) {
4164
+ const oldest = worktreeTopologyByRepository.keys().next().value;
4165
+ if (oldest === undefined) break;
4166
+ worktreeTopologyByRepository.delete(oldest);
4167
+ }
4168
+ if (entry.directories.size < MAX_DIRECTORIES_PER_WORKTREE_REPOSITORY) {
4169
+ entry.directories.add(directoryPath);
4170
+ }
4171
+ return entry;
4172
+ };
4173
+
4174
+ const notifyWorktreeTopologyChanged = (entry) => {
4175
+ const event = { directories: [...entry.directories], at: Date.now() };
4176
+ for (const listener of worktreeTopologyListeners) {
4177
+ try {
4178
+ listener(event);
4179
+ } catch (error) {
4180
+ console.warn('Worktree topology listener failed:', error?.message || error);
4070
4181
  }
4071
- return [];
4182
+ }
4183
+ };
4184
+
4185
+ /**
4186
+ * Compare the repository's worktree set with the last one seen for it and
4187
+ * notify listeners when it changed. The first observation only records a
4188
+ * baseline. Called from request handlers that already touch the repository;
4189
+ * never throws.
4190
+ */
4191
+ export async function observeWorktreeTopology(directory) {
4192
+ const directoryPath = normalizeDirectoryPath(directory);
4193
+ if (!directoryPath) return;
4194
+ try {
4195
+ const repositoryKey = await resolveWorktreeRepositoryKey(directoryPath);
4196
+ if (!repositoryKey) return;
4197
+ const entry = trackWorktreeTopologyDirectory(repositoryKey, directoryPath);
4198
+ const fingerprint = readWorktreeTopologyFingerprint(repositoryKey);
4199
+ if (entry.fingerprint === fingerprint) return;
4200
+ const hadBaseline = entry.fingerprint !== null;
4201
+ entry.fingerprint = fingerprint;
4202
+ if (hadBaseline) notifyWorktreeTopologyChanged(entry);
4203
+ } catch (error) {
4204
+ console.warn('Failed to observe worktree topology:', error?.message || error);
4072
4205
  }
4073
4206
  }
4074
4207
 
4208
+ /**
4209
+ * Record that this server changed the repository's worktree set itself and
4210
+ * notify listeners right away. `directory` is any directory inside the
4211
+ * repository; never throws so a notification problem cannot fail the
4212
+ * operation that triggered it.
4213
+ */
4214
+ const publishWorktreeTopologyChange = async (directory) => {
4215
+ const directoryPath = normalizeDirectoryPath(directory);
4216
+ if (!directoryPath) return;
4217
+ try {
4218
+ const repositoryKey = await resolveWorktreeRepositoryKey(directoryPath);
4219
+ if (!repositoryKey) return;
4220
+ const entry = trackWorktreeTopologyDirectory(repositoryKey, directoryPath);
4221
+ entry.fingerprint = readWorktreeTopologyFingerprint(repositoryKey);
4222
+ notifyWorktreeTopologyChanged(entry);
4223
+ } catch (error) {
4224
+ console.warn('Failed to publish worktree topology change:', error?.message || error);
4225
+ }
4226
+ };
4227
+
4075
4228
  export async function validateWorktreeCreate(directory, input = {}) {
4076
4229
  const mode = input?.mode === 'existing' ? 'existing' : 'new';
4077
4230
  const errors = [];
@@ -4329,6 +4482,7 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
4329
4482
  }
4330
4483
 
4331
4484
  await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
4485
+ await publishWorktreeTopologyChange(context.primaryWorktree);
4332
4486
 
4333
4487
  const upstreamRemote = shouldSetUpstream
4334
4488
  ? String(input?.upstreamRemote || inferredUpstream?.remote || '').trim()
@@ -4551,6 +4705,7 @@ export async function removeWorktree(directory, input = {}) {
4551
4705
  ['worktree', 'remove', '--force', matchedEntry.worktree],
4552
4706
  'Failed to remove git worktree'
4553
4707
  );
4708
+ await publishWorktreeTopologyChange(context.primaryWorktree);
4554
4709
 
4555
4710
  if (deleteLocalBranch) {
4556
4711
  const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim());
@@ -23,6 +23,7 @@ import {
23
23
  getStatus,
24
24
  getWorktrees,
25
25
  isGitRepository,
26
+ observeWorktreeTopology,
26
27
  populateWorktreeWithLockRecovery,
27
28
  removeWorktree,
28
29
  resolvePrimaryWorktreeRoot,
@@ -32,6 +33,7 @@ import {
32
33
  revertCommit,
33
34
  setLocalIdentity,
34
35
  stageFiles,
36
+ subscribeWorktreeTopologyChanges,
35
37
  unstageFiles,
36
38
  applyHunk,
37
39
  getDiff,
@@ -680,6 +682,78 @@ describe('getWorktrees', () => {
680
682
  expect(Array.isArray(result)).toBe(true);
681
683
  expect(warnSpy).not.toHaveBeenCalled();
682
684
  });
685
+ it('notifies subscribers only when another git process changes the worktree set', async () => {
686
+ if (!canRunGit()) return;
687
+
688
+ const repo = createTempDir();
689
+ runGit(repo, ['init', '-b', 'main']);
690
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
691
+ runGit(repo, ['config', 'user.name', 'Test User']);
692
+ runGit(repo, ['commit', '--allow-empty', '-m', 'init']);
693
+ const worktreePath = path.join(createTempDir(), 'feature');
694
+
695
+ const events = [];
696
+ const unsubscribe = subscribeWorktreeTopologyChanges((event) => events.push(event));
697
+ try {
698
+ await observeWorktreeTopology(repo);
699
+ await observeWorktreeTopology(repo);
700
+ expect(events).toHaveLength(0);
701
+
702
+ runGit(repo, ['worktree', 'add', worktreePath, '-b', 'feature']);
703
+ await observeWorktreeTopology(worktreePath);
704
+ expect(events).toHaveLength(1);
705
+ expect(events[0].directories).toEqual(expect.arrayContaining([repo, worktreePath]));
706
+
707
+ await observeWorktreeTopology(repo);
708
+ expect(events).toHaveLength(1);
709
+
710
+ runGit(repo, ['worktree', 'remove', worktreePath]);
711
+ await observeWorktreeTopology(repo);
712
+ expect(events).toHaveLength(2);
713
+ } finally {
714
+ unsubscribe();
715
+ }
716
+ });
717
+
718
+ it('publishes worktrees this server creates and removes', async () => {
719
+ if (!canRunGit()) return;
720
+
721
+ const previousXdgDataHome = process.env.XDG_DATA_HOME;
722
+ process.env.XDG_DATA_HOME = createTempDir();
723
+ const events = [];
724
+ const unsubscribe = subscribeWorktreeTopologyChanges((event) => events.push(event));
725
+ try {
726
+ const repo = createTempDir();
727
+ runGit(repo, ['init', '-b', 'main']);
728
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
729
+ runGit(repo, ['config', 'user.name', 'Test User']);
730
+ runGit(repo, ['commit', '--allow-empty', '-m', 'init']);
731
+ await observeWorktreeTopology(repo);
732
+
733
+ const created = await createWorktree(repo, {
734
+ mode: 'new',
735
+ worktreeName: 'published',
736
+ branchName: 'aiworker/published',
737
+ });
738
+ expect(events).toHaveLength(1);
739
+ expect(events[0].directories).toContain(repo);
740
+
741
+ // The publish refreshed the baseline, so the next observation is quiet.
742
+ await observeWorktreeTopology(repo);
743
+ expect(events).toHaveLength(1);
744
+
745
+ await removeWorktree(repo, { directory: created.path });
746
+ expect(events).toHaveLength(2);
747
+ } finally {
748
+ unsubscribe();
749
+ if (previousXdgDataHome === undefined) {
750
+ delete process.env.XDG_DATA_HOME;
751
+ } else {
752
+ process.env.XDG_DATA_HOME = previousXdgDataHome;
753
+ }
754
+ }
755
+ });
756
+
683
757
  it('flags a worktree whose directory was deleted outside git as prunable', async () => {
684
758
  const repo = createTempDir();
685
759
  runGit(repo, ['init', '-b', 'main']);
@@ -63,6 +63,11 @@
63
63
 
64
64
  ## Consumers of PR data
65
65
 
66
+ PR list/search failures propagate as errors instead of successful empty lists.
67
+ Comparison pickers use these responses to offer retry rather than claiming the
68
+ repository has no PRs. A failed repository in a multi-repository listing fails
69
+ that page, so callers cannot mistake a partial page for a complete one.
70
+
66
71
  - `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
67
72
  - `packages/ui/src/components/session/sidebar/SessionGroupSection.tsx` renders the compact badge, PR number, title, checks summary, and GitHub link.
68
73
  - `packages/ui/src/components/views/git/PullRequestSection.tsx` uses the same shared entry for the full PR workflow.
@@ -1513,7 +1513,7 @@ export function registerGitHubRoutes(app) {
1513
1513
  return res.json({ connected: true, repo, prs, page: effectivePage, hasMore });
1514
1514
  } catch (error) {
1515
1515
  console.error('Failed to search GitHub PRs:', error);
1516
- return res.json({ connected: true, repo, prs: [], page: effectivePage, hasMore: false });
1516
+ throw error;
1517
1517
  }
1518
1518
  }
1519
1519
 
@@ -1532,7 +1532,7 @@ export function registerGitHubRoutes(app) {
1532
1532
  return { prs, hasMore };
1533
1533
  } catch (error) {
1534
1534
  console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
1535
- return { prs: [], hasMore: false };
1535
+ throw error;
1536
1536
  }
1537
1537
  };
1538
1538
 
@@ -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
@@ -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.
@@ -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()`
@@ -422,6 +426,16 @@ within a ten-minute overall deadline.
422
426
  - Generic `/api/*` forwarding with hop-by-hop header filtering
423
427
  - Windows `/session` merge fallback path behavior
424
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.
425
439
 
426
440
  ## Public exports (watcher.js)
427
441
  - `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime backed by the shared upstream SSE reader.
@@ -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;