@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -200,6 +200,45 @@ export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW
200
200
  // envelope stripper below.
201
201
  export const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-[A-Z0-9-]+\][^\n]*(?:\n(?!\n|Message Type\s*:)[^\n]*)*(?=\n{2,}|\nMessage Type\s*:|$)/gi;
202
202
 
203
+ export const MAX_AGENT_TASK_ENCRYPTED_PARTS = 32;
204
+ export const MAX_AGENT_TASK_CIPHERTEXT_BYTES = 2 * 1024 * 1024;
205
+
206
+ /** Detection only: joining fragments never authorizes recovery or proves authenticity. */
207
+ function splitFernetParts(content: unknown[]): Set<object> {
208
+ const protectedParts = new Set<object>();
209
+ let run: Array<{ part: object; text: string }> = [];
210
+ let bytes = 0;
211
+ let overLimit = false;
212
+ const finish = (): void => {
213
+ if (!overLimit && run.length > 1
214
+ && run.every(({ text }) => !isStructurallyValidFernetToken(text))
215
+ && isStructurallyValidFernetToken(run.map(({ text }) => text).join(""))) {
216
+ for (const { part } of run) protectedParts.add(part);
217
+ }
218
+ run = [];
219
+ bytes = 0;
220
+ overLimit = false;
221
+ };
222
+ for (const part of content) {
223
+ if (!part || typeof part !== "object" || (part as { type?: unknown }).type !== "encrypted_content"
224
+ || typeof (part as { encrypted_content?: unknown }).encrypted_content !== "string") {
225
+ finish();
226
+ continue;
227
+ }
228
+ if (overLimit) continue;
229
+ const text = (part as { encrypted_content: string }).encrypted_content;
230
+ bytes += Buffer.byteLength(text);
231
+ if (run.length >= MAX_AGENT_TASK_ENCRYPTED_PARTS || bytes > MAX_AGENT_TASK_CIPHERTEXT_BYTES) {
232
+ overLimit = true;
233
+ run = [];
234
+ continue;
235
+ }
236
+ run.push({ part, text });
237
+ }
238
+ finish();
239
+ return protectedParts;
240
+ }
241
+
203
242
  export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
204
243
  if (!Array.isArray(input)) return false;
205
244
 
@@ -222,7 +261,8 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
222
261
  const content = (item as { content?: unknown }).content;
223
262
  if (!Array.isArray(content)) return false;
224
263
 
225
- let hasFernetTask = false;
264
+ const fragmentParts = splitFernetParts(content);
265
+ let hasFernetTask = fragmentParts.size > 0;
226
266
  const readableParts: string[] = [];
227
267
  for (const part of content) {
228
268
  if (!part || typeof part !== "object") continue;
@@ -238,6 +278,7 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
238
278
  continue;
239
279
  }
240
280
 
281
+ if (fragmentParts.has(part)) continue;
241
282
  const runs = fernetTokenRuns(record.encrypted_content);
242
283
  if (runs.length > 0) hasFernetTask = true;
243
284
  readableParts.push(textWithoutFernetRuns(record.encrypted_content, runs));
@@ -282,6 +323,7 @@ export function hasEncryptedContentPart(content: unknown): boolean {
282
323
  export function sanitizeEncryptedContentInPlace(input: unknown): number {
283
324
  if (!Array.isArray(input)) return 0;
284
325
  let rewritten = 0;
326
+ const protectedFragments = new WeakSet<object>();
285
327
  type VisitFrame =
286
328
  | { kind: "visit"; node: unknown }
287
329
  | { kind: "array"; node: unknown[]; index: number }
@@ -293,6 +335,7 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
293
335
  const frame = stack.pop()!;
294
336
  if (frame.kind === "visit") {
295
337
  if (Array.isArray(frame.node)) {
338
+ for (const part of splitFernetParts(frame.node)) protectedFragments.add(part);
296
339
  stack.push({ kind: "array", node: frame.node, index: 0 });
297
340
  } else if (frame.node && typeof frame.node === "object") {
298
341
  stack.push({ kind: "object", values: Object.values(frame.node), index: 0 });
@@ -309,7 +352,7 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
309
352
  && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string"
310
353
  ) {
311
354
  const payload = (child as { encrypted_content: string }).encrypted_content;
312
- if (!looksLikeBackendCiphertext(payload)) {
355
+ if (!protectedFragments.has(child) && !looksLikeBackendCiphertext(payload)) {
313
356
  const parts = encryptedSlotParts(payload);
314
357
  frame.node.splice(frame.index, 1, ...parts);
315
358
  rewritten += 1;
@@ -179,5 +179,8 @@ export function codexWsUpstreamFetch(
179
179
  } catch {
180
180
  return sseFallback(url, init);
181
181
  }
182
- return codexWsExchange({ session, url, init, prepared, sseFallback, onQuota, beforeDispatch });
182
+ return codexWsExchange({
183
+ session, url, init, prepared, sseFallback, onQuota, beforeDispatch,
184
+ bunVersion: typeof runtime === "string" ? runtime : runtime.version,
185
+ });
183
186
  }
@@ -115,10 +115,8 @@ export function collectSelfNamedNamespaceScrubAuthorization(
115
115
  * `None | "" | "functions"` as the default namespace; anything else is concatenated into a flat
116
116
  * name before routing. A backend answer of `{ name: "exec", namespace: "exec" }` therefore
117
117
  * becomes `execexec`, which no client tool matches, and Codex re-issues the same call forever.
118
- * The malformed Spark shape is scrubbed only when the current turn authorized a bare custom tool
118
+ * The malformed shape is scrubbed only when the current turn authorized a bare custom tool
119
119
  * with that name. A genuine namespaced tool may intentionally use the same namespace and name.
120
- * The adapter fix that stops provoking the answer lives in `stripSparkCompatibility`; this is the
121
- * belt to that suspender.
122
120
  */
123
121
  export function scrubSelfNamedToolCallNamespace(
124
122
  value: unknown,
@@ -106,7 +106,7 @@ function addWireToolName(
106
106
  }
107
107
  // `exec` is the one name that also switches on nested-helper normalization, so a bare alias
108
108
  // for a namespaced MCP tool would silently authorize `exec_command`/`shell_command`/
109
- // `apply_patch` the request never declared. Every other inner name keeps the bare alias.
109
+ // `apply_patch`/`view_image` the request never declared. Every other inner name keeps the bare alias.
110
110
  if (name !== CODE_MODE_EXEC_TOOL_NAME) names.add(name);
111
111
  }
112
112
 
@@ -15,6 +15,7 @@ import {
15
15
  cooldownErrorResponse,
16
16
  CodexAuthContextError,
17
17
  CodexMainProfileDrainingError,
18
+ CodexModelAvailabilityError,
18
19
  CodexPoolAuthenticationError,
19
20
  CodexThreadAffinityExpiredError,
20
21
  } from "../codex/auth-context";
@@ -39,6 +40,7 @@ import type { RequestLogContext } from "./request-log";
39
40
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
40
41
  import type { AdmissionLease } from "../lib/admission";
41
42
  import { codexAccountSelectionForTurn } from "./lifecycle";
43
+ import { codexModelAvailabilityErrorResponse } from "./responses/codex-auth-error";
42
44
 
43
45
  /**
44
46
  * Default TOTAL deadline for one search relay. alpha/search is non-streaming JSON — response
@@ -143,6 +145,7 @@ export async function handleSearch(
143
145
  console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`);
144
146
  return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
145
147
  }
148
+ if (err instanceof CodexModelAvailabilityError) return codexModelAvailabilityErrorResponse(err);
146
149
  if (err instanceof CodexPoolAuthenticationError) return formatErrorResponse(401, "authentication_error", err.message);
147
150
  throw err;
148
151
  }
@@ -1,4 +1,5 @@
1
1
  import type { TranslatorBudget } from "../lib/translator-budget";
2
+ import { Buffer } from "node:buffer";
2
3
 
3
4
  /**
4
5
  * Shared client-facing SSE payload rewrite shell.
@@ -37,18 +38,21 @@ export function payloadRewriteAsBlockRewrite(rewrite: SsePayloadRewrite): SseBlo
37
38
  export function composeSseBlockRewrites(...rewrites: SseBlockRewrite[]): SseBlockRewrite {
38
39
  const active = rewrites.filter(Boolean);
39
40
  if (active.length === 0) return Object.assign((block: string) => [block], {});
41
+ let disposed = false;
40
42
  const composed: SseBlockRewrite = (block: string) => {
41
43
  let blocks: readonly string[] = [block];
42
44
  for (const rewrite of active) {
43
45
  const next: string[] = [];
44
- for (const current of blocks) next.push(...rewrite(current));
46
+ for (const current of blocks) {
47
+ if (disposed) return [];
48
+ next.push(...rewrite(current));
49
+ }
45
50
  blocks = next;
46
51
  }
47
52
  return blocks;
48
53
  };
49
54
  // Child disposal is part of the contract: one idempotent disposer for the
50
55
  // whole chain, so relay teardown never leaks a nested collector.
51
- let disposed = false;
52
56
  composed.dispose = () => {
53
57
  if (disposed) return;
54
58
  disposed = true;
@@ -70,10 +74,116 @@ export function nextSseBlock(buffer: string): { block: string; delimiter: string
70
74
  };
71
75
  }
72
76
 
77
+ /**
78
+ * Incremental form of nextSseBlock for bounded relays. The scan cursor visits only new text and
79
+ * consuming a block subtracts its byte length instead of recounting the remaining suffix.
80
+ * Old/new buffer overlap still requires admission before either append or consumption commits.
81
+ * Call compact before yielding to stop retaining an already-consumed prefix across pulls.
82
+ */
83
+ export function createSseBlockBuffer(
84
+ budget: TranslatorBudget,
85
+ assertAppendSize?: (bytes: number) => void,
86
+ ): {
87
+ append(fragment: string): void;
88
+ next(): { block: string; delimiter: string } | null;
89
+ tail(): string;
90
+ compact(): void;
91
+ clear(): void;
92
+ } {
93
+ const scope = { kind: "live_transient" as const };
94
+ let buffer = "";
95
+ let offset = 0;
96
+ let scanOffset = 0;
97
+ let bufferBytes = 0;
98
+
99
+ const compact = (): void => {
100
+ if (offset === 0) return;
101
+ buffer = buffer.slice(offset);
102
+ scanOffset -= offset;
103
+ offset = 0;
104
+ };
105
+
106
+ return {
107
+ append(fragment) {
108
+ if (!fragment) return;
109
+ let fragmentBytes = Buffer.byteLength(fragment, "utf8");
110
+ // Decoder output never splits a surrogate pair, but keep the helper exact for string callers.
111
+ const last = buffer.charCodeAt(buffer.length - 1);
112
+ const first = fragment.charCodeAt(0);
113
+ if (offset < buffer.length && last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff) {
114
+ fragmentBytes -= 2;
115
+ }
116
+ const nextBytes = bufferBytes + fragmentBytes;
117
+ assertAppendSize?.(nextBytes);
118
+ const reservation = budget.reserveTransient(nextBytes, scope);
119
+ try {
120
+ compact();
121
+ buffer += fragment;
122
+ reservation.commitRetained();
123
+ budget.releaseRetained(bufferBytes, scope);
124
+ bufferBytes = nextBytes;
125
+ } catch (error) {
126
+ reservation.release();
127
+ throw error;
128
+ }
129
+ },
130
+ next() {
131
+ for (;;) {
132
+ const newline = buffer.indexOf("\n", scanOffset);
133
+ if (newline < 0) {
134
+ scanOffset = buffer.length;
135
+ return null;
136
+ }
137
+ let end = newline + 1;
138
+ if (buffer[end] === "\r") end += 1;
139
+ if (end === buffer.length) {
140
+ // Keep the candidate first newline until its possible blank-line delimiter arrives.
141
+ scanOffset = newline;
142
+ return null;
143
+ }
144
+ if (buffer[end] !== "\n") {
145
+ scanOffset = newline + 1;
146
+ continue;
147
+ }
148
+ end += 1;
149
+ const start = newline > offset && buffer[newline - 1] === "\r" ? newline - 1 : newline;
150
+ const block = buffer.slice(offset, start);
151
+ const delimiter = buffer.slice(start, end);
152
+ const nextBytes = bufferBytes - Buffer.byteLength(block, "utf8") - delimiter.length;
153
+ const reservation = budget.reserveTransient(nextBytes, scope);
154
+ reservation.commitRetained();
155
+ budget.releaseRetained(bufferBytes, scope);
156
+ bufferBytes = nextBytes;
157
+ offset = end;
158
+ scanOffset = end;
159
+ if (offset === buffer.length) {
160
+ buffer = "";
161
+ offset = 0;
162
+ scanOffset = 0;
163
+ }
164
+ return { block, delimiter };
165
+ }
166
+ },
167
+ tail: () => buffer.slice(offset),
168
+ compact,
169
+ clear() {
170
+ budget.releaseRetained(bufferBytes, scope);
171
+ buffer = "";
172
+ offset = 0;
173
+ scanOffset = 0;
174
+ bufferBytes = 0;
175
+ },
176
+ };
177
+ }
178
+
73
179
  /** Join all data lines from one SSE event according to the event-stream field rules. */
74
180
  export function sseDataPayload(block: string): string | null {
75
181
  const data: string[] = [];
76
182
  for (const line of block.split(/\r?\n/)) {
183
+ if (line === "data") {
184
+ data.push("");
185
+ continue;
186
+ }
77
187
  if (!line.startsWith("data:")) continue;
78
188
  const value = line.slice(5);
79
189
  data.push(value.startsWith(" ") ? value.slice(1) : value);
@@ -88,7 +198,7 @@ export function replaceSseDataPayload(block: string, payload: string): string {
88
198
  const rewritten: string[] = [];
89
199
  let replaced = false;
90
200
  for (const line of lines) {
91
- if (!line.startsWith("data:")) {
201
+ if (line !== "data" && !line.startsWith("data:")) {
92
202
  rewritten.push(line);
93
203
  continue;
94
204
  }
@@ -137,8 +247,7 @@ export function relaySseWithBlockRewrite(
137
247
  const reader = body.getReader();
138
248
  const decoder = new TextDecoder();
139
249
  const encoder = new TextEncoder();
140
- let buffer = "";
141
- let bufferBytes = 0;
250
+ const buffer = createSseBlockBuffer(translatorBudget);
142
251
  // Relays have several independent teardown paths; disposal is exactly once.
143
252
  let disposed = false;
144
253
  let cancelled = false;
@@ -148,40 +257,16 @@ export function relaySseWithBlockRewrite(
148
257
  try { rewrite.dispose?.(); } catch { /* teardown must not throw */ }
149
258
  };
150
259
 
151
- const appendBuffer = (fragment: string): void => {
152
- if (!fragment) return;
153
- const nextBytes = bufferBytes + encoder.encode(fragment).byteLength;
154
- const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "live_transient" });
155
- try {
156
- buffer += fragment;
157
- reservation.commitRetained();
158
- translatorBudget.releaseRetained(bufferBytes, { kind: "live_transient" });
159
- bufferBytes = nextBytes;
160
- } catch (error) {
161
- reservation.release();
162
- throw error;
163
- }
164
- };
165
-
166
- const replaceBuffer = (next: string): void => {
167
- const nextBytes = encoder.encode(next).byteLength;
168
- const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "live_transient" });
169
- reservation.commitRetained();
170
- buffer = next;
171
- translatorBudget.releaseRetained(bufferBytes, { kind: "live_transient" });
172
- bufferBytes = nextBytes;
173
- };
174
-
175
260
  const enqueueText = (
176
261
  controller: ReadableStreamDefaultController<Uint8Array>,
177
262
  text: string,
178
263
  ): void => {
179
- const bytes = encoder.encode(text).byteLength;
264
+ const bytes = Buffer.byteLength(text, "utf8");
180
265
  const reservation = translatorBudget.reserveTransient(bytes, { kind: "live_transient" });
181
266
  try {
182
267
  const encoded = encoder.encode(text);
183
- reservation.commitRetained();
184
268
  controller.enqueue(encoded);
269
+ reservation.commitRetained();
185
270
  translatorBudget.releaseRetained(bytes, { kind: "live_transient" });
186
271
  } catch (error) {
187
272
  reservation.release();
@@ -189,35 +274,33 @@ export function relaySseWithBlockRewrite(
189
274
  }
190
275
  };
191
276
 
192
- const releaseBuffer = (): void => {
193
- translatorBudget.releaseRetained(bufferBytes, { kind: "live_transient" });
194
- buffer = "";
195
- bufferBytes = 0;
196
- };
197
-
198
277
  const emitProcessedBlocks = (
199
278
  controller: ReadableStreamDefaultController<Uint8Array>,
200
279
  flushFinal = false,
201
280
  ): number => {
202
281
  let emitted = 0;
203
- let next: { block: string; delimiter: string; rest: string } | null;
204
- while ((next = nextSseBlock(buffer))) {
205
- replaceBuffer(next.rest);
206
- for (const outBlock of rewrite(next.block)) {
282
+ let next: { block: string; delimiter: string } | null;
283
+ while (!cancelled && (next = buffer.next())) {
284
+ const outBlocks = rewrite(next.block);
285
+ if (cancelled) return emitted;
286
+ for (const outBlock of outBlocks) {
207
287
  enqueueText(controller, outBlock + next.delimiter);
208
288
  emitted += 1;
209
289
  }
210
290
  }
211
- if (flushFinal && buffer.length > 0) {
212
- const tailBlocks = rewrite(buffer);
291
+ buffer.compact();
292
+ const tail = flushFinal ? buffer.tail() : "";
293
+ if (tail.length > 0) {
294
+ const tailBlocks = rewrite(tail);
295
+ if (cancelled) return emitted;
213
296
  // A trailing fragment has no delimiter of its own; multiple emitted
214
297
  // blocks must still be framed as separate events (#893 review).
215
- const tailDelimiter = buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n";
298
+ const tailDelimiter = tail.includes("\r\n") ? "\r\n\r\n" : "\n\n";
216
299
  for (let i = 0; i < tailBlocks.length; i++) {
217
300
  enqueueText(controller, tailBlocks[i]! + (i < tailBlocks.length - 1 ? tailDelimiter : ""));
218
301
  emitted += 1;
219
302
  }
220
- releaseBuffer();
303
+ buffer.clear();
221
304
  }
222
305
  return emitted;
223
306
  };
@@ -236,18 +319,20 @@ export function relaySseWithBlockRewrite(
236
319
  // after its disposal (#893 review).
237
320
  if (cancelled) return;
238
321
  if (done) {
239
- appendBuffer(decoder.decode());
322
+ buffer.append(decoder.decode());
240
323
  emitProcessedBlocks(controller, true);
241
- releaseBuffer();
324
+ if (cancelled) return;
325
+ buffer.clear();
242
326
  disposeRewrite();
243
327
  controller.close();
244
328
  return;
245
329
  }
246
- appendBuffer(decoder.decode(value, { stream: true }));
247
- if (emitProcessedBlocks(controller) > 0) return;
330
+ buffer.append(decoder.decode(value, { stream: true }));
331
+ const emitted = emitProcessedBlocks(controller);
332
+ if (cancelled || emitted > 0) return;
248
333
  }
249
334
  } catch (error) {
250
- releaseBuffer();
335
+ buffer.clear();
251
336
  disposeRewrite();
252
337
  // Cancelling one tee branch waits for its sibling. Surface the failure
253
338
  // now so downstream can abort upstream and release the inspection branch.
@@ -257,7 +342,7 @@ export function relaySseWithBlockRewrite(
257
342
  },
258
343
  cancel(reason) {
259
344
  cancelled = true;
260
- releaseBuffer();
345
+ buffer.clear();
261
346
  disposeRewrite();
262
347
  reader.cancel(reason).catch(() => {});
263
348
  },
@@ -8,6 +8,7 @@ import type { DataPlaneAdmission } from "./auth-cors";
8
8
  import type { AdmissionLease, AdmissionReservation } from "../lib/admission";
9
9
  import { BoundedSseFrameBuffer } from "./sse-frame-buffer";
10
10
  import { safeResponseHeaders } from "./safe-response-headers";
11
+ import type { AudioSocketTarget } from "./audio-dictation";
11
12
 
12
13
  export { safeResponseHeaders } from "./safe-response-headers";
13
14
 
@@ -31,14 +32,28 @@ export interface WsData {
31
32
  /** Fixed-size logical session lane derived at the HTTP upgrade boundary. */
32
33
  sessionLaneId?: string;
33
34
  /** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */
34
- kind?: "responses" | "live-sideband";
35
+ kind?: "responses" | "live-sideband" | "remote-workspace-agent";
36
+ remoteWorkspaceConnection?: { receive(raw: string | Uint8Array): void };
37
+ remoteWorkspaceOpen?: (socket: ServerWebSocket<WsData>) => { receive(raw: string | Uint8Array): void };
38
+ remoteWorkspaceClose?: () => void;
35
39
  liveUpstream?: WebSocket;
36
40
  liveUpstreamUrl?: string;
37
41
  liveUpstreamHeaders?: Record<string, string>;
42
+ liveUpstreamProtocols?: string[];
43
+ liveValidateFrame?: AudioSocketTarget["validateFrame"];
44
+ liveFinish?: AudioSocketTarget["finish"];
45
+ liveOutcome?: number | "timeout" | "connect_error";
46
+ liveMaxSessionMs?: number;
47
+ liveConnectTimer?: ReturnType<typeof setTimeout>;
48
+ liveSessionTimer?: ReturnType<typeof setTimeout>;
49
+ liveAbortSignal?: AbortSignal;
50
+ liveAbortListener?: () => void;
38
51
  livePending?: Array<string | Buffer>;
39
52
  /** Total encoded bytes retained in livePending while the upstream connects. */
40
53
  livePendingBytes?: number;
41
54
  liveOpened?: boolean;
55
+ /** Owns captured frames and terminal state until the downstream relay attaches. */
56
+ liveUpstreamHandoff?: LiveSidebandUpstreamHandoff;
42
57
  /** Once teardown starts, ignore new client frames until the upstream closes. */
43
58
  liveClosing?: boolean;
44
59
  /** Schedules one bounded close retry without surrendering native-main ownership. */
@@ -48,6 +63,25 @@ export interface WsData {
48
63
  admissionLease?: AdmissionReservation<ServerWebSocket<WsData>>;
49
64
  }
50
65
 
66
+ export interface LiveSidebandUpstreamFailure {
67
+ status: number;
68
+ code: string;
69
+ message: string;
70
+ closeCode?: number;
71
+ closeReason?: string;
72
+ }
73
+
74
+ export type LiveSidebandUpstreamTakeover =
75
+ | { ok: true; frames: Array<string | Buffer> }
76
+ | { ok: false; failure: LiveSidebandUpstreamFailure };
77
+
78
+ export interface LiveSidebandUpstreamHandoff {
79
+ /** Observe failure before the downstream upgrade without ending capture. */
80
+ failure(): LiveSidebandUpstreamFailure | undefined;
81
+ /** Atomically ends capture and transfers buffered frames or terminal state. */
82
+ take(): LiveSidebandUpstreamTakeover;
83
+ }
84
+
51
85
  /**
52
86
  * Build the Responses WebSocket upgrade payload.
53
87
  *