@vantran-se/openchamber-web 2.0.3 → 2.0.4

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 (93) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DZF8ntaq.js} +1 -1
  7. package/dist/assets/{DiffView-CX3wAX2S.js → DiffView-D2p4Ar0H.js} +1 -1
  8. package/dist/assets/{FilesView-OL3CON3r.js → FilesView-BeTtbsip.js} +1 -1
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-DwQwqDvu.js} +1 -1
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-CoXM0gh_.js} +1 -1
  11. package/dist/assets/{GuestSurfacePane-B_cqX5I5.js → GuestSurfacePane-DIJvkkM-.js} +1 -1
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-Cq_27v0y.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-CsSSRMhn.js} +1 -1
  14. package/dist/assets/{MarkdownImageGallery-DFiTFZgj.js → MarkdownImageGallery-CY0dWTaE.js} +1 -1
  15. package/dist/assets/{MarkdownRendererImpl-DZm5sf1W.js → MarkdownRendererImpl-ILdXzLrt.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-ClatMQAX.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-DOwlrIK9.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-CrcGqqD_.js} +1 -1
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-BGLsMgQk.js} +1 -1
  20. package/dist/assets/{PluginPane-BibJit86.js → PluginPane-B39JEUL8.js} +1 -1
  21. package/dist/assets/{SettingsView-h-VpVO47.js → SettingsView-13B2_G-A.js} +1 -1
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-B0BAy-f9.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-fcdDzCSW.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-B7xaUz2H.js} +1 -1
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-CkWCaUmZ.js} +1 -1
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-DONC4eI2.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-CXDImOXW.js} +1 -1
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-Br2TLSlb.js} +1 -1
  29. package/dist/assets/{commitAndPush-BIPfOMXY.js → commitAndPush-CcI4ol7c.js} +1 -1
  30. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-_-20umCs.js} +1 -1
  31. package/dist/assets/{de-BqCNGROu.js → de-BtYnUgII.js} +1 -1
  32. package/dist/assets/{es-BTPpTRgN.js → es-crP5qPye.js} +1 -1
  33. package/dist/assets/{fr-hxbzMdAg.js → fr-CLSRuALk.js} +1 -1
  34. package/dist/assets/{index-CL7ZQ94-.js → index-OKDYjcMF.js} +1 -1
  35. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-D77M6q1f.js} +1 -1
  36. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-C54B4Mns.js} +1 -1
  37. package/dist/assets/{ja-_zLwwYiy.js → ja-SZ1-ucLV.js} +1 -1
  38. package/dist/assets/{ko-CFn3Dzhc.js → ko-HthbT_Ve.js} +1 -1
  39. package/dist/assets/{main-BLJaug9I.js → main-BJklC7Cl.js} +4 -4
  40. package/dist/assets/{main-quLSA7jc.js → main-CGEvLMd2.js} +2 -2
  41. package/dist/assets/{markdownCore-DS9FdwrI.js → markdownCore-BzQuETm0.js} +1 -1
  42. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-BSxkRQLj.js} +2 -2
  43. package/dist/assets/{mobile-VTtphbW6.js → mobile-C5KRTNOB.js} +2 -2
  44. package/dist/assets/{patchFileDiff-D9iBcTEi.js → patchFileDiff-DGw2KryM.js} +1 -1
  45. package/dist/assets/{pl-DFHE7xac.js → pl-C8eYfjL1.js} +1 -1
  46. package/dist/assets/{pt-BR-CUjZF179.js → pt-BR-cYOrZdxS.js} +1 -1
  47. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-CnkAQV6o.js} +2 -2
  48. package/dist/assets/{renderMobileApp-CNQhdXsi.js → renderMobileApp-CMxjwisj.js} +3 -3
  49. package/dist/assets/{runtimeEndpointReset-DeaERc0M.js → runtimeEndpointReset-C45jrFq4.js} +3 -3
  50. package/dist/assets/{scheduledTasksApi-BD6Vx9pu.js → scheduledTasksApi-CkK1F9s2.js} +1 -1
  51. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-4O0ZM14D.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-yNq7b0DZ.js} +1 -1
  53. package/dist/assets/{tr-BB6sA_zH.js → tr-B4IQ5t-T.js} +1 -1
  54. package/dist/assets/{uk-CB4o8g-o.js → uk-DCoXG6ve.js} +1 -1
  55. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-Bzyszv2N.js} +9 -9
  56. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-zOf6ORAq.js} +1 -1
  57. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-DCaZ4BWi.js} +2 -2
  58. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-6cEFPNUR.js} +2 -2
  59. package/dist/assets/{web-components-C583Pmfm.js → web-components-BEo4-ISu.js} +1 -1
  60. package/dist/assets/{zh-CN-BRyDuWhc.js → zh-CN-BXBoxsFk.js} +1 -1
  61. package/dist/assets/{zh-TW-BDGbWA1Q.js → zh-TW-C6oelpnL.js} +1 -1
  62. package/dist/index.html +1 -1
  63. package/dist/mini-chat.html +1 -1
  64. package/dist/mobile.html +1 -1
  65. package/package.json +1 -1
  66. package/server/index.d.ts +2 -0
  67. package/server/index.js +114 -52
  68. package/server/lib/agent-tool/DOCUMENTATION.md +47 -127
  69. package/server/lib/agent-tool/runtime.js +66 -47
  70. package/server/lib/agent-tool/runtime.test.js +2 -0
  71. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  72. package/server/lib/event-stream/global-hub.js +22 -6
  73. package/server/lib/event-stream/global-hub.test.js +93 -2
  74. package/server/lib/event-stream/rebind.test.js +1 -0
  75. package/server/lib/event-stream/runtime.js +1 -2
  76. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  77. package/server/lib/opencode/lifecycle.js +126 -0
  78. package/server/lib/opencode/lifecycle.test.js +228 -1
  79. package/server/lib/opencode/network-runtime.js +16 -7
  80. package/server/lib/opencode/network-runtime.test.js +29 -1
  81. package/server/lib/opencode/proxy.js +15 -14
  82. package/server/lib/opencode/server-startup-runtime.js +25 -0
  83. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  84. package/server/lib/opencode/shared-service-runtime.js +155 -0
  85. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  86. package/server/lib/opencode/shutdown-runtime.js +11 -5
  87. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  88. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  89. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  90. package/server/lib/opencode/watcher.test.js +54 -0
  91. package/server/opencode-proxy.test.js +158 -0
  92. package/server/proxy-headers.js +8 -2
  93. package/server/proxy-headers.test.js +20 -0
@@ -167,6 +167,40 @@ const resolveConcreteBoundAddress = (value) => {
167
167
  * answer with an empty completion instead of an error, and the `oneOf` branches
168
168
  * are what carry the per-action descriptions the model reads.
169
169
  */
170
+ const createEnvironmentExecutionSource = (name) => `const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
171
+ const token = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN
172
+ const callbackHeaders = {}
173
+ if (!endpoint || !token) {
174
+ return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber managed tool connection is unavailable" } })
175
+ }
176
+
177
+ try {
178
+ const response = await fetch(endpoint, {
179
+ method: "POST",
180
+ headers: {
181
+ authorization: "Bearer " + token,
182
+ "content-type": "application/json",
183
+ ...callbackHeaders,
184
+ },
185
+ // OpenCode 2 no longer hands a tool its session directory, so the
186
+ // session id goes over instead and OpenChamber resolves the
187
+ // directory on its own side. The signal fires when the session is
188
+ // aborted, so the OpenChamber side stops the action too.
189
+ body: JSON.stringify({ input: args, sessionID: context.sessionID, tool: ${JSON.stringify(name)} }),
190
+ signal: context.signal,
191
+ })
192
+ const content = await response.text()
193
+ let result = null
194
+ try { result = JSON.parse(content) } catch {}
195
+ const valid = result?.schemaVersion === ${TOOL_SCHEMA_VERSION} && typeof result?.ok === "boolean" && typeof result?.action === "string"
196
+ await progress({ ok: valid && result.ok === true })
197
+ if (valid) return { content, metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: result.ok === true } } }
198
+ return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber returned an invalid response", kind: "runtime", status: response.status } })
199
+ } catch (error) {
200
+ return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: error instanceof Error ? error.message : String(error), kind: "runtime" } })
201
+ }`;
202
+
203
+
170
204
  const createToolEntry = ({ name, description, definitions, parameters }) => String.raw` tools.add({
171
205
  name: ${JSON.stringify(name)},
172
206
  description: ${JSON.stringify(description)},
@@ -199,8 +233,6 @@ const createToolEntry = ({ name, description, definitions, parameters }) => Stri
199
233
  },
200
234
  })
201
235
  await progress()
202
- const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
203
- const token = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN
204
236
  // No output schema is declared, so a result must never carry an output
205
237
  // field: OpenCode rejects that with "Tool result declared output
206
238
  // without an output schema". The envelope travels as text content.
@@ -208,39 +240,17 @@ const createToolEntry = ({ name, description, definitions, parameters }) => Stri
208
240
  content: JSON.stringify(payload),
209
241
  metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: false } },
210
242
  })
211
- if (!endpoint || !token) {
212
- return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber managed tool connection is unavailable" } })
213
- }
214
-
215
- try {
216
- const response = await fetch(endpoint, {
217
- method: "POST",
218
- headers: {
219
- authorization: "Bearer " + token,
220
- "content-type": "application/json",
221
- },
222
- // OpenCode 2 no longer hands a tool its session directory, so the
223
- // session id goes over instead and OpenChamber resolves the
224
- // directory on its own side. The signal fires when the session is
225
- // aborted, so the OpenChamber side stops the action too.
226
- body: JSON.stringify({ input: args, sessionID: context.sessionID, tool: ${JSON.stringify(name)} }),
227
- signal: context.signal,
228
- })
229
- const content = await response.text()
230
- let result = null
231
- try { result = JSON.parse(content) } catch {}
232
- const valid = result?.schemaVersion === ${TOOL_SCHEMA_VERSION} && typeof result?.ok === "boolean" && typeof result?.action === "string"
233
- await progress({ ok: valid && result.ok === true })
234
- if (valid) return { content, metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: result.ok === true } } }
235
- return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber returned an invalid response", kind: "runtime", status: response.status } })
236
- } catch (error) {
237
- return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: error instanceof Error ? error.message : String(error), kind: "runtime" } })
238
- }
243
+ ${createEnvironmentExecutionSource(name)}
239
244
  },
240
245
  })
241
246
  `;
242
247
 
243
- const createPluginSource = ({ includeControl, includeWeb, includeMemory }) => {
248
+
249
+ export const createPluginSource = ({
250
+ includeControl,
251
+ includeWeb,
252
+ includeMemory,
253
+ }) => {
244
254
  const entries = [];
245
255
  if (includeControl) {
246
256
  entries.push(createToolEntry({
@@ -267,15 +277,13 @@ const createPluginSource = ({ includeControl, includeWeb, includeMemory }) => {
267
277
  }));
268
278
  }
269
279
 
270
- // The callback carries the per-child token over plain HTTP. With a proxy in
271
- // the child's environment, fetch would hand a non-loopback callback, token
272
- // included, to that proxy, and no per-request option turns that off. The
273
- // exemption is added inside the child because only there is the final
274
- // NO_PROXY, merged from the shell and server environments, visible.
275
- return `const exemptCallbackFromProxy = () => {
276
- const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
277
- if (!endpoint || !URL.canParse(endpoint)) return
278
- const host = new URL(endpoint).hostname.replace(/^\\[|\\]$/g, "")
280
+ // The callback carries a bearer token over plain HTTP. With a proxy in the
281
+ // OpenCode environment, fetch could hand a non-loopback callback and token to
282
+ // that proxy. Add the callback host to both proxy exemption variables before
283
+ // a request.
284
+ return `const exemptCallbackFromProxy = (callbackEndpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL) => {
285
+ if (!callbackEndpoint || !URL.canParse(callbackEndpoint)) return
286
+ const host = new URL(callbackEndpoint).hostname.replace(/^\\[|\\]$/g, "")
279
287
  for (const key of ["NO_PROXY", "no_proxy"]) {
280
288
  const entries = (process.env[key] || "").split(",").map((entry) => entry.trim()).filter(Boolean)
281
289
  if (!entries.includes(host)) process.env[key] = [...entries, host].join(",")
@@ -295,7 +303,7 @@ ${entries.join('')} })
295
303
 
296
304
  // A configured plugin has to be a directory carrying a package.json that
297
305
  // resolves an entrypoint; OpenCode skips a plain .js path.
298
- const PLUGIN_PACKAGE_JSON = `${JSON.stringify({
306
+ export const AGENT_TOOL_PLUGIN_PACKAGE_JSON = `${JSON.stringify({
299
307
  name: 'openchamber-agent-tool',
300
308
  version: '0.0.0',
301
309
  private: true,
@@ -334,8 +342,12 @@ export const createAgentToolRuntime = (dependencies) => {
334
342
  throw new Error('At least one OpenChamber managed tool must be enabled to inject the plugin');
335
343
  }
336
344
  await fsPromises.mkdir(pluginDirectory, { recursive: true });
337
- await fsPromises.writeFile(pluginManifestPath, PLUGIN_PACKAGE_JSON, { mode: 0o600 });
338
- await fsPromises.writeFile(pluginPath, createPluginSource({ includeControl, includeWeb, includeMemory }), { mode: 0o600 });
345
+ await fsPromises.writeFile(pluginManifestPath, AGENT_TOOL_PLUGIN_PACKAGE_JSON, { mode: 0o600 });
346
+ await fsPromises.writeFile(pluginPath, createPluginSource({
347
+ includeControl,
348
+ includeWeb,
349
+ includeMemory,
350
+ }), { mode: 0o600 });
339
351
  return pluginDirectory;
340
352
  };
341
353
 
@@ -371,15 +383,21 @@ export const createAgentToolRuntime = (dependencies) => {
371
383
  return boundAddress !== null && normalizeAddress(value) === boundAddress;
372
384
  };
373
385
 
374
- const authorize = (req) => {
375
- if (!activeToken || !isSameMachineAddress(req.socket?.remoteAddress)) return false;
386
+ const verifyBearer = (req, token) => {
387
+ if (!token) return false;
376
388
  const header = asNonEmptyString(req.headers?.authorization);
377
389
  if (!header?.startsWith('Bearer ')) return false;
378
390
  const provided = Buffer.from(header.slice(7));
379
- const expected = Buffer.from(activeToken);
391
+ const expected = Buffer.from(token);
380
392
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
381
393
  };
382
394
 
395
+
396
+ const authorizeManagedChild = (req) => (
397
+ isSameMachineAddress(req.socket?.remoteAddress) && verifyBearer(req, activeToken)
398
+ );
399
+
400
+
383
401
  const execute = async (payload = {}, options = {}) => {
384
402
  const requested = asNonEmptyString(payload.input?.action);
385
403
  // Resolved against the calling tool's own actions: models drop the
@@ -455,8 +473,9 @@ export const createAgentToolRuntime = (dependencies) => {
455
473
  };
456
474
 
457
475
  const registerRoutes = (app, express) => {
476
+
458
477
  app.post('/api/openchamber/agent-tool', express.json({ limit: '1mb' }), async (req, res) => {
459
- if (!authorize(req)) return res.status(401).json({ error: 'Unauthorized' });
478
+ if (!authorizeManagedChild(req)) return res.status(401).json({ error: 'Unauthorized' });
460
479
  const controller = new AbortController();
461
480
  const abortOnDisconnect = () => {
462
481
  if (!res.writableEnded) controller.abort();
@@ -445,6 +445,8 @@ describe('managed agent tool runtime', () => {
445
445
  expect(response.body).toEqual(expect.objectContaining({ ok: true, action: 'projects.list' }));
446
446
  });
447
447
 
448
+
449
+
448
450
  it.each([
449
451
  ['0.0.0.0', 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
450
452
  ['::', 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
@@ -29,7 +29,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
29
29
  - `serializeMessageStreamWsEvent(payload, options)` and `sendSerializedMessageStreamWsFrame(socket, frame)` separate encoding from per-socket delivery. Delivery retains ready-state and backpressure checks.
30
30
 
31
31
  ### Runtime helpers
32
- - `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
32
+ - `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay. The composition root owns the singleton and stops it during application shutdown; watchers and WebSocket bridges that receive it are borrowers and must not stop it.
33
33
  - `createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent })`: returns a broadcaster that fans out the same synthetic UI event to SSE and WS clients.
34
34
  - `createMessageStreamWsRuntime(...)`: mounts the message-stream WS server, upgrade handler, and SSE-to-WS bridge onto the web HTTP server.
35
35
 
@@ -59,7 +59,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
59
59
  - The hub numbers every event that arrives without an SSE id (`oc-<process>-<sequence>`). OpenCode 1.18 sends no ids at all, and an event without an id used to skip the replay buffer, so reconnecting clients had no cursor and lost whatever fell into the gap. The process prefix makes a cursor from before a server restart miss, which reports `replayReset`, instead of matching an unrelated sequence number.
60
60
  - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
61
61
  - 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.
62
- - 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.
62
+ - 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 runs its established scoped authoritative repair without requiring the browser WS to close. Shared-service recovery uses this edge rather than manufacturing a server-side interruption. Rebinding retires the old reader generation before starting its replacement, so delayed events, errors, and disconnect completion from the old reader cannot overwrite the replacement's connection state.
63
63
  - Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
64
64
  - Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
65
65
  - Global UI broadcasts are fan-out capable across both SSE and WS clients.
@@ -40,9 +40,9 @@ export function createGlobalMessageStreamHub({
40
40
 
41
41
  let controller = null;
42
42
  let reader = null;
43
+ let readerGeneration = 0;
43
44
  let connected = false;
44
45
  let everConnected = false;
45
- let buildUrlFailed = false;
46
46
 
47
47
  const notifySubscriber = (kind, subscriber, payload) => {
48
48
  try {
@@ -124,8 +124,11 @@ export function createGlobalMessageStreamHub({
124
124
  }
125
125
 
126
126
  controller = new AbortController();
127
+ const generation = ++readerGeneration;
128
+ const currentController = controller;
129
+ let buildUrlFailed = false;
127
130
  reader = createUpstreamSseReader({
128
- signal: controller.signal,
131
+ signal: currentController.signal,
129
132
  stallTimeoutMs: upstreamStallTimeoutMs,
130
133
  reconnectDelayMs: upstreamReconnectDelayMs,
131
134
  fetchImpl,
@@ -140,20 +143,23 @@ export function createGlobalMessageStreamHub({
140
143
  },
141
144
  getHeaders: getOpenCodeAuthHeaders,
142
145
  onConnect() {
146
+ if (generation !== readerGeneration) return;
143
147
  connected = true;
144
148
  const wasReady = everConnected;
145
149
  everConnected = true;
146
150
  notifyStatus({ type: 'connect', wasReady });
147
151
  },
148
152
  onDisconnect({ reason }) {
153
+ if (generation !== readerGeneration) return;
149
154
  connected = false;
150
155
  notifyStatus({ type: 'disconnect', reason });
151
156
  },
152
157
  onEvent(event) {
158
+ if (generation !== readerGeneration) return;
153
159
  coalescer.push(event);
154
160
  },
155
161
  onError(error) {
156
- if (controller?.signal.aborted) {
162
+ if (generation !== readerGeneration || currentController.signal.aborted) {
157
163
  return;
158
164
  }
159
165
 
@@ -168,8 +174,9 @@ export function createGlobalMessageStreamHub({
168
174
  void reader.start();
169
175
  };
170
176
 
171
- const stop = () => {
177
+ const stopReader = ({ resetConnectionHistory }) => {
172
178
  connected = false;
179
+ readerGeneration += 1;
173
180
  // Text that already arrived belongs in the retained replay suffix.
174
181
  coalescer.flush();
175
182
  reader?.stop();
@@ -178,13 +185,22 @@ export function createGlobalMessageStreamHub({
178
185
  }
179
186
  reader = null;
180
187
  controller = null;
181
- everConnected = false;
182
- buildUrlFailed = false;
188
+ if (resetConnectionHistory) {
189
+ everConnected = false;
190
+ }
191
+ };
192
+
193
+ const stop = () => {
194
+ stopReader({ resetConnectionHistory: true });
183
195
  };
184
196
 
185
197
  return {
186
198
  start,
187
199
  stop,
200
+ rebind() {
201
+ stopReader({ resetConnectionHistory: false });
202
+ start();
203
+ },
188
204
  isConnected() {
189
205
  return connected;
190
206
  },
@@ -25,7 +25,7 @@ it('bounds a contiguous replay suffix by UTF-8 bytes and event count', async ()
25
25
  } finally { hub.stop(); }
26
26
  });
27
27
 
28
- function createSseResponse({ blocks = [] } = {}) {
28
+ function createSseResponse({ blocks = [], signal, holdOpen = false } = {}) {
29
29
  const encoder = new TextEncoder();
30
30
  let index = 0;
31
31
 
@@ -38,7 +38,18 @@ function createSseResponse({ blocks = [] } = {}) {
38
38
  if (index < blocks.length) {
39
39
  return { value: encoder.encode(blocks[index++]), done: false };
40
40
  }
41
- return { value: undefined, done: true };
41
+ if (!holdOpen) {
42
+ return { value: undefined, done: true };
43
+ }
44
+ return new Promise((_resolve, reject) => {
45
+ const onAbort = () => {
46
+ signal.removeEventListener('abort', onAbort);
47
+ const error = new Error('Aborted');
48
+ error.name = 'AbortError';
49
+ reject(error);
50
+ };
51
+ signal.addEventListener('abort', onAbort, { once: true });
52
+ });
42
53
  },
43
54
  };
44
55
  },
@@ -224,6 +235,86 @@ describe('delta coalescing in the global hub', () => {
224
235
  });
225
236
 
226
237
  describe('createGlobalMessageStreamHub', () => {
238
+ it('uses the replacement service endpoint and auth after rebind', async () => {
239
+ let generation = 0;
240
+ const urls = ['http://127.0.0.1:4096/api/event', 'http://127.0.0.1:5096/api/event'];
241
+ const auth = ['Basic first', 'Basic second'];
242
+ const fetchImpl = vi.fn(async (_url, options) => createSseResponse({
243
+ signal: options.signal,
244
+ holdOpen: true,
245
+ }));
246
+ const hub = createGlobalMessageStreamHub({
247
+ buildOpenCodeUrl: () => urls[generation],
248
+ getOpenCodeAuthHeaders: () => ({ Authorization: auth[generation] }),
249
+ fetchImpl,
250
+ upstreamReconnectDelayMs: 60_000,
251
+ });
252
+
253
+ try {
254
+ hub.start();
255
+ await waitForAssertion(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
256
+
257
+ generation = 1;
258
+ hub.rebind();
259
+
260
+ await waitForAssertion(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
261
+ expect(fetchImpl).toHaveBeenLastCalledWith(urls[1], expect.objectContaining({
262
+ headers: expect.objectContaining({ Authorization: auth[1] }),
263
+ }));
264
+ } finally {
265
+ hub.stop();
266
+ }
267
+ });
268
+
269
+ it('ignores a retired reader disconnect that completes after its replacement connects', async () => {
270
+ let finishRetiredRead;
271
+ let fetchCount = 0;
272
+ const statuses = [];
273
+ const fetchImpl = vi.fn(async (_url, options) => {
274
+ fetchCount += 1;
275
+ if (fetchCount === 1) {
276
+ return {
277
+ ok: true,
278
+ body: {
279
+ getReader() {
280
+ return {
281
+ read() {
282
+ return new Promise((resolve) => {
283
+ finishRetiredRead = () => resolve({ value: undefined, done: true });
284
+ });
285
+ },
286
+ };
287
+ },
288
+ },
289
+ };
290
+ }
291
+ return createSseResponse({ signal: options.signal, holdOpen: true });
292
+ });
293
+ const hub = createGlobalMessageStreamHub({
294
+ buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
295
+ getOpenCodeAuthHeaders: () => ({}),
296
+ fetchImpl,
297
+ upstreamReconnectDelayMs: 60_000,
298
+ });
299
+ hub.subscribeStatus((status) => statuses.push(status.type));
300
+
301
+ try {
302
+ hub.start();
303
+ await waitForAssertion(() => expect(statuses).toEqual(['connect']));
304
+
305
+ hub.rebind();
306
+ await waitForAssertion(() => expect(statuses).toEqual(['connect', 'connect']));
307
+ expect(hub.isConnected()).toBe(true);
308
+
309
+ finishRetiredRead();
310
+ await new Promise((resolve) => setTimeout(resolve, 10));
311
+ expect(statuses).toEqual(['connect', 'connect']);
312
+ expect(hub.isConnected()).toBe(true);
313
+ } finally {
314
+ hub.stop();
315
+ }
316
+ });
317
+
227
318
  it('continues fanout when an event subscriber throws', async () => {
228
319
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
229
320
  const received = [];
@@ -132,6 +132,7 @@ describe('rebindUpstream (#2638)', () => {
132
132
  expect(fetchCalls).toBe(2);
133
133
  expect(fetchImpl.mock.calls[1][0]).toContain(':5000/api/event');
134
134
  expect(socket.sent.some((frame) => frame.type === 'event' && frame.eventId === 'evt-2')).toBe(true);
135
+ expect(socket.sent.filter((frame) => frame.type === 'ready' && frame.scope === 'global')).toHaveLength(2);
135
136
 
136
137
  socket.close();
137
138
  await runtime.close();
@@ -182,8 +182,7 @@ export function createMessageStreamWsRuntime({
182
182
  * `Last-Event-ID` and re-establish the stream against the new port.
183
183
  */
184
184
  rebindUpstream() {
185
- globalHub.stop();
186
- globalHub.start();
185
+ globalHub.rebind();
187
186
  for (const socket of Array.from(directorySockets)) {
188
187
  try {
189
188
  socket.close(1012, 'OpenCode upstream restarted');
@@ -11,13 +11,14 @@ This module provides OpenCode server integration utilities for the web server ru
11
11
  - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
12
12
  - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
13
13
  - `packages/web/server/lib/opencode/v1-migration-topup.js`: re-arms OpenCode's own V1 -> V2 session import for V1 sessions created after that migration already completed; runs only before a managed spawn. See "v1-migration-topup.js" below.
14
- - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open.
14
+ - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode connection policy and the managed process lifecycle runtime. The composition helper selects `explicit-external` when `OPENCODE_HOST` is configured, `managed-owned` for Desktop, and `shared-local` for ordinary Web. The managed path still owns startup, restart, readiness, health monitoring, orphan reaping, credentials, and V1 migration top-up. After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open.
15
15
  - `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names. Canonical implementation shared by web lifecycle and the VS Code managed spawn path (`packages/vscode/src/provider-env-aliases.ts` re-exports this module).
16
16
  - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
17
17
  - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
18
18
  - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
19
19
  - `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring.
20
- - `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime.
20
+ - `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime. URL and service headers are resolved for every request so shared-service endpoint or credential rotation is immediate; lowercase service `authorization` is normalized to `Authorization` while other service headers are preserved.
21
+ - `packages/web/server/lib/opencode/shared-service-runtime.js`: discovery-first connection used by ordinary Web when no explicit `OPENCODE_HOST` is configured. It probes `/api/info` before accepting an endpoint, refuses to replace an incompatible discovered service, and never owns or stops the shared process. Because the pinned client's discovery hides incompatible and unhealthy registrations, Web checks that the registration file is genuinely absent before calling its destructive `ensure`; an existing hidden registration fails startup untouched. Shared restart and health controls recover the connection, then rebind live event readers so their established reconnect edge repairs authoritative client state without synthetic interruption. Desktop does not instantiate this runtime.
21
22
  - `packages/web/server/lib/opencode/project-directory-runtime.js`: request-scoped and settings-backed project directory resolution/validation runtime.
22
23
  - `packages/web/server/lib/opencode/config-entity-routes.js`: route registration for agent/command/MCP config orchestration. OpenCode 2 watches these files, so a write is live as soon as it lands and the route answers plain success.
23
24
  - `packages/web/server/lib/opencode/websearch-config.js`: writes OpenCode's `websearch` choice (`PUT /api/config/websearch` in `routes.js`; body `{ selection: false | null | "random" | "<provider id>" }`, `null` removes the key, so OpenCode falls back to the answer given in its chat consent form, which it keeps in its own store) to `OPENCODE_CONFIG` when set, else the user config. `GET /api/config/websearch` returns `{ projectPath }`: the project config whose `websearch` overrides that write (OpenCode merges user < project < `OPENCODE_CONFIG`), so Settings disables the choice and names the file instead of letting it snap back; `findWebSearchProjectOverride` in `config-v2.js` holds the rule. The pure transform is `writeWebSearchSelection` in `config-v2.js`, shared with the VS Code bridge (`api:config/websearch`).
@@ -25,18 +26,18 @@ This module provides OpenCode server integration utilities for the web server ru
25
26
  - `packages/web/server/lib/opencode/snippets.js`: opencode-snippets-compatible snippet file CRUD, discovery, and hashtag expansion.
26
27
  - `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
27
28
  - `packages/web/server/lib/opencode/core-routes.js`: server status/system routes, auth/access guard routes, and settings utility route registration.
28
- - `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration runtime for watcher/session/guest-services/terminal/process/server teardown.
29
- - `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen/startup tunnel flow and process/signal handler orchestration runtime.
30
- - `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
31
- - `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
32
- - `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
33
- - `packages/web/server/lib/opencode/upgrade-capability.js`: authoritative upgrade ownership policy for the active OpenCode runtime. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode.
34
- - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
35
- - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
29
+ - `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration. Only `managed-owned` may close an OpenCode child, kill by port, or wait for port release. Shared and external connections dispose local state only.
30
+ - `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen, startup tunnel flow, and process or signal handler orchestration.
31
+ - `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset and SPA fallback route registration.
32
+ - `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition for dynamic route modules.
33
+ - `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot for settings routes and diagnostics.
34
+ - `packages/web/server/lib/opencode/upgrade-capability.js`: upgrade ownership policy. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode.
35
+ - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service and active-port wiring.
36
+ - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: terminal, proxy, static route, and listen orchestration. Shared Web starts no managed-tool installation or registration step.
36
37
  - `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
37
- - `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, same-machine authentication (loopback, or the bound address for a concrete bind), and fixed CLI action dispatch.
38
- - `packages/web/server/lib/opencode/managed-plugin-config.js`: the `OPENCODE_CONFIG_CONTENT` merge used only on the fallback path, when the user's own environment owns `OPENCODE_CONFIG`.
39
- - `packages/web/server/lib/opencode/managed-config-file.js`: the managed OpenCode config layer — materializes the enabled OpenChamber plugins (agent tools, system prompt optimizer) and publishes them in a file OpenCode watches.
38
+ - `packages/web/server/lib/agent-tool/runtime.js`: Desktop's custom-tool schema and source generation, managed plugin materialization, environment injection, same-machine authentication, and fixed action dispatch.
39
+ - `packages/web/server/lib/opencode/managed-plugin-config.js`: the `OPENCODE_CONFIG_CONTENT` merge used only when the user's environment owns `OPENCODE_CONFIG`.
40
+ - `packages/web/server/lib/opencode/managed-config-file.js`: Desktop's managed OpenCode config layer for environment-backed OpenChamber tools and the system prompt optimizer.
40
41
 
41
42
  ### Managed plugins on OpenCode 2.x
42
43
  A configured plugin must be a DIRECTORY holding a `package.json` that resolves an
@@ -296,7 +297,8 @@ Installer output is discarded, not forwarded to clients or logs.
296
297
  The runtime maintains active-session count incrementally from idempotent activity phase transitions. Upstream stall-timeout and lifecycle health checks read it in O(1); the hourly cleanup removes activity phases older than 24 hours without broadcasting synthetic state transitions. Snapshot generation remains reserved for the session-activity API.
297
298
 
298
299
  ## Public exports (lifecycle.js)
299
- - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart. `index.js` rebinds event-stream readers to the possibly-new port (#2638), then calls `interruptBusySessionsAfterRestart()` and broadcasts one `opencode-restart-interrupted` UI notification when interrupted turns exist (#2943).
300
+ - `createOpenCodeRecoveryCallbacks(dependencies)`: composes the production recovery callbacks used by `index.js`. Managed restart resets providers, rebinds event readers, calls `interruptBusySessionsAfterRestart()`, and broadcasts the existing interruption notification. Shared recovery resets providers and rebinds readers only, leaving authoritative browser reconnect repair to the existing second `ready` edge.
301
+ - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart. `index.js` supplies the managed callback above.
300
302
  - Returned API:
301
303
  - `startOpenCode()`
302
304
  - `restartOpenCode()`
@@ -396,7 +398,7 @@ ConPTY or Console Window Host behavior.
396
398
  - `setupBaseRoutes(app, options)`
397
399
 
398
400
  ## Public exports (network-runtime.js)
399
- - `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns.
401
+ - `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns. `getOpenCodeBaseUrl` and `getOpenCodeAuthHeaders` are read at call time; credentials remain request headers and never enter generated URLs.
400
402
  - Returned API:
401
403
  - `waitForReady(url, timeoutMs?)`
402
404
  - `normalizeApiPrefix(prefix)`
@@ -751,15 +753,15 @@ headers }` or v1 `{ npm, options }`. The stored entry is always a
751
753
  - `setupProxy(app)`
752
754
 
753
755
  ## Public exports (shutdown-runtime.js)
754
- - `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown runtime for managed OpenCode and web server teardown sequencing.
755
- - Daemon signals, `POST /api/system/shutdown`, and embedded `stop()` share one shutdown promise. Guest admission closes synchronously before any await. Cleanup stops the relay reconciliation timer, guest viewers, realtime proxy, relay host, dictation worker and session runtimes before draining guest services, including pending starts. Each cleanup is best-effort and runs once per shutdown, including after partial startup. A hard kill still requires the separate crash/SIGKILL recovery work; no persistent registry or boot reaper is provided here.
756
- - Register TCP connection tracking before the HTTP server starts listening. After stopping owned runtimes and OpenCode, HTTP shutdown closes the listener and all remaining sockets, including WebSocket upgrades and unanswered upgrade requests accepted during cleanup. This prevents client reconnects from holding Desktop open until the HTTP close deadline. Each runtime still owns its protocol cleanup; socket teardown runs afterwards and preserves the existing terminal and process grace periods.
756
+ - `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown for Web-owned resources and explicit OpenCode process ownership.
757
+ - Daemon signals, `POST /api/system/shutdown`, and embedded `stop()` share one shutdown promise. Guest admission closes before asynchronous cleanup. Each cleanup is best-effort and runs once, including after partial startup. The application-owned global event hub is stopped here after its watcher, so embedded shutdown aborts the upstream event fetch and reconnect loop; watcher and WebSocket bridges do not stop injected hubs.
758
+ - Only `managed-owned` may close the OpenCode child, kill by port, or wait for port release. `shared-local` disposes only local connection state. `explicit-external` performs no OpenCode process teardown. TCP connection tracking closes the Web listener and remaining sockets after owned runtimes stop, while shared or external OpenCode and TUI work continue.
757
759
  - Returned API:
758
760
  - `gracefulShutdown(options?)`
759
761
  - `trackServerConnections(server)`: call once before listening; closed sockets leave the tracking set, and the server close event removes the connection listener.
760
762
 
761
763
  ## Public exports (server-startup-runtime.js)
762
- - `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring.
764
+ - `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring. Daemon readiness is emitted only after listener-dependent startup completes. A startup failure sends `openchamber:error`, closes the listener and accepted connections, and rejects startup without a ready event.
763
765
  - Returned API:
764
766
  - `resolveBindHost(host)`
765
767
  - `startListeningAndMaybeTunnel(options)`
@@ -786,13 +788,11 @@ headers }` or v1 `{ npm, options }`. The stored entry is always a
786
788
  - `initialize(app, initialPort, hasUiPassword)`
787
789
 
788
790
  ## Public exports (startup-pipeline-runtime.js)
789
- - `createStartupPipelineRuntime(dependencies)`: creates runtime for terminal wiring, proxy/bootstrap scheduling, static route registration, and server startup/listen flow.
791
+ - `createStartupPipelineRuntime(dependencies)`: creates terminal, proxy, static route, and server listen orchestration.
790
792
  - Returned API:
791
793
  - `run(options)`
792
794
 
793
- The pipeline binds the OpenChamber listener and publishes its active port
794
- before starting managed OpenCode. The managed custom tool therefore receives
795
- an authoritative loopback callback URL even when OpenChamber binds port `0`.
795
+ The pipeline publishes the listener port before bootstrapping OpenCode, then waits for connection authentication and version validation before daemon readiness. Desktop's generated custom tool therefore receives the correct loopback callback URL even when OpenChamber binds port `0`, while ordinary Web cannot advertise readiness with an unusable shared service. Shared Web does not install a plugin or publish callback registration.
796
796
 
797
797
  ## Public exports (openchamber-routes.js)
798
798
  Browser completion checks use `appType=web&updateStatus=true` to stay on the