@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
package/dist/index.html CHANGED
@@ -509,16 +509,16 @@
509
509
  pointer-events: none;
510
510
  }
511
511
  </style>
512
- <script type="module" crossorigin src="/assets/main-CzdPuL0s.js"></script>
512
+ <script type="module" crossorigin src="/assets/main-D3ZDQglc.js"></script>
513
513
  <link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
514
+ <link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
514
515
  <link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
515
516
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
516
517
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
517
- <link rel="modulepreload" crossorigin href="/assets/vendor-zod-BPAANqvH.js">
518
518
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
519
- <link rel="modulepreload" crossorigin href="/assets/index-DVwMarHI.js">
519
+ <link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
520
520
  <link rel="modulepreload" crossorigin href="/assets/contextPanelEmbeddedChat-BdA6bdky.js">
521
- <link rel="stylesheet" crossorigin href="/assets/index-B68h5S5B.css">
521
+ <link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
522
522
  </head>
523
523
  <body class="h-full bg-background text-foreground">
524
524
  <div id="root" class="h-full">
@@ -74,15 +74,15 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-CCkqKOLp.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-d01k801U.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
79
+ <link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
79
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
80
81
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
81
82
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
82
- <link rel="modulepreload" crossorigin href="/assets/vendor-zod-BPAANqvH.js">
83
83
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
84
- <link rel="modulepreload" crossorigin href="/assets/index-DVwMarHI.js">
85
- <link rel="stylesheet" crossorigin href="/assets/index-B68h5S5B.css">
84
+ <link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
85
+ <link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
86
86
  </head>
87
87
  <body class="h-full bg-background text-foreground">
88
88
  <div id="root" class="h-full">
package/dist/mobile.html CHANGED
@@ -63,15 +63,15 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-mvxTo055.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-D7VV-Ufz.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
68
+ <link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
68
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
69
70
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
70
71
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
71
- <link rel="modulepreload" crossorigin href="/assets/vendor-zod-BPAANqvH.js">
72
72
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
73
- <link rel="modulepreload" crossorigin href="/assets/index-DVwMarHI.js">
74
- <link rel="stylesheet" crossorigin href="/assets/index-B68h5S5B.css">
73
+ <link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
74
+ <link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
75
75
  </head>
76
76
  <body class="h-full bg-background text-foreground">
77
77
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiworker/web",
3
- "version": "1.23.0",
3
+ "version": "1.23.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.js CHANGED
@@ -112,7 +112,6 @@ import { createDevServerScanner } from './lib/dev-servers/routes.js';
112
112
  import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js';
113
113
  import { registerBrowserControlRoutes } from './lib/browser-control/routes.js';
114
114
  import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
115
- import { createMcpReconnectRuntime } from './lib/mcp-reconnect/runtime.js';
116
115
  import { createAiWorkerSessionService } from './lib/openchamber-sessions/routes.js';
117
116
  import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
118
117
  import { createAiWorkerControlService } from './lib/openchamber-control/service.js';
@@ -302,7 +301,6 @@ const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromD
302
301
  let notificationTemplateRuntime = null;
303
302
  let agentToolRuntime = null;
304
303
  let systemPromptRuntime = null;
305
- let mcpReconnectRuntime = null;
306
304
 
307
305
  const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
308
306
  const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
@@ -1256,14 +1254,11 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
1256
1254
  ? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
1257
1255
  : {};
1258
1256
 
1259
- // Each managed plugin appends itself to the config the previous one produced.
1260
- let configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
1261
1257
  if (settings?.optimizeSystemPrompt === true) {
1262
- ({ OPENCODE_CONFIG_CONTENT: configContent } = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent));
1258
+ const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
1259
+ Object.assign(managedEnv, await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent));
1263
1260
  }
1264
- // Always on for managed OpenCode: it only retries servers OpenCode gave up on.
1265
- const mcpReconnectEnv = await mcpReconnectRuntime.prepareManagedOpenCodeEnv(configContent);
1266
- return { ...managedEnv, ...mcpReconnectEnv };
1261
+ return managedEnv;
1267
1262
  },
1268
1263
  });
1269
1264
 
@@ -1537,11 +1532,6 @@ async function main(options = {}) {
1537
1532
  path,
1538
1533
  dataDir: AIWORKER_DATA_DIR,
1539
1534
  });
1540
- mcpReconnectRuntime = createMcpReconnectRuntime({
1541
- fsPromises,
1542
- path,
1543
- dataDir: AIWORKER_DATA_DIR,
1544
- });
1545
1535
 
1546
1536
  // Pairing transports advertised to the create-device dialog. LAN reachability is
1547
1537
  // derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
@@ -4,7 +4,10 @@
4
4
  * What the agent has learned and chose to keep, in two scopes:
5
5
  *
6
6
  * - **project** — `<projectsDir>/<projectId>/memory.json`. How this codebase
7
- * works, what was decided, where things live.
7
+ * works, what was decided, where things live. `<projectId>` is the bounded
8
+ * stem `projectConfigFileStemOf` gives the id (the id itself up to 200
9
+ * characters, `path_sha256_<digest>` beyond), the same folder that holds
10
+ * `project-context`'s `context.json` and sits beside the config file.
8
11
  * - **global** — `<userConfigRoot>/memory.json`. Who the user is and how they
9
12
  * want to be worked with. It belongs to no project, so it cannot live under
10
13
  * one.
@@ -51,6 +54,7 @@ const PROJECT_MEMORY_MAX_ITEMS = 200;
51
54
  */
52
55
  const MEMORY_TYPES = new Set(['fact', 'preference', 'reference']);
53
56
 
57
+ import { projectConfigFileStemOf } from '../projects/project-id.js';
54
58
  import { findThreatPattern } from './threat-patterns.js';
55
59
 
56
60
  const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
@@ -211,7 +215,8 @@ export const createAgentMemoryRuntime = (deps) => {
211
215
  return {
212
216
  scope: 'project',
213
217
  key: `project:${projectId}`,
214
- filePath: path.join(projectsDirPath, projectId, 'memory.json'),
218
+ // Same bounded folder name as `project-context` and the config file.
219
+ filePath: path.join(projectsDirPath, projectConfigFileStemOf(projectId), 'memory.json'),
215
220
  };
216
221
  }
217
222
  throw new Error('scope is required');
@@ -3,6 +3,7 @@ import fsPromises from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
+ import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
6
7
  import { createAgentMemoryRuntime } from './runtime.js';
7
8
 
8
9
  const PROJECT_ID = 'path_dGVzdA';
@@ -48,6 +49,27 @@ describe('scope resolution', () => {
48
49
  await fsPromises.access(projectPath());
49
50
  });
50
51
 
52
+ test('a project id too long for a folder name is stored under the bounded stem', async () => {
53
+ // A checkout nested deep enough that its `path_<base64url>` id is longer
54
+ // than a folder name may be; the store lands under the bounded stem the
55
+ // config file and project context use, instead of failing with ENAMETOOLONG.
56
+ const longProjectId = createProjectIdFromPath(`/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`);
57
+ const stem = projectConfigFileStemOf(longProjectId);
58
+ expect(longProjectId.length).toBeGreaterThan(240);
59
+ expect(stem.startsWith('path_sha256_')).toBe(true);
60
+ const target = { scope: 'project', projectId: longProjectId };
61
+
62
+ expect((await runtime.read(target)).entries).toEqual([]);
63
+ await runtime.create(target, { title: 'Uses bun', body: 'Tests run with bun test.' });
64
+
65
+ expect((await runtime.read(target)).entries.map((e) => e.title)).toEqual(['Uses bun']);
66
+ expect((await runtime.readAll(longProjectId)).project.map((e) => e.title)).toEqual(['Uses bun']);
67
+ await fsPromises.access(path.join(rootDir, 'config', 'projects', stem, 'memory.json'));
68
+ expect(await fsPromises.readdir(path.join(rootDir, 'config', 'projects'))).toEqual([stem]);
69
+ // A short id keeps naming its folder itself.
70
+ expect(runtime.resolveTarget(PROJECT).filePath).toBe(projectPath());
71
+ });
72
+
51
73
  test('rejects an unknown scope', async () => {
52
74
  await expect(runtime.read({ scope: 'nope' })).rejects.toThrow('scope is required');
53
75
  });
@@ -24,6 +24,7 @@ This module contains the AiWorker message-stream WebSocket protocol and runtime
24
24
  - `parseSseEventEnvelope(block)`: parses an SSE block into `{ eventId, directory, payload }`.
25
25
  - `sendMessageStreamWsFrame(socket, payload)`: serializes and sends a JSON WS frame.
26
26
  - `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
27
+ - `serializeMessageStreamWsEvent(payload, options)` and `sendSerializedMessageStreamWsFrame(socket, frame)` separate encoding from per-socket delivery. Delivery retains ready-state and backpressure checks.
27
28
 
28
29
  ### Runtime helpers
29
30
  - `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
@@ -40,12 +41,14 @@ This module contains the AiWorker message-stream WebSocket protocol and runtime
40
41
  - AiWorker still fetches OpenCode upstream event streams over SSE.
41
42
  - The web server creates one shared global message-stream hub. OpenCode watcher side effects and global WS clients subscribe to that hub, so there is one upstream `/global/event` SSE reader for both server-side processing and browser fan-out.
42
43
  - The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`.
44
+ - Replay retains at most 2,048 events and 8 MiB of UTF-8 wire frames. It stores encoded frames rather than retaining parsed payloads as well. Encoding is shared with live WS fanout. An oversized event still reaches live clients in full, but clears the retained suffix so replay cannot cross its gap. A missing replay cursor returns `null`, distinct from a complete empty tail; the bridge sends `ready` with `replayReset: true` and no partial replay. The client retires its cursor and requests authoritative repair, including during the early-boot reconnect grace period. A stopped hub retains its bounded suffix for clients reconnecting after the last socket closed.
43
45
  - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
44
46
  - If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
45
47
  - When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close.
46
48
  - Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
47
49
  - Global synthetic events such as `aiworker:session-status`, `aiworker:session-activity`, `aiworker:notification`, and `aiworker:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
48
50
  - Global UI broadcasts are fan-out capable across both SSE and WS clients.
51
+ - Global UI broadcasts serialize once per wire format, irrespective of client count. The SSE writer accepts an optional pre-serialized payload; isolated callers keep the two-argument contract. Failed or backpressured clients cannot block delivery to healthy clients.
49
52
  - The reusable upstream reader centralizes SSE fetch/parsing/reconnect behavior for the WS runtime and OpenCode watcher. Additional event consumers should move to it only with parity tests for their lifecycle and error semantics.
50
53
  - Browser transport concerns live in the WS bridge modules; server-side global stream ownership lives in `global-hub.js`.
51
54
 
@@ -1,8 +1,10 @@
1
1
  import { createUpstreamSseReader } from './upstream-reader.js';
2
+ import { serializeMessageStreamWsEvent } from './protocol.js';
2
3
 
3
4
  // Raised from 512 → 2048 to improve recovery after brief disconnects during
4
5
  // long-running agent sessions where many events accumulate quickly.
5
6
  const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
7
+ const MESSAGE_STREAM_GLOBAL_REPLAY_BYTES = 8 * 1024 * 1024;
6
8
 
7
9
  export function createGlobalMessageStreamHub({
8
10
  buildOpenCodeUrl,
@@ -11,10 +13,16 @@ export function createGlobalMessageStreamHub({
11
13
  upstreamStallTimeoutMs,
12
14
  upstreamReconnectDelayMs,
13
15
  replayLimit = MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
16
+ replayByteLimit = MESSAGE_STREAM_GLOBAL_REPLAY_BYTES,
14
17
  }) {
18
+ if (!Number.isSafeInteger(replayLimit) || replayLimit < 0 || !Number.isSafeInteger(replayByteLimit) || replayByteLimit < 0) {
19
+ throw new RangeError('Replay limits must be nonnegative safe integers');
20
+ }
15
21
  const eventSubscribers = new Set();
16
22
  const statusSubscribers = new Set();
17
23
  const replay = [];
24
+ let replayBytes = 0;
25
+ let latestEventId;
18
26
 
19
27
  let controller = null;
20
28
  let reader = null;
@@ -45,11 +53,16 @@ export function createGlobalMessageStreamHub({
45
53
  const directory =
46
54
  typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global';
47
55
  const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined;
56
+ let serializedFrame;
48
57
  return {
49
58
  envelope,
50
59
  payload,
51
60
  directory,
52
61
  eventId,
62
+ serialize() {
63
+ serializedFrame ??= serializeMessageStreamWsEvent(payload, { directory, eventId });
64
+ return serializedFrame;
65
+ },
53
66
  };
54
67
  };
55
68
 
@@ -87,9 +100,20 @@ export function createGlobalMessageStreamHub({
87
100
  onEvent(event) {
88
101
  const normalized = normalizeEvent(event);
89
102
  if (normalized.eventId) {
90
- replay.push(normalized);
91
- if (replay.length > replayLimit) {
92
- replay.splice(0, replay.length - replayLimit);
103
+ latestEventId = normalized.eventId;
104
+ const serializedFrame = normalized.serialize();
105
+ const bytes = Buffer.byteLength(serializedFrame);
106
+ if (bytes > replayByteLimit) {
107
+ // An oversized live event creates a hole: retain only a contiguous
108
+ // suffix after it, never replay an older prefix across the gap.
109
+ replay.length = 0;
110
+ replayBytes = 0;
111
+ } else {
112
+ replay.push({ eventId: normalized.eventId, serializedFrame, bytes });
113
+ replayBytes += bytes;
114
+ while (replay.length > replayLimit || replayBytes > replayByteLimit) {
115
+ replayBytes -= replay.shift().bytes;
116
+ }
93
117
  }
94
118
  }
95
119
 
@@ -152,7 +176,8 @@ export function createGlobalMessageStreamHub({
152
176
  }
153
177
 
154
178
  const index = replay.findIndex((entry) => entry.eventId === eventId);
155
- return index === -1 ? [] : replay.slice(index + 1);
179
+ if (eventId === latestEventId) return [];
180
+ return index === -1 ? null : replay.slice(index + 1);
156
181
  },
157
182
  };
158
183
  }
@@ -2,6 +2,28 @@ import { describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { createGlobalMessageStreamHub } from './global-hub.js';
4
4
 
5
+ it('bounds a contiguous replay suffix by UTF-8 bytes and event count', async () => {
6
+ const blocks = Array.from({ length: 8 }, (_, i) => `id: e${i}\ndata: ${JSON.stringify({ type: 'message', properties: { text: '界'.repeat(40) } })}\n\n`);
7
+ const received = [];
8
+ const hub = createGlobalMessageStreamHub({
9
+ buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
10
+ getOpenCodeAuthHeaders: () => ({}), replayLimit: 3, replayByteLimit: 550,
11
+ upstreamReconnectDelayMs: 60_000,
12
+ fetchImpl: async () => createSseResponse({ blocks }),
13
+ });
14
+ hub.subscribeEvent(event => received.push(event.eventId));
15
+ try {
16
+ hub.start();
17
+ await waitForAssertion(() => expect(received).toHaveLength(8));
18
+ expect(hub.replayAfter('e0')).toBeNull();
19
+ expect(hub.replayAfter('e5')).toBeNull();
20
+ const tail = hub.replayAfter('e6');
21
+ expect(tail.map(entry => entry.eventId)).toEqual(['e7']);
22
+ expect(Buffer.byteLength(tail[0].serializedFrame) * 2).toBeLessThanOrEqual(550);
23
+ expect(Buffer.byteLength(tail[0].serializedFrame) * 3).toBeGreaterThan(550);
24
+ } finally { hub.stop(); }
25
+ });
26
+
5
27
  function createSseResponse({ blocks = [] } = {}) {
6
28
  const encoder = new TextEncoder();
7
29
  let index = 0;
@@ -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() {
@@ -50,12 +50,21 @@ The following functions are exported and used by the web server:
50
50
  - `getRemotes(directory)`: Get list of configured remotes.
51
51
 
52
52
  ### Worktree Operations
53
- - `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.
54
56
  - `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
55
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.
56
58
  - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
57
59
  - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
58
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
+
59
68
  ### Worktree creation from a GitHub pull request
60
69
  The UI provisions `pr-<owner>` via `ensureRemoteName`/`ensureRemoteUrl`
61
70
  (HTTPS clone URL preferred over SSH) and checks out
@@ -1,8 +1,11 @@
1
- export function registerGitRoutes(app) {
1
+ export function registerGitRoutes(app, { emitWorktreeChanged } = {}) {
2
2
  let gitLibraries = null;
3
3
  const getGitLibraries = async () => {
4
4
  if (!gitLibraries) {
5
5
  gitLibraries = await import('./index.js');
6
+ if (emitWorktreeChanged) {
7
+ gitLibraries.subscribeWorktreeTopologyChanges(emitWorktreeChanged);
8
+ }
6
9
  }
7
10
  return gitLibraries;
8
11
  };
@@ -220,7 +223,7 @@ export function registerGitRoutes(app) {
220
223
  });
221
224
 
222
225
  app.get('/api/git/status', async (req, res) => {
223
- const { getStatus, isGitRepository } = await getGitLibraries();
226
+ const { getStatus, isGitRepository, observeWorktreeTopology } = await getGitLibraries();
224
227
 
225
228
  try {
226
229
  const directory = resolveDirectoryQuery(req.query.directory);
@@ -233,6 +236,11 @@ export function registerGitRoutes(app) {
233
236
  return res.json(nonRepoStatusPayload());
234
237
  }
235
238
 
239
+ // Clients ask for status while they work in a repository, so this is
240
+ // where an externally added or removed worktree gets noticed. It runs
241
+ // beside the status call and never delays or fails the response.
242
+ void observeWorktreeTopology(directory);
243
+
236
244
  const mode = req.query.mode === 'light' ? 'light' : undefined;
237
245
  const status = await getStatus(directory, { mode });
238
246
  res.json(status);
@@ -1078,7 +1086,7 @@ export function registerGitRoutes(app) {
1078
1086
  });
1079
1087
 
1080
1088
  app.get('/api/git/worktrees', async (req, res) => {
1081
- const { getWorktrees } = await getGitLibraries();
1089
+ const { getWorktrees, observeWorktreeTopology } = await getGitLibraries();
1082
1090
  try {
1083
1091
  const directory = req.query.directory;
1084
1092
  if (!directory) {
@@ -1086,13 +1094,18 @@ export function registerGitRoutes(app) {
1086
1094
  }
1087
1095
 
1088
1096
  const worktrees = await getWorktrees(directory);
1097
+ // A repository always lists at least its primary worktree; an empty
1098
+ // list means "not a repository" and has no topology to track.
1099
+ if (worktrees.length > 0) {
1100
+ void observeWorktreeTopology(directory);
1101
+ }
1089
1102
  res.json(worktrees);
1090
1103
  } catch (error) {
1091
- // Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries)
1092
- // when the directory isn't a git repo or uses shell shorthand like "~/".
1093
- console.warn('Failed to get worktrees, returning empty list:', error?.message || error);
1094
- res.setHeader('X-AiWorker-Warning', 'git worktrees unavailable');
1095
- res.json([]);
1104
+ // A directory outside any repository still answers `[]` from getWorktrees.
1105
+ // Anything else is a real failure the client must not mistake for "no
1106
+ // worktrees", or it would drop the ones it already knows.
1107
+ console.error('Failed to get worktrees:', error);
1108
+ res.status(500).json({ error: error.message || 'Failed to get worktrees' });
1096
1109
  }
1097
1110
  });
1098
1111