openmausbot 0.1.96 → 0.1.97

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 (123) hide show
  1. package/dist/assets/{DesktopViewer-DsnA23KM.js → DesktopViewer-CPidMCtt.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Dkbd2hcj.js → abnfDiagram-O67JEVCF-k1UCNXb7.js} +1 -1
  3. package/dist/assets/{arc-Duj5Coy2.js → arc-BUWGa9UU.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-uYUsDawP.js → architectureDiagram-NJMV4G6O-DiPor3UT.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CWDlTpXe.js → blockDiagram-BEXU5L5S-Ag9nLW4H.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-B0t15PDc.js → c4Diagram-YGBWAQC7-Bh0sjgUa.js} +1 -1
  7. package/dist/assets/channel-DR8eD6QW.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BJSCL-HV.js → chunk-2Q5K7J3B-BFBcIIqu.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-D3_ILccd.js → chunk-3FUC2YCW-Dvfpex-f.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-D3dmwT_S.js → chunk-5VM5RSS4-CMjP2YZW.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Br73HFPD.js → chunk-GTNCS2PH-BryoeTc7.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-uSsjxubC.js → chunk-GWA4HPMP-BhfvcchO.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C40VWFyr.js → chunk-JWPE2WC7-bcCVp8rp.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-D6WNNYwU.js → chunk-NETBCI7D-yKIjIlkQ.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-DOEhq64P.js → chunk-WEXAMYUT-D3Zqbmup.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-BqJEi6Pi.js → chunk-XXDRQBXY-CjVoIMG6.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-ByU_GnxT.js → classDiagram-v2-NBCMYWYE-mUCWdVyH.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D-Uglxqq.js → cose-bilkent-JH36ORCC-BBmR1bbX.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CZWO7aYj.js → cynefin-OW5HDTMX-BpRrxoO4.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-C70-w0la.js → cynefinDiagram-VND7K2PF-D3VBWCPK.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-CnhjhExB.js → dagre-6A5THRUB-DxEIACOO.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-bnjWCNb4.js → diagram-22UHCM2B-BANgZEOS.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-A4uTWoFV.js → diagram-3UASUU5V-Cnq7nJir.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Zi6m3NUl.js → diagram-ATOU4E4O-4TBDIWnz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DtCcmd5k.js → diagram-CDSNMT55-CD_B39D4.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-DofuETS3.js → diagram-MLGK6HIB-CzvnFhEC.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-Dw2T9PuU.js → diagram-MPIPVDR6-C4w2SNml.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-DbrwPiem.js → ebnfDiagram-ZINNZB2B-gkUEBm2s.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BvCt1dCb.js → elk-276RUBZZ-B_56X6mw.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-Y-QKqPnU.js → erDiagram-OPXOYQCR-C_oByoDS.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CDkL1B5w.js → flowDiagram-KWPJA3E3-CR9xXJkC.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-CKV5D2F6.js → ganttDiagram-FUAMR5RP-gtYBTu0B.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-nL9iFTCv.js → gitGraphDiagram-X574FWY7-VJ6-Tp_p.js} +1 -1
  34. package/dist/assets/{index-DreDnjOu.js → index-Cp4S4NOL.js} +1 -1
  35. package/dist/assets/index-CpaIXRLp.js +614 -0
  36. package/dist/assets/index-Z81SMsgR.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-C7aHjzsf.js → infoDiagram-VRGFBTTK-2Lu9te4D.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-KUZsNBTh.js → ishikawaDiagram-OU5B5YK6-DR49XY-e.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-CGUuodFl.js → journeyDiagram-ZHPQQLJL-ZoR0M305.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-Cy6r868Z.js → kanban-definition-PNTS6WVX-DLKHxrO-.js} +1 -1
  41. package/dist/assets/{linear-Bl669148.js → linear-BASsfWly.js} +1 -1
  42. package/dist/assets/{mermaid.core-86Xqz5Mr.js → mermaid.core-BVidiPkk.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-G4DzHLrd.js → mindmap-definition-NLK3R4M7-g_Qx0U5C.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-K4eruzY0.js → pegDiagram-GJSIUBJH-DI9Cjs8w.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DYdR9PPq.js → pieDiagram-5QR66LMP-sRI0Uhr5.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-BCAh0nEy.js → quadrantDiagram-O4NWA36T-CamKTby3.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-p0dGRc4g.js → railroadDiagram-XR7U4H2S-DOLlkbf3.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-tLeBG0aL.js → requirementDiagram-PLB6GJNP-DrRQ5r97.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-QRuN16rV.js → sankeyDiagram-IPEJSGJF-DGS_lXcX.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-ueT1nVoa.js → sequenceDiagram-PO4LG4MO-CkjWqwtf.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-wRS2zWg2.js → sizeCapture-INFHLROL-CepRgful.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CYzA_hE1.js → stateDiagram-v2-GCMORJYK-hykr-9RS.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-kDnv6LMc.js → swimlanes-2SLR337P-CKOHEZbI.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-D1y2UbbH.js → timeline-definition-EJHVYXUP-CB8pJ6AC.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-IqB4v9HX.js → usecaseDiagram-POWQR4AR-DrclJHdl.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-BEi7KYVF.js → vennDiagram-UO4OBE2U-zS23qjv8.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-Dkybo-Yf.js → wardleyDiagram-VNRHLVJA-52jgeKxz.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-BAPFJLpg.js → xychartDiagram-PMCCYNJV-DSdl9rQ8.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +1 -1
  62. package/dist-server/drivers/agents-proxy.js +5 -5
  63. package/dist-server/index.js +2379 -1596
  64. package/dist-server/openmausbot.js +857 -1012
  65. package/dist-server/pair-cli.js +857 -1012
  66. package/dist-server/permission-proxy.js +0 -4
  67. package/dist-server/server/auto-approve.js +1 -1
  68. package/dist-server/server/auto-vm-claims.js +13 -1
  69. package/dist-server/server/automatic-recovery.js +1 -2
  70. package/dist-server/server/boat.js +68 -84
  71. package/dist-server/server/bot-overview.js +1 -1
  72. package/dist-server/server/browser-engine.js +38 -1
  73. package/dist-server/server/browser-live.js +112 -25
  74. package/dist-server/server/browser-runtime.js +31 -1
  75. package/dist-server/server/checkpoints.js +275 -44
  76. package/dist-server/server/cli-setup.js +3 -3
  77. package/dist-server/server/cloud-computer-tools.js +11 -4
  78. package/dist-server/server/cloud-home-start.js +1 -1
  79. package/dist-server/server/cloud-home.js +3 -9
  80. package/dist-server/server/cloud-owner.js +1 -1
  81. package/dist-server/server/computer-engine-removal.js +70 -0
  82. package/dist-server/server/computer-selection.js +18 -0
  83. package/dist-server/server/config.js +7 -11
  84. package/dist-server/server/connector-scope-hint.js +83 -0
  85. package/dist-server/server/default-model-selection.js +20 -14
  86. package/dist-server/server/delegations.js +11 -5
  87. package/dist-server/server/delta-context.js +6 -1
  88. package/dist-server/server/drivers/acp/core.js +1 -1
  89. package/dist-server/server/drivers/acp/opencode-go.js +16 -21
  90. package/dist-server/server/drivers/agents-call.js +1 -1
  91. package/dist-server/server/drivers/agents-catalog.js +3 -3
  92. package/dist-server/server/drivers/builtIn.js +0 -2
  93. package/dist-server/server/drivers/claude.js +49 -30
  94. package/dist-server/server/drivers/codex.js +2 -2
  95. package/dist-server/server/drivers/openai-chat.js +98 -6
  96. package/dist-server/server/drivers/pi.js +184 -49
  97. package/dist-server/server/index.js +555 -279
  98. package/dist-server/server/live-call.js +11 -6
  99. package/dist-server/server/managed-policy.js +1 -1
  100. package/dist-server/server/model-catalog/presets.js +8 -0
  101. package/dist-server/server/peer-roster.js +10 -2
  102. package/dist-server/server/room-turn-end.js +15 -0
  103. package/dist-server/server/routes/live.js +3 -3
  104. package/dist-server/server/routine-requests.js +1 -1
  105. package/dist-server/server/routines.js +1 -1
  106. package/dist-server/server/screen-frame-gate.js +1 -3
  107. package/dist-server/server/store.js +125 -1
  108. package/dist-server/server/surface.js +29 -49
  109. package/dist-server/server/system-prompt.js +10 -10
  110. package/dist-server/server/turn-continuation.js +162 -0
  111. package/dist-server/server/workspace.js +1 -1
  112. package/dist-server/shared/ask-question.js +0 -70
  113. package/dist-server/shared/cloud-computer.js +4 -6
  114. package/dist-server/shared/credential-request.js +1 -1
  115. package/dist-server/shared/failed-turn.js +1 -4
  116. package/dist-server/shared/place-view.js +409 -0
  117. package/dist-server/shared/tool-scope-support.js +1 -2
  118. package/package.json +1 -1
  119. package/dist/assets/channel-BZxwC-Gw.js +0 -1
  120. package/dist/assets/index-Du7o6Dsf.js +0 -610
  121. package/dist/assets/index-KxG4bWSr.css +0 -1
  122. package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +0 -8
  123. package/dist-server/server/drivers/boatagent.js +0 -443
@@ -10,6 +10,7 @@ import { createChatToolApproval } from "./chat-tool-approval.js";
10
10
  import { ChatProtocolError, ChatReasoningDetails, ChatToolCalls, object } from "./openai-chat-protocol.js";
11
11
  import { appendNative } from "./native.js";
12
12
  import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
13
+ import { classifyContinuable, writeTurnHandoff } from "../turn-continuation.js";
13
14
  /** The message of a JSON error body a provider returned with HTTP 200.
14
15
  * MiniMax reports auth, balance, and parameter failures as `base_resp`. */
15
16
  function providerError(json) {
@@ -87,6 +88,53 @@ function rejectsReasoningReplay(status, body) {
87
88
  return typeof message === "string" && /\breasoning_content\b/.test(message) &&
88
89
  /\b(?:unsupported|not supported|unknown|unrecognized|unexpected|not permitted|not allowed)\b/i.test(message);
89
90
  }
91
+ /** Groq checks each generated tool call against the request and fails the
92
+ * completion with `tool_use_failed` when the call is invalid: most often a
93
+ * tool it was not offered (gpt-oss inventing a "json" tool to answer in
94
+ * JSON), sometimes arguments that miss the schema. `refusal` is the
95
+ * provider's whole error object, `failed_generation` included. */
96
+ class RejectedToolCallError extends ChatProtocolError {
97
+ refusal;
98
+ constructor(message, refusal) {
99
+ super(message);
100
+ this.refusal = refusal;
101
+ }
102
+ }
103
+ const toolCallRefusal = (error) => {
104
+ const body = object(error);
105
+ return body?.code === "tool_use_failed" ? body : undefined;
106
+ };
107
+ function refusedToolCall(status, body) {
108
+ if (status !== 400)
109
+ return undefined;
110
+ try {
111
+ return toolCallRefusal(object(JSON.parse(body))?.error);
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ function completionError(json, label) {
118
+ const message = providerError(json);
119
+ if (!message)
120
+ return null;
121
+ const text = `provider returned a ${label}: ${message.slice(0, 200)}`;
122
+ const refusal = toolCallRefusal(json.error);
123
+ return refusal ? new RejectedToolCallError(text, refusal) : new ChatProtocolError(text);
124
+ }
125
+ /** What a failed turn says once the provider refused every attempt: what
126
+ * happened, the next step, then the provider's words. The refused call ran
127
+ * nothing, but tool calls from earlier steps of the turn did, and a Retry
128
+ * of the whole request would repeat them. */
129
+ function refusedToolCallFailure(error, earlierSteps) {
130
+ const words = typeof error.refusal.message === "string" ? error.refusal.message : error.message;
131
+ const tool = /attempted to call tool '([^']+)'/.exec(words)?.[1];
132
+ const what = tool ? `tried to use a tool it was not given ("${tool}")` : "made a tool call the provider rejected";
133
+ const next = earlierSteps
134
+ ? "The steps before it already ran, so ask only for what's left."
135
+ : "Nothing ran. Retry; if it keeps happening, rephrase the request or choose another model.";
136
+ return `The model ${what}. ${next} Provider: ${words.slice(0, 200)}`;
137
+ }
90
138
  /** Shared runtime for the three providers that speak OpenAI chat completions. */
91
139
  export function createOpenAIChatRuntime(options) {
92
140
  const { input } = options;
@@ -137,6 +185,9 @@ export function createOpenAIChatRuntime(options) {
137
185
  const message = `${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`;
138
186
  if (rejectsToolsParameter(response.status, body))
139
187
  throw new UnsupportedChatToolsError(message);
188
+ const refusal = refusedToolCall(response.status, body);
189
+ if (refusal)
190
+ throw new RejectedToolCallError(message, refusal);
140
191
  if (messages.some((entry) => entry.reasoning_content !== undefined) && rejectsReasoningReplay(response.status, body)) {
141
192
  throw new UnsupportedReasoningReplayError(message);
142
193
  }
@@ -155,9 +206,9 @@ export function createOpenAIChatRuntime(options) {
155
206
  costUsd: count(json.usage?.cost),
156
207
  });
157
208
  }
158
- const bodyError = providerError(json);
209
+ const bodyError = completionError(json, "completion error");
159
210
  if (bodyError)
160
- throw new ChatProtocolError(`provider returned a completion error: ${bodyError.slice(0, 200)}`);
211
+ throw bodyError;
161
212
  const message = json.choices?.[0]?.message;
162
213
  if (!message || !object(message) || !["content", "reasoning_content", "reasoning", "reasoning_details", "tool_calls", "function_call"].some((key) => key in message)) {
163
214
  throw new ChatProtocolError("provider returned no completion message");
@@ -219,9 +270,9 @@ export function createOpenAIChatRuntime(options) {
219
270
  malformedFrame = true;
220
271
  return false;
221
272
  }
222
- const chunkError = providerError(chunk);
273
+ const chunkError = completionError(chunk, "streaming completion error");
223
274
  if (chunkError)
224
- throw new ChatProtocolError(`provider returned a streaming completion error: ${chunkError.slice(0, 200)}`);
275
+ throw chunkError;
225
276
  const choice = chunk.choices?.find((row) => row.index === undefined || row.index === 0);
226
277
  const delta = choice?.delta;
227
278
  if (object(delta))
@@ -271,9 +322,9 @@ export function createOpenAIChatRuntime(options) {
271
322
  catch {
272
323
  body = undefined;
273
324
  }
274
- const bodyError = body ? providerError(body) : null;
325
+ const bodyError = body ? completionError(body, "completion error") : null;
275
326
  if (bodyError)
276
- throw new ChatProtocolError(`provider returned a completion error: ${bodyError.slice(0, 200)}`);
327
+ throw bodyError;
277
328
  }
278
329
  throw new ChatProtocolError("Stream ended before completion");
279
330
  }
@@ -436,6 +487,7 @@ export function createOpenAIChatRuntime(options) {
436
487
  let completion;
437
488
  for (;;) {
438
489
  let streamed = false;
490
+ let answerStreamed = false;
439
491
  const pending = { assistant_text: "", reasoning_text: "" };
440
492
  const delta = (text, streamKind, flush = false) => {
441
493
  let combined = pending[streamKind] + text;
@@ -464,6 +516,8 @@ export function createOpenAIChatRuntime(options) {
464
516
  try {
465
517
  completion = await complete(messages, model, true, abort.signal, (text, streamKind) => {
466
518
  streamed = true;
519
+ if (streamKind === "assistant_text")
520
+ answerStreamed = true;
467
521
  delta(text, streamKind);
468
522
  }, tools.definitions);
469
523
  delta("", "assistant_text", true);
@@ -483,6 +537,20 @@ export function createOpenAIChatRuntime(options) {
483
537
  delete message.reasoning_content;
484
538
  continue;
485
539
  }
540
+ // A refused tool call ran nothing, so the same request goes
541
+ // again; the model usually answers properly next time. Not once
542
+ // answer text streamed: the phones' live bubble would join it to
543
+ // the next attempt's. Streamed thinking stays thinking. Each
544
+ // refusal, failed_generation included, goes to the native log.
545
+ if (error instanceof RejectedToolCallError) {
546
+ native("in", { refused: "tool_use_failed", attempt: attempt + 1, error: error.refusal });
547
+ if (!answerStreamed && !abort.signal.aborted && attempt < RETRY_MAX_ATTEMPTS - 1) {
548
+ emit({ ...base(turn.threadId, turnId), type: "turn.retrying", attempt: ++attempt, delayMs: 0, reason: "tool_use_failed" });
549
+ continue;
550
+ }
551
+ if (!abort.signal.aborted)
552
+ throw new ChatProtocolError(refusedToolCallFailure(error, seenCalls.size > 0));
553
+ }
486
554
  // Only our optional question changed a previously plain request.
487
555
  // A structured parameter rejection has executed nothing; never
488
556
  // downgrade mounted tools, streamed output or a handled call.
@@ -526,6 +594,9 @@ export function createOpenAIChatRuntime(options) {
526
594
  continue;
527
595
  }
528
596
  if (toolFailed) {
597
+ // A failed or denied tool op ends the turn: the final answer is
598
+ // not an execution receipt, and a person's "no" must never be
599
+ // answered with a prompt to run the same thing again.
529
600
  stopReason = "tool_error";
530
601
  throw new ChatProtocolError("One or more tool operations failed or were denied. See the tool results; the final response is not an execution receipt.");
531
602
  }
@@ -680,6 +751,27 @@ export function createOpenAIChatRuntime(options) {
680
751
  if (failure && (!abort.signal.aborted || cleanupFailed)) {
681
752
  emit({ ...base(turn.threadId, turnId), type: "runtime.error", message: failure, terminal: !abort.signal.aborted });
682
753
  }
754
+ // Automatic continuity: a resumable terminal (budget cap or tool
755
+ // errors) persists a handoff and raises cap.exhausted so the harness
756
+ // can start a `Continue:` thread. Interruptions and provider config
757
+ // errors are excluded by classifyContinuable.
758
+ if (!ok && !abort.signal.aborted) {
759
+ const continuable = classifyContinuable(stopReason, failure);
760
+ if (continuable) {
761
+ const handoffPath = writeTurnHandoff({
762
+ threadId: turn.threadId,
763
+ turnId,
764
+ model,
765
+ usage,
766
+ hasUsage,
767
+ failure,
768
+ toolCalls: seenCalls.size,
769
+ messages,
770
+ });
771
+ if (handoffPath)
772
+ emit({ ...base(turn.threadId, turnId), type: "cap.exhausted", handoffPath, reason: continuable });
773
+ }
774
+ }
683
775
  active.delete(turn.threadId);
684
776
  emit({ ...base(turn.threadId, turnId), type: "turn.completed", ok, stopReason, cost: null,
685
777
  ...(hasUsage && (options.includeUsageInCompleted || seenCalls.size) ? { usage } : {}),
@@ -40,6 +40,12 @@ const DRIVER_KIND = "piAgent";
40
40
  const PI_ARGS = ["--mode", "rpc", "--no-session"];
41
41
  const PI_MODEL_UPDATE_ARGS = ["update", "--models", "--no-approve"];
42
42
  const NODE_ENV_FLAG = { ELECTRON_RUN_AS_NODE: "1" };
43
+ /** omp's RPC transport caps one frame at 1 MiB and only chunks oversized
44
+ * frames after the client negotiates protocol 2. Sent before the first
45
+ * command on every spawn: stdin order guarantees the declaration lands
46
+ * before the response to that command is encoded, and vanilla pi has no
47
+ * handshake frame to wait for, so nobody gates on a reply. */
48
+ const PI_NEGOTIATE_FRAME = { id: "negotiate", type: "negotiate_protocol", protocolVersion: 2 };
43
49
  /** After this many bare turns on one pi session the full prompt rides
44
50
  * again even without a compaction event: any history rewrite the events
45
51
  * miss (an older pi, a missed line) still loses at most this many turns
@@ -135,12 +141,26 @@ export function buildMcpServers(turn) {
135
141
  }
136
142
  return Object.keys(servers).length ? servers : null;
137
143
  }
144
+ /** One decoded frame → catalog, or null when it is not a successful
145
+ * `get_available_models` response. Every option is `custom` (pi is BYOK)
146
+ * and id is the `provider/modelId` composite the picker and `set_model`
147
+ * both use. */
148
+ function piCatalogFromFrame(frame, fallbackDefault) {
149
+ const res = frame;
150
+ if (res?.type !== "response" || res.command !== "get_available_models" || !res.success)
151
+ return null;
152
+ const options = [];
153
+ for (const m of res.data?.models ?? []) {
154
+ if (!m?.provider || !m?.id)
155
+ continue;
156
+ const id = `${m.provider}/${m.id}`;
157
+ options.push({ id, label: m.name ?? m.id, custom: true, provider: m.provider });
158
+ }
159
+ return { default: fallbackDefault || (options[0]?.id ?? ""), options };
160
+ }
138
161
  /** Pure parser: turn a `get_available_models` stdout blob into a catalog.
139
- * Every option is `custom` (pi is BYOK) and id is the `provider/modelId`
140
- * composite the picker and `set_model` both use. Exported for the test. */
162
+ * Exported for the test. */
141
163
  export function parsePiCatalog(stdout, fallbackDefault = "") {
142
- const options = [];
143
- let def = fallbackDefault;
144
164
  for (const line of stdout.split("\n")) {
145
165
  if (!line.trim())
146
166
  continue;
@@ -151,20 +171,142 @@ export function parsePiCatalog(stdout, fallbackDefault = "") {
151
171
  catch {
152
172
  continue;
153
173
  }
154
- const res = msg;
155
- if (res?.type !== "response" || res.command !== "get_available_models" || !res.success)
156
- continue;
157
- for (const m of res.data?.models ?? []) {
158
- if (!m?.provider || !m?.id)
159
- continue;
160
- const id = `${m.provider}/${m.id}`;
161
- options.push({ id, label: m.name ?? m.id, custom: true, provider: m.provider });
174
+ const catalog = piCatalogFromFrame(msg, fallbackDefault);
175
+ if (catalog)
176
+ return catalog;
177
+ }
178
+ return { default: fallbackDefault, options: [] };
179
+ }
180
+ /** omp's RPC v2 splits any frame larger than 1 MiB into base64 `rpc_chunk`
181
+ * pieces: one `chunkId`/`count`/`byteLength` for the whole frame, a
182
+ * sequential `index`, and `data` carrying one piece. Reassembles one
183
+ * logical frame at a time; any inconsistency drops the partial sequence so
184
+ * a corrupt stream can never feed a half-built frame to the parser.
185
+ * Exported for the test. */
186
+ export class PiRpcChunks {
187
+ /** omp's advertised cap on one reassembled frame. */
188
+ static MAX_BYTES = 64 * 1024 * 1024;
189
+ /** omp's cap on one decoded piece, which also bounds the piece count. */
190
+ static MAX_PIECE_BYTES = 256 * 1024;
191
+ static MAX_COUNT = PiRpcChunks.MAX_BYTES / PiRpcChunks.MAX_PIECE_BYTES;
192
+ chunkId = null;
193
+ count = 0;
194
+ byteLength = 0;
195
+ bytes = 0;
196
+ parts = [];
197
+ /** Feed one `rpc_chunk`; returns the reassembled frame when the last
198
+ * piece arrives, or null while the sequence is incomplete or broken. */
199
+ accept(message) {
200
+ const { chunkId, index, count, byteLength, data } = message;
201
+ if (typeof chunkId !== "string" || chunkId.length === 0 || typeof data !== "string" ||
202
+ // Bound the base64 before decoding it, so an oversized piece is
203
+ // rejected without allocating its bytes.
204
+ data.length === 0 || data.length > Math.ceil(PiRpcChunks.MAX_PIECE_BYTES / 3) * 4 ||
205
+ !Number.isInteger(index) || !Number.isInteger(count) || !Number.isInteger(byteLength) ||
206
+ index < 0 || count < 2 || count > PiRpcChunks.MAX_COUNT ||
207
+ index >= count ||
208
+ byteLength < 0 || byteLength > PiRpcChunks.MAX_BYTES) {
209
+ this.reset();
210
+ return null;
211
+ }
212
+ if (chunkId !== this.chunkId) {
213
+ // A new sequence may only start at index 0; a different chunkId
214
+ // mid-sequence means the previous one can never complete.
215
+ if (index !== 0) {
216
+ this.reset();
217
+ return null;
218
+ }
219
+ this.chunkId = chunkId;
220
+ this.count = count;
221
+ this.byteLength = byteLength;
222
+ this.parts = [];
223
+ this.bytes = 0;
224
+ }
225
+ else if (count !== this.count || byteLength !== this.byteLength) {
226
+ this.reset();
227
+ return null;
228
+ }
229
+ const part = Buffer.from(data, "base64");
230
+ // Every piece must carry bytes: an empty one would let a sequence grow
231
+ // `parts` without ever approaching `byteLength`.
232
+ if (index !== this.parts.length || part.length === 0 || part.length > PiRpcChunks.MAX_PIECE_BYTES ||
233
+ this.bytes + part.length > this.byteLength) {
234
+ this.reset();
235
+ return null;
236
+ }
237
+ this.parts.push(part);
238
+ this.bytes += part.length;
239
+ if (this.parts.length < this.count)
240
+ return null;
241
+ const complete = this.bytes === this.byteLength;
242
+ const text = Buffer.concat(this.parts).toString("utf8");
243
+ this.reset();
244
+ if (!complete)
245
+ return null;
246
+ try {
247
+ const frame = JSON.parse(text);
248
+ return frame && typeof frame === "object" && !Array.isArray(frame)
249
+ ? frame
250
+ : null;
251
+ }
252
+ catch {
253
+ return null;
162
254
  }
163
- break;
164
255
  }
165
- if (!def && options.length)
166
- def = options[0].id;
167
- return { default: def, options };
256
+ reset() {
257
+ this.chunkId = null;
258
+ this.count = 0;
259
+ this.byteLength = 0;
260
+ this.bytes = 0;
261
+ this.parts = [];
262
+ }
263
+ }
264
+ /** Decode one pi stdout line: a plain JSON frame, or one piece of an
265
+ * `rpc_chunk` sequence (omp's protocol v2) with the reassembled frame
266
+ * returned once complete. Non-JSON lines, scalars and broken sequences
267
+ * resolve null and are skipped. Exported for the test. */
268
+ export function decodePiFrame(line, chunks) {
269
+ let parsed;
270
+ try {
271
+ parsed = JSON.parse(line);
272
+ }
273
+ catch {
274
+ return null;
275
+ }
276
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
277
+ return null;
278
+ const frame = parsed;
279
+ return frame.type === "rpc_chunk" ? chunks.accept(frame) : frame;
280
+ }
281
+ /** Longest stdout line a reader buffers: omp's cap on one whole frame. */
282
+ const PI_MAX_LINE = PiRpcChunks.MAX_BYTES;
283
+ /** Split a pi child's stdout into non-blank lines for `onLine`, which
284
+ * returns true to stop reading. A line longer than PI_MAX_LINE is dropped
285
+ * through its newline instead of growing the buffer without bound.
286
+ * Exported for the test. */
287
+ export function piLineReader(onLine) {
288
+ let buf = "";
289
+ let dropping = false;
290
+ return (chunk) => {
291
+ buf += chunk;
292
+ let nl;
293
+ while ((nl = buf.indexOf("\n")) !== -1) {
294
+ const line = buf.slice(0, nl);
295
+ buf = buf.slice(nl + 1);
296
+ if (dropping) {
297
+ dropping = false;
298
+ continue;
299
+ }
300
+ if (line.length > PI_MAX_LINE || !line.trim())
301
+ continue;
302
+ if (onLine(line))
303
+ return;
304
+ }
305
+ if (buf.length > PI_MAX_LINE) {
306
+ buf = "";
307
+ dropping = true;
308
+ }
309
+ };
168
310
  }
169
311
  /** Split a picker id into pi's `{provider, modelId}`. Accepts both the
170
312
  * native `provider/modelId` composite and a live-host `host::model`
@@ -306,7 +448,6 @@ function readPiDefaultModel(env) {
306
448
  export async function fetchPiModels(cli, env) {
307
449
  const child = spawnCli(cli, PI_ARGS, { stdio: ["pipe", "pipe", "pipe"], env });
308
450
  return new Promise((resolve) => {
309
- let buf = "";
310
451
  let done = false;
311
452
  const fallbackDefault = readPiDefaultModel(env);
312
453
  const finish = (catalog) => {
@@ -323,26 +464,20 @@ export async function fetchPiModels(cli, env) {
323
464
  };
324
465
  const timer = setTimeout(() => finish({ default: "", options: [] }), 15_000);
325
466
  timer.unref?.();
467
+ const chunks = new PiRpcChunks();
326
468
  child.stdout.setEncoding("utf8");
327
- child.stdout.on("data", (chunk) => {
328
- buf += chunk;
329
- let nl;
330
- while ((nl = buf.indexOf("\n")) !== -1) {
331
- const line = buf.slice(0, nl);
332
- buf = buf.slice(nl + 1);
333
- if (!line.trim())
334
- continue;
335
- const parsed = parsePiCatalog(line + "\n", fallbackDefault);
336
- if (parsed.options.length || line.includes('"get_available_models"')) {
337
- clearTimeout(timer);
338
- finish(parsed);
339
- return;
340
- }
341
- }
342
- });
469
+ child.stdout.on("data", piLineReader((line) => {
470
+ const frame = decodePiFrame(line, chunks);
471
+ if (frame?.command !== "get_available_models")
472
+ return false;
473
+ clearTimeout(timer);
474
+ finish(piCatalogFromFrame(frame, fallbackDefault) ?? { default: fallbackDefault, options: [] });
475
+ return true;
476
+ }));
343
477
  child.on("error", () => finish({ default: "", options: [] }));
344
478
  child.on("close", () => finish({ default: "", options: [] }));
345
479
  try {
480
+ child.stdin.write(`${JSON.stringify(PI_NEGOTIATE_FRAME)}\n`);
346
481
  child.stdin.write(JSON.stringify({ id: "catalog", type: "get_available_models" }) + "\n");
347
482
  }
348
483
  catch {
@@ -542,7 +677,6 @@ export const PiDriver = {
542
677
  throw err;
543
678
  }
544
679
  })();
545
- let buf = "";
546
680
  let assistantText = "";
547
681
  // set when a compaction event arrives this turn; gates the receipt
548
682
  // write below so a compacted-around delivery is never claimed
@@ -575,6 +709,11 @@ export const PiDriver = {
575
709
  appendNative(threadId, { dir: "out", source: "pi.rpc", msg: piNativeLogMessage(obj) });
576
710
  child.stdin.write(JSON.stringify(obj) + "\n");
577
711
  };
712
+ // Declare protocol 2 on the same stdin before the first command, so an
713
+ // omp-style runtime chunks any oversized frame it sends back. Vanilla
714
+ // pi refuses the unknown command; no waiter is keyed for the reply,
715
+ // so either answer is ignored.
716
+ send(PI_NEGOTIATE_FRAME);
578
717
  /** Emit buffered assistant text as its own item, then clear it. */
579
718
  const flushAssistantText = () => {
580
719
  const text = assistantText;
@@ -911,23 +1050,19 @@ export const PiDriver = {
911
1050
  return;
912
1051
  }
913
1052
  };
1053
+ const chunks = new PiRpcChunks();
914
1054
  child.stdout.setEncoding("utf8");
915
- child.stdout.on("data", (chunk) => {
916
- buf += chunk;
917
- let nl;
918
- while ((nl = buf.indexOf("\n")) !== -1) {
919
- const line = buf.slice(0, nl);
920
- buf = buf.slice(nl + 1);
921
- if (!line.trim())
922
- continue;
923
- try {
924
- onEvent(JSON.parse(line));
925
- }
926
- catch {
927
- /* skip non-JSON line */
928
- }
1055
+ child.stdout.on("data", piLineReader((line) => {
1056
+ const frame = decodePiFrame(line, chunks);
1057
+ if (!frame)
1058
+ return;
1059
+ try {
1060
+ onEvent(frame);
929
1061
  }
930
- });
1062
+ catch {
1063
+ /* skip a frame the event handler can't consume */
1064
+ }
1065
+ }));
931
1066
  child.on("error", (err) => {
932
1067
  const fail = describeSpawnFailure(err, config.cli);
933
1068
  rejectWaiters(new Error(fail.message));