@superblocksteam/gateway 2.0.167-next.0 → 2.0.169-next.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 (86) hide show
  1. package/README.md +163 -28
  2. package/dist/agents/resolve-orchestrator-url.js +1 -2
  3. package/dist/agents/resolve-orchestrator-url.js.map +1 -1
  4. package/dist/capabilities/lifecycle.d.ts +3 -0
  5. package/dist/capabilities/lifecycle.js +330 -128
  6. package/dist/capabilities/lifecycle.js.map +1 -1
  7. package/dist/capabilities/live-state-attaches.d.ts +26 -0
  8. package/dist/capabilities/live-state-attaches.js +41 -0
  9. package/dist/capabilities/live-state-attaches.js.map +1 -0
  10. package/dist/capabilities/tool-permission-reply.d.ts +28 -0
  11. package/dist/capabilities/tool-permission-reply.js +55 -0
  12. package/dist/capabilities/tool-permission-reply.js.map +1 -0
  13. package/dist/capabilities/types.d.ts +19 -1
  14. package/dist/capabilities/types.js.map +1 -1
  15. package/dist/deadline.d.ts +6 -0
  16. package/dist/deadline.js +23 -0
  17. package/dist/deadline.js.map +1 -0
  18. package/dist/deps.d.ts +7 -0
  19. package/dist/health/readiness.js +1 -17
  20. package/dist/health/readiness.js.map +1 -1
  21. package/dist/integrations/filters.js +2 -2
  22. package/dist/integrations/filters.js.map +1 -1
  23. package/dist/orchestrator/client.d.ts +2 -1
  24. package/dist/orchestrator/client.js +4 -2
  25. package/dist/orchestrator/client.js.map +1 -1
  26. package/dist/sabs/app-state.d.ts +1 -0
  27. package/dist/sabs/app-state.js +9 -0
  28. package/dist/sabs/app-state.js.map +1 -1
  29. package/dist/sabs/awaited-decision.d.ts +24 -1
  30. package/dist/sabs/awaited-decision.js +40 -9
  31. package/dist/sabs/awaited-decision.js.map +1 -1
  32. package/dist/sabs/editor-client-methods.d.ts +21 -10
  33. package/dist/sabs/editor-client-methods.js +48 -24
  34. package/dist/sabs/editor-client-methods.js.map +1 -1
  35. package/dist/sabs/session-peer.d.ts +47 -14
  36. package/dist/sabs/session-peer.js.map +1 -1
  37. package/dist/sabs/turn-collector.d.ts +27 -1
  38. package/dist/sabs/turn-collector.js +38 -2
  39. package/dist/sabs/turn-collector.js.map +1 -1
  40. package/dist/sabs/websocket-session-peer.d.ts +27 -3
  41. package/dist/sabs/websocket-session-peer.js +476 -76
  42. package/dist/sabs/websocket-session-peer.js.map +1 -1
  43. package/dist/server/client.d.ts +5 -0
  44. package/dist/server/client.js +9 -3
  45. package/dist/server/client.js.map +1 -1
  46. package/dist/start.d.ts +13 -2
  47. package/dist/start.js +112 -12
  48. package/dist/start.js.map +1 -1
  49. package/dist/telemetry/metrics.js +8 -21
  50. package/dist/telemetry/metrics.js.map +1 -1
  51. package/dist/telemetry/runtime.d.ts +5 -0
  52. package/dist/telemetry/runtime.js +22 -8
  53. package/dist/telemetry/runtime.js.map +1 -1
  54. package/dist/transports/mcp/admin-tools.d.ts +2 -1
  55. package/dist/transports/mcp/admin-tools.js +19 -2
  56. package/dist/transports/mcp/admin-tools.js.map +1 -1
  57. package/dist/transports/mcp/client-presentation.d.ts +9 -1
  58. package/dist/transports/mcp/client-presentation.js +10 -0
  59. package/dist/transports/mcp/client-presentation.js.map +1 -1
  60. package/dist/transports/mcp/decision-card-html.d.ts +4 -2
  61. package/dist/transports/mcp/decision-card-html.js +107 -25
  62. package/dist/transports/mcp/decision-card-html.js.map +1 -1
  63. package/dist/transports/mcp/decision-elicitation.d.ts +3 -2
  64. package/dist/transports/mcp/decision-elicitation.js +67 -2
  65. package/dist/transports/mcp/decision-elicitation.js.map +1 -1
  66. package/dist/transports/mcp/format-tool-content.d.ts +7 -0
  67. package/dist/transports/mcp/format-tool-content.js +3 -3
  68. package/dist/transports/mcp/format-tool-content.js.map +1 -1
  69. package/dist/transports/mcp/instructions/result.js +1 -0
  70. package/dist/transports/mcp/instructions/result.js.map +1 -1
  71. package/dist/transports/mcp/instructions/tools/ask-user.d.ts +2 -1
  72. package/dist/transports/mcp/instructions/tools/ask-user.js +9 -2
  73. package/dist/transports/mcp/instructions/tools/ask-user.js.map +1 -1
  74. package/dist/transports/mcp/instructions/tools/copy.d.ts +5 -2
  75. package/dist/transports/mcp/instructions/tools/copy.js +20 -5
  76. package/dist/transports/mcp/instructions/tools/copy.js.map +1 -1
  77. package/dist/transports/mcp/instructions/tools/edit-app.d.ts +1 -1
  78. package/dist/transports/mcp/instructions/tools/edit-app.js +11 -1
  79. package/dist/transports/mcp/instructions/tools/edit-app.js.map +1 -1
  80. package/dist/transports/mcp/mount.d.ts +2 -3
  81. package/dist/transports/mcp/mount.js +395 -112
  82. package/dist/transports/mcp/mount.js.map +1 -1
  83. package/dist/transports/mcp/plan-approval.d.ts +23 -13
  84. package/dist/transports/mcp/plan-approval.js +39 -26
  85. package/dist/transports/mcp/plan-approval.js.map +1 -1
  86. package/package.json +5 -5
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { WebSocket } from "ws";
3
+ import { AiMode as SharedAiMode, llmConfigFromFlags, } from "@superblocksteam/library-shared/types";
3
4
  import { createISocketClient, ISocket, SocketHeartbeat, } from "@superblocksteam/shared";
4
5
  import { canonicalizeOrchestratorBaseUrl, orchestratorUrlDependsOnProfile, pickOrchestratorBaseUrl, } from "../agents/resolve-orchestrator-url.js";
5
6
  import { gatewayDebug } from "../debug.js";
@@ -10,7 +11,7 @@ import { recordCheckpointOutcome, recordIntegrationProfileResolution, } from "..
10
11
  import { sanitizeAgentFacingText } from "./agent-facing-text.js";
11
12
  import { AppState } from "./app-state.js";
12
13
  import { buildBrowserContext } from "./browser-context.js";
13
- import { createEditorClientMethods, loadIntegrationsForPrompt, } from "./editor-client-methods.js";
14
+ import { createEditorClientMethods, loadIntegrationsForPrompt, ToolPermissionListenerUnavailableError, } from "./editor-client-methods.js";
14
15
  import { EditorSocket } from "./editor-socket.js";
15
16
  import { appendRecentToolRun, buildRecentToolRun } from "./recent-tool-runs.js";
16
17
  import { PromptNotSentError, } from "./session-peer.js";
@@ -185,6 +186,12 @@ const DEFAULT_RECONNECT_GRACE_MS = 20_000;
185
186
  * on, so reconnect attempts are capped independently.
186
187
  */
187
188
  const DEFAULT_RECONNECT_ATTEMPTS = 3;
189
+ /**
190
+ * How many times a takeover rereads a claim snapshot that browser events keep
191
+ * overtaking. Every read it made was answered, so running out keeps the claim
192
+ * instead of failing a working socket as unreachable.
193
+ */
194
+ const MAX_CLAIM_REFRESHES = 3;
188
195
  /**
189
196
  * After the job settles, how long to wait for the dev server to finish flushing
190
197
  * Clark's files before giving up on checkpointing them.
@@ -206,10 +213,6 @@ const PENDING_START_POLL_INTERVAL_MS = 250;
206
213
  * {@link WebSocketSessionPeer.headlessTurnHandle}.
207
214
  */
208
215
  const GATEWAY_PROTOCOL = "gateway";
209
- const DEFAULT_LLM = {
210
- coreModel: "claude-sonnet-4-5",
211
- provider: "bedrock",
212
- };
213
216
  /**
214
217
  * Reads a {@link LiveEdit} off the caller-facing pair, which names the same two
215
218
  * things with the prompt's field names.
@@ -226,6 +229,106 @@ function isBrowserObserverEvent(event) {
226
229
  (event.kind === "generation_state" &&
227
230
  event.gatewayObserverSource === "browser"));
228
231
  }
232
+ /**
233
+ * Names why a New Chat reset must be ignored, or returns undefined when it may
234
+ * clear chat state. A reset for another generation, or one that cannot be
235
+ * matched to a turn still running, would otherwise end a turn it was never
236
+ * about.
237
+ */
238
+ function ignoredChatResetReason(held, reset, previous) {
239
+ if (held.pendingTurn) {
240
+ return "pending_turn";
241
+ }
242
+ if (previous &&
243
+ (!reset.generationId || !previous.generationId) &&
244
+ !previous.finished &&
245
+ !previous.collector.isSettling()) {
246
+ return "unkeyed_active_turn";
247
+ }
248
+ if (reset.generationId &&
249
+ previous?.generationId &&
250
+ reset.generationId !== previous.generationId) {
251
+ return "other_generation";
252
+ }
253
+ return undefined;
254
+ }
255
+ /**
256
+ * Outcomes that leave the turn with the editor: one waiting on the user, and
257
+ * one that may still be running where only the editor can see it. An idle
258
+ * editor refutes both.
259
+ */
260
+ function leavesTurnWithEditor(outcome) {
261
+ return outcome === "detached" || outcome === "needs_decision";
262
+ }
263
+ /**
264
+ * Whether a snapshot shows nothing newer than the owned turn's outcome: an idle
265
+ * editor, the owned generation completing as the turn already reported, or
266
+ * that generation still waiting on the tool permission the turn asked for. A
267
+ * permission reaches the gateway over its own RPC and never appears as the
268
+ * snapshot's interactive message, so the editor waiting with none is that
269
+ * permission still pending.
270
+ */
271
+ function agreesWithOwnedOutcome({ generationId, state }, { hasActiveInteractiveMessage, owned, }) {
272
+ const outcome = owned?.finished;
273
+ if (!outcome) {
274
+ return false;
275
+ }
276
+ if (state === "idle") {
277
+ return !leavesTurnWithEditor(outcome.generationStatus);
278
+ }
279
+ if (generationId !== owned.generationId) {
280
+ return false;
281
+ }
282
+ if (state === "completed") {
283
+ return (outcome.generationStatus === "completed" ||
284
+ outcome.generationStatus === "no_changes");
285
+ }
286
+ return (state === "awaiting_user_input" &&
287
+ !hasActiveInteractiveMessage &&
288
+ outcome.decision?.kind === "tool_permission");
289
+ }
290
+ /**
291
+ * Whether a snapshot read while taking a turn over is that turn's own state,
292
+ * to replay onto it. One naming the turn's generation is, unless it awaits a
293
+ * decision it does not carry. So is an idle one naming no generation: that is
294
+ * what the editor shows once nothing runs and no terminal state is left to
295
+ * show, and the turn's collector still ignores an idle before the turn was
296
+ * seen generating.
297
+ */
298
+ function replaysOntoTurn(generationState, known) {
299
+ if (!generationState.generationId) {
300
+ return generationState.state === "idle";
301
+ }
302
+ return (generationState.generationId === known.turn.generationId &&
303
+ (generationState.state !== "awaiting_user_input" ||
304
+ known.hasActiveInteractiveMessage ||
305
+ known.turn.collector.decision() !== undefined));
306
+ }
307
+ /**
308
+ * What a chat snapshot read on attach says about the turn this peer knew of.
309
+ *
310
+ * `unchanged` when it shows nothing newer than a finished owned turn (see
311
+ * {@link agreesWithOwnedOutcome}). Clearing then would erase the turn's
312
+ * outcome, checkpoint, retry advice or pending permission while the editor
313
+ * agrees with them. `settled` when nothing is running or waiting on the user.
314
+ * `observable` when there is a generation to follow: one running, one waiting
315
+ * on an answer, or the watched generation completing.
316
+ */
317
+ function readChatSnapshot(generationState, known) {
318
+ if (agreesWithOwnedOutcome(generationState, known)) {
319
+ return "unchanged";
320
+ }
321
+ const { generationId, state } = generationState;
322
+ const { owned } = known;
323
+ const watchedGenerationId = known.observedGenerationId ?? owned?.generationId;
324
+ if (state === "idle" ||
325
+ (state === "completed" &&
326
+ (!watchedGenerationId || watchedGenerationId !== generationId)) ||
327
+ (state === "awaiting_user_input" && !known.hasActiveInteractiveMessage)) {
328
+ return "settled";
329
+ }
330
+ return "observable";
331
+ }
229
332
  export function buildSessionUrl(input) {
230
333
  // liveEditUrl is server-sourced (connectLiveEdit), not caller-supplied, so a
231
334
  // host allowlist would mostly duplicate the Superblocks API's own trust
@@ -505,6 +608,11 @@ async function attempt(url, timeoutMs, onSocketError) {
505
608
  // emitter 'error' rather than reporting it to the caller. Recording it
506
609
  // rather than discarding it is what makes a later 1006 explainable.
507
610
  socket.on("error", onSocketError);
611
+ // The dev server flushes queued pushes the moment a peer binds, and
612
+ // those frames can share a read with the upgrade response. Without the
613
+ // pause they are emitted before ISocket is listening and lost. The
614
+ // caller resumes once its handlers are attached.
615
+ socket.pause();
508
616
  resolve(socket);
509
617
  });
510
618
  const onError = (error) => settle(() => reject(error));
@@ -811,9 +919,12 @@ export class WebSocketSessionPeer {
811
919
  ...(failed.recoveryAction
812
920
  ? { recoveryAction: failed.recoveryAction }
813
921
  : {}),
814
- ...(failed.retryable === false ? { reply: failed.reply } : {}),
922
+ ...(failed.retryable === false || !failed.retryAction
923
+ ? { reply: failed.reply }
924
+ : {}),
815
925
  ...(failed.retryAction ? { retryAction: failed.retryAction } : {}),
816
926
  ...(failed.retryable === false ? { retryable: false } : {}),
927
+ startFailed: true,
817
928
  };
818
929
  }
819
930
  if (turn && !turn.finished) {
@@ -837,7 +948,11 @@ export class WebSocketSessionPeer {
837
948
  ? { checkpointWarning: turn.finished.checkpointWarning }
838
949
  : {}),
839
950
  generationStatus: turn.finished.generationStatus,
951
+ ...(turn.mode ? { mode: turn.mode } : {}),
840
952
  phase: "finished",
953
+ ...(turn.finished.answered
954
+ ? { answered: true, reply: turn.finished.reply }
955
+ : {}),
841
956
  ...(turn.retryAction ? { retryAction: turn.retryAction } : {}),
842
957
  ...(turn.finished.retryable === false
843
958
  ? { retryable: false }
@@ -860,7 +975,7 @@ export class WebSocketSessionPeer {
860
975
  ? { ...(entry.branchName ? { branch: entry.branchName } : {}) }
861
976
  : undefined;
862
977
  }
863
- libraryBrowserContext(applicationId, requestedBranch) {
978
+ async libraryBrowserContext(applicationId, requestedBranch) {
864
979
  const held = this.held.get(applicationId);
865
980
  if (!held || requestedBranch !== held.branchName) {
866
981
  return undefined;
@@ -894,8 +1009,100 @@ export class WebSocketSessionPeer {
894
1009
  held.organizationId = input.organizationId;
895
1010
  }
896
1011
  held.sessionToken = input.sessionToken;
897
- await this.resolveHeldProfileKey(held);
898
- await this.resolveHeldOrchestrator(held);
1012
+ try {
1013
+ await this.synchronizeChatState(held, input.sessionToken);
1014
+ await this.resolveHeldProfileKey(held);
1015
+ // TODO(AR-592): a discovery failure here fails the read even though the
1016
+ // chat state above was just synced.
1017
+ await this.resolveHeldOrchestrator(held);
1018
+ }
1019
+ finally {
1020
+ this.armReadRelease(held);
1021
+ }
1022
+ }
1023
+ /**
1024
+ * Bounds a socket held only to read the editor state. Nothing runs on it,
1025
+ * so it gets a finished turn's grace instead of keeping the sandbox awake
1026
+ * and contesting the editor's binding for the life of the process. A turn
1027
+ * seen on it clears this timer and arms its own.
1028
+ */
1029
+ armReadRelease(held) {
1030
+ const observed = this.observedTurns.get(held.applicationId);
1031
+ if (this.held.get(held.applicationId) !== held ||
1032
+ held.idleTimer ||
1033
+ (observed?.held === held && !observed.finished)) {
1034
+ return;
1035
+ }
1036
+ held.idleTimer = setTimeout(() => {
1037
+ if (this.held.get(held.applicationId) === held) {
1038
+ this.drop(held, { preserveFinishedObserved: true });
1039
+ }
1040
+ }, this.options.releaseAfterReadMs ?? DEFAULT_RELEASE_AFTER_TURN_MS);
1041
+ held.idleTimer.unref?.();
1042
+ }
1043
+ async synchronizeChatState(held, sessionToken, retryDecision = true) {
1044
+ const revision = held.chatStateRevision;
1045
+ const owned = this.turns.get(held.applicationId);
1046
+ // Read before the snapshot call: ordinary browser text on the new socket
1047
+ // does not bump the revision, yet it replaces the watched turn with one
1048
+ // that has no generation, which would hide a matching completion.
1049
+ const observedGenerationId = this.observedTurns.get(held.applicationId)?.generationId;
1050
+ const isCurrent = () => this.held.get(held.applicationId) === held &&
1051
+ isSocketOpen(held.ws) &&
1052
+ !held.pendingTurn &&
1053
+ this.turns.get(held.applicationId) === owned;
1054
+ const snapshot = await editorSocket(held, sessionToken)
1055
+ .aiChatGetMessages({ force: false })
1056
+ .catch((error) => {
1057
+ const dropped = isCurrent() && held.chatStateRevision === revision;
1058
+ console.warn("gateway chat state sync failed", {
1059
+ applicationId: held.applicationId,
1060
+ dropped,
1061
+ message: describeSocketError(error),
1062
+ });
1063
+ if (dropped) {
1064
+ this.drop(held);
1065
+ }
1066
+ throw error;
1067
+ });
1068
+ if (!snapshot?.generationState || !isCurrent()) {
1069
+ return;
1070
+ }
1071
+ if (held.chatStateRevision !== revision) {
1072
+ const observed = this.observedTurns.get(held.applicationId);
1073
+ if (retryDecision &&
1074
+ observed?.finished?.generationStatus === "needs_decision") {
1075
+ await this.synchronizeChatState(held, sessionToken, false);
1076
+ }
1077
+ return;
1078
+ }
1079
+ const reading = readChatSnapshot(snapshot.generationState, {
1080
+ hasActiveInteractiveMessage: Boolean(snapshot.activeInteractiveMessage),
1081
+ observedGenerationId,
1082
+ owned,
1083
+ });
1084
+ if (reading === "unchanged") {
1085
+ return;
1086
+ }
1087
+ this.clearChatSessionState(held.applicationId);
1088
+ if (reading === "settled") {
1089
+ return;
1090
+ }
1091
+ if (snapshot.generationState.state === "awaiting_user_input" &&
1092
+ snapshot.activeInteractiveMessage) {
1093
+ this.observeEditorEvent(held, {
1094
+ kind: "message",
1095
+ message: {
1096
+ ...snapshot.activeInteractiveMessage,
1097
+ gatewayObserverSource: "browser",
1098
+ },
1099
+ });
1100
+ }
1101
+ this.observeEditorEvent(held, {
1102
+ ...snapshot.generationState,
1103
+ gatewayObserverSource: "browser",
1104
+ kind: "generation_state",
1105
+ });
899
1106
  }
900
1107
  release(applicationId) {
901
1108
  const entry = this.held.get(applicationId);
@@ -904,14 +1111,18 @@ export class WebSocketSessionPeer {
904
1111
  }
905
1112
  const turn = this.turns.get(applicationId);
906
1113
  if (turn?.held === entry && !turn.finished) {
907
- this.cancelTurn(turn, "This turn was cancelled because its live-edit session was released.");
1114
+ this.cancelTurn(turn, {
1115
+ reply: "This turn was cancelled because its live-edit session was released.",
1116
+ });
908
1117
  }
909
1118
  this.drop(entry);
910
1119
  }
911
1120
  closeAll() {
912
1121
  for (const turn of this.turns.values()) {
913
1122
  if (!turn.finished) {
914
- this.cancelTurn(turn, "This turn was cancelled because the gateway shut down.");
1123
+ this.cancelTurn(turn, {
1124
+ reply: "This turn was cancelled because the gateway shut down.",
1125
+ });
915
1126
  }
916
1127
  }
917
1128
  for (const applicationId of [...this.observedTurns.keys()]) {
@@ -921,6 +1132,14 @@ export class WebSocketSessionPeer {
921
1132
  this.release(applicationId);
922
1133
  }
923
1134
  }
1135
+ resetForLogin() {
1136
+ this.closeAll();
1137
+ this.promptStarts.clear();
1138
+ this.connecting.clear();
1139
+ this.turns.clear();
1140
+ this.editorStructureByScope.clear();
1141
+ this.inboundMethods.clear();
1142
+ }
924
1143
  /**
925
1144
  * Sends the prompt and waits for Clark's whole turn.
926
1145
  *
@@ -947,7 +1166,7 @@ export class WebSocketSessionPeer {
947
1166
  throw new Error("Superblocks turn wait aborted");
948
1167
  }
949
1168
  if (update.status === "finished") {
950
- const { activity: _activity, elapsedMs: _elapsedMs, status: _status, ...finished } = update;
1169
+ const { activity: _activity, elapsedMs: _elapsedMs, mode: _mode, status: _status, ...finished } = update;
951
1170
  return {
952
1171
  ...finished,
953
1172
  // See startPrompt, which resolved this: the turn may have run on a
@@ -1081,6 +1300,7 @@ export class WebSocketSessionPeer {
1081
1300
  }, input.idempotencyKey, input.pendingStartToken))) {
1082
1301
  throw new PromptNotSentError("A newer start attempt replaced this one before its prompt was sent");
1083
1302
  }
1303
+ const mode = this.turnMode(input);
1084
1304
  const turn = {
1085
1305
  abort: new AbortController(),
1086
1306
  activity: [],
@@ -1093,6 +1313,7 @@ export class WebSocketSessionPeer {
1093
1313
  ? {}
1094
1314
  : { idempotencyKey: input.idempotencyKey }),
1095
1315
  listener: () => undefined,
1316
+ ...(mode ? { mode } : {}),
1096
1317
  ...(input.pendingStartToken === undefined
1097
1318
  ? {}
1098
1319
  : { pendingStartToken: input.pendingStartToken }),
@@ -1193,7 +1414,10 @@ export class WebSocketSessionPeer {
1193
1414
  async sendPrompt(args) {
1194
1415
  const { held, input, integrations, provisional, turn } = args;
1195
1416
  const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1196
- const browserContext = this.browserContextFor(held);
1417
+ const browserContext = await this.browserContextFor(held);
1418
+ if (!held.llmConfig) {
1419
+ console.warn("gateway sending prompt without org model flags; using defaults", { applicationId: held.applicationId });
1420
+ }
1197
1421
  let acknowledgment;
1198
1422
  try {
1199
1423
  // The editor and the smoke client both send the credential twice: once
@@ -1204,7 +1428,11 @@ export class WebSocketSessionPeer {
1204
1428
  ...(browserContext ? { browserContext } : {}),
1205
1429
  jwt: input.sessionToken,
1206
1430
  llmConfig: {
1207
- coreModel: this.options.coreModel ?? DEFAULT_LLM.coreModel,
1431
+ ...(held.llmConfig ?? llmConfigFromFlags(undefined)),
1432
+ ...(this.options.coreModel
1433
+ ? { coreModel: this.options.coreModel }
1434
+ : {}),
1435
+ ...(this.options.provider ? { provider: this.options.provider } : {}),
1208
1436
  disabledTools: ["build_captureScreenshot", "build_navigatePreview"],
1209
1437
  // The ai-service loads org/user/integration knowledge only when
1210
1438
  // the caller opts in. Gateway reads the same org flag as the editor
@@ -1217,13 +1445,12 @@ export class WebSocketSessionPeer {
1217
1445
  // a browser context, so the two travel together or not at all.
1218
1446
  ...(browserContext ? { isTestingEnabled: true } : {}),
1219
1447
  },
1220
- interleavedThinking: false,
1221
- provider: this.options.provider ?? DEFAULT_LLM.provider,
1222
- thinkingBudgetTokens: "0",
1223
- thinkingEnabled: false,
1224
- unifiedProviderEnabled: true,
1225
1448
  },
1226
- mode: (input.mode ?? "PLAN"),
1449
+ ...(input.responseMetadata?.type === "tool_permission_response"
1450
+ ? {}
1451
+ : {
1452
+ mode: input.mode === "BUILD" ? SharedAiMode.BUILD : SharedAiMode.PLAN,
1453
+ }),
1227
1454
  prompt: input.prompt,
1228
1455
  promptContext: {
1229
1456
  attachments: input.attachments ?? [],
@@ -1303,6 +1530,7 @@ export class WebSocketSessionPeer {
1303
1530
  : new Error(`Prompt forward failed: ${rejection.message ?? "unknown socket error"}`);
1304
1531
  }
1305
1532
  turn.revision = this.appState.markTurnStarted();
1533
+ turn.generationId = acknowledgment?.generationId;
1306
1534
  args.acceptBufferedEvents(acknowledgment?.generationId);
1307
1535
  input.onProgress?.("Superblocks accepted the prompt");
1308
1536
  // Only now, with the prompt accepted and a live turn about to go in the
@@ -1350,9 +1578,32 @@ export class WebSocketSessionPeer {
1350
1578
  status: "accepted",
1351
1579
  };
1352
1580
  }
1581
+ /**
1582
+ * The mode a new turn runs in. A reply to a tool permission carries no mode
1583
+ * to Superblocks, which carries on in the mode of the turn that asked, so the
1584
+ * reply takes that turn's mode here. When this peer did not run the turn that
1585
+ * asked, the mode is unknown rather than guessed.
1586
+ */
1587
+ turnMode(input) {
1588
+ const response = input.responseMetadata;
1589
+ if (response?.type !== "tool_permission_response") {
1590
+ return input.mode ?? "PLAN";
1591
+ }
1592
+ const asking = this.turns.get(input.applicationId);
1593
+ const decision = asking?.finished?.decision;
1594
+ return decision?.kind === "tool_permission" &&
1595
+ decision.messageId === response.responseToMessageId
1596
+ ? asking?.mode
1597
+ : undefined;
1598
+ }
1353
1599
  latestTurn(applicationId) {
1354
1600
  return (this.observedTurns.get(applicationId) ?? this.turns.get(applicationId));
1355
1601
  }
1602
+ clearChatSessionState(applicationId) {
1603
+ this.clearPendingDecision(applicationId);
1604
+ this.forgetObservedTurn(applicationId);
1605
+ this.supersedeTurn(applicationId);
1606
+ }
1356
1607
  observeEditorEvent(held, event) {
1357
1608
  if (held.dropping || this.held.get(held.applicationId) !== held) {
1358
1609
  return;
@@ -1376,6 +1627,63 @@ export class WebSocketSessionPeer {
1376
1627
  this.clearPendingDecision(held.applicationId);
1377
1628
  }
1378
1629
  const owned = this.turns.get(held.applicationId);
1630
+ const previous = observed ?? owned;
1631
+ if (event.kind === "generation_state" &&
1632
+ event.state === "idle" &&
1633
+ event.chatSessionReset) {
1634
+ const ignored = ignoredChatResetReason(held, event, previous);
1635
+ if (ignored) {
1636
+ console.warn("gateway chat session reset ignored", {
1637
+ applicationId: held.applicationId,
1638
+ reason: ignored,
1639
+ resetHasGenerationId: Boolean(event.generationId),
1640
+ turnHasGenerationId: Boolean(previous?.generationId),
1641
+ });
1642
+ return;
1643
+ }
1644
+ this.clearChatSessionState(held.applicationId);
1645
+ this.armReadRelease(held);
1646
+ return;
1647
+ }
1648
+ if (previous?.finished &&
1649
+ event.kind === "generation_state" &&
1650
+ event.generationId &&
1651
+ event.generationId === previous.generationId &&
1652
+ previous.finished.generationStatus !== "needs_decision") {
1653
+ return;
1654
+ }
1655
+ if (previous?.finished &&
1656
+ previous.held === held &&
1657
+ !userContent &&
1658
+ !activeGeneration) {
1659
+ if (previous.finished.generationStatus === "needs_decision" &&
1660
+ event.kind === "generation_state" &&
1661
+ (event.state === "cancelled" ||
1662
+ event.state === "completed" ||
1663
+ event.state === "idle") &&
1664
+ (!event.generationId ||
1665
+ !previous.generationId ||
1666
+ event.generationId === previous.generationId)) {
1667
+ previous.finished.generationStatus =
1668
+ event.state === "cancelled"
1669
+ ? "cancelled"
1670
+ : event.hasSuggestions ||
1671
+ previous.collector.workSignals().length > 0
1672
+ ? "completed"
1673
+ : "no_changes";
1674
+ delete previous.finished.decision;
1675
+ this.clearPendingDecision(held.applicationId);
1676
+ this.wake(previous);
1677
+ if (observed) {
1678
+ this.armObservedTurnRelease(held.applicationId, observed);
1679
+ }
1680
+ else if (owned) {
1681
+ this.armPostTurnRelease(held, owned);
1682
+ this.armKeepalive(held);
1683
+ }
1684
+ }
1685
+ return;
1686
+ }
1379
1687
  if (owned) {
1380
1688
  if (!owned.finished && !userContent && !activeGeneration) {
1381
1689
  return;
@@ -1399,12 +1707,6 @@ export class WebSocketSessionPeer {
1399
1707
  if (!activeGeneration) {
1400
1708
  return;
1401
1709
  }
1402
- if (event.kind === "generation_state" &&
1403
- event.generationId &&
1404
- event.generationId === observed.generationId &&
1405
- observed.finished.generationStatus !== "needs_decision") {
1406
- return;
1407
- }
1408
1710
  observed = undefined;
1409
1711
  this.clearPendingDecision(held.applicationId);
1410
1712
  }
@@ -1495,7 +1797,7 @@ export class WebSocketSessionPeer {
1495
1797
  }
1496
1798
  }
1497
1799
  turn.finished = {
1498
- reply: turn.collector.reply(),
1800
+ ...turn.collector.replyFields(),
1499
1801
  generationStatus,
1500
1802
  viewStatus: turn.collector.viewableStatus(),
1501
1803
  ...decisionFields(decision),
@@ -1504,13 +1806,15 @@ export class WebSocketSessionPeer {
1504
1806
  this.wake(turn);
1505
1807
  this.armObservedTurnRelease(applicationId, turn);
1506
1808
  }
1507
- settleObservedTurn(turn, generationStatus, reply = turn.collector.reply()) {
1809
+ settleObservedTurn(turn, generationStatus, gatewayReply) {
1508
1810
  if (turn.finished) {
1509
1811
  return;
1510
1812
  }
1511
1813
  tagRetryableLiveEditDeath(turn, { generationStatus });
1512
1814
  turn.finished = {
1513
- reply,
1815
+ ...(gatewayReply === undefined
1816
+ ? turn.collector.replyFields()
1817
+ : { reply: gatewayReply }),
1514
1818
  generationStatus,
1515
1819
  viewStatus: turn.collector.viewableStatus(),
1516
1820
  ...decisionFields(turn.collector.decision()),
@@ -1538,7 +1842,7 @@ export class WebSocketSessionPeer {
1538
1842
  this.clearTimers(held);
1539
1843
  const delayMs = this.options.releaseAfterTurnMs ?? DEFAULT_RELEASE_AFTER_TURN_MS;
1540
1844
  held.idleTimer = setTimeout(() => {
1541
- if (this.observedTurns.get(held.applicationId) === turn &&
1845
+ if ((this.observedTurns.get(held.applicationId) ?? turn) === turn &&
1542
1846
  this.held.get(held.applicationId) === held) {
1543
1847
  this.drop(held, { preserveFinishedObserved: true });
1544
1848
  }
@@ -1671,6 +1975,7 @@ export class WebSocketSessionPeer {
1671
1975
  activity,
1672
1976
  ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1673
1977
  elapsedMs,
1978
+ ...(turn.mode ? { mode: turn.mode } : {}),
1674
1979
  status: "finished",
1675
1980
  ...turn.finished,
1676
1981
  viewStatus: this.currentViewStatus(input.applicationId, turn.branch, turn.finished.viewStatus),
@@ -1732,7 +2037,7 @@ export class WebSocketSessionPeer {
1732
2037
  inboundMethods: [...this.inboundMethods].sort(),
1733
2038
  });
1734
2039
  turn.finished ??= {
1735
- reply: collector.reply(),
2040
+ ...collector.replyFields(),
1736
2041
  generationStatus: "detached",
1737
2042
  viewStatus: collector.viewableStatus(),
1738
2043
  ...decisionFields(collector.decision()),
@@ -1830,7 +2135,7 @@ export class WebSocketSessionPeer {
1830
2135
  ...(checkpointOutcome?.status === "saved" && checkpointOutcome.warning
1831
2136
  ? { checkpointWarning: checkpointOutcome.warning }
1832
2137
  : {}),
1833
- reply: collector.reply(),
2138
+ ...collector.replyFields(),
1834
2139
  generationStatus,
1835
2140
  ...(turn.recentRuns.length > 0
1836
2141
  ? { recentRuns: [...turn.recentRuns] }
@@ -1849,7 +2154,7 @@ export class WebSocketSessionPeer {
1849
2154
  // A disconnect / supersede already settled the turn with the reason; the
1850
2155
  // abort it caused says less than what is already recorded.
1851
2156
  turn.finished ??= {
1852
- reply: collector.reply(),
2157
+ ...collector.replyFields(),
1853
2158
  generationStatus: "timeout",
1854
2159
  viewStatus: collector.viewableStatus(),
1855
2160
  ...decisionFields(collector.decision()),
@@ -2031,13 +2336,38 @@ export class WebSocketSessionPeer {
2031
2336
  fresh.listeners.add(turn.listener);
2032
2337
  previous.listeners.delete(turn.listener);
2033
2338
  fresh.heartbeat.start();
2034
- const claim = fresh.sessionToken === undefined
2035
- ? "refused"
2339
+ let claim = fresh.sessionToken === undefined
2340
+ ? { kind: "refused" }
2036
2341
  : await this.proveIdentity({
2037
2342
  held: fresh,
2343
+ deadline: input.deadline,
2038
2344
  sessionToken: fresh.sessionToken,
2039
2345
  turnId: turn.turnId,
2040
2346
  });
2347
+ let refreshes = 0;
2348
+ while (claim.kind === "claimed" &&
2349
+ fresh.browserChatStateRevision !== claim.browserRevision &&
2350
+ !turn.finished &&
2351
+ turn.held === previous &&
2352
+ this.turns.get(fresh.applicationId) === turn &&
2353
+ fresh.sessionToken !== undefined) {
2354
+ if (refreshes === MAX_CLAIM_REFRESHES || Date.now() >= input.deadline) {
2355
+ console.warn("gateway clark reattach snapshot kept changing", {
2356
+ applicationId: fresh.applicationId,
2357
+ reason: refreshes === MAX_CLAIM_REFRESHES ? "refresh_cap" : "deadline",
2358
+ refreshes,
2359
+ turnId: turn.turnId,
2360
+ });
2361
+ break;
2362
+ }
2363
+ refreshes++;
2364
+ claim = await this.proveIdentity({
2365
+ held: fresh,
2366
+ deadline: input.deadline,
2367
+ sessionToken: fresh.sessionToken,
2368
+ turnId: turn.turnId,
2369
+ });
2370
+ }
2041
2371
  // Opening and claiming take seconds, and the turn can settle inside them:
2042
2372
  // a relayed terminal event, the turn being cancelled, or a newer prompt
2043
2373
  // taking the app. runTurn arms the post-turn timers once, against the
@@ -2046,6 +2376,11 @@ export class WebSocketSessionPeer {
2046
2376
  // this point but the listener, so there is nothing to undo beyond letting
2047
2377
  // the replacement go.
2048
2378
  if (turn.finished || turn.held !== previous) {
2379
+ if (fresh.pendingChatSessionReset &&
2380
+ turn.held === previous &&
2381
+ this.turns.get(fresh.applicationId) === turn) {
2382
+ this.clearChatSessionState(fresh.applicationId);
2383
+ }
2049
2384
  delete fresh.pendingTurn;
2050
2385
  fresh.listeners.delete(turn.listener);
2051
2386
  if (fresh.listeners.size === 0 &&
@@ -2054,7 +2389,7 @@ export class WebSocketSessionPeer {
2054
2389
  }
2055
2390
  return { kind: "stale" };
2056
2391
  }
2057
- if (claim === "unreachable") {
2392
+ if (claim.kind === "unreachable") {
2058
2393
  delete fresh.pendingTurn;
2059
2394
  fresh.listeners.delete(turn.listener);
2060
2395
  this.drop(fresh);
@@ -2063,7 +2398,7 @@ export class WebSocketSessionPeer {
2063
2398
  message: "the live-edit session stopped answering on a fresh socket",
2064
2399
  };
2065
2400
  }
2066
- if (claim === "refused") {
2401
+ if (claim.kind === "refused") {
2067
2402
  delete fresh.pendingTurn;
2068
2403
  fresh.listeners.delete(turn.listener);
2069
2404
  // Go back to the socket the turn was on, but only while it is still
@@ -2084,6 +2419,28 @@ export class WebSocketSessionPeer {
2084
2419
  this.setHeld(fresh);
2085
2420
  fresh.heartbeat.start();
2086
2421
  this.drop(previous);
2422
+ if (fresh.pendingChatSessionReset) {
2423
+ this.observeEditorEvent(fresh, fresh.pendingChatSessionReset);
2424
+ delete fresh.pendingChatSessionReset;
2425
+ }
2426
+ const snapshot = claim.snapshot;
2427
+ if (!turn.finished &&
2428
+ this.turns.get(fresh.applicationId) === turn &&
2429
+ fresh.chatStateRevision === claim.revision &&
2430
+ snapshot?.generationState &&
2431
+ replaysOntoTurn(snapshot.generationState, {
2432
+ hasActiveInteractiveMessage: Boolean(snapshot.activeInteractiveMessage),
2433
+ turn,
2434
+ })) {
2435
+ if (snapshot.generationState.state === "awaiting_user_input" &&
2436
+ snapshot.activeInteractiveMessage) {
2437
+ turn.listener({
2438
+ kind: "message",
2439
+ message: snapshot.activeInteractiveMessage,
2440
+ });
2441
+ }
2442
+ turn.listener({ ...snapshot.generationState, kind: "generation_state" });
2443
+ }
2087
2444
  return { kind: "took_over" };
2088
2445
  }
2089
2446
  /**
@@ -2096,6 +2453,7 @@ export class WebSocketSessionPeer {
2096
2453
  fresh.apiKey = previous.apiKey;
2097
2454
  fresh.branchName = previous.branchName;
2098
2455
  fresh.isFactsEnabled = previous.isFactsEnabled;
2456
+ fresh.llmConfig = previous.llmConfig;
2099
2457
  fresh.orchestrator = previous.orchestrator;
2100
2458
  fresh.orchestratorAgentType = previous.orchestratorAgentType;
2101
2459
  fresh.orchestratorStale = previous.orchestratorStale;
@@ -2124,12 +2482,17 @@ export class WebSocketSessionPeer {
2124
2482
  * between two sockets on it.
2125
2483
  */
2126
2484
  async proveIdentity(input) {
2485
+ if (Date.now() >= input.deadline) {
2486
+ return { kind: "unreachable" };
2487
+ }
2488
+ const revision = input.held.chatStateRevision;
2489
+ const browserRevision = input.held.browserChatStateRevision;
2127
2490
  try {
2128
- await editorSocket(input.held, input.sessionToken).aiChatGetMessages({
2491
+ const snapshot = await beforePromptDeadline(editorSocket(input.held, input.sessionToken).aiChatGetMessages({
2129
2492
  force: false,
2130
2493
  ...this.turnCorrelator(input.turnId),
2131
- });
2132
- return "claimed";
2494
+ }), input.deadline);
2495
+ return { kind: "claimed", snapshot, revision, browserRevision };
2133
2496
  }
2134
2497
  catch (error) {
2135
2498
  const message = describeSocketError(error);
@@ -2140,7 +2503,7 @@ export class WebSocketSessionPeer {
2140
2503
  // A call that went unanswered says the socket opened onto something that
2141
2504
  // is not servicing requests, which is the same fact a lost liveness beat
2142
2505
  // reports and nothing like a session declining to hand a turn over.
2143
- return isTimeout({ message }) ? "unreachable" : "refused";
2506
+ return { kind: isTimeout({ message }) ? "unreachable" : "refused" };
2144
2507
  }
2145
2508
  }
2146
2509
  async reconnect(previous, deadline) {
@@ -2166,6 +2529,15 @@ export class WebSocketSessionPeer {
2166
2529
  this.held.set(held.applicationId, held);
2167
2530
  this.publishEditorStructure(held);
2168
2531
  }
2532
+ hasStoredToolPermission(held, messageId) {
2533
+ if (held.dropping || this.held.get(held.applicationId) !== held) {
2534
+ return false;
2535
+ }
2536
+ const pending = this.appState.pendingDecision(held.applicationId);
2537
+ return (pending?.branch === held.branchName &&
2538
+ pending?.decision.kind === "tool_permission" &&
2539
+ pending.decision.messageId === messageId);
2540
+ }
2169
2541
  recordEditorStructure(held, hasDefaultStructure) {
2170
2542
  held.editorHasDefaultStructure = hasDefaultStructure;
2171
2543
  this.publishEditorStructure(held);
@@ -2483,6 +2855,44 @@ export class WebSocketSessionPeer {
2483
2855
  return undefined;
2484
2856
  }
2485
2857
  return (event) => {
2858
+ if (isBrowserObserverEvent(event) &&
2859
+ event.kind === "generation_state" &&
2860
+ event.state === "idle" &&
2861
+ event.chatSessionReset &&
2862
+ held.pendingTurn &&
2863
+ this.turns.get(held.applicationId) === held.pendingTurn) {
2864
+ // Whether or not the reset names the pending turn, a claim
2865
+ // snapshot read before it is stale and must be read again.
2866
+ held.browserChatStateRevision++;
2867
+ held.chatStateRevision++;
2868
+ if (event.generationId &&
2869
+ event.generationId === held.pendingTurn.generationId) {
2870
+ held.pendingChatSessionReset = event;
2871
+ }
2872
+ else {
2873
+ console.warn("gateway chat session reset ignored", {
2874
+ applicationId: held.applicationId,
2875
+ reason: "uncorrelated_during_takeover",
2876
+ resetHasGenerationId: Boolean(event.generationId),
2877
+ turnHasGenerationId: Boolean(held.pendingTurn.generationId),
2878
+ });
2879
+ }
2880
+ return;
2881
+ }
2882
+ const browserText = isBrowserObserverEvent(event) &&
2883
+ event.kind === "message" &&
2884
+ event.message.role !== "user" &&
2885
+ (event.message.type === undefined ||
2886
+ event.message.type === "text" ||
2887
+ event.message.type === "text-delta" ||
2888
+ event.message.type === "reasoning");
2889
+ if (isBrowserObserverEvent(event) && !browserText) {
2890
+ held.browserChatStateRevision++;
2891
+ }
2892
+ if (!browserText &&
2893
+ (event.kind === "message" || event.kind === "generation_state")) {
2894
+ held.chatStateRevision++;
2895
+ }
2486
2896
  if (event.kind === "sync_status" && event.status) {
2487
2897
  held.syncStatus = event.status;
2488
2898
  }
@@ -2499,6 +2909,10 @@ export class WebSocketSessionPeer {
2499
2909
  }
2500
2910
  return;
2501
2911
  }
2912
+ if (event.kind === "tool_permission_request" &&
2913
+ !this.hasStoredToolPermission(held, event.messageId)) {
2914
+ throw new ToolPermissionListenerUnavailableError();
2915
+ }
2502
2916
  };
2503
2917
  }, (method) => {
2504
2918
  this.inboundMethods.add(method);
@@ -2545,32 +2959,6 @@ export class WebSocketSessionPeer {
2545
2959
  turn.activity.push(line);
2546
2960
  this.wake(turn);
2547
2961
  },
2548
- respondToToolPermission: async (payload) => {
2549
- const current = heldRef.current;
2550
- if (!current) {
2551
- return;
2552
- }
2553
- const turn = current.pendingTurn ?? this.turns.get(current.applicationId);
2554
- if (!turn ||
2555
- turn.finished ||
2556
- (turn.held !== current && current.pendingTurn !== turn)) {
2557
- return;
2558
- }
2559
- try {
2560
- await editorSocket(current, current.sessionToken).aiRespondToToolPermission({
2561
- approved: payload.approved,
2562
- ...this.turnCorrelator(turn.turnId),
2563
- messageId: payload.messageId,
2564
- requestId: payload.requestId ?? payload.messageId,
2565
- });
2566
- }
2567
- catch (error) {
2568
- console.warn("gateway auto-approve tool permission failed", {
2569
- applicationId: current.applicationId,
2570
- message: error instanceof Error ? error.message : String(error),
2571
- });
2572
- }
2573
- },
2574
2962
  }), [], {
2575
2963
  onClose: () => {
2576
2964
  const current = this.held.get(input.applicationId);
@@ -2599,7 +2987,7 @@ export class WebSocketSessionPeer {
2599
2987
  const applicationEnded = isApplicationClose(close);
2600
2988
  this.settleObservedTurn(observed, applicationEnded ? "live_edit_terminated" : "detached", applicationEnded
2601
2989
  ? LIVE_EDIT_TERMINATED_REPLY + describeClose(close)
2602
- : observed.collector.reply());
2990
+ : undefined);
2603
2991
  return;
2604
2992
  }
2605
2993
  // An application code is the live edit stating its own end, so there
@@ -2624,6 +3012,8 @@ export class WebSocketSessionPeer {
2624
3012
  const held = {
2625
3013
  applicationId: input.applicationId,
2626
3014
  branchName: input.branchName,
3015
+ chatStateRevision: 0,
3016
+ browserChatStateRevision: 0,
2627
3017
  client,
2628
3018
  heartbeat,
2629
3019
  idleTimer: undefined,
@@ -2647,6 +3037,9 @@ export class WebSocketSessionPeer {
2647
3037
  ws,
2648
3038
  };
2649
3039
  heldRef.current = held;
3040
+ // Paused by `attempt` so no frame arrives before ISocket is listening and
3041
+ // `heldRef` can route it. Frames resume on a later tick, after this block.
3042
+ ws.resume();
2650
3043
  if (publish) {
2651
3044
  try {
2652
3045
  this.assertReadOnlyHoldCurrent(input.applicationId, expectedTurn);
@@ -3122,7 +3515,7 @@ export class WebSocketSessionPeer {
3122
3515
  closeCode: input.close.code,
3123
3516
  });
3124
3517
  this.settleTurn(turn, {
3125
- reply: turn.collector.reply(),
3518
+ ...turn.collector.replyFields(),
3126
3519
  generationStatus: "detached",
3127
3520
  viewStatus: turn.collector.viewableStatus(),
3128
3521
  ...decisionFields(turn.collector.decision()),
@@ -3140,11 +3533,14 @@ export class WebSocketSessionPeer {
3140
3533
  if (!prior) {
3141
3534
  return;
3142
3535
  }
3143
- this.cancelTurn(prior);
3536
+ this.cancelTurn(prior, {
3537
+ replaced: true,
3538
+ reply: "This turn was replaced by a newer prompt on the same app, so its work was cancelled.",
3539
+ });
3144
3540
  }
3145
- cancelTurn(turn, reply = "This turn was replaced by a newer prompt on the same app, so its work was cancelled.") {
3541
+ cancelTurn(turn, outcome) {
3146
3542
  turn.finished ??= {
3147
- reply,
3543
+ ...outcome,
3148
3544
  generationStatus: "cancelled",
3149
3545
  viewStatus: turn.collector.viewableStatus(),
3150
3546
  };
@@ -3294,7 +3690,7 @@ export class WebSocketSessionPeer {
3294
3690
  * the app shell cannot boot without — Clark then runs the turn with its
3295
3691
  * ordinary tools rather than failing it.
3296
3692
  */
3297
- browserContextFor(held) {
3693
+ async browserContextFor(held) {
3298
3694
  if (!this.options.testingEnabled) {
3299
3695
  return undefined;
3300
3696
  }
@@ -3304,7 +3700,7 @@ export class WebSocketSessionPeer {
3304
3700
  * Library iframe bootstrap from a held session. Used for Gateway's own
3305
3701
  * live-canvas screenshot even when Clark's in-sandbox Playwright is off.
3306
3702
  */
3307
- buildHeldBrowserContext(held) {
3703
+ async buildHeldBrowserContext(held) {
3308
3704
  if (!held.orchestratorUrl) {
3309
3705
  console.error("gateway live canvas skipped: missing_orchestrator_url", {
3310
3706
  applicationId: held.applicationId,
@@ -3323,8 +3719,11 @@ export class WebSocketSessionPeer {
3323
3719
  });
3324
3720
  return undefined;
3325
3721
  }
3722
+ const apiKey = this.options.getAccessToken
3723
+ ? await this.options.getAccessToken()
3724
+ : held.apiKey;
3326
3725
  return buildBrowserContext({
3327
- ...(held.apiKey ? { apiKey: held.apiKey } : {}),
3726
+ ...(apiKey ? { apiKey } : {}),
3328
3727
  applicationId: held.applicationId,
3329
3728
  liveEditUrl: held.liveEdit.url,
3330
3729
  orchestratorUrl: held.orchestratorUrl,
@@ -3482,6 +3881,7 @@ export class WebSocketSessionPeer {
3482
3881
  organizationId: held.organizationId,
3483
3882
  });
3484
3883
  held.isFactsEnabled = inventory.isFactsEnabled;
3884
+ held.llmConfig = inventory.llmConfig;
3485
3885
  const skipped = held.dropping
3486
3886
  ? "dropping"
3487
3887
  : held.profileKey !== profileKey
@@ -3503,7 +3903,7 @@ export class WebSocketSessionPeer {
3503
3903
  profileKey,
3504
3904
  });
3505
3905
  held.orchestratorUrl = orchestratorUrl;
3506
- held.orchestrator = new HttpOrchestratorClient(orchestratorUrl);
3906
+ held.orchestrator = new HttpOrchestratorClient(orchestratorUrl, undefined, this.options.getAccessToken);
3507
3907
  held.orchestratorAgentType = inventory.agentType;
3508
3908
  delete held.orchestratorStale;
3509
3909
  console.error("gateway resolved orchestrator url", {