@vantran-se/openchamber-web 1.22.1 → 1.23.0

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 (238) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +11 -1
  3. package/bin/cli.test.js +92 -0
  4. package/bin/lib/cli-startup.js +49 -0
  5. package/bin/lib/commands-startup.js +41 -6
  6. package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
  7. package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
  8. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
  9. package/dist/assets/FilesView-BdTOpew9.js +17 -0
  10. package/dist/assets/GitView-Dqf9vHJh.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
  12. package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
  13. package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
  14. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
  15. package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
  17. package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
  18. package/dist/assets/PlanView-BoJxdRZe.js +5 -0
  19. package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
  21. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  22. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
  23. package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
  25. package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
  26. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
  27. package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
  28. package/dist/assets/de-C8jV7J9y.js +10 -0
  29. package/dist/assets/es-BI6J1M5R.js +16 -0
  30. package/dist/assets/fr-D6oDFt7E.js +14 -0
  31. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  32. package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
  33. package/dist/assets/index-BqhiCmYS.js +39 -0
  34. package/dist/assets/index-FqSOBz0J.css +1 -0
  35. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
  36. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
  37. package/dist/assets/ja-C_4i5EQu.js +16 -0
  38. package/dist/assets/ko-BFbOTpbq.js +16 -0
  39. package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
  40. package/dist/assets/main-CJmb2dF8.js +930 -0
  41. package/dist/assets/markdownCore-DVuZpnli.js +5 -0
  42. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
  43. package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
  44. package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
  45. package/dist/assets/pl-COFvT56l.js +16 -0
  46. package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
  48. package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
  50. package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
  51. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
  52. package/dist/assets/surface-BvRzS58U.js +1 -0
  53. package/dist/assets/tr-B8Q0Go2l.js +7 -0
  54. package/dist/assets/uk-Dk4a-Fn-.js +16 -0
  55. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
  56. package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
  57. package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
  58. package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
  59. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  60. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
  61. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  62. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  63. package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
  64. package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
  65. package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
  66. package/dist/index.html +5 -43
  67. package/dist/mini-chat.html +4 -4
  68. package/dist/mobile.html +4 -4
  69. package/package.json +8 -5
  70. package/server/index.d.ts +15 -0
  71. package/server/index.js +82 -13
  72. package/server/lib/agent-tool/runtime.js +2 -19
  73. package/server/lib/changelog/update-notes.js +82 -0
  74. package/server/lib/changelog/update-notes.test.js +59 -0
  75. package/server/lib/data-dir-migration.js +40 -0
  76. package/server/lib/data-dir-migration.test.js +59 -0
  77. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  78. package/server/lib/dev-tunnel/runtime.js +4 -4
  79. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  80. package/server/lib/event-stream/runtime.test.js +12 -12
  81. package/server/lib/fs/DOCUMENTATION.md +5 -0
  82. package/server/lib/fs/routes.js +63 -21
  83. package/server/lib/fs/routes.test.js +187 -1
  84. package/server/lib/git/DOCUMENTATION.md +32 -6
  85. package/server/lib/git/routes.js +40 -4
  86. package/server/lib/git/service.js +291 -215
  87. package/server/lib/git/service.test.js +593 -25
  88. package/server/lib/inherited-env.js +16 -9
  89. package/server/lib/inherited-env.test.js +8 -9
  90. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  91. package/server/lib/mcp-reconnect/runtime.js +122 -0
  92. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  93. package/server/lib/message-queue/DOCUMENTATION.md +161 -0
  94. package/server/lib/message-queue/runtime.js +870 -0
  95. package/server/lib/message-queue/runtime.test.js +482 -0
  96. package/server/lib/network-defaults.js +23 -0
  97. package/server/lib/network-defaults.test.js +52 -0
  98. package/server/lib/openchamber-sessions/routes.js +102 -0
  99. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  100. package/server/lib/opencode/DOCUMENTATION.md +30 -6
  101. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  102. package/server/lib/opencode/config-paths.test.js +116 -0
  103. package/server/lib/opencode/core-routes.js +1 -0
  104. package/server/lib/opencode/feature-routes-runtime.js +7 -0
  105. package/server/lib/opencode/input-history-scope.js +14 -0
  106. package/server/lib/opencode/managed-plugin-config.js +35 -0
  107. package/server/lib/opencode/openchamber-routes.js +64 -4
  108. package/server/lib/opencode/openchamber-routes.test.js +130 -3
  109. package/server/lib/opencode/plugins.js +2 -2
  110. package/server/lib/opencode/providers.js +44 -2
  111. package/server/lib/opencode/providers.test.js +131 -4
  112. package/server/lib/opencode/routes.js +9 -7
  113. package/server/lib/opencode/server-startup-runtime.js +1 -1
  114. package/server/lib/opencode/settings-files.js +214 -0
  115. package/server/lib/opencode/settings-helpers.js +113 -41
  116. package/server/lib/opencode/settings-helpers.test.js +342 -0
  117. package/server/lib/opencode/settings-registry.json +747 -0
  118. package/server/lib/opencode/settings-runtime.js +141 -20
  119. package/server/lib/opencode/settings-runtime.test.js +191 -4
  120. package/server/lib/opencode/shared.js +4 -1
  121. package/server/lib/opencode/shutdown-runtime.js +2 -0
  122. package/server/lib/opencode/skill-routes.js +2 -1
  123. package/server/lib/opencode/snippets.js +1 -2
  124. package/server/lib/package-manager.js +4 -29
  125. package/server/lib/package-manager.test.js +2 -2
  126. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  127. package/server/lib/project-context/routes.http.test.js +18 -0
  128. package/server/lib/project-context/routes.js +15 -0
  129. package/server/lib/project-context/runtime.js +248 -23
  130. package/server/lib/project-context/runtime.test.js +106 -3
  131. package/server/lib/projects/DOCUMENTATION.md +76 -0
  132. package/server/lib/projects/project-config.js +142 -9
  133. package/server/lib/projects/project-id.js +17 -0
  134. package/server/lib/projects/project-setup.js +409 -0
  135. package/server/lib/projects/project-setup.test.js +557 -0
  136. package/server/lib/projects/routes.js +59 -0
  137. package/server/lib/quota/DOCUMENTATION.md +40 -2
  138. package/server/lib/quota/credentials/providers.js +4 -0
  139. package/server/lib/quota/credentials/store.js +1 -1
  140. package/server/lib/quota/credentials/store.test.js +4 -4
  141. package/server/lib/quota/index.js +1 -0
  142. package/server/lib/quota/providers/cline-pass.js +125 -0
  143. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  144. package/server/lib/quota/providers/exe-dev.js +75 -0
  145. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  146. package/server/lib/quota/providers/hyper.js +118 -0
  147. package/server/lib/quota/providers/hyper.test.js +126 -0
  148. package/server/lib/quota/providers/index.js +22 -0
  149. package/server/lib/quota/providers/neuralwatt.js +3 -6
  150. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  151. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  152. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  153. package/server/lib/quota/providers/opencode-go.js +2 -1
  154. package/server/lib/quota/providers/opencode-go.test.js +31 -2
  155. package/server/lib/quota/providers/openrouter.js +119 -24
  156. package/server/lib/quota/providers/openrouter.test.js +330 -0
  157. package/server/lib/quota/routes.js +2 -0
  158. package/server/lib/quota/utils/auth.js +1 -1
  159. package/server/lib/relay/DOCUMENTATION.md +56 -1
  160. package/server/lib/relay/cross-compat.test.js +26 -0
  161. package/server/lib/relay/downstream-scheduler.js +154 -0
  162. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  163. package/server/lib/relay/e2ee.js +10 -8
  164. package/server/lib/relay/flow-control.test.js +177 -0
  165. package/server/lib/relay/host-client.js +37 -8
  166. package/server/lib/relay/host-client.test.js +9 -1
  167. package/server/lib/relay/tunnel-codec.js +9 -0
  168. package/server/lib/relay/tunnel-host.js +69 -18
  169. package/server/lib/relay/tunnel-host.test.js +9 -1
  170. package/server/lib/security/request-security.js +21 -6
  171. package/server/lib/security/request-security.test.js +42 -8
  172. package/server/lib/session-assist/runtime.js +7 -10
  173. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  174. package/server/lib/session-goal/create.js +3 -1
  175. package/server/lib/session-goal/runtime.js +81 -20
  176. package/server/lib/session-goal/runtime.test.js +426 -0
  177. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  178. package/server/lib/skills-catalog/install.js +3 -2
  179. package/server/lib/small-model/DOCUMENTATION.md +7 -2
  180. package/server/lib/small-model/call.js +17 -6
  181. package/server/lib/small-model/call.test.js +52 -0
  182. package/server/lib/small-model/index.js +7 -11
  183. package/server/lib/small-model/routes.js +2 -1
  184. package/server/lib/small-model/runtime-providers.js +15 -7
  185. package/server/lib/small-model/runtime-providers.test.js +7 -1
  186. package/server/lib/system-prompt/runtime.js +2 -21
  187. package/server/lib/terminal/DOCUMENTATION.md +14 -10
  188. package/server/lib/terminal/runtime.js +190 -31
  189. package/server/lib/terminal/runtime.test.js +571 -37
  190. package/server/lib/terminal/shells.js +17 -0
  191. package/server/lib/terminal/shells.test.js +32 -1
  192. package/server/lib/ui-auth/ui-auth.js +4 -1
  193. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  194. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  195. package/server/lib/walkthrough/index.js +1 -0
  196. package/server/lib/walkthrough/model-settings.js +8 -12
  197. package/server/lib/walkthrough/prompt.js +3 -1
  198. package/server/lib/walkthrough/routes.test.js +26 -5
  199. package/server/lib/walkthrough/sources.js +23 -2
  200. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  201. package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
  202. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  203. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  204. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  205. package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
  206. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  207. package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
  208. package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
  209. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  210. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  211. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  212. package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
  213. package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
  214. package/dist/assets/de-DqVn5vme.js +0 -9
  215. package/dist/assets/es-hj43Z53i.js +0 -15
  216. package/dist/assets/fr-BmkZt2mF.js +0 -13
  217. package/dist/assets/index-L_ncV-6H.css +0 -1
  218. package/dist/assets/index-qES19dlJ.js +0 -7
  219. package/dist/assets/ja-DioMIEGF.js +0 -15
  220. package/dist/assets/ko-BqPgaQJb.js +0 -15
  221. package/dist/assets/main-CYgSu6dx.js +0 -928
  222. package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
  223. package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
  224. package/dist/assets/pl-slULBF4R.js +0 -15
  225. package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
  226. package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
  227. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  228. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  229. package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
  230. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  231. package/dist/assets/uk-BkkfxbOs.js +0 -15
  232. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  233. package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
  234. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  235. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  236. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
  237. package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
  238. package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
@@ -15,7 +15,6 @@ import {
15
15
  createFragmentAssembler,
16
16
  decodeJsonPayload,
17
17
  decodeTunnelFrame,
18
- encodeFragmentedMessage,
19
18
  encodeJsonPayload,
20
19
  encodeTunnelFrame,
21
20
  } from './tunnel-codec.js';
@@ -33,7 +32,9 @@ const ALLOWED_WS_PATHS = new Set([
33
32
  '/api/event/ws',
34
33
  '/api/terminal/ws',
35
34
  '/api/dictation/ws',
35
+ '/api/dev-tunnel',
36
36
  ]);
37
+ export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname);
37
38
 
38
39
  // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
39
40
  // to the loopback origin. content-length is dropped too because the body is
@@ -72,6 +73,11 @@ const BODY_BUFFER_MAX_BYTES = 512 * 1024;
72
73
  // completes, so a stalled tunnel converts into an ambiguous transport failure
73
74
  // (which the client already retries) instead of a hung loopback request.
74
75
  const BODY_DELIVERY_TIMEOUT_MS = 15_000;
76
+ const MAX_PENDING_WS_BYTES = 16 * 1024 * 1024;
77
+ const MAX_PENDING_WS_MESSAGES = 1024;
78
+ // Node ws supports read backpressure. Bun's ws shim does not implement pause;
79
+ // there the bounded queue fails the affected substream explicitly on overflow.
80
+ const CAN_PAUSE_WS_READS = !process.versions.bun;
75
81
 
76
82
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
77
83
 
@@ -97,13 +103,17 @@ const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object
97
103
  * sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
98
104
  * getBufferedAmount: () => number,
99
105
  * bodyDeliveryTimeoutMs?: number,
106
+ * responseChunkBytes?: number,
107
+ * cancelPendingFrames?: (streamId: number) => void,
100
108
  * }} deps
101
109
  */
102
- export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount, bodyDeliveryTimeoutMs = BODY_DELIVERY_TIMEOUT_MS }) => {
110
+ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount, bodyDeliveryTimeoutMs = BODY_DELIVERY_TIMEOUT_MS, responseChunkBytes = MAX_TUNNEL_PAYLOAD_BYTES, cancelPendingFrames = () => {} }) => {
103
111
  /** @type {Map<number, { kind: 'http', abort: AbortController, body: { enqueue(payload: Uint8Array): void, close(): void, error(error: Error): void } | null, noBody: boolean } | { kind: 'ws', socket: WebSocket, opened: boolean }>} */
104
112
  const streams = new Map();
105
113
  const assembler = createFragmentAssembler();
106
114
  let closed = false;
115
+ let pendingWsBytes = 0;
116
+ let pendingWsMessages = 0;
107
117
 
108
118
  const send = async (frame) => {
109
119
  if (closed) return;
@@ -126,6 +136,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
126
136
  const stream = streams.get(streamId);
127
137
  if (!stream) return;
128
138
  dropStream(streamId);
139
+ cancelPendingFrames(streamId);
129
140
  if (stream.kind === 'http') {
130
141
  try {
131
142
  stream.body?.error(new Error(String(reason ?? 'aborted')));
@@ -203,17 +214,27 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
203
214
  if (STRIPPED_RESPONSE_HEADERS.has(name)) continue;
204
215
  responseHeaders[name] = value;
205
216
  }
206
- await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
217
+ if (closed || stream.abort.signal.aborted) {
218
+ await response.body?.cancel();
219
+ return;
220
+ }
207
221
 
208
222
  try {
223
+ await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
209
224
  if (response.body) {
210
225
  for await (const chunk of response.body) {
211
226
  if (closed || stream.abort.signal.aborted) return;
212
227
  const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
213
- for (const piece of chunkPayload(bytes, MAX_TUNNEL_PAYLOAD_BYTES)) {
228
+ const pieces = chunkPayload(bytes, responseChunkBytes);
229
+ // Offer at most one plaintext-frame budget at a time. The scheduler
230
+ // can batch small slices and interleave streams without buffering the
231
+ // entire source or encrypting hundreds of tiny messages separately.
232
+ const groupSize = Math.max(1, Math.floor(MAX_TUNNEL_PAYLOAD_BYTES / responseChunkBytes));
233
+ for (let offset = 0; offset < pieces.length; offset += groupSize) {
214
234
  await waitForBackpressure(stream.abort.signal);
215
235
  if (closed || stream.abort.signal.aborted) return;
216
- await send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece));
236
+ await Promise.all(pieces.slice(offset, offset + groupSize).map(piece =>
237
+ send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece))));
217
238
  }
218
239
  }
219
240
  }
@@ -425,7 +446,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
425
446
  void sendAbort(streamId, error?.message ?? 'malformed ws open');
426
447
  return;
427
448
  }
428
- if (!ALLOWED_WS_PATHS.has(open.path)) {
449
+ if (!isAllowedRelayWebSocketPath(open.path)) {
429
450
  void sendAbort(streamId, 'Path is not allowed through the relay');
430
451
  return;
431
452
  }
@@ -446,6 +467,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
446
467
  try {
447
468
  socket = new WebSocket(url, open.protocols, {
448
469
  headers: dialHeaders,
470
+ maxPayload: MAX_PENDING_WS_BYTES,
449
471
  });
450
472
  } catch (error) {
451
473
  void sendAbort(streamId, error?.message ?? 'ws dial failed');
@@ -453,6 +475,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
453
475
  }
454
476
  const stream = { kind: 'ws', socket, opened: false };
455
477
  streams.set(streamId, stream);
478
+ let outputChain = Promise.resolve();
479
+ let socketPendingMessages = 0;
456
480
 
457
481
  socket.on('open', () => {
458
482
  if (streams.get(streamId) !== stream) return;
@@ -463,23 +487,47 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
463
487
  if (streams.get(streamId) !== stream || closed) return;
464
488
  const bytes = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.concat(data));
465
489
  const frameType = isBinary ? TunnelFrameType.WsBinary : TunnelFrameType.WsText;
466
- void (async () => {
467
- for (const frame of encodeFragmentedMessage(frameType, streamId, bytes)) {
490
+ if (pendingWsBytes + bytes.length > MAX_PENDING_WS_BYTES || pendingWsMessages >= MAX_PENDING_WS_MESSAGES) {
491
+ abortLocalStream(streamId, 'upstream WebSocket exceeded downstream queue limit');
492
+ void sendAbort(streamId, 'upstream WebSocket exceeded downstream queue limit');
493
+ return;
494
+ }
495
+ pendingWsBytes += bytes.length;
496
+ pendingWsMessages += 1;
497
+ socketPendingMessages += 1;
498
+ if (CAN_PAUSE_WS_READS) socket.pause();
499
+ outputChain = outputChain.then(async () => {
500
+ // Serialize entire messages, including their fragments. A later close
501
+ // must also wait here or it can overtake the final output.
502
+ const chunks = chunkPayload(bytes, responseChunkBytes);
503
+ const groupSize = Math.max(1, Math.floor(MAX_TUNNEL_PAYLOAD_BYTES / responseChunkBytes));
504
+ for (let offset = 0; offset < chunks.length; offset += groupSize) {
468
505
  await waitForBackpressure(null);
469
506
  if (streams.get(streamId) !== stream || closed) return;
470
- await send(frame);
507
+ await Promise.all(chunks.slice(offset, offset + groupSize).map((chunk, index) =>
508
+ send(encodeTunnelFrame(frameType, streamId, chunk, offset + index < chunks.length - 1))));
471
509
  }
472
- })();
510
+ }).catch(() => {
511
+ abortLocalStream(streamId, 'upstream WebSocket forwarding failed');
512
+ void sendAbort(streamId, 'upstream WebSocket forwarding failed');
513
+ }).finally(() => {
514
+ pendingWsBytes -= bytes.length;
515
+ pendingWsMessages -= 1;
516
+ socketPendingMessages -= 1;
517
+ if (CAN_PAUSE_WS_READS && socketPendingMessages === 0 && streams.get(streamId) === stream && socket.readyState === WebSocket.OPEN) socket.resume();
518
+ });
473
519
  });
474
520
  socket.on('close', (code, reasonBuffer) => {
475
- if (streams.get(streamId) !== stream) return;
476
- dropStream(streamId);
477
- const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
478
- if (stream.opened) {
479
- void sendJson(TunnelFrameType.WsClose, streamId, { code: code || 1000, reason });
480
- } else {
481
- void sendAbort(streamId, reason || `upstream ws closed (${code || 'no code'})`);
482
- }
521
+ void outputChain.then(async () => {
522
+ if (streams.get(streamId) !== stream) return;
523
+ dropStream(streamId);
524
+ const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
525
+ if (stream.opened) {
526
+ await sendJson(TunnelFrameType.WsClose, streamId, { code: code || 1000, reason });
527
+ } else {
528
+ await sendAbort(streamId, reason || `upstream ws closed (${code || 'no code'})`);
529
+ }
530
+ });
483
531
  });
484
532
  socket.on('error', (error) => {
485
533
  if (streams.get(streamId) !== stream) return;
@@ -510,6 +558,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
510
558
  const stream = streams.get(streamId);
511
559
  if (!stream || stream.kind !== 'ws') return;
512
560
  dropStream(streamId);
561
+ cancelPendingFrames(streamId);
513
562
  let close = { code: 1000, reason: '' };
514
563
  try {
515
564
  close = decodeJsonPayload(payload, isWsClosePayload);
@@ -518,6 +567,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
518
567
  }
519
568
  const code = Number.isInteger(close.code) && close.code >= 1000 && close.code <= 4999 ? close.code : 1000;
520
569
  try {
570
+ // A paused receiver still needs to read the peer's close handshake.
571
+ if (CAN_PAUSE_WS_READS) stream.socket.resume();
521
572
  stream.socket.close(code, typeof close.reason === 'string' ? close.reason : '');
522
573
  } catch {
523
574
  stream.socket.terminate();
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import http from 'node:http';
3
3
 
4
- import { createTunnelHost } from './tunnel-host.js';
4
+ import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
5
5
  import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
6
6
 
7
7
  const startLoopback = () =>
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
145
145
  await loopback.stop();
146
146
  });
147
147
  });
148
+
149
+ describe('relay host WebSocket allowlist', () => {
150
+ test('allows only the exact dev-server tunnel path', () => {
151
+ expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
152
+ expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
153
+ expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
154
+ });
155
+ });
@@ -67,6 +67,7 @@ export const createRequestSecurityRuntime = (deps) => {
67
67
 
68
68
  const getRequestOriginCandidates = async (req) => {
69
69
  const origins = new Set();
70
+ const hosts = new Set();
70
71
  const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
71
72
  ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
72
73
  : '';
@@ -78,6 +79,7 @@ export const createRequestSecurityRuntime = (deps) => {
78
79
  const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
79
80
 
80
81
  if (host) {
82
+ hosts.add(host.toLowerCase());
81
83
  origins.add(`${protocol}://${host}`);
82
84
  const [hostname, port] = host.split(':');
83
85
  const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
@@ -98,10 +100,10 @@ export const createRequestSecurityRuntime = (deps) => {
98
100
  } catch {
99
101
  }
100
102
 
101
- return origins;
103
+ return { origins, hosts };
102
104
  };
103
105
 
104
- const isRequestOriginAllowed = async (req) => {
106
+ const isRequestOriginAllowed = (req) => {
105
107
  const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
106
108
  if (!originHeader) {
107
109
  return false;
@@ -111,15 +113,28 @@ export const createRequestSecurityRuntime = (deps) => {
111
113
  return true;
112
114
  }
113
115
 
114
- let normalizedOrigin = '';
116
+ let origin;
115
117
  try {
116
- normalizedOrigin = new URL(originHeader).origin;
118
+ origin = new URL(originHeader);
117
119
  } catch {
118
120
  return false;
119
121
  }
120
122
 
121
- const allowedOrigins = await getRequestOriginCandidates(req);
122
- return allowedOrigins.has(normalizedOrigin);
123
+ const forwardedHostHeader = req.headers['x-forwarded-host'];
124
+ const forwardedHost = (Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader || '')
125
+ .split(',')[0].trim().toLowerCase();
126
+ const hostHeader = req.headers.host;
127
+ const host = forwardedHost || (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader || '').trim().toLowerCase();
128
+ if (host && host === origin.host.toLowerCase()) return true;
129
+
130
+ // TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber.
131
+ // In that setup the browser's Origin is https while a generic reverse
132
+ // proxy reports the upstream request as http. The external host remains
133
+ // authoritative, so compare it directly instead of requiring the proxy to
134
+ // preserve the browser-facing protocol.
135
+ return getRequestOriginCandidates(req).then((candidates) => (
136
+ candidates.origins.has(origin.origin) || candidates.hosts.has(origin.host.toLowerCase())
137
+ ));
123
138
  };
124
139
 
125
140
  return {
@@ -9,41 +9,75 @@ describe('request security runtime', () => {
9
9
  test('allows packaged client origins for remote client transports', async () => {
10
10
  const runtime = createRuntime();
11
11
 
12
- await expect(runtime.isRequestOriginAllowed({
12
+ expect(await runtime.isRequestOriginAllowed({
13
13
  headers: {
14
14
  origin: 'openchamber-ui://app',
15
15
  host: '192.168.1.130:1202',
16
16
  },
17
17
  socket: {},
18
- })).resolves.toBe(true);
18
+ })).toBe(true);
19
19
 
20
- await expect(runtime.isRequestOriginAllowed({
20
+ expect(await runtime.isRequestOriginAllowed({
21
21
  headers: {
22
22
  origin: 'capacitor://localhost',
23
23
  host: '192.168.1.130:1202',
24
24
  },
25
25
  socket: {},
26
- })).resolves.toBe(true);
26
+ })).toBe(true);
27
27
 
28
28
  // Android Capacitor WebView (androidScheme 'https') reports this origin.
29
- await expect(runtime.isRequestOriginAllowed({
29
+ expect(await runtime.isRequestOriginAllowed({
30
30
  headers: {
31
31
  origin: 'https://localhost',
32
32
  host: '192.168.1.130:1202',
33
33
  },
34
34
  socket: {},
35
- })).resolves.toBe(true);
35
+ })).toBe(true);
36
36
  });
37
37
 
38
38
  test('rejects unknown origins', async () => {
39
39
  const runtime = createRuntime();
40
40
 
41
- await expect(runtime.isRequestOriginAllowed({
41
+ expect(await runtime.isRequestOriginAllowed({
42
42
  headers: {
43
43
  origin: 'https://evil.example.com',
44
44
  host: '192.168.1.130:1202',
45
45
  },
46
46
  socket: {},
47
- })).resolves.toBe(false);
47
+ })).toBe(false);
48
+ });
49
+
50
+ test('allows the external host when TLS terminates before an HTTP proxy hop', async () => {
51
+ const runtime = createRuntime();
52
+
53
+ expect(await runtime.isRequestOriginAllowed({
54
+ headers: {
55
+ origin: 'https://devchamber.example.com',
56
+ host: 'devchamber.example.com',
57
+ 'x-forwarded-proto': 'http',
58
+ },
59
+ socket: {},
60
+ })).toBe(true);
61
+ });
62
+
63
+ test('uses the forwarded external host without trusting a different origin', async () => {
64
+ const runtime = createRuntime();
65
+ const request = {
66
+ headers: {
67
+ host: '127.0.0.1:3000',
68
+ 'x-forwarded-host': 'devchamber.example.com',
69
+ 'x-forwarded-proto': 'http',
70
+ },
71
+ socket: {},
72
+ };
73
+
74
+ expect(await runtime.isRequestOriginAllowed({
75
+ ...request,
76
+ headers: { ...request.headers, origin: 'https://devchamber.example.com' },
77
+ })).toBe(true);
78
+ expect(await runtime.isRequestOriginAllowed({
79
+ ...request,
80
+ headers: { ...request.headers, origin: 'https://evil.example.com' },
81
+ })).toBe(false);
48
82
  });
49
83
  });
@@ -11,6 +11,7 @@
11
11
  import fs from 'fs';
12
12
  import os from 'os';
13
13
  import path from 'path';
14
+ import { readMergedSettingsSync } from '../opencode/settings-files.js';
14
15
 
15
16
  const OPENCHAMBER_SETTINGS_FILE = path.join(
16
17
  process.env.OPENCHAMBER_DATA_DIR
@@ -23,16 +24,11 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
23
24
  // off, no small-model calls and no metadata writes happen at all. Existing
24
25
  // payloads stay untouched — clients keep showing them and dismissal still works.
25
26
  const getSessionAssistTargets = () => {
26
- try {
27
- const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
28
- const settings = JSON.parse(raw);
29
- return {
30
- recap: settings?.sessionRecapEnabled !== false,
31
- suggestion: settings?.sessionSuggestionEnabled !== false,
32
- };
33
- } catch {
34
- return { recap: true, suggestion: true };
35
- }
27
+ const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
28
+ return {
29
+ recap: settings.sessionRecapEnabled !== false,
30
+ suggestion: settings.sessionSuggestionEnabled !== false,
31
+ };
36
32
  };
37
33
 
38
34
  const IDLE_QUIET_MS = 60_000;
@@ -257,6 +253,7 @@ export const createSessionAssistRuntime = ({
257
253
  prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`,
258
254
  system: buildAssistSystemPrompt(targets),
259
255
  directory,
256
+ sessionID: sessionId,
260
257
  preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
261
258
  preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
262
259
  });
@@ -108,13 +108,26 @@ before touching the filesystem). Rationale: metadata rides every
108
108
  stop), with a tick-side safety net. Messages sent while paused leave
109
109
  the goal alone; Resume re-arms the loop, and resuming over an aborted
110
110
  tail skips the audit and goes straight to a continuation nudge;
111
- - terminal checks, cheapest first: assistant turn error → `blocked`;
112
- `tokensUsed >= tokenBudget` → `budgetLimited`;
113
- `turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;
114
- - if the latest message is a compaction summary, skip the audit and
115
- continue unconditionally — running into the context window mid-work is
116
- by definition "in progress, not finished" (the summary is a retelling,
117
- not evidence, and must not be judged);
111
+ - terminal checks, cheapest first: assistant turn error → `blocked`;
112
+ `tokensUsed >= tokenBudget` → `budgetLimited`;
113
+ `turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;
114
+ - error classification is independent of `finish`: `MessageAbortedError`
115
+ keeps the pause/resume behavior; only a `finish: "length"` with no
116
+ error, or `MessageOutputLengthError`, is an in-progress truncation that
117
+ skips the audit and continues. Any other non-null error wins over a
118
+ length finish and blocks with its non-empty `error.name`, or
119
+ `assistant turn failed` when unnamed;
120
+ - length recovery is bounded separately from the token budget and
121
+ auto-continuation cap: the first truncation permits one continuation, but
122
+ a second consecutive completed, non-summary assistant turn that is also
123
+ truncated settles the goal as `blocked` (`repeated output truncation`).
124
+ The consecutive state is derived from the loaded message history, not
125
+ persisted, using `info.time.created` chronology rather than message IDs.
126
+ Summary messages are not agent turns; an ordinary completed assistant
127
+ turn naturally breaks the consecutive condition. Explicit Resume grants
128
+ one new recovery attempt over the same transcript; the continuation
129
+ consumes that permission, so another truncation blocks again. Resume
130
+ does not bypass assistant errors or the token budget;
118
131
  - otherwise, small-model audit of the objective + the last assistant turn
119
132
  only — no conversation history and no continuation prompts
120
133
  (`restrictToPreferredProvider`, session's own provider/model preferred):
@@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => {
14
14
  + '\n</system-reminder>';
15
15
  };
16
16
 
17
- const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => {
17
+ const fitObjective = async ({ objective, directory, sessionID, providerID, modelID, warn }) => {
18
18
  if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective;
19
19
 
20
20
  let distilled = null;
@@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn })
32
32
  'Write in the same language as the task text.',
33
33
  ].join('\n'),
34
34
  directory,
35
+ sessionID,
35
36
  preferredProviderID: providerID,
36
37
  preferredModelID: modelID,
37
38
  });
@@ -66,6 +67,7 @@ export const createSessionGoal = async ({
66
67
  const objectiveText = await fitObjective({
67
68
  objective: String(objective ?? '').trim(),
68
69
  directory,
70
+ sessionID,
69
71
  providerID,
70
72
  modelID,
71
73
  warn,
@@ -19,6 +19,7 @@ import os from 'os';
19
19
  import path from 'path';
20
20
 
21
21
  import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
22
+ import { readMergedSettingsSync } from '../opencode/settings-files.js';
22
23
 
23
24
  const OPENCHAMBER_SETTINGS_FILE = path.join(
24
25
  process.env.OPENCHAMBER_DATA_DIR
@@ -27,15 +28,9 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
27
28
  'settings.json',
28
29
  );
29
30
 
30
- const isSessionGoalEnabled = () => {
31
- try {
32
- const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
33
- const settings = JSON.parse(raw);
34
- return settings?.sessionGoalEnabled !== false;
35
- } catch {
36
- return true;
37
- }
38
- };
31
+ const isSessionGoalEnabled = () => (
32
+ readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE }).sessionGoalEnabled !== false
33
+ );
39
34
 
40
35
  const IDLE_QUIET_MS = 15_000;
41
36
  // A goal set while the session is already idle should kick off promptly.
@@ -244,6 +239,57 @@ const messageTokenTotal = (info) => {
244
239
  return input + cachedRead + output;
245
240
  };
246
241
 
242
+ const getErrorName = (error) => error?.name?.trim?.() ?? '';
243
+
244
+ const isLengthTruncated = (info, errorName = getErrorName(info?.error)) => {
245
+ const error = info?.error;
246
+ const hasError = error !== null && error !== undefined;
247
+ return errorName === 'MessageOutputLengthError' || (!hasError && info?.finish === 'length');
248
+ };
249
+
250
+ // Summary messages are assistant-shaped, but they are compaction turns rather
251
+ // than agent turns. They must not break or satisfy the consecutive truncation
252
+ // check; only completed, non-summary assistant turns participate. Chronology
253
+ // comes from `time.created`, never from message IDs; array position is only a
254
+ // tie-breaker for equal timestamps.
255
+ const hasRepeatedLengthTail = (messages, latestAssistant, goalCreatedAt) => {
256
+ const latestInfo = latestAssistant?.info;
257
+ if (latestInfo?.summary === true) return false;
258
+ const latestIndex = messages.indexOf(latestAssistant);
259
+ const latestCreated = latestInfo?.time?.created;
260
+ if (
261
+ latestIndex < 0
262
+ || !(latestInfo?.time?.completed > 0)
263
+ || !(Number.isFinite(latestCreated) && latestCreated > 0)
264
+ || !isLengthTruncated(latestInfo)
265
+ ) return false;
266
+
267
+ let previous = null;
268
+ for (let i = 0; i < messages.length; i += 1) {
269
+ const info = messages[i]?.info;
270
+ if (info?.role !== 'assistant' || info.summary === true || !(info.time?.completed > 0)) continue;
271
+ const created = info.time?.created;
272
+ // An unknown timestamp cannot safely participate in chronology. Ignore it
273
+ // rather than letting an unrelated older message hide known chronology.
274
+ if (!(Number.isFinite(created) && created > 0)) continue;
275
+ if (i === latestIndex) continue;
276
+ if (created > latestCreated || (created === latestCreated && i > latestIndex)) continue;
277
+ if (
278
+ !previous
279
+ || created > previous.created
280
+ || (created === previous.created && i > previous.index)
281
+ ) {
282
+ previous = { info, created, index: i };
283
+ }
284
+ }
285
+
286
+ return Boolean(
287
+ previous
288
+ && previous.created > goalCreatedAt
289
+ && isLengthTruncated(previous.info),
290
+ );
291
+ };
292
+
247
293
  export const createSessionGoalRuntime = ({
248
294
  buildOpenCodeUrl,
249
295
  getOpenCodeAuthHeaders,
@@ -378,6 +424,7 @@ export const createSessionGoalRuntime = ({
378
424
  prompt: `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`,
379
425
  system: buildAuditSystemPrompt(),
380
426
  directory,
427
+ sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined,
381
428
  preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
382
429
  preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
383
430
  });
@@ -608,7 +655,11 @@ export const createSessionGoalRuntime = ({
608
655
  // resumed over an aborted tail: that is an explicit "keep going", so it
609
656
  // falls through to the continuation below (skipping the audit — an
610
657
  // aborted reply is not evidence of anything).
611
- const abortedTail = lastAssistantInfo.error?.name === 'MessageAbortedError';
658
+ const error = lastAssistantInfo.error;
659
+ const errorName = getErrorName(error);
660
+ const hasError = error !== null && error !== undefined;
661
+ const abortedTail = errorName === 'MessageAbortedError';
662
+ const lengthTail = isLengthTruncated(lastAssistantInfo, errorName);
612
663
  if (abortedTail && goal.statusReason !== 'resumed') {
613
664
  await writeGoal(sessionId, directory, goal.id, () => ({
614
665
  status: 'paused',
@@ -622,13 +673,12 @@ export const createSessionGoalRuntime = ({
622
673
  return;
623
674
  }
624
675
 
625
- // Turn error → blocked (prevents runaway auto-continuation into failures).
626
- if (!abortedTail && lastAssistantInfo.error && typeof lastAssistantInfo.error === 'object') {
627
- const reason = typeof lastAssistantInfo.error.name === 'string' && lastAssistantInfo.error.name
628
- ? lastAssistantInfo.error.name
629
- : 'assistant turn failed';
676
+ // Non-length turn error → blocked (prevents runaway auto-continuation into
677
+ // failures). Recognized length cutoffs are in-progress continuations, not
678
+ // hard failures.
679
+ if (!abortedTail && !lengthTail && hasError) {
630
680
  await settleGoal({
631
- sessionId, directory, goal, status: 'blocked', statusReason: reason, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
681
+ sessionId, directory, goal, status: 'blocked', statusReason: errorName || 'assistant turn failed', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
632
682
  });
633
683
  return;
634
684
  }
@@ -649,17 +699,28 @@ export const createSessionGoalRuntime = ({
649
699
  return;
650
700
  }
651
701
 
702
+ // A second consecutive completed, non-summary length-truncated turn is a
703
+ // bounded recovery failure. Derive this from the loaded transcript rather
704
+ // than persisting another goal counter.
705
+ if (lengthTail && goal.statusReason !== 'resumed' && hasRepeatedLengthTail(messages, lastAssistant, goal.createdAt)) {
706
+ await settleGoal({
707
+ sessionId, directory, goal, status: 'blocked', statusReason: 'repeated output truncation', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
708
+ });
709
+ return;
710
+ }
711
+
652
712
  // --- Small-model audit: the sole termination authority besides the hard
653
713
  // stops above (turn error, budget, continuation cap). The working agent
654
714
  // has no channel to settle its own goal.
655
715
  //
656
- // Exception: when the latest message is a compaction summary, the agent
657
- // by definition ran into the context window mid-work — that IS
658
- // "in progress, not finished". No audit call; continue unconditionally.
716
+ // Exception: when the latest message is a compaction summary or was cut off
717
+ // by the output token limit (length stop), the agent by definition ran into
718
+ // the context/output limit mid-work — that IS "in progress, not finished".
719
+ // No audit call; continue unconditionally.
659
720
  let audit = null;
660
721
  let blockedStreak = 0;
661
722
  let auditFailStreak = goal.auditFailStreak;
662
- if (lastAssistantInfo.summary === true || abortedTail) {
723
+ if (lastAssistantInfo.summary === true || abortedTail || lengthTail) {
663
724
  blockedStreak = goal.blockedStreak;
664
725
  } else {
665
726
  audit = await runAudit({ goal: { ...goal, objective: effectiveObjective }, assistantText, directory, lastAssistantInfo: executionInfo ?? lastAssistantInfo });