@superblocksteam/gateway 2.0.167-next.0 → 2.0.168-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 +466 -66
  42. package/dist/sabs/websocket-session-peer.js.map +1 -1
  43. package/dist/server/client.d.ts +3 -0
  44. package/dist/server/client.js +7 -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, } 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.
@@ -226,6 +233,106 @@ function isBrowserObserverEvent(event) {
226
233
  (event.kind === "generation_state" &&
227
234
  event.gatewayObserverSource === "browser"));
228
235
  }
236
+ /**
237
+ * Names why a New Chat reset must be ignored, or returns undefined when it may
238
+ * clear chat state. A reset for another generation, or one that cannot be
239
+ * matched to a turn still running, would otherwise end a turn it was never
240
+ * about.
241
+ */
242
+ function ignoredChatResetReason(held, reset, previous) {
243
+ if (held.pendingTurn) {
244
+ return "pending_turn";
245
+ }
246
+ if (previous &&
247
+ (!reset.generationId || !previous.generationId) &&
248
+ !previous.finished &&
249
+ !previous.collector.isSettling()) {
250
+ return "unkeyed_active_turn";
251
+ }
252
+ if (reset.generationId &&
253
+ previous?.generationId &&
254
+ reset.generationId !== previous.generationId) {
255
+ return "other_generation";
256
+ }
257
+ return undefined;
258
+ }
259
+ /**
260
+ * Outcomes that leave the turn with the editor: one waiting on the user, and
261
+ * one that may still be running where only the editor can see it. An idle
262
+ * editor refutes both.
263
+ */
264
+ function leavesTurnWithEditor(outcome) {
265
+ return outcome === "detached" || outcome === "needs_decision";
266
+ }
267
+ /**
268
+ * Whether a snapshot shows nothing newer than the owned turn's outcome: an idle
269
+ * editor, the owned generation completing as the turn already reported, or
270
+ * that generation still waiting on the tool permission the turn asked for. A
271
+ * permission reaches the gateway over its own RPC and never appears as the
272
+ * snapshot's interactive message, so the editor waiting with none is that
273
+ * permission still pending.
274
+ */
275
+ function agreesWithOwnedOutcome({ generationId, state }, { hasActiveInteractiveMessage, owned, }) {
276
+ const outcome = owned?.finished;
277
+ if (!outcome) {
278
+ return false;
279
+ }
280
+ if (state === "idle") {
281
+ return !leavesTurnWithEditor(outcome.generationStatus);
282
+ }
283
+ if (generationId !== owned.generationId) {
284
+ return false;
285
+ }
286
+ if (state === "completed") {
287
+ return (outcome.generationStatus === "completed" ||
288
+ outcome.generationStatus === "no_changes");
289
+ }
290
+ return (state === "awaiting_user_input" &&
291
+ !hasActiveInteractiveMessage &&
292
+ outcome.decision?.kind === "tool_permission");
293
+ }
294
+ /**
295
+ * Whether a snapshot read while taking a turn over is that turn's own state,
296
+ * to replay onto it. One naming the turn's generation is, unless it awaits a
297
+ * decision it does not carry. So is an idle one naming no generation: that is
298
+ * what the editor shows once nothing runs and no terminal state is left to
299
+ * show, and the turn's collector still ignores an idle before the turn was
300
+ * seen generating.
301
+ */
302
+ function replaysOntoTurn(generationState, known) {
303
+ if (!generationState.generationId) {
304
+ return generationState.state === "idle";
305
+ }
306
+ return (generationState.generationId === known.turn.generationId &&
307
+ (generationState.state !== "awaiting_user_input" ||
308
+ known.hasActiveInteractiveMessage ||
309
+ known.turn.collector.decision() !== undefined));
310
+ }
311
+ /**
312
+ * What a chat snapshot read on attach says about the turn this peer knew of.
313
+ *
314
+ * `unchanged` when it shows nothing newer than a finished owned turn (see
315
+ * {@link agreesWithOwnedOutcome}). Clearing then would erase the turn's
316
+ * outcome, checkpoint, retry advice or pending permission while the editor
317
+ * agrees with them. `settled` when nothing is running or waiting on the user.
318
+ * `observable` when there is a generation to follow: one running, one waiting
319
+ * on an answer, or the watched generation completing.
320
+ */
321
+ function readChatSnapshot(generationState, known) {
322
+ if (agreesWithOwnedOutcome(generationState, known)) {
323
+ return "unchanged";
324
+ }
325
+ const { generationId, state } = generationState;
326
+ const { owned } = known;
327
+ const watchedGenerationId = known.observedGenerationId ?? owned?.generationId;
328
+ if (state === "idle" ||
329
+ (state === "completed" &&
330
+ (!watchedGenerationId || watchedGenerationId !== generationId)) ||
331
+ (state === "awaiting_user_input" && !known.hasActiveInteractiveMessage)) {
332
+ return "settled";
333
+ }
334
+ return "observable";
335
+ }
229
336
  export function buildSessionUrl(input) {
230
337
  // liveEditUrl is server-sourced (connectLiveEdit), not caller-supplied, so a
231
338
  // host allowlist would mostly duplicate the Superblocks API's own trust
@@ -505,6 +612,11 @@ async function attempt(url, timeoutMs, onSocketError) {
505
612
  // emitter 'error' rather than reporting it to the caller. Recording it
506
613
  // rather than discarding it is what makes a later 1006 explainable.
507
614
  socket.on("error", onSocketError);
615
+ // The dev server flushes queued pushes the moment a peer binds, and
616
+ // those frames can share a read with the upgrade response. Without the
617
+ // pause they are emitted before ISocket is listening and lost. The
618
+ // caller resumes once its handlers are attached.
619
+ socket.pause();
508
620
  resolve(socket);
509
621
  });
510
622
  const onError = (error) => settle(() => reject(error));
@@ -811,9 +923,12 @@ export class WebSocketSessionPeer {
811
923
  ...(failed.recoveryAction
812
924
  ? { recoveryAction: failed.recoveryAction }
813
925
  : {}),
814
- ...(failed.retryable === false ? { reply: failed.reply } : {}),
926
+ ...(failed.retryable === false || !failed.retryAction
927
+ ? { reply: failed.reply }
928
+ : {}),
815
929
  ...(failed.retryAction ? { retryAction: failed.retryAction } : {}),
816
930
  ...(failed.retryable === false ? { retryable: false } : {}),
931
+ startFailed: true,
817
932
  };
818
933
  }
819
934
  if (turn && !turn.finished) {
@@ -837,7 +952,11 @@ export class WebSocketSessionPeer {
837
952
  ? { checkpointWarning: turn.finished.checkpointWarning }
838
953
  : {}),
839
954
  generationStatus: turn.finished.generationStatus,
955
+ ...(turn.mode ? { mode: turn.mode } : {}),
840
956
  phase: "finished",
957
+ ...(turn.finished.answered
958
+ ? { answered: true, reply: turn.finished.reply }
959
+ : {}),
841
960
  ...(turn.retryAction ? { retryAction: turn.retryAction } : {}),
842
961
  ...(turn.finished.retryable === false
843
962
  ? { retryable: false }
@@ -860,7 +979,7 @@ export class WebSocketSessionPeer {
860
979
  ? { ...(entry.branchName ? { branch: entry.branchName } : {}) }
861
980
  : undefined;
862
981
  }
863
- libraryBrowserContext(applicationId, requestedBranch) {
982
+ async libraryBrowserContext(applicationId, requestedBranch) {
864
983
  const held = this.held.get(applicationId);
865
984
  if (!held || requestedBranch !== held.branchName) {
866
985
  return undefined;
@@ -894,8 +1013,100 @@ export class WebSocketSessionPeer {
894
1013
  held.organizationId = input.organizationId;
895
1014
  }
896
1015
  held.sessionToken = input.sessionToken;
897
- await this.resolveHeldProfileKey(held);
898
- await this.resolveHeldOrchestrator(held);
1016
+ try {
1017
+ await this.synchronizeChatState(held, input.sessionToken);
1018
+ await this.resolveHeldProfileKey(held);
1019
+ // TODO(AR-592): a discovery failure here fails the read even though the
1020
+ // chat state above was just synced.
1021
+ await this.resolveHeldOrchestrator(held);
1022
+ }
1023
+ finally {
1024
+ this.armReadRelease(held);
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Bounds a socket held only to read the editor state. Nothing runs on it,
1029
+ * so it gets a finished turn's grace instead of keeping the sandbox awake
1030
+ * and contesting the editor's binding for the life of the process. A turn
1031
+ * seen on it clears this timer and arms its own.
1032
+ */
1033
+ armReadRelease(held) {
1034
+ const observed = this.observedTurns.get(held.applicationId);
1035
+ if (this.held.get(held.applicationId) !== held ||
1036
+ held.idleTimer ||
1037
+ (observed?.held === held && !observed.finished)) {
1038
+ return;
1039
+ }
1040
+ held.idleTimer = setTimeout(() => {
1041
+ if (this.held.get(held.applicationId) === held) {
1042
+ this.drop(held, { preserveFinishedObserved: true });
1043
+ }
1044
+ }, this.options.releaseAfterReadMs ?? DEFAULT_RELEASE_AFTER_TURN_MS);
1045
+ held.idleTimer.unref?.();
1046
+ }
1047
+ async synchronizeChatState(held, sessionToken, retryDecision = true) {
1048
+ const revision = held.chatStateRevision;
1049
+ const owned = this.turns.get(held.applicationId);
1050
+ // Read before the snapshot call: ordinary browser text on the new socket
1051
+ // does not bump the revision, yet it replaces the watched turn with one
1052
+ // that has no generation, which would hide a matching completion.
1053
+ const observedGenerationId = this.observedTurns.get(held.applicationId)?.generationId;
1054
+ const isCurrent = () => this.held.get(held.applicationId) === held &&
1055
+ isSocketOpen(held.ws) &&
1056
+ !held.pendingTurn &&
1057
+ this.turns.get(held.applicationId) === owned;
1058
+ const snapshot = await editorSocket(held, sessionToken)
1059
+ .aiChatGetMessages({ force: false })
1060
+ .catch((error) => {
1061
+ const dropped = isCurrent() && held.chatStateRevision === revision;
1062
+ console.warn("gateway chat state sync failed", {
1063
+ applicationId: held.applicationId,
1064
+ dropped,
1065
+ message: describeSocketError(error),
1066
+ });
1067
+ if (dropped) {
1068
+ this.drop(held);
1069
+ }
1070
+ throw error;
1071
+ });
1072
+ if (!snapshot?.generationState || !isCurrent()) {
1073
+ return;
1074
+ }
1075
+ if (held.chatStateRevision !== revision) {
1076
+ const observed = this.observedTurns.get(held.applicationId);
1077
+ if (retryDecision &&
1078
+ observed?.finished?.generationStatus === "needs_decision") {
1079
+ await this.synchronizeChatState(held, sessionToken, false);
1080
+ }
1081
+ return;
1082
+ }
1083
+ const reading = readChatSnapshot(snapshot.generationState, {
1084
+ hasActiveInteractiveMessage: Boolean(snapshot.activeInteractiveMessage),
1085
+ observedGenerationId,
1086
+ owned,
1087
+ });
1088
+ if (reading === "unchanged") {
1089
+ return;
1090
+ }
1091
+ this.clearChatSessionState(held.applicationId);
1092
+ if (reading === "settled") {
1093
+ return;
1094
+ }
1095
+ if (snapshot.generationState.state === "awaiting_user_input" &&
1096
+ snapshot.activeInteractiveMessage) {
1097
+ this.observeEditorEvent(held, {
1098
+ kind: "message",
1099
+ message: {
1100
+ ...snapshot.activeInteractiveMessage,
1101
+ gatewayObserverSource: "browser",
1102
+ },
1103
+ });
1104
+ }
1105
+ this.observeEditorEvent(held, {
1106
+ ...snapshot.generationState,
1107
+ gatewayObserverSource: "browser",
1108
+ kind: "generation_state",
1109
+ });
899
1110
  }
900
1111
  release(applicationId) {
901
1112
  const entry = this.held.get(applicationId);
@@ -904,14 +1115,18 @@ export class WebSocketSessionPeer {
904
1115
  }
905
1116
  const turn = this.turns.get(applicationId);
906
1117
  if (turn?.held === entry && !turn.finished) {
907
- this.cancelTurn(turn, "This turn was cancelled because its live-edit session was released.");
1118
+ this.cancelTurn(turn, {
1119
+ reply: "This turn was cancelled because its live-edit session was released.",
1120
+ });
908
1121
  }
909
1122
  this.drop(entry);
910
1123
  }
911
1124
  closeAll() {
912
1125
  for (const turn of this.turns.values()) {
913
1126
  if (!turn.finished) {
914
- this.cancelTurn(turn, "This turn was cancelled because the gateway shut down.");
1127
+ this.cancelTurn(turn, {
1128
+ reply: "This turn was cancelled because the gateway shut down.",
1129
+ });
915
1130
  }
916
1131
  }
917
1132
  for (const applicationId of [...this.observedTurns.keys()]) {
@@ -921,6 +1136,14 @@ export class WebSocketSessionPeer {
921
1136
  this.release(applicationId);
922
1137
  }
923
1138
  }
1139
+ resetForLogin() {
1140
+ this.closeAll();
1141
+ this.promptStarts.clear();
1142
+ this.connecting.clear();
1143
+ this.turns.clear();
1144
+ this.editorStructureByScope.clear();
1145
+ this.inboundMethods.clear();
1146
+ }
924
1147
  /**
925
1148
  * Sends the prompt and waits for Clark's whole turn.
926
1149
  *
@@ -947,7 +1170,7 @@ export class WebSocketSessionPeer {
947
1170
  throw new Error("Superblocks turn wait aborted");
948
1171
  }
949
1172
  if (update.status === "finished") {
950
- const { activity: _activity, elapsedMs: _elapsedMs, status: _status, ...finished } = update;
1173
+ const { activity: _activity, elapsedMs: _elapsedMs, mode: _mode, status: _status, ...finished } = update;
951
1174
  return {
952
1175
  ...finished,
953
1176
  // See startPrompt, which resolved this: the turn may have run on a
@@ -1081,6 +1304,7 @@ export class WebSocketSessionPeer {
1081
1304
  }, input.idempotencyKey, input.pendingStartToken))) {
1082
1305
  throw new PromptNotSentError("A newer start attempt replaced this one before its prompt was sent");
1083
1306
  }
1307
+ const mode = this.turnMode(input);
1084
1308
  const turn = {
1085
1309
  abort: new AbortController(),
1086
1310
  activity: [],
@@ -1093,6 +1317,7 @@ export class WebSocketSessionPeer {
1093
1317
  ? {}
1094
1318
  : { idempotencyKey: input.idempotencyKey }),
1095
1319
  listener: () => undefined,
1320
+ ...(mode ? { mode } : {}),
1096
1321
  ...(input.pendingStartToken === undefined
1097
1322
  ? {}
1098
1323
  : { pendingStartToken: input.pendingStartToken }),
@@ -1193,7 +1418,7 @@ export class WebSocketSessionPeer {
1193
1418
  async sendPrompt(args) {
1194
1419
  const { held, input, integrations, provisional, turn } = args;
1195
1420
  const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1196
- const browserContext = this.browserContextFor(held);
1421
+ const browserContext = await this.browserContextFor(held);
1197
1422
  let acknowledgment;
1198
1423
  try {
1199
1424
  // The editor and the smoke client both send the credential twice: once
@@ -1223,7 +1448,11 @@ export class WebSocketSessionPeer {
1223
1448
  thinkingEnabled: false,
1224
1449
  unifiedProviderEnabled: true,
1225
1450
  },
1226
- mode: (input.mode ?? "PLAN"),
1451
+ ...(input.responseMetadata?.type === "tool_permission_response"
1452
+ ? {}
1453
+ : {
1454
+ mode: input.mode === "BUILD" ? SharedAiMode.BUILD : SharedAiMode.PLAN,
1455
+ }),
1227
1456
  prompt: input.prompt,
1228
1457
  promptContext: {
1229
1458
  attachments: input.attachments ?? [],
@@ -1303,6 +1532,7 @@ export class WebSocketSessionPeer {
1303
1532
  : new Error(`Prompt forward failed: ${rejection.message ?? "unknown socket error"}`);
1304
1533
  }
1305
1534
  turn.revision = this.appState.markTurnStarted();
1535
+ turn.generationId = acknowledgment?.generationId;
1306
1536
  args.acceptBufferedEvents(acknowledgment?.generationId);
1307
1537
  input.onProgress?.("Superblocks accepted the prompt");
1308
1538
  // Only now, with the prompt accepted and a live turn about to go in the
@@ -1350,9 +1580,32 @@ export class WebSocketSessionPeer {
1350
1580
  status: "accepted",
1351
1581
  };
1352
1582
  }
1583
+ /**
1584
+ * The mode a new turn runs in. A reply to a tool permission carries no mode
1585
+ * to Superblocks, which carries on in the mode of the turn that asked, so the
1586
+ * reply takes that turn's mode here. When this peer did not run the turn that
1587
+ * asked, the mode is unknown rather than guessed.
1588
+ */
1589
+ turnMode(input) {
1590
+ const response = input.responseMetadata;
1591
+ if (response?.type !== "tool_permission_response") {
1592
+ return input.mode ?? "PLAN";
1593
+ }
1594
+ const asking = this.turns.get(input.applicationId);
1595
+ const decision = asking?.finished?.decision;
1596
+ return decision?.kind === "tool_permission" &&
1597
+ decision.messageId === response.responseToMessageId
1598
+ ? asking?.mode
1599
+ : undefined;
1600
+ }
1353
1601
  latestTurn(applicationId) {
1354
1602
  return (this.observedTurns.get(applicationId) ?? this.turns.get(applicationId));
1355
1603
  }
1604
+ clearChatSessionState(applicationId) {
1605
+ this.clearPendingDecision(applicationId);
1606
+ this.forgetObservedTurn(applicationId);
1607
+ this.supersedeTurn(applicationId);
1608
+ }
1356
1609
  observeEditorEvent(held, event) {
1357
1610
  if (held.dropping || this.held.get(held.applicationId) !== held) {
1358
1611
  return;
@@ -1376,6 +1629,63 @@ export class WebSocketSessionPeer {
1376
1629
  this.clearPendingDecision(held.applicationId);
1377
1630
  }
1378
1631
  const owned = this.turns.get(held.applicationId);
1632
+ const previous = observed ?? owned;
1633
+ if (event.kind === "generation_state" &&
1634
+ event.state === "idle" &&
1635
+ event.chatSessionReset) {
1636
+ const ignored = ignoredChatResetReason(held, event, previous);
1637
+ if (ignored) {
1638
+ console.warn("gateway chat session reset ignored", {
1639
+ applicationId: held.applicationId,
1640
+ reason: ignored,
1641
+ resetHasGenerationId: Boolean(event.generationId),
1642
+ turnHasGenerationId: Boolean(previous?.generationId),
1643
+ });
1644
+ return;
1645
+ }
1646
+ this.clearChatSessionState(held.applicationId);
1647
+ this.armReadRelease(held);
1648
+ return;
1649
+ }
1650
+ if (previous?.finished &&
1651
+ event.kind === "generation_state" &&
1652
+ event.generationId &&
1653
+ event.generationId === previous.generationId &&
1654
+ previous.finished.generationStatus !== "needs_decision") {
1655
+ return;
1656
+ }
1657
+ if (previous?.finished &&
1658
+ previous.held === held &&
1659
+ !userContent &&
1660
+ !activeGeneration) {
1661
+ if (previous.finished.generationStatus === "needs_decision" &&
1662
+ event.kind === "generation_state" &&
1663
+ (event.state === "cancelled" ||
1664
+ event.state === "completed" ||
1665
+ event.state === "idle") &&
1666
+ (!event.generationId ||
1667
+ !previous.generationId ||
1668
+ event.generationId === previous.generationId)) {
1669
+ previous.finished.generationStatus =
1670
+ event.state === "cancelled"
1671
+ ? "cancelled"
1672
+ : event.hasSuggestions ||
1673
+ previous.collector.workSignals().length > 0
1674
+ ? "completed"
1675
+ : "no_changes";
1676
+ delete previous.finished.decision;
1677
+ this.clearPendingDecision(held.applicationId);
1678
+ this.wake(previous);
1679
+ if (observed) {
1680
+ this.armObservedTurnRelease(held.applicationId, observed);
1681
+ }
1682
+ else if (owned) {
1683
+ this.armPostTurnRelease(held, owned);
1684
+ this.armKeepalive(held);
1685
+ }
1686
+ }
1687
+ return;
1688
+ }
1379
1689
  if (owned) {
1380
1690
  if (!owned.finished && !userContent && !activeGeneration) {
1381
1691
  return;
@@ -1399,12 +1709,6 @@ export class WebSocketSessionPeer {
1399
1709
  if (!activeGeneration) {
1400
1710
  return;
1401
1711
  }
1402
- if (event.kind === "generation_state" &&
1403
- event.generationId &&
1404
- event.generationId === observed.generationId &&
1405
- observed.finished.generationStatus !== "needs_decision") {
1406
- return;
1407
- }
1408
1712
  observed = undefined;
1409
1713
  this.clearPendingDecision(held.applicationId);
1410
1714
  }
@@ -1495,7 +1799,7 @@ export class WebSocketSessionPeer {
1495
1799
  }
1496
1800
  }
1497
1801
  turn.finished = {
1498
- reply: turn.collector.reply(),
1802
+ ...turn.collector.replyFields(),
1499
1803
  generationStatus,
1500
1804
  viewStatus: turn.collector.viewableStatus(),
1501
1805
  ...decisionFields(decision),
@@ -1504,13 +1808,15 @@ export class WebSocketSessionPeer {
1504
1808
  this.wake(turn);
1505
1809
  this.armObservedTurnRelease(applicationId, turn);
1506
1810
  }
1507
- settleObservedTurn(turn, generationStatus, reply = turn.collector.reply()) {
1811
+ settleObservedTurn(turn, generationStatus, gatewayReply) {
1508
1812
  if (turn.finished) {
1509
1813
  return;
1510
1814
  }
1511
1815
  tagRetryableLiveEditDeath(turn, { generationStatus });
1512
1816
  turn.finished = {
1513
- reply,
1817
+ ...(gatewayReply === undefined
1818
+ ? turn.collector.replyFields()
1819
+ : { reply: gatewayReply }),
1514
1820
  generationStatus,
1515
1821
  viewStatus: turn.collector.viewableStatus(),
1516
1822
  ...decisionFields(turn.collector.decision()),
@@ -1538,7 +1844,7 @@ export class WebSocketSessionPeer {
1538
1844
  this.clearTimers(held);
1539
1845
  const delayMs = this.options.releaseAfterTurnMs ?? DEFAULT_RELEASE_AFTER_TURN_MS;
1540
1846
  held.idleTimer = setTimeout(() => {
1541
- if (this.observedTurns.get(held.applicationId) === turn &&
1847
+ if ((this.observedTurns.get(held.applicationId) ?? turn) === turn &&
1542
1848
  this.held.get(held.applicationId) === held) {
1543
1849
  this.drop(held, { preserveFinishedObserved: true });
1544
1850
  }
@@ -1671,6 +1977,7 @@ export class WebSocketSessionPeer {
1671
1977
  activity,
1672
1978
  ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1673
1979
  elapsedMs,
1980
+ ...(turn.mode ? { mode: turn.mode } : {}),
1674
1981
  status: "finished",
1675
1982
  ...turn.finished,
1676
1983
  viewStatus: this.currentViewStatus(input.applicationId, turn.branch, turn.finished.viewStatus),
@@ -1732,7 +2039,7 @@ export class WebSocketSessionPeer {
1732
2039
  inboundMethods: [...this.inboundMethods].sort(),
1733
2040
  });
1734
2041
  turn.finished ??= {
1735
- reply: collector.reply(),
2042
+ ...collector.replyFields(),
1736
2043
  generationStatus: "detached",
1737
2044
  viewStatus: collector.viewableStatus(),
1738
2045
  ...decisionFields(collector.decision()),
@@ -1830,7 +2137,7 @@ export class WebSocketSessionPeer {
1830
2137
  ...(checkpointOutcome?.status === "saved" && checkpointOutcome.warning
1831
2138
  ? { checkpointWarning: checkpointOutcome.warning }
1832
2139
  : {}),
1833
- reply: collector.reply(),
2140
+ ...collector.replyFields(),
1834
2141
  generationStatus,
1835
2142
  ...(turn.recentRuns.length > 0
1836
2143
  ? { recentRuns: [...turn.recentRuns] }
@@ -1849,7 +2156,7 @@ export class WebSocketSessionPeer {
1849
2156
  // A disconnect / supersede already settled the turn with the reason; the
1850
2157
  // abort it caused says less than what is already recorded.
1851
2158
  turn.finished ??= {
1852
- reply: collector.reply(),
2159
+ ...collector.replyFields(),
1853
2160
  generationStatus: "timeout",
1854
2161
  viewStatus: collector.viewableStatus(),
1855
2162
  ...decisionFields(collector.decision()),
@@ -2031,13 +2338,38 @@ export class WebSocketSessionPeer {
2031
2338
  fresh.listeners.add(turn.listener);
2032
2339
  previous.listeners.delete(turn.listener);
2033
2340
  fresh.heartbeat.start();
2034
- const claim = fresh.sessionToken === undefined
2035
- ? "refused"
2341
+ let claim = fresh.sessionToken === undefined
2342
+ ? { kind: "refused" }
2036
2343
  : await this.proveIdentity({
2037
2344
  held: fresh,
2345
+ deadline: input.deadline,
2038
2346
  sessionToken: fresh.sessionToken,
2039
2347
  turnId: turn.turnId,
2040
2348
  });
2349
+ let refreshes = 0;
2350
+ while (claim.kind === "claimed" &&
2351
+ fresh.browserChatStateRevision !== claim.browserRevision &&
2352
+ !turn.finished &&
2353
+ turn.held === previous &&
2354
+ this.turns.get(fresh.applicationId) === turn &&
2355
+ fresh.sessionToken !== undefined) {
2356
+ if (refreshes === MAX_CLAIM_REFRESHES || Date.now() >= input.deadline) {
2357
+ console.warn("gateway clark reattach snapshot kept changing", {
2358
+ applicationId: fresh.applicationId,
2359
+ reason: refreshes === MAX_CLAIM_REFRESHES ? "refresh_cap" : "deadline",
2360
+ refreshes,
2361
+ turnId: turn.turnId,
2362
+ });
2363
+ break;
2364
+ }
2365
+ refreshes++;
2366
+ claim = await this.proveIdentity({
2367
+ held: fresh,
2368
+ deadline: input.deadline,
2369
+ sessionToken: fresh.sessionToken,
2370
+ turnId: turn.turnId,
2371
+ });
2372
+ }
2041
2373
  // Opening and claiming take seconds, and the turn can settle inside them:
2042
2374
  // a relayed terminal event, the turn being cancelled, or a newer prompt
2043
2375
  // taking the app. runTurn arms the post-turn timers once, against the
@@ -2046,6 +2378,11 @@ export class WebSocketSessionPeer {
2046
2378
  // this point but the listener, so there is nothing to undo beyond letting
2047
2379
  // the replacement go.
2048
2380
  if (turn.finished || turn.held !== previous) {
2381
+ if (fresh.pendingChatSessionReset &&
2382
+ turn.held === previous &&
2383
+ this.turns.get(fresh.applicationId) === turn) {
2384
+ this.clearChatSessionState(fresh.applicationId);
2385
+ }
2049
2386
  delete fresh.pendingTurn;
2050
2387
  fresh.listeners.delete(turn.listener);
2051
2388
  if (fresh.listeners.size === 0 &&
@@ -2054,7 +2391,7 @@ export class WebSocketSessionPeer {
2054
2391
  }
2055
2392
  return { kind: "stale" };
2056
2393
  }
2057
- if (claim === "unreachable") {
2394
+ if (claim.kind === "unreachable") {
2058
2395
  delete fresh.pendingTurn;
2059
2396
  fresh.listeners.delete(turn.listener);
2060
2397
  this.drop(fresh);
@@ -2063,7 +2400,7 @@ export class WebSocketSessionPeer {
2063
2400
  message: "the live-edit session stopped answering on a fresh socket",
2064
2401
  };
2065
2402
  }
2066
- if (claim === "refused") {
2403
+ if (claim.kind === "refused") {
2067
2404
  delete fresh.pendingTurn;
2068
2405
  fresh.listeners.delete(turn.listener);
2069
2406
  // Go back to the socket the turn was on, but only while it is still
@@ -2084,6 +2421,28 @@ export class WebSocketSessionPeer {
2084
2421
  this.setHeld(fresh);
2085
2422
  fresh.heartbeat.start();
2086
2423
  this.drop(previous);
2424
+ if (fresh.pendingChatSessionReset) {
2425
+ this.observeEditorEvent(fresh, fresh.pendingChatSessionReset);
2426
+ delete fresh.pendingChatSessionReset;
2427
+ }
2428
+ const snapshot = claim.snapshot;
2429
+ if (!turn.finished &&
2430
+ this.turns.get(fresh.applicationId) === turn &&
2431
+ fresh.chatStateRevision === claim.revision &&
2432
+ snapshot?.generationState &&
2433
+ replaysOntoTurn(snapshot.generationState, {
2434
+ hasActiveInteractiveMessage: Boolean(snapshot.activeInteractiveMessage),
2435
+ turn,
2436
+ })) {
2437
+ if (snapshot.generationState.state === "awaiting_user_input" &&
2438
+ snapshot.activeInteractiveMessage) {
2439
+ turn.listener({
2440
+ kind: "message",
2441
+ message: snapshot.activeInteractiveMessage,
2442
+ });
2443
+ }
2444
+ turn.listener({ ...snapshot.generationState, kind: "generation_state" });
2445
+ }
2087
2446
  return { kind: "took_over" };
2088
2447
  }
2089
2448
  /**
@@ -2124,12 +2483,17 @@ export class WebSocketSessionPeer {
2124
2483
  * between two sockets on it.
2125
2484
  */
2126
2485
  async proveIdentity(input) {
2486
+ if (Date.now() >= input.deadline) {
2487
+ return { kind: "unreachable" };
2488
+ }
2489
+ const revision = input.held.chatStateRevision;
2490
+ const browserRevision = input.held.browserChatStateRevision;
2127
2491
  try {
2128
- await editorSocket(input.held, input.sessionToken).aiChatGetMessages({
2492
+ const snapshot = await beforePromptDeadline(editorSocket(input.held, input.sessionToken).aiChatGetMessages({
2129
2493
  force: false,
2130
2494
  ...this.turnCorrelator(input.turnId),
2131
- });
2132
- return "claimed";
2495
+ }), input.deadline);
2496
+ return { kind: "claimed", snapshot, revision, browserRevision };
2133
2497
  }
2134
2498
  catch (error) {
2135
2499
  const message = describeSocketError(error);
@@ -2140,7 +2504,7 @@ export class WebSocketSessionPeer {
2140
2504
  // A call that went unanswered says the socket opened onto something that
2141
2505
  // is not servicing requests, which is the same fact a lost liveness beat
2142
2506
  // reports and nothing like a session declining to hand a turn over.
2143
- return isTimeout({ message }) ? "unreachable" : "refused";
2507
+ return { kind: isTimeout({ message }) ? "unreachable" : "refused" };
2144
2508
  }
2145
2509
  }
2146
2510
  async reconnect(previous, deadline) {
@@ -2166,6 +2530,15 @@ export class WebSocketSessionPeer {
2166
2530
  this.held.set(held.applicationId, held);
2167
2531
  this.publishEditorStructure(held);
2168
2532
  }
2533
+ hasStoredToolPermission(held, messageId) {
2534
+ if (held.dropping || this.held.get(held.applicationId) !== held) {
2535
+ return false;
2536
+ }
2537
+ const pending = this.appState.pendingDecision(held.applicationId);
2538
+ return (pending?.branch === held.branchName &&
2539
+ pending?.decision.kind === "tool_permission" &&
2540
+ pending.decision.messageId === messageId);
2541
+ }
2169
2542
  recordEditorStructure(held, hasDefaultStructure) {
2170
2543
  held.editorHasDefaultStructure = hasDefaultStructure;
2171
2544
  this.publishEditorStructure(held);
@@ -2483,6 +2856,44 @@ export class WebSocketSessionPeer {
2483
2856
  return undefined;
2484
2857
  }
2485
2858
  return (event) => {
2859
+ if (isBrowserObserverEvent(event) &&
2860
+ event.kind === "generation_state" &&
2861
+ event.state === "idle" &&
2862
+ event.chatSessionReset &&
2863
+ held.pendingTurn &&
2864
+ this.turns.get(held.applicationId) === held.pendingTurn) {
2865
+ // Whether or not the reset names the pending turn, a claim
2866
+ // snapshot read before it is stale and must be read again.
2867
+ held.browserChatStateRevision++;
2868
+ held.chatStateRevision++;
2869
+ if (event.generationId &&
2870
+ event.generationId === held.pendingTurn.generationId) {
2871
+ held.pendingChatSessionReset = event;
2872
+ }
2873
+ else {
2874
+ console.warn("gateway chat session reset ignored", {
2875
+ applicationId: held.applicationId,
2876
+ reason: "uncorrelated_during_takeover",
2877
+ resetHasGenerationId: Boolean(event.generationId),
2878
+ turnHasGenerationId: Boolean(held.pendingTurn.generationId),
2879
+ });
2880
+ }
2881
+ return;
2882
+ }
2883
+ const browserText = isBrowserObserverEvent(event) &&
2884
+ event.kind === "message" &&
2885
+ event.message.role !== "user" &&
2886
+ (event.message.type === undefined ||
2887
+ event.message.type === "text" ||
2888
+ event.message.type === "text-delta" ||
2889
+ event.message.type === "reasoning");
2890
+ if (isBrowserObserverEvent(event) && !browserText) {
2891
+ held.browserChatStateRevision++;
2892
+ }
2893
+ if (!browserText &&
2894
+ (event.kind === "message" || event.kind === "generation_state")) {
2895
+ held.chatStateRevision++;
2896
+ }
2486
2897
  if (event.kind === "sync_status" && event.status) {
2487
2898
  held.syncStatus = event.status;
2488
2899
  }
@@ -2499,6 +2910,10 @@ export class WebSocketSessionPeer {
2499
2910
  }
2500
2911
  return;
2501
2912
  }
2913
+ if (event.kind === "tool_permission_request" &&
2914
+ !this.hasStoredToolPermission(held, event.messageId)) {
2915
+ throw new ToolPermissionListenerUnavailableError();
2916
+ }
2502
2917
  };
2503
2918
  }, (method) => {
2504
2919
  this.inboundMethods.add(method);
@@ -2545,32 +2960,6 @@ export class WebSocketSessionPeer {
2545
2960
  turn.activity.push(line);
2546
2961
  this.wake(turn);
2547
2962
  },
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
2963
  }), [], {
2575
2964
  onClose: () => {
2576
2965
  const current = this.held.get(input.applicationId);
@@ -2599,7 +2988,7 @@ export class WebSocketSessionPeer {
2599
2988
  const applicationEnded = isApplicationClose(close);
2600
2989
  this.settleObservedTurn(observed, applicationEnded ? "live_edit_terminated" : "detached", applicationEnded
2601
2990
  ? LIVE_EDIT_TERMINATED_REPLY + describeClose(close)
2602
- : observed.collector.reply());
2991
+ : undefined);
2603
2992
  return;
2604
2993
  }
2605
2994
  // An application code is the live edit stating its own end, so there
@@ -2624,6 +3013,8 @@ export class WebSocketSessionPeer {
2624
3013
  const held = {
2625
3014
  applicationId: input.applicationId,
2626
3015
  branchName: input.branchName,
3016
+ chatStateRevision: 0,
3017
+ browserChatStateRevision: 0,
2627
3018
  client,
2628
3019
  heartbeat,
2629
3020
  idleTimer: undefined,
@@ -2647,6 +3038,9 @@ export class WebSocketSessionPeer {
2647
3038
  ws,
2648
3039
  };
2649
3040
  heldRef.current = held;
3041
+ // Paused by `attempt` so no frame arrives before ISocket is listening and
3042
+ // `heldRef` can route it. Frames resume on a later tick, after this block.
3043
+ ws.resume();
2650
3044
  if (publish) {
2651
3045
  try {
2652
3046
  this.assertReadOnlyHoldCurrent(input.applicationId, expectedTurn);
@@ -3122,7 +3516,7 @@ export class WebSocketSessionPeer {
3122
3516
  closeCode: input.close.code,
3123
3517
  });
3124
3518
  this.settleTurn(turn, {
3125
- reply: turn.collector.reply(),
3519
+ ...turn.collector.replyFields(),
3126
3520
  generationStatus: "detached",
3127
3521
  viewStatus: turn.collector.viewableStatus(),
3128
3522
  ...decisionFields(turn.collector.decision()),
@@ -3140,11 +3534,14 @@ export class WebSocketSessionPeer {
3140
3534
  if (!prior) {
3141
3535
  return;
3142
3536
  }
3143
- this.cancelTurn(prior);
3537
+ this.cancelTurn(prior, {
3538
+ replaced: true,
3539
+ reply: "This turn was replaced by a newer prompt on the same app, so its work was cancelled.",
3540
+ });
3144
3541
  }
3145
- cancelTurn(turn, reply = "This turn was replaced by a newer prompt on the same app, so its work was cancelled.") {
3542
+ cancelTurn(turn, outcome) {
3146
3543
  turn.finished ??= {
3147
- reply,
3544
+ ...outcome,
3148
3545
  generationStatus: "cancelled",
3149
3546
  viewStatus: turn.collector.viewableStatus(),
3150
3547
  };
@@ -3294,7 +3691,7 @@ export class WebSocketSessionPeer {
3294
3691
  * the app shell cannot boot without — Clark then runs the turn with its
3295
3692
  * ordinary tools rather than failing it.
3296
3693
  */
3297
- browserContextFor(held) {
3694
+ async browserContextFor(held) {
3298
3695
  if (!this.options.testingEnabled) {
3299
3696
  return undefined;
3300
3697
  }
@@ -3304,7 +3701,7 @@ export class WebSocketSessionPeer {
3304
3701
  * Library iframe bootstrap from a held session. Used for Gateway's own
3305
3702
  * live-canvas screenshot even when Clark's in-sandbox Playwright is off.
3306
3703
  */
3307
- buildHeldBrowserContext(held) {
3704
+ async buildHeldBrowserContext(held) {
3308
3705
  if (!held.orchestratorUrl) {
3309
3706
  console.error("gateway live canvas skipped: missing_orchestrator_url", {
3310
3707
  applicationId: held.applicationId,
@@ -3323,8 +3720,11 @@ export class WebSocketSessionPeer {
3323
3720
  });
3324
3721
  return undefined;
3325
3722
  }
3723
+ const apiKey = this.options.getAccessToken
3724
+ ? await this.options.getAccessToken()
3725
+ : held.apiKey;
3326
3726
  return buildBrowserContext({
3327
- ...(held.apiKey ? { apiKey: held.apiKey } : {}),
3727
+ ...(apiKey ? { apiKey } : {}),
3328
3728
  applicationId: held.applicationId,
3329
3729
  liveEditUrl: held.liveEdit.url,
3330
3730
  orchestratorUrl: held.orchestratorUrl,
@@ -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", {