@openchambery/web 1.18.4-beta.2 → 1.18.4-beta.20

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 (138) hide show
  1. package/bin/cli.test.js +25 -0
  2. package/bin/lib/DOCUMENTATION.md +2 -1
  3. package/bin/lib/cli-args.js +19 -0
  4. package/bin/lib/commands-serve.js +73 -4
  5. package/dist/assets/{AgentAvatar-BvhXxFvg.js → AgentAvatar-DDcjamcJ.js} +1 -1
  6. package/dist/assets/AssistantView-BKtWtK9a.js +1 -0
  7. package/dist/assets/{ChatContainer-Ba2Qqd2R.js → ChatContainer-CmZuBd4k.js} +42 -42
  8. package/dist/assets/{DiagramView--CkYLtF9.js → DiagramView-C3H0iaE1.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-98buIVdv.js → FadeInOnReveal-By2F9vcI.js} +1 -1
  10. package/dist/assets/{Icon-BhSwbLaF.js → Icon-DpjYnXxn.js} +1 -1
  11. package/dist/assets/{MarkdownRendererImpl-CuaQbwUx.js → MarkdownRendererImpl-Oq7v-1r2.js} +1 -1
  12. package/dist/assets/{MobileDetailNavigation-ld2mS8hq.js → MobileDetailNavigation-Bh_f-iat.js} +1 -1
  13. package/dist/assets/MobileShareBridge-BvcbH_WJ.js +4 -0
  14. package/dist/assets/MobileSurface-BTMvLLPa.js +2 -0
  15. package/dist/assets/{MultiRunWindow-kryfNvF6.js → MultiRunWindow-Cv4K6i1U.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-C1n_wVb-.js → OnboardingScreen-BKmuT7AL.js} +1 -1
  17. package/dist/assets/{OverlayScrollbar-CXFN6Cqq.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
  18. package/dist/assets/{SettingsView-BBTUVI-a.js → SettingsView-DZT_nuTt.js} +11 -11
  19. package/dist/assets/{SettingsWindow-4RS_41Wb.js → SettingsWindow-Bjb84x0C.js} +1 -1
  20. package/dist/assets/{TerminalView-B1he4xRe.js → TerminalView-CXq2MDyL.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-ak07C60J.js → ToolOutputDialog-CTrqAwo1.js} +1 -1
  22. package/dist/assets/appThemeRegistry-D6EEEMQw.js +2 -0
  23. package/dist/assets/{button-BQY5ugjh.js → button-C7OXr4ae.js} +1 -1
  24. package/dist/assets/{clipboard-BvJ7NunE.js → clipboard-DWuULPzo.js} +1 -1
  25. package/dist/assets/{collapsible-CqcZGw3W.js → collapsible-DaEya6b6.js} +1 -1
  26. package/dist/assets/{context-menu-DSUJY2i3.js → context-menu-Cjof3V7x.js} +1 -1
  27. package/dist/assets/desktopHosts-Txp4xOVQ.js +1 -0
  28. package/dist/assets/desktopRelayRestore-C_FSEHKM.js +1 -0
  29. package/dist/assets/{diagram-B8D9Klct.js → diagram-CHfginjS.js} +1 -1
  30. package/dist/assets/{dialog-sGFkIKP2.js → dialog-DPS0W9eS.js} +1 -1
  31. package/dist/assets/{es-BuinIayO.js → es-BX0Vm3Ac.js} +4 -4
  32. package/dist/assets/fileQueries-BrUKKgPW.js +1 -0
  33. package/dist/assets/{fr-CT7us58h.js → fr-CuPzXTYx.js} +4 -4
  34. package/dist/assets/{gitApi-DtV2V_Fp.js → gitApi-mV826uOd.js} +2 -2
  35. package/dist/assets/{input-CgIoxUJc.js → input-BzYA603C.js} +1 -1
  36. package/dist/assets/insertionBoundaries-fFm8sTaK.js +1 -0
  37. package/dist/assets/{ja-CCuAH4nO.js → ja-DGtzjrFO.js} +4 -4
  38. package/dist/assets/{ko-lXbhGt6P.js → ko-DZ9Di0je.js} +4 -4
  39. package/dist/assets/{main-CoNVZvj1.js → main-CqaUoTu5.js} +2 -2
  40. package/dist/assets/main-DaX6y8EG.js +172 -0
  41. package/dist/assets/{markdown-worker-C5Om4TVK.js → markdown-worker-W06-MhF5.js} +1 -1
  42. package/dist/assets/{markdownTheme-BdF4C4-o.js → markdownTheme-B89dbj3N.js} +1 -1
  43. package/dist/assets/{miniChat-CYd1PMdJ.js → miniChat-CX64FVKM.js} +2 -2
  44. package/dist/assets/mobile-BljNuNl6.js +2 -0
  45. package/dist/assets/multirun-Bdv-VeHM.js +4 -0
  46. package/dist/assets/persistence-6sclr7B6.js +3 -0
  47. package/dist/assets/pl-BuCO3qb1.js +15 -0
  48. package/dist/assets/platform-CbeLpKPk.js +2 -0
  49. package/dist/assets/projectMeta-EGeQpzvX.js +3 -0
  50. package/dist/assets/{pt-BR-DWbGs1bc.js → pt-BR-Brf4RMsw.js} +4 -4
  51. package/dist/assets/radio-BPJ5XDOL.js +1 -0
  52. package/dist/assets/renderElectronMiniChatApp-7_MeDuZ2.js +2 -0
  53. package/dist/assets/renderMobileApp-J09WW2wY.js +23 -0
  54. package/dist/assets/runtimeConfig-DbkphGhQ.js +2 -0
  55. package/dist/assets/runtimeEndpointReset-DooWSsoH.js +147 -0
  56. package/dist/assets/{sortable-tabs-strip-pSnPuBXA.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
  57. package/dist/assets/src-Yqt5hwRm.css +2 -0
  58. package/dist/assets/{store-BuXogmxp.js → store-DQY8Fkb0.js} +4 -4
  59. package/dist/assets/{terminalApi-jfes-HIU.js → terminalApi-BpPeXWIu.js} +1 -1
  60. package/dist/assets/{tooltip-rN8LrxMF.js → tooltip-D_orJMYd.js} +1 -1
  61. package/dist/assets/{uk-DgOxtvS7.js → uk-C-wmaPTi.js} +4 -4
  62. package/dist/assets/{url-B0Ak7X6b.js → url-BsaBG5-1.js} +1 -1
  63. package/dist/assets/useAppFontEffects-DFRR7hqN.js +7 -0
  64. package/dist/assets/{useAssistantUIStore-SPh025NE.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-BSHPIcFE.js → useDesktopWindowControlsLayout-7oe0nJkK.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-Cbsu48gT.js → useEffectiveDirectory-DEbnpVAu.js} +1 -1
  67. package/dist/assets/{useFontPreferences-Bz3crRRO.js → useFontPreferences-ckzpXQaJ.js} +1 -1
  68. package/dist/assets/useMobileNavigationStore-CPi-bAr0.js +475 -0
  69. package/dist/assets/useSessionAutoCleanup-BFwpZAij.js +1 -0
  70. package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
  71. package/dist/assets/{useWorkerHighlightedLines-D7ZRwkm3.js → useWorkerHighlightedLines-Cb7i5p7s.js} +1 -1
  72. package/dist/assets/{utils-DfPDhqys.js → utils-B1EK5zKa.js} +1 -1
  73. package/dist/assets/{zh-CN-BPGatJp9.js → zh-CN-DueRrIql.js} +4 -4
  74. package/dist/assets/{zh-TW-qUWWsaxK.js → zh-TW-6875X80V.js} +4 -4
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.d.ts +0 -4
  80. package/server/index.js +11 -20
  81. package/server/lib/client-auth/pairing.js +1 -7
  82. package/server/lib/client-auth/pairing.test.js +0 -45
  83. package/server/lib/config-sync/config-sync.test.js +52 -0
  84. package/server/lib/config-sync/scripts.js +10 -2
  85. package/server/lib/notifications/DOCUMENTATION.md +2 -1
  86. package/server/lib/notifications/emitter-runtime.js +78 -1
  87. package/server/lib/notifications/emitter-runtime.test.js +94 -1
  88. package/server/lib/opencode/DOCUMENTATION.md +4 -4
  89. package/server/lib/opencode/bootstrap-runtime.js +0 -6
  90. package/server/lib/opencode/cli-entry-runtime.js +5 -1
  91. package/server/lib/opencode/cli-entry-runtime.test.js +17 -0
  92. package/server/lib/opencode/core-routes.js +0 -3
  93. package/server/lib/opencode/core-routes.test.js +0 -15
  94. package/server/lib/opencode/openchamber-routes.js +0 -11
  95. package/server/lib/relay/DOCUMENTATION.md +3 -45
  96. package/server/lib/relay/host-client.js +1 -4
  97. package/server/lib/relay/service.js +28 -16
  98. package/server/lib/relay/service.test.js +64 -4
  99. package/server/lib/relay/tunnel-host.js +2 -52
  100. package/server/lib/relay/tunnel-host.test.js +2 -250
  101. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -1
  102. package/server/lib/scheduled-tasks/routes.js +51 -8
  103. package/server/lib/session-assist/DOCUMENTATION.md +4 -2
  104. package/server/lib/session-assist/runtime.js +67 -30
  105. package/server/lib/session-assist/runtime.test.js +114 -0
  106. package/server/lib/session-title/DOCUMENTATION.md +15 -10
  107. package/server/lib/session-title/runtime.js +24 -0
  108. package/server/lib/session-title/runtime.test.js +143 -0
  109. package/server/lib/ui-auth/ui-auth.js +3 -1
  110. package/server/lib/ui-auth/ui-auth.test.js +25 -0
  111. package/server/sse-routes.test.js +64 -1
  112. package/dist/assets/AssistantView-DMK-KCPX.js +0 -1
  113. package/dist/assets/MobileShareBridge-lTfofNXr.js +0 -4
  114. package/dist/assets/MobileSurface-Bc_pAGrN.js +0 -2
  115. package/dist/assets/appThemeRegistry-ChhJvOPo.js +0 -2
  116. package/dist/assets/desktopHosts-aA40MHzB.js +0 -1
  117. package/dist/assets/desktopRelayRestore-CxUYRC6v.js +0 -1
  118. package/dist/assets/fileQueries-BSE4ycQt.js +0 -1
  119. package/dist/assets/insertionBoundaries-BIS62xbD.js +0 -1
  120. package/dist/assets/main-ClQdYXuu.js +0 -172
  121. package/dist/assets/mobile-CJkbELE1.js +0 -2
  122. package/dist/assets/multirun-DfYluvqf.js +0 -4
  123. package/dist/assets/persistence-DwXJK4-j.js +0 -3
  124. package/dist/assets/pl-DCsKBRAg.js +0 -15
  125. package/dist/assets/platform-CXJwZIZe.js +0 -2
  126. package/dist/assets/projectMeta-jG2rzIks.js +0 -3
  127. package/dist/assets/radio-CIR_xKSG.js +0 -1
  128. package/dist/assets/renderElectronMiniChatApp-D6cOGBOZ.js +0 -2
  129. package/dist/assets/renderMobileApp-BuiAqCNI.js +0 -23
  130. package/dist/assets/runtimeConfig-B9P24SI-.js +0 -2
  131. package/dist/assets/runtimeEndpointReset-BLmUiRJq.js +0 -147
  132. package/dist/assets/src-DF4hiLrB.css +0 -2
  133. package/dist/assets/useAppFontEffects-DCQnaU6M.js +0 -7
  134. package/dist/assets/useMobileNavigationStore-BYiwDqQe.js +0 -476
  135. package/dist/assets/useSessionAutoCleanup-DDdudIWc.js +0 -1
  136. package/dist/assets/useUIStore-BJpkPwky.js +0 -1
  137. package/server/lib/desktop-hosts/routes.js +0 -157
  138. package/server/lib/desktop-hosts/routes.test.js +0 -227
@@ -7,18 +7,17 @@
7
7
  // corrupted payload. This test must stay red until send serialization is fixed.
8
8
  // Production send path is intentionally not changed here.
9
9
 
10
- import { afterEach, describe, expect, it } from 'bun:test';
10
+ import { describe, expect, it } from 'bun:test';
11
11
 
12
12
  import {
13
13
  MAX_TUNNEL_PAYLOAD_BYTES,
14
14
  TunnelFrameType,
15
15
  createFragmentAssembler,
16
- decodeJsonPayload,
17
16
  decodeTunnelFrame,
18
17
  encodeJsonPayload,
19
18
  encodeTunnelFrame,
20
19
  } from './tunnel-codec.js';
21
- import { createTunnelHost, resolveTargetPort } from './tunnel-host.js';
20
+ import { createTunnelHost } from './tunnel-host.js';
22
21
 
23
22
  const textEncoder = new TextEncoder();
24
23
  const textDecoder = new TextDecoder();
@@ -224,250 +223,3 @@ describe('createTunnelHost WS fragment ordering', () => {
224
223
  }
225
224
  });
226
225
  });
227
-
228
- const collectHttpExchange = async (outbound, streamId) => {
229
- await waitFor(() => outbound.some((frame) => {
230
- const decoded = decodeTunnelFrame(frame);
231
- return decoded.streamId === streamId
232
- && (decoded.frameType === TunnelFrameType.StreamEnd
233
- || decoded.frameType === TunnelFrameType.StreamAbort);
234
- }));
235
- let status = null;
236
- /** @type {Uint8Array[]} */
237
- const bodyChunks = [];
238
- for (const raw of outbound) {
239
- const frame = decodeTunnelFrame(raw);
240
- if (frame.streamId !== streamId) continue;
241
- if (frame.frameType === TunnelFrameType.HttpResponse) {
242
- status = decodeJsonPayload(frame.payload, (v) => v && typeof v.status === 'number').status;
243
- } else if (frame.frameType === TunnelFrameType.HttpBody) {
244
- bodyChunks.push(frame.payload);
245
- }
246
- }
247
- let body = null;
248
- if (bodyChunks.length > 0) {
249
- const total = bodyChunks.reduce((n, c) => n + c.length, 0);
250
- const merged = new Uint8Array(total);
251
- let off = 0;
252
- for (const c of bodyChunks) {
253
- merged.set(c, off);
254
- off += c.length;
255
- }
256
- body = JSON.parse(textDecoder.decode(merged));
257
- }
258
- return { status, body };
259
- };
260
-
261
- describe('createTunnelHost SSH target-port routing', () => {
262
- /** @type {typeof globalThis.fetch | undefined} */
263
- let originalFetch;
264
-
265
- afterEach(() => {
266
- if (originalFetch) {
267
- globalThis.fetch = originalFetch;
268
- originalFetch = undefined;
269
- }
270
- });
271
-
272
- const installFetchProbe = () => {
273
- /** @type {{ url: string, headers: Record<string, string> }[]} */
274
- const calls = [];
275
- originalFetch = globalThis.fetch;
276
- globalThis.fetch = async (url, init) => {
277
- calls.push({
278
- url: String(url),
279
- headers: { ...(init?.headers || {}) },
280
- });
281
- return new Response(JSON.stringify({ ok: true }), {
282
- status: 200,
283
- headers: { 'content-type': 'application/json' },
284
- });
285
- };
286
- return calls;
287
- };
288
-
289
- it('dials the routing-table port when x-openchamber-target-port is allowed', async () => {
290
- const fetchCalls = installFetchProbe();
291
- /** @type {Uint8Array[]} */
292
- const outbound = [];
293
- const host = createTunnelHost({
294
- connectionId: 'test-conn',
295
- getLocalPort: () => 3000,
296
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
297
- sendFrame: (frame) => { outbound.push(frame); },
298
- getBufferedAmount: () => 0,
299
- });
300
-
301
- const streamId = 11;
302
- await host.handleFrame(encodeTunnelFrame(
303
- TunnelFrameType.HttpRequest,
304
- streamId,
305
- encodeJsonPayload({
306
- method: 'GET',
307
- path: '/health',
308
- query: '',
309
- headers: { 'x-openchamber-target-port': '41234', accept: 'application/json' },
310
- }),
311
- ));
312
-
313
- const exchange = await collectHttpExchange(outbound, streamId);
314
- expect(exchange.status).toBe(200);
315
- expect(fetchCalls).toHaveLength(1);
316
- expect(fetchCalls[0].url).toBe('http://127.0.0.1:41234/health');
317
- host.close();
318
- });
319
-
320
- it('returns 503 and does not fall back when target-port is unknown', async () => {
321
- const fetchCalls = installFetchProbe();
322
- /** @type {Uint8Array[]} */
323
- const outbound = [];
324
- const host = createTunnelHost({
325
- connectionId: 'test-conn',
326
- getLocalPort: () => 3000,
327
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
328
- sendFrame: (frame) => { outbound.push(frame); },
329
- getBufferedAmount: () => 0,
330
- });
331
-
332
- const streamId = 12;
333
- await host.handleFrame(encodeTunnelFrame(
334
- TunnelFrameType.HttpRequest,
335
- streamId,
336
- encodeJsonPayload({
337
- method: 'GET',
338
- path: '/health',
339
- query: '',
340
- headers: { 'x-openchamber-target-port': '9999' },
341
- }),
342
- ));
343
-
344
- const exchange = await collectHttpExchange(outbound, streamId);
345
- expect(exchange.status).toBe(503);
346
- expect(exchange.body?.error).toBe('ssh-host-unreachable');
347
- expect(fetchCalls).toHaveLength(0);
348
- host.close();
349
- });
350
-
351
- it('strips x-openchamber-target-port before forwarding to loopback', async () => {
352
- const fetchCalls = installFetchProbe();
353
- /** @type {Uint8Array[]} */
354
- const outbound = [];
355
- const host = createTunnelHost({
356
- connectionId: 'test-conn',
357
- getLocalPort: () => 3000,
358
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
359
- sendFrame: (frame) => { outbound.push(frame); },
360
- getBufferedAmount: () => 0,
361
- });
362
-
363
- const streamId = 13;
364
- await host.handleFrame(encodeTunnelFrame(
365
- TunnelFrameType.HttpRequest,
366
- streamId,
367
- encodeJsonPayload({
368
- method: 'GET',
369
- path: '/api/version',
370
- query: '',
371
- headers: {
372
- 'x-openchamber-target-port': '41234',
373
- authorization: 'Bearer client-token',
374
- },
375
- }),
376
- ));
377
-
378
- await collectHttpExchange(outbound, streamId);
379
- expect(fetchCalls).toHaveLength(1);
380
- expect(fetchCalls[0].headers['x-openchamber-target-port']).toBeUndefined();
381
- expect(fetchCalls[0].headers.authorization).toBe('Bearer client-token');
382
- expect(fetchCalls[0].headers['x-openchamber-relay-connection']).toBe('test-conn');
383
- host.close();
384
- });
385
-
386
- it('dials the default local port when target-port header is absent', async () => {
387
- const fetchCalls = installFetchProbe();
388
- /** @type {Uint8Array[]} */
389
- const outbound = [];
390
- const host = createTunnelHost({
391
- connectionId: 'test-conn',
392
- getLocalPort: () => 3000,
393
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
394
- sendFrame: (frame) => { outbound.push(frame); },
395
- getBufferedAmount: () => 0,
396
- });
397
-
398
- const streamId = 14;
399
- await host.handleFrame(encodeTunnelFrame(
400
- TunnelFrameType.HttpRequest,
401
- streamId,
402
- encodeJsonPayload({
403
- method: 'GET',
404
- path: '/health',
405
- query: '',
406
- headers: { accept: 'application/json' },
407
- }),
408
- ));
409
-
410
- await collectHttpExchange(outbound, streamId);
411
- expect(fetchCalls).toHaveLength(1);
412
- expect(fetchCalls[0].url).toBe('http://127.0.0.1:3000/health');
413
- host.close();
414
- });
415
-
416
- it('WS open dials routing-table port and aborts on miss without fallback', async () => {
417
- FakeWebSocket.reset();
418
- /** @type {Uint8Array[]} */
419
- const outbound = [];
420
- const host = createTunnelHost({
421
- connectionId: 'test-conn',
422
- getLocalPort: () => 3000,
423
- getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
424
- sendFrame: (frame) => { outbound.push(frame); },
425
- getBufferedAmount: () => 0,
426
- createWebSocket: (url, protocols) => new FakeWebSocket(url, protocols),
427
- });
428
-
429
- await host.handleFrame(encodeTunnelFrame(
430
- TunnelFrameType.WsOpen,
431
- 21,
432
- encodeJsonPayload({
433
- path: '/api/event/ws',
434
- query: '',
435
- headers: { 'x-openchamber-target-port': '41234' },
436
- }),
437
- ));
438
- expect(FakeWebSocket.instances).toHaveLength(1);
439
- expect(FakeWebSocket.instances[0].url).toBe('ws://127.0.0.1:41234/api/event/ws');
440
-
441
- await host.handleFrame(encodeTunnelFrame(
442
- TunnelFrameType.WsOpen,
443
- 22,
444
- encodeJsonPayload({
445
- path: '/api/event/ws',
446
- query: '',
447
- headers: { 'x-openchamber-target-port': '9999' },
448
- }),
449
- ));
450
- // Miss must not open another socket (no fallback to 3000).
451
- expect(FakeWebSocket.instances).toHaveLength(1);
452
- await waitFor(() => outbound.some((frame) => {
453
- const decoded = decodeTunnelFrame(frame);
454
- return decoded.streamId === 22 && decoded.frameType === TunnelFrameType.StreamAbort;
455
- }));
456
- const abort = outbound
457
- .map((frame) => decodeTunnelFrame(frame))
458
- .find((frame) => frame.streamId === 22 && frame.frameType === TunnelFrameType.StreamAbort);
459
- expect(decodeJsonPayload(abort.payload, () => true).reason).toBe('ssh-host-unreachable');
460
- host.close();
461
- });
462
- });
463
-
464
- describe('resolveTargetPort', () => {
465
- it('returns default when header is absent and null on table miss', () => {
466
- const table = () => [{ id: 'ssh-1', localPort: 41234 }];
467
- expect(resolveTargetPort({}, 3000, table)).toBe(3000);
468
- expect(resolveTargetPort({ 'x-openchamber-target-port': '41234' }, 3000, table)).toBe(41234);
469
- expect(resolveTargetPort({ 'X-OpenChamber-Target-Port': '41234' }, 3000, table)).toBe(41234);
470
- expect(resolveTargetPort({ 'x-openchamber-target-port': '9999' }, 3000, table)).toBeNull();
471
- expect(resolveTargetPort({ 'x-openchamber-target-port': 'not-a-port' }, 3000, table)).toBeNull();
472
- });
473
- });
@@ -31,7 +31,10 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
31
31
  - Global run history list endpoint
32
32
  - Project scheduled task CRUD endpoints
33
33
  - Manual run endpoint
34
- - OpenChamber events SSE stream endpoint
34
+ - OpenChamber events SSE stream endpoint (`GET /api/openchamber/events`): 25s
35
+ heartbeat interval unchanged; half-open clients that stay paused across two
36
+ consecutive heartbeat cycles are destroyed and removed from the client set
37
+ (idempotent cleanup). SSE is best-effort; authoritative data is HTTP pull.
35
38
  - Returns persisted mutation results with `schedulerSynced`; a failed scheduler
36
39
  sync schedules one bounded retry while preserving the committed response
37
40
 
@@ -321,6 +321,33 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
321
321
  const clients = getOpenChamberEventClients();
322
322
  clients.add(res);
323
323
 
324
+ let closed = false;
325
+ let heartbeatTimer = null;
326
+ // Half-open / stalled-drain eviction: destroy after this many consecutive
327
+ // heartbeat cycles while the socket remains paused (no drain). Interval
328
+ // itself is unchanged — only failure cleanup was added.
329
+ // SSE is best-effort; authoritative data is fetched over HTTP.
330
+ const maxPausedHeartbeatCycles = 2;
331
+
332
+ const cleanup = () => {
333
+ if (closed) {
334
+ return;
335
+ }
336
+ closed = true;
337
+ if (heartbeatTimer) {
338
+ clearInterval(heartbeatTimer);
339
+ heartbeatTimer = null;
340
+ }
341
+ clients.delete(res);
342
+ try {
343
+ if (!res.destroyed && !res.writableEnded) {
344
+ res.destroy();
345
+ }
346
+ } catch {
347
+ // ignore — destroy + delete must stay idempotent
348
+ }
349
+ };
350
+
324
351
  try {
325
352
  writeSseEvent(res, {
326
353
  type: 'openchamber:event-stream-ready',
@@ -329,25 +356,41 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
329
356
  },
330
357
  });
331
358
  } catch {
359
+ cleanup();
360
+ return;
332
361
  }
333
362
 
334
- const heartbeat = setInterval(() => {
363
+ heartbeatTimer = setInterval(() => {
364
+ if (closed || res.writableEnded || res.destroyed) {
365
+ cleanup();
366
+ return;
367
+ }
368
+
335
369
  try {
336
- writeSseEvent(res, {
370
+ const ok = writeSseEvent(res, {
337
371
  type: 'openchamber:heartbeat',
338
372
  properties: {
339
373
  timestamp: Date.now(),
340
374
  },
341
375
  });
376
+ if (ok === false || res.__ssePaused) {
377
+ const cycles = (res.__ssePausedHeartbeatCycles || 0) + 1;
378
+ res.__ssePausedHeartbeatCycles = cycles;
379
+ if (cycles >= maxPausedHeartbeatCycles) {
380
+ // Intentional half-open cleanup: TCP may still look open while the
381
+ // relay tunnel is dead and drain never fires. SSE is best-effort;
382
+ // authoritative data is fetched over HTTP.
383
+ cleanup();
384
+ }
385
+ return;
386
+ }
387
+ res.__ssePausedHeartbeatCycles = 0;
342
388
  } catch {
343
- clearInterval(heartbeat);
344
- clients.delete(res);
389
+ cleanup();
345
390
  }
346
391
  }, 25_000);
347
392
 
348
- req.on('close', () => {
349
- clearInterval(heartbeat);
350
- clients.delete(res);
351
- });
393
+ req.on('close', cleanup);
394
+ res.on('error', cleanup);
352
395
  });
353
396
  };
@@ -14,8 +14,10 @@ under `metadata.openchamber.assist`.
14
14
  `retry` status or a user `message.updated` clears it (the "1 minute of
15
15
  quiet" rule).
16
16
  3. On fire: fetch the session (skip sub-agent sessions with `parentID`),
17
- take the LAST exchange only the final assistant reply plus the user
18
- message it answered (assistant `parentID` user id) and call
17
+ then build a trimmed excerpt via `buildAssistTranscript`: the LAST text
18
+ block of the last assistant message (earlier blocks, reasoning, and tool
19
+ output are excluded) plus the latest 3 real user messages (synthetic
20
+ hidden-instruction parts never count). The excerpt is sent to
19
21
  `generateSmallModelText` with the
20
22
  session's own provider/model taken from the last assistant message — so
21
23
  the utility call spends the same subscription as the conversation.
@@ -33,6 +33,8 @@ const isSessionRecapEnabled = () => {
33
33
 
34
34
  const IDLE_QUIET_MS = 60_000;
35
35
  const TRANSCRIPT_MESSAGE_LIMIT = 12;
36
+ /** Latest real user messages fed to the recap prompt. */
37
+ const TRANSCRIPT_USER_MESSAGE_LIMIT = 3;
36
38
  const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
37
39
  const RECAP_CHAR_LIMIT = 320;
38
40
  const FETCH_TIMEOUT_MS = 5_000;
@@ -92,13 +94,63 @@ const extractUserMessage = (payload) => {
92
94
  };
93
95
  };
94
96
 
95
- const messagePartsToText = (message) => {
97
+ /** Real (non-synthetic) text parts of a message — hidden instructions excluded. */
98
+ const realTextParts = (message) => {
96
99
  const parts = Array.isArray(message?.parts) ? message.parts : [];
97
100
  return parts
98
- .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
99
- .filter(Boolean)
100
- .join('\n')
101
- .slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
101
+ .filter((part) => part?.type === 'text'
102
+ && typeof part.text === 'string'
103
+ && part.text.trim().length > 0
104
+ && part.synthetic !== true)
105
+ .map((part) => part.text);
106
+ };
107
+
108
+ /** Body text of a message: only its LAST text block, char-clamped. */
109
+ const lastMessageText = (message) => {
110
+ const texts = realTextParts(message);
111
+ return texts.length > 0 ? texts[texts.length - 1].slice(0, TRANSCRIPT_PART_CHAR_LIMIT) : '';
112
+ };
113
+
114
+ /**
115
+ * Build the recap transcript from a bounded recent-message window.
116
+ * - Assistant: only the LAST text block of the last assistant message —
117
+ * earlier blocks, reasoning, and tool output are token waste for a
118
+ * one-line recap.
119
+ * - User: the latest real user messages (default 3), chronological.
120
+ */
121
+ export const buildAssistTranscript = (
122
+ messages,
123
+ { userLimit = TRANSCRIPT_USER_MESSAGE_LIMIT } = {},
124
+ ) => {
125
+ if (!Array.isArray(messages) || messages.length === 0) {
126
+ return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
127
+ }
128
+ let lastAssistant = null;
129
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
130
+ if (messages[i]?.info?.role === 'assistant') {
131
+ lastAssistant = messages[i];
132
+ break;
133
+ }
134
+ }
135
+ const lastAssistantInfo = lastAssistant?.info ?? null;
136
+ if (!lastAssistantInfo?.id) {
137
+ return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
138
+ }
139
+
140
+ const userTexts = [];
141
+ for (let i = messages.length - 1; i >= 0 && userTexts.length < userLimit; i -= 1) {
142
+ const message = messages[i];
143
+ if (message?.info?.role !== 'user') continue;
144
+ const text = lastMessageText(message);
145
+ if (!text) continue;
146
+ userTexts.unshift(text);
147
+ }
148
+ const assistantText = lastMessageText(lastAssistant);
149
+ const transcript = [
150
+ ...userTexts.map((text) => `User:\n${text}`),
151
+ assistantText ? `Assistant:\n${assistantText}` : '',
152
+ ].filter(Boolean).join('\n\n');
153
+ return { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText };
102
154
  };
103
155
 
104
156
  export const createSessionAssistRuntime = ({
@@ -169,36 +221,21 @@ export const createSessionAssistRuntime = ({
169
221
  return;
170
222
  }
171
223
 
172
- let lastAssistant = null;
173
- for (let i = messages.length - 1; i >= 0; i -= 1) {
174
- const info = messages[i]?.info;
175
- if (info?.role === 'assistant') {
176
- lastAssistant = messages[i];
177
- break;
178
- }
179
- }
180
- const lastAssistantInfo = lastAssistant?.info;
224
+ // Trimmed extraction for a one-line recap: the last assistant text block
225
+ // plus the latest real user messages. Everything else (earlier blocks,
226
+ // reasoning, tool output, older turns) is token waste.
227
+ const { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText } = buildAssistTranscript(messages);
181
228
  if (!lastAssistantInfo?.id) return;
182
-
183
- // Only the last exchange: the assistant reply plus the user message it
184
- // answered (assistant info.parentID → user info.id). Everything else is
185
- // token waste for a one-line recap.
186
- const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
187
- ? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
188
- : null;
189
- const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
190
- const assistantText = messagePartsToText(lastAssistant);
191
- const transcript = [
192
- userText ? `User:\n${userText}` : '',
193
- assistantText ? `Assistant:\n${assistantText}` : '',
194
- ].filter(Boolean).join('\n\n');
195
229
  if (!transcript) return;
196
230
 
197
231
  const { generateSmallModelText } = await getSmallModelService();
198
232
  // Instruct the language by example, not by description — account-side
199
233
  // personalization (e.g. the ChatGPT backend knowing the user's locale)
200
234
  // otherwise leaks a different language into the output.
201
- const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim();
235
+ const languageSample = (userTexts[userTexts.length - 1] || assistantText)
236
+ .slice(0, 200)
237
+ .replace(/\s+/g, ' ')
238
+ .trim();
202
239
  let generated;
203
240
  try {
204
241
  generated = await generateSmallModelText({
@@ -206,7 +243,7 @@ export const createSessionAssistRuntime = ({
206
243
  // session's own provider unless the user explicitly picked a small
207
244
  // model (settings override / opencode config).
208
245
  restrictToPreferredProvider: true,
209
- prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
246
+ prompt: `The latest conversation excerpt:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
210
247
  system: ASSIST_SYSTEM_PROMPT,
211
248
  directory,
212
249
  preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
@@ -228,7 +265,7 @@ export const createSessionAssistRuntime = ({
228
265
  // no Cyrillic/CJK at all, the output must not either.
229
266
  const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
230
267
  const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
231
- const inputText = `${userText}\n${assistantText}`;
268
+ const inputText = `${userTexts.join('\n')}\n${assistantText}`;
232
269
  const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
233
270
  || (hasCjk(text) && !hasCjk(inputText));
234
271
  if (recap && scriptMismatch(recap)) {
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { buildAssistTranscript } from './runtime.js';
3
+
4
+ const textPart = (text, { synthetic = false } = {}) => ({
5
+ type: 'text',
6
+ text,
7
+ ...(synthetic ? { synthetic: true } : {}),
8
+ });
9
+
10
+ const userMessage = (id, parts) => ({ info: { id, role: 'user' }, parts });
11
+ const assistantMessage = (id, parts) => ({ info: { id, role: 'assistant' }, parts });
12
+
13
+ describe('buildAssistTranscript', () => {
14
+ test('assistant keeps only the LAST text block of the last assistant message', () => {
15
+ const result = buildAssistTranscript([
16
+ userMessage('u1', [textPart('first user message')]),
17
+ assistantMessage('a1', [textPart('old assistant reply')]),
18
+ userMessage('u2', [textPart('second user message')]),
19
+ assistantMessage('a2', [
20
+ textPart('intro before tools'),
21
+ { type: 'tool', tool: 'bash' },
22
+ textPart('final body text'),
23
+ ]),
24
+ ]);
25
+ expect(result.assistantInfo.id).toBe('a2');
26
+ expect(result.assistantText).toBe('final body text');
27
+ expect(result.transcript).toBe(
28
+ 'User:\nfirst user message\n\nUser:\nsecond user message\n\nAssistant:\nfinal body text',
29
+ );
30
+ });
31
+
32
+ test('reasoning parts never enter the transcript', () => {
33
+ const result = buildAssistTranscript([
34
+ userMessage('u1', [textPart('请帮我看看')]),
35
+ assistantMessage('a1', [
36
+ { type: 'reasoning', text: 'thinking about the problem' },
37
+ textPart('最终答复'),
38
+ ]),
39
+ ]);
40
+ expect(result.transcript).toBe('User:\n请帮我看看\n\nAssistant:\n最终答复');
41
+ expect(result.transcript).not.toContain('thinking');
42
+ });
43
+
44
+ test('user side keeps only the latest 3 real user messages, chronologically', () => {
45
+ const result = buildAssistTranscript([
46
+ userMessage('u1', [textPart('question 1')]),
47
+ assistantMessage('a1', [textPart('answer 1')]),
48
+ userMessage('u2', [textPart('question 2')]),
49
+ assistantMessage('a2', [textPart('answer 2')]),
50
+ userMessage('u3', [textPart('question 3')]),
51
+ userMessage('u4', [textPart('question 4')]),
52
+ assistantMessage('a3', [textPart('answer 3')]),
53
+ ]);
54
+ expect(result.userTexts).toEqual(['question 2', 'question 3', 'question 4']);
55
+ expect(result.transcript).toBe(
56
+ 'User:\nquestion 2\n\nUser:\nquestion 3\n\nUser:\nquestion 4\n\nAssistant:\nanswer 3',
57
+ );
58
+ });
59
+
60
+ test('synthetic-only user messages do not count toward the user window', () => {
61
+ const result = buildAssistTranscript([
62
+ userMessage('u1', [textPart('visible request')]),
63
+ userMessage('u2', [textPart('hidden instruction', { synthetic: true })]),
64
+ userMessage('u3', [textPart('real follow-up')]),
65
+ assistantMessage('a1', [textPart('reply body')]),
66
+ ]);
67
+ expect(result.userTexts).toEqual(['visible request', 'real follow-up']);
68
+ expect(result.transcript).not.toContain('hidden instruction');
69
+ });
70
+
71
+ test('synthetic instruction part on a real user message is dropped', () => {
72
+ const result = buildAssistTranscript([
73
+ userMessage('u1', [
74
+ textPart('visible request'),
75
+ textPart('/summary hidden instructions', { synthetic: true }),
76
+ ]),
77
+ assistantMessage('a1', [textPart('reply body')]),
78
+ ]);
79
+ expect(result.userTexts).toEqual(['visible request']);
80
+ expect(result.transcript).not.toContain('/summary hidden instructions');
81
+ });
82
+
83
+ test('fewer than 3 user messages keeps all of them', () => {
84
+ const result = buildAssistTranscript([
85
+ userMessage('u1', [textPart('only question')]),
86
+ assistantMessage('a1', [textPart('only answer')]),
87
+ ]);
88
+ expect(result.userTexts).toEqual(['only question']);
89
+ expect(result.transcript).toBe('User:\nonly question\n\nAssistant:\nonly answer');
90
+ });
91
+
92
+ test('no assistant message yields an empty transcript', () => {
93
+ const result = buildAssistTranscript([
94
+ userMessage('u1', [textPart('unanswered question')]),
95
+ ]);
96
+ expect(result.transcript).toBe('');
97
+ expect(result.assistantInfo).toBeNull();
98
+ expect(result.userTexts).toEqual([]);
99
+ });
100
+
101
+ test('assistant with no text parts still summarizes the user side', () => {
102
+ const result = buildAssistTranscript([
103
+ userMessage('u1', [textPart('question')]),
104
+ assistantMessage('a1', [{ type: 'tool', tool: 'bash' }]),
105
+ ]);
106
+ expect(result.assistantInfo.id).toBe('a1');
107
+ expect(result.transcript).toBe('User:\nquestion');
108
+ });
109
+
110
+ test('empty input yields an empty transcript', () => {
111
+ expect(buildAssistTranscript([]).transcript).toBe('');
112
+ expect(buildAssistTranscript(null).transcript).toBe('');
113
+ });
114
+ });
@@ -18,16 +18,21 @@ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
18
18
  pattern as session-assist. Purely event-driven — dormant sessions never
19
19
  generate anything.
20
20
  2. Auto title refresh is intentionally sparse (title stability first). A newly
21
- observed root `session.created` generates its first title immediately on the
22
- first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
23
- newly-created user message; the matching assistant completion triggers an
24
- immediate title refresh that bypasses inherited title metadata and throttle.
25
- Ordinary later idle transitions do **not** arm another refresh. Any
26
- `busy`/`retry` status or a fresh user `message.updated` still clears an
27
- already-armed timer (so initial/fork timers cancel if the user keeps going).
28
- A sidebar smart-title request sets `titleRefresh.requestedAt`; its
29
- `session.updated` event arms the same flow immediately (forced / manual
30
- refresh is unaffected by the background gate).
21
+ observed root `session.created` generates its first title immediately on the
22
+ first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
23
+ newly-created user message; the matching assistant completion triggers an
24
+ immediate title refresh that bypasses inherited title metadata and throttle.
25
+ If the fork's `session.created` was lost (SSE reconnect gap, server or
26
+ OpenCode restart), the first newly-created user message lazily re-registers
27
+ the pending fork from the session read inside `recordUserActivity` (fork
28
+ title + message created after the fork + activity timestamp not yet past
29
+ the fork time), so the first-reply refresh still fires.
30
+ Ordinary later idle transitions do **not** arm another refresh. Any
31
+ `busy`/`retry` status or a fresh user `message.updated` still clears an
32
+ already-armed timer (so initial/fork timers cancel if the user keeps going).
33
+ A sidebar smart-title request sets `titleRefresh.requestedAt`; its
34
+ `session.updated` event arms the same flow immediately (forced / manual
35
+ refresh is unaffected by the background gate).
31
36
  3. On fire:
32
37
  - Skip when Settings → Chat → session title refresh is off.
33
38
  - Skip system-owned sessions whose metadata carries a non-empty