@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
@@ -5,6 +5,7 @@ import { detectAttachmentArchiveType, getAttachmentMimeTypeFromFileName, getText
5
5
  import { buildBrowserDriverContract, HOST_BROWSER_TOOLS_COWORK, needsBrowserDriverError, } from "../capture/browser-contract.js";
6
6
  import { withCoworkEditorHost } from "../capture/cowork-editor-url.js";
7
7
  import { resolveCaptureMode } from "../capture/mode.js";
8
+ import { withDeadline } from "../deadline.js";
8
9
  import { gatewayDebug } from "../debug.js";
9
10
  import { playwrightChromiumPresent } from "../playwright/ensure-chromium.js";
10
11
  import { isTrustedEmbedOrigin, } from "../preview/capture-screenshot.js";
@@ -13,6 +14,7 @@ import { PromptNotSentError, } from "../sabs/session-peer.js";
13
14
  import { ServerHttpError } from "../server/client.js";
14
15
  import { IdentityUnavailableError } from "../server/identity-reads.js";
15
16
  import { archiveSourceFiles } from "./source-files-archive.js";
17
+ import { TOOL_PERMISSION_ANSWERS, ToolPermissionReply, } from "./tool-permission-reply.js";
16
18
  import { buildStatusOf, progressStatusOf } from "./types.js";
17
19
  import { IMPORT_ZIP_MAX_BYTES } from "./types.js";
18
20
  export { IMPORT_ZIP_MAX_BYTES } from "./types.js";
@@ -66,8 +68,8 @@ async function captureModeOf(ctx) {
66
68
  hostMcpConfigured: ctx.config.captureHostMcp,
67
69
  });
68
70
  }
69
- function libraryCaptureInput(ctx, applicationId, branch) {
70
- const context = ctx.sessionPeer.libraryBrowserContext(applicationId, branch);
71
+ async function libraryCaptureInput(ctx, applicationId, branch) {
72
+ const context = await ctx.sessionPeer.libraryBrowserContext(applicationId, branch);
71
73
  if (!context) {
72
74
  return undefined;
73
75
  }
@@ -115,30 +117,71 @@ function libraryCaptureInput(ctx, applicationId, branch) {
115
117
  },
116
118
  };
117
119
  }
120
+ function heldSocketFor(ctx, applicationId, branch) {
121
+ const held = ctx.sessionPeer.heldContext(applicationId);
122
+ if (!held) {
123
+ return "none";
124
+ }
125
+ return held.branch === branch ? "this_branch" : "other_branch";
126
+ }
118
127
  /**
119
- * Live-library capture input for get_app: reuse a held session, or re-open
120
- * live-edit when the in-memory hold is empty (MCP respawn / tool timeout).
128
+ * `branch` is the branch the read resolved to, undefined for the default
129
+ * branch: a socket held for any other branch cannot answer for this read.
130
+ *
131
+ * A cold live edit can take minutes to attach. Past `deadlineAtMs` the read
132
+ * answers from what the gateway already knows, and the attach carries on so
133
+ * the next read finds the session held.
121
134
  */
122
- async function ensureLibraryCaptureInput(ctx, principal, applicationId, branch) {
123
- const existing = libraryCaptureInput(ctx, applicationId, branch);
124
- if (existing) {
125
- return existing;
126
- }
127
- if (ctx.appState.pendingStart({
135
+ async function ensureLiveState(ctx, principal, applicationId, branch, deadlineAtMs) {
136
+ const scope = {
128
137
  applicationId,
129
- ...(branch ? { branch } : {}),
138
+ branch,
130
139
  organizationId: principal.organizationId,
131
140
  userId: principal.userId,
132
- })) {
133
- return undefined;
134
- }
135
- if (!(await gatewayChromiumIsPresent(ctx))) {
136
- console.error("gateway live canvas skipped: chromium_absent_no_rehold", {
141
+ };
142
+ const held = ctx.sessionPeer.heldContext(applicationId);
143
+ if ((held &&
144
+ held.branch === branch &&
145
+ !ctx.liveStateAttaches.isAttaching(scope)) ||
146
+ ctx.appState.pendingStart(scope) ||
147
+ ctx.sessionPeer.turnState(scope)?.phase === "running") {
148
+ return { kind: "ok", value: undefined };
149
+ }
150
+ const activeTurn = ctx.sessionPeer.turnState({ ...scope, branch: undefined });
151
+ if (activeTurn?.phase === "running" ||
152
+ ctx.appState.pendingStart({ ...scope, branch: undefined })) {
153
+ return {
137
154
  applicationId,
138
- });
139
- return undefined;
155
+ code: "live_edit_unavailable",
156
+ kind: "error",
157
+ message: "Another branch is building. Wait for it to finish before reading this branch.",
158
+ };
159
+ }
160
+ return await withDeadline(ctx.liveStateAttaches.run(scope, () => attachLiveState(ctx, principal, scope)), Math.max(0, deadlineAtMs - nowMs(ctx)), () => ({ kind: "ok", value: undefined }));
161
+ }
162
+ /**
163
+ * Whether a read may answer from what this process knows when the editor
164
+ * state could not be refreshed. A running turn is followed on its own socket
165
+ * and a recorded dead start is this process's own account. Beyond those, only
166
+ * a decision or a finished turn can have been overtaken in the editor. With
167
+ * neither there is nothing a refresh could correct, and failing the read
168
+ * would turn another user's edit or a full sandbox pool into an error on an
169
+ * app that is simply ready.
170
+ */
171
+ function knownStateAnswersWithoutRefresh(ctx, scope) {
172
+ const turn = ctx.sessionPeer.turnState(scope);
173
+ if (turn?.phase === "running" || ctx.appState.failedStart(scope)) {
174
+ return true;
140
175
  }
176
+ return (turn === undefined &&
177
+ !ctx.appState.pendingDecision(scope.applicationId, scope.branch) &&
178
+ !ctx.appState.pendingPlan(scope.applicationId, scope.branch));
179
+ }
180
+ async function attachLiveState(ctx, principal, scope) {
181
+ const { applicationId, branch } = scope;
141
182
  try {
183
+ // TODO(AR-593): read-only tools reach this attach, which can start the
184
+ // caller's sandbox and take a free editor lock for the read hold.
142
185
  const session = await openLiveEdit(ctx, {
143
186
  apiKey: principal.apiKey,
144
187
  applicationId,
@@ -175,17 +218,24 @@ async function ensureLibraryCaptureInput(ctx, principal, applicationId, branch)
175
218
  });
176
219
  }
177
220
  catch (error) {
178
- console.error("gateway live canvas skipped: rehold_failed", {
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ const answeredWithoutRefresh = knownStateAnswersWithoutRefresh(ctx, scope);
223
+ console.error("gateway live state refresh failed", {
224
+ answeredWithoutRefresh,
179
225
  applicationId,
180
- message: error instanceof Error ? error.message : String(error),
226
+ message,
181
227
  });
182
- return undefined;
183
- }
184
- const live = libraryCaptureInput(ctx, applicationId, branch);
185
- if (!live) {
186
- console.error("gateway live canvas skipped: context_incomplete_after_rehold", { applicationId });
228
+ if (answeredWithoutRefresh) {
229
+ return { kind: "ok", value: undefined };
230
+ }
231
+ return {
232
+ applicationId,
233
+ code: "live_edit_unavailable",
234
+ kind: "error",
235
+ message: `Could not refresh the editor state: ${message}`,
236
+ };
187
237
  }
188
- return live;
238
+ return { kind: "ok", value: undefined };
189
239
  }
190
240
  async function captureLiveLibraryIfNeeded(ctx, applicationId, live) {
191
241
  if (!live) {
@@ -623,14 +673,6 @@ async function waitForDeployLive(ctx, input) {
623
673
  * see what they built.
624
674
  */
625
675
  const NEXT_STEPS = "Inspect it with get_app, or publish it with publish_app";
626
- /**
627
- * Which side of a waiting turn owes the next move.
628
- *
629
- * A stream hiccup can deliver the plan as chat text instead of a plan card,
630
- * leaving needs_decision with no structured decision. The turn is waiting on a
631
- * human either way, so the absence of a plan reads as a question, never as
632
- * nothing to answer.
633
- */
634
676
  function pendingActionForDecision(decision) {
635
677
  if (!decision) {
636
678
  return "decide_in_editor";
@@ -639,6 +681,7 @@ function pendingActionForDecision(decision) {
639
681
  case "missing_integrations":
640
682
  return "create_integration";
641
683
  case "multi_choice":
684
+ case "tool_permission":
642
685
  return "answer_question";
643
686
  case "plan":
644
687
  return "approve_plan";
@@ -670,6 +713,9 @@ function decisionSummary(decision) {
670
713
  if (decision.kind === "multi_choice") {
671
714
  return "Superblocks asked the user a question and stopped.";
672
715
  }
716
+ if (decision.kind === "tool_permission") {
717
+ return "Superblocks needs a user approval decision before it can continue.";
718
+ }
673
719
  const unhandled = decision;
674
720
  return unhandled;
675
721
  }
@@ -1265,7 +1311,30 @@ async function runAcknowledgedStart(ctx, args) {
1265
1311
  // prompt went out.
1266
1312
  args.approvalSettlement?.onNotSent();
1267
1313
  }
1268
- if (result.kind === "error" && result.code === "reinvoke_required") {
1314
+ if (result.kind === "error" &&
1315
+ (result.code === "plan_approval_stale" ||
1316
+ result.code === "tool_permission_pending" ||
1317
+ result.code === "tool_permission_stale")) {
1318
+ const currentDecision = ctx.appState.pendingDecision(args.applicationId, args.branch);
1319
+ // A cleared decision leaves the live edit healthy: only the approval
1320
+ // target went away, so the turn reads as ended, not terminal.
1321
+ rememberFailedStart(ctx, {
1322
+ applicationId: args.applicationId,
1323
+ branch: args.branch,
1324
+ ...(currentDecision ? { decision: currentDecision.decision } : {}),
1325
+ error: new Error(result.message),
1326
+ generationStatus: currentDecision ? "needs_decision" : "no_changes",
1327
+ idempotencyKey: args.idempotencyKey,
1328
+ pendingStartRequired: true,
1329
+ pendingStartToken: args.pendingStartToken,
1330
+ principal: args.principal,
1331
+ reason: currentDecision
1332
+ ? result.message
1333
+ : "The approval request is no longer pending, so this response was not sent to Superblocks.",
1334
+ revision: args.pendingStartToken,
1335
+ });
1336
+ }
1337
+ else if (result.kind === "error" && result.code === "reinvoke_required") {
1269
1338
  failed(new Error(result.message), `Authentication completed before Superblocks accepted the prompt for application ${args.applicationId}. Call edit_app with that applicationId and the same prompt to continue.`, "live_edit_terminated", "retry_build");
1270
1339
  }
1271
1340
  else if (result.kind === "auth_required" ||
@@ -1704,6 +1773,10 @@ function nowMs(ctx) {
1704
1773
  function hostCallBudgetMs(ctx, requestedMs) {
1705
1774
  return Math.max(0, Math.min(requestedMs, ctx.config.hostCallCeilingMs));
1706
1775
  }
1776
+ /** When a call that starts now has to have answered by. */
1777
+ function hostCallDeadlineAtMs(ctx) {
1778
+ return nowMs(ctx) + Math.max(0, ctx.config.hostCallCeilingMs);
1779
+ }
1707
1780
  /**
1708
1781
  * How long the next turn must collect activity for before it may answer.
1709
1782
  *
@@ -1868,7 +1941,7 @@ async function awaitTurns(ctx, requestedBranch, principal, applicationId, hostDe
1868
1941
  }
1869
1942
  }
1870
1943
  export async function checkAppProgress(ctx, input) {
1871
- const hostDeadlineAtMs = nowMs(ctx) + Math.max(0, ctx.config.hostCallCeilingMs);
1944
+ const hostDeadlineAtMs = hostCallDeadlineAtMs(ctx);
1872
1945
  const principalResult = await ensurePrincipal(ctx);
1873
1946
  if (principalResult.kind !== "ok") {
1874
1947
  return principalResult;
@@ -1886,11 +1959,18 @@ export async function checkAppProgress(ctx, input) {
1886
1959
  if (ctx.signal?.aborted) {
1887
1960
  return namingApplication(applicationId, cancelled("check_app_progress"));
1888
1961
  }
1962
+ const refreshed = await ensureLiveState(ctx, principal, applicationId, currentTurnBranch(ctx, principal, applicationId, requestedBranch), hostDeadlineAtMs);
1963
+ if (refreshed.kind !== "ok") {
1964
+ return refreshed;
1965
+ }
1889
1966
  const updateResult = await awaitTurns(ctx, requestedBranch, principal, applicationId, hostDeadlineAtMs);
1890
1967
  if (updateResult.kind !== "ok") {
1891
1968
  return updateResult;
1892
1969
  }
1893
1970
  const update = updateResult.value;
1971
+ const untracked = update.status === "unknown"
1972
+ ? await untrackedStatus(ctx, principal, applicationId, requestedBranch)
1973
+ : undefined;
1894
1974
  const elapsedSeconds = Math.round(update.elapsedMs / 1000);
1895
1975
  const saidSomething = update.activity.length > 0;
1896
1976
  const activity = update.status === "running" && !saidSomething
@@ -1899,7 +1979,7 @@ export async function checkAppProgress(ctx, input) {
1899
1979
  for (const message of activity) {
1900
1980
  ctx.onProgress?.({ message, type: "progress" });
1901
1981
  }
1902
- const status = progressStatusOf(update);
1982
+ const status = untracked?.status ?? progressStatusOf(update);
1903
1983
  const terminal = status !== "building";
1904
1984
  const turnSignals = await recordTurnSignals(ctx, {
1905
1985
  appScope: { applicationId, organizationId: principal.organizationId },
@@ -1911,7 +1991,7 @@ export async function checkAppProgress(ctx, input) {
1911
1991
  saidSomething,
1912
1992
  terminal,
1913
1993
  });
1914
- const branch = update.branch ?? requestedBranch;
1994
+ const branch = update.branch ?? untracked?.branch ?? requestedBranch;
1915
1995
  const base = {
1916
1996
  ...turnSignals,
1917
1997
  activity,
@@ -1931,6 +2011,7 @@ export async function checkAppProgress(ctx, input) {
1931
2011
  kind: "ok",
1932
2012
  value: {
1933
2013
  ...base,
2014
+ ...untracked,
1934
2015
  status,
1935
2016
  },
1936
2017
  };
@@ -1949,21 +2030,16 @@ export async function checkAppProgress(ctx, input) {
1949
2030
  ...(update.checkpointWarning
1950
2031
  ? { checkpointWarning: update.checkpointWarning }
1951
2032
  : {}),
1952
- reply: update.reply,
1953
2033
  ...(update.decision ? { decision: update.decision } : {}),
1954
2034
  generationStatus: update.generationStatus,
1955
- ...(update.checkpointFailure
1956
- ? { pendingAction: "open_editor" }
1957
- : status === "needs_decision"
1958
- ? { pendingAction: pendingActionForDecision(update.decision) }
1959
- : update.retryAction && emptyTurnStatus(update.generationStatus)
1960
- ? { pendingAction: update.retryAction }
1961
- : update.retryable === false &&
1962
- emptyTurnStatus(update.generationStatus)
1963
- ? update.recoveryAction
1964
- ? { pendingAction: update.recoveryAction }
1965
- : {}
1966
- : {}),
2035
+ ...(planQuestionOutcome(update) ?? {
2036
+ reply: update.reply,
2037
+ ...(update.checkpointFailure
2038
+ ? { pendingAction: "open_editor" }
2039
+ : status === "needs_decision"
2040
+ ? { pendingAction: pendingActionForDecision(update.decision) }
2041
+ : emptyTurnRecovery(update)),
2042
+ }),
1967
2043
  status,
1968
2044
  viewStatus: update.viewStatus,
1969
2045
  },
@@ -2079,10 +2155,55 @@ function stalePlanApproval(applicationId) {
2079
2155
  function planApprovalChanged(approvedPlan, pendingPlan) {
2080
2156
  return approvedPlan !== undefined && pendingPlan !== approvedPlan;
2081
2157
  }
2158
+ // These messages stay host-neutral: the host-specific way to answer comes from
2159
+ // the tool's agentInstructions, which know whether the host can collect an
2160
+ // approval at all.
2161
+ function ambiguousToolPermissionReply(applicationId) {
2162
+ return {
2163
+ applicationId,
2164
+ kind: "error",
2165
+ code: "tool_permission_reply_ambiguous",
2166
+ message: `This reply to the pending permission is neither an approval nor a decline, so nothing was sent to Superblocks. Answer with permissionMessageId set to decision.messageId and ${TOOL_PERMISSION_ANSWERS}.`,
2167
+ };
2168
+ }
2169
+ function pendingToolPermission(applicationId) {
2170
+ return {
2171
+ applicationId,
2172
+ kind: "error",
2173
+ code: "tool_permission_pending",
2174
+ message: `Superblocks is waiting on a permission request, so it cannot take a new prompt yet. Answer it with permissionMessageId set to decision.messageId: \`Decline: <the new instruction>\` to replace it with a new instruction, or ${TOOL_PERMISSION_ANSWERS}.`,
2175
+ };
2176
+ }
2177
+ function staleToolPermissionResponse(applicationId) {
2178
+ return {
2179
+ applicationId,
2180
+ kind: "error",
2181
+ code: "tool_permission_stale",
2182
+ message: "This permission request is no longer current, so nothing was sent to Superblocks. Get the current request from check_app_progress and follow its instructions.",
2183
+ };
2184
+ }
2082
2185
  async function promptExistingApplication(ctx, args) {
2083
2186
  const { applicationId, approvedPlan, input, principal } = args;
2084
2187
  const branch = currentTurnBranch(ctx, principal, applicationId, input.branch);
2085
- const answeringQuestion = pendingQuestion(ctx, applicationId, branch);
2188
+ const pendingInteractive = pendingInteractiveDecision(ctx, applicationId, branch);
2189
+ const answeringQuestion = Boolean(pendingInteractive);
2190
+ const answeringToolPermission = pendingInteractive?.kind === "tool_permission";
2191
+ const permissionMismatch = () => {
2192
+ const current = pendingInteractiveDecision(ctx, applicationId, branch);
2193
+ const pendingPermission = current?.kind === "tool_permission" ? current : undefined;
2194
+ if (input.permissionMessageId === undefined) {
2195
+ return pendingPermission
2196
+ ? pendingToolPermission(applicationId)
2197
+ : undefined;
2198
+ }
2199
+ return pendingPermission?.messageId === input.permissionMessageId
2200
+ ? undefined
2201
+ : staleToolPermissionResponse(applicationId);
2202
+ };
2203
+ const mismatchBeforeOpen = permissionMismatch();
2204
+ if (mismatchBeforeOpen) {
2205
+ return mismatchBeforeOpen;
2206
+ }
2086
2207
  // Approve reuses the pending plan as the prompt. Every other path needs the
2087
2208
  // caller's words - an empty or missing prompt would otherwise forward
2088
2209
  // undefined into Clark. Answering a question is not a plan approval, even if
@@ -2095,6 +2216,12 @@ async function promptExistingApplication(ctx, args) {
2095
2216
  message: 'edit_app needs a prompt, unless planAction is "approve" (Build it) for a pending plan.',
2096
2217
  };
2097
2218
  }
2219
+ const permissionReply = answeringToolPermission
2220
+ ? ToolPermissionReply.parse(input.prompt ?? "")
2221
+ : undefined;
2222
+ if (permissionReply?.isAmbiguous) {
2223
+ return ambiguousToolPermissionReply(applicationId);
2224
+ }
2098
2225
  const pendingBeforeOpen = ctx.appState.pendingPlan(applicationId, branch);
2099
2226
  if (planApprovalChanged(approvedPlan, pendingBeforeOpen)) {
2100
2227
  return stalePlanApproval(applicationId);
@@ -2157,7 +2284,7 @@ async function promptExistingApplication(ctx, args) {
2157
2284
  applicationId,
2158
2285
  ...(branch ? { branch } : {}),
2159
2286
  editUrl: editUrl(ctx, applicationId, branch),
2160
- mode,
2287
+ ...(answeringToolPermission ? {} : { mode }),
2161
2288
  status: "building",
2162
2289
  }),
2163
2290
  };
@@ -2201,25 +2328,12 @@ async function promptExistingApplication(ctx, args) {
2201
2328
  type: "progress",
2202
2329
  });
2203
2330
  const pending = ctx.appState.pendingPlan(applicationId, branch);
2331
+ const mismatchAfterOpen = permissionMismatch();
2332
+ if (mismatchAfterOpen) {
2333
+ return mismatchAfterOpen;
2334
+ }
2204
2335
  if (planApprovalChanged(approvedPlan, pending)) {
2205
- const stale = stalePlanApproval(applicationId);
2206
- if (args.pendingStartToken !== undefined) {
2207
- const currentDecision = ctx.appState.pendingDecision(applicationId, branch);
2208
- rememberFailedStart(ctx, {
2209
- applicationId,
2210
- branch,
2211
- ...(currentDecision ? { decision: currentDecision.decision } : {}),
2212
- error: new Error(stale.message),
2213
- generationStatus: "needs_decision",
2214
- idempotencyKey: input.idempotencyKey,
2215
- pendingStartRequired: true,
2216
- pendingStartToken: args.pendingStartToken,
2217
- principal,
2218
- reason: stale.message,
2219
- revision,
2220
- });
2221
- }
2222
- return stale;
2336
+ return stalePlanApproval(applicationId);
2223
2337
  }
2224
2338
  // Approve / refine a pending plan the way the editor's "Build it" / chat
2225
2339
  // refine path does. A bare prompt while a plan is pending is refine
@@ -2253,6 +2367,12 @@ async function promptExistingApplication(ctx, args) {
2253
2367
  revision,
2254
2368
  userId: principal.userId,
2255
2369
  };
2370
+ if (permissionReply && pendingInteractive?.kind === "tool_permission") {
2371
+ promptArgs = {
2372
+ ...promptArgs,
2373
+ responseMetadata: permissionReply.responseMetadata(pendingInteractive.messageId),
2374
+ };
2375
+ }
2256
2376
  if (effectiveAction === "approve") {
2257
2377
  if (pending?.plan) {
2258
2378
  promptArgs = {
@@ -2341,7 +2461,7 @@ async function promptExistingApplication(ctx, args) {
2341
2461
  applicationId,
2342
2462
  ...(branch ? { branch } : {}),
2343
2463
  editUrl: editUrl(ctx, applicationId, branch),
2344
- mode: promptArgs.mode,
2464
+ ...(answeringToolPermission ? {} : { mode: promptArgs.mode }),
2345
2465
  status: "building",
2346
2466
  }),
2347
2467
  };
@@ -2384,7 +2504,7 @@ async function promptExistingApplication(ctx, args) {
2384
2504
  ...(forward.value.decision ? { decision: forward.value.decision } : {}),
2385
2505
  editUrl: editUrl(ctx, applicationId, branch),
2386
2506
  generationStatus: forward.value.generationStatus,
2387
- mode: promptArgs.mode,
2507
+ ...(answeringToolPermission ? {} : { mode: promptArgs.mode }),
2388
2508
  ...(forward.value.recentRuns && forward.value.recentRuns.length > 0
2389
2509
  ? { recentRuns: forward.value.recentRuns }
2390
2510
  : {}),
@@ -2687,9 +2807,12 @@ async function applicationName(ctx, principal, applicationId) {
2687
2807
  return undefined;
2688
2808
  }
2689
2809
  }
2690
- function pendingQuestion(ctx, applicationId, branch) {
2691
- return (ctx.appState.pendingDecision(applicationId, branch)?.decision.kind ===
2692
- "multi_choice");
2810
+ function pendingInteractiveDecision(ctx, applicationId, branch) {
2811
+ const pending = ctx.appState.pendingDecision(applicationId, branch)?.decision;
2812
+ if (pending?.kind === "multi_choice" || pending?.kind === "tool_permission") {
2813
+ return pending;
2814
+ }
2815
+ return undefined;
2693
2816
  }
2694
2817
  function editTurnMode(input, pendingQuestion) {
2695
2818
  if (pendingQuestion) {
@@ -2710,6 +2833,66 @@ function awaitingDecision(ctx, scope) {
2710
2833
  const turn = ctx.sessionPeer.turnState(scope);
2711
2834
  return (turn?.phase === "finished" && turn.generationStatus === "needs_decision");
2712
2835
  }
2836
+ /**
2837
+ * How a finished Plan turn that changed nothing and owes the host no other step
2838
+ * reads on both `check_app_progress` and `get_app`, or undefined for any other
2839
+ * turn.
2840
+ *
2841
+ * Plan turns cannot write, so one that answered has nothing to retry: its reply
2842
+ * is the answer. One that ended on status labels, reasoning, or silence
2843
+ * answered nothing, so it offers no reply to relay and asks to be run again. A
2844
+ * turn that carries a decision, such as integrations to create, or a failed
2845
+ * checkpoint keeps the step that decision or failure calls for.
2846
+ */
2847
+ function planQuestionOutcome(turn) {
2848
+ if (turn.generationStatus !== "no_changes" ||
2849
+ turn.mode !== "PLAN" ||
2850
+ turn.decision ||
2851
+ turn.checkpointFailure) {
2852
+ return undefined;
2853
+ }
2854
+ if (!turn.answered) {
2855
+ return { pendingAction: turn.retryAction ?? "retry_build" };
2856
+ }
2857
+ return turn.reply ? { reply: turn.reply } : {};
2858
+ }
2859
+ /**
2860
+ * What a turn that wrote nothing asks the host to do next, read the same way
2861
+ * by `check_app_progress` and `get_app`, or undefined for any other turn.
2862
+ *
2863
+ * A turn a newer prompt replaced is never sent again: that would replace the
2864
+ * newer turn in turn. Otherwise an explicit retry wins, a turn that cannot be
2865
+ * retried offers its recovery, if any, a start that never reached Superblocks
2866
+ * offers only the retry it names, and a turn that has nothing to send again
2867
+ * offers no retry (see {@link settledWithoutRetry}). Every other empty turn is
2868
+ * sent again.
2869
+ */
2870
+ function emptyTurnRecovery(turn) {
2871
+ if (turn.checkpointCommitId || !emptyTurnStatus(turn.generationStatus)) {
2872
+ return undefined;
2873
+ }
2874
+ if (turn.replaced) {
2875
+ return {};
2876
+ }
2877
+ if (turn.retryAction) {
2878
+ return { pendingAction: turn.retryAction };
2879
+ }
2880
+ if (turn.retryable === false) {
2881
+ return turn.recoveryAction ? { pendingAction: turn.recoveryAction } : {};
2882
+ }
2883
+ return turn.startFailed || settledWithoutRetry(turn)
2884
+ ? {}
2885
+ : { pendingAction: "retry_build" };
2886
+ }
2887
+ /**
2888
+ * Whether sending an empty turn's prompt again would do no good: the turn may
2889
+ * still be running elsewhere (detached), or it answered in text without
2890
+ * writing, so the same prompt would most likely get the same answer.
2891
+ */
2892
+ function settledWithoutRetry(turn) {
2893
+ return (turn.generationStatus === "detached" ||
2894
+ (turn.answered === true && turn.generationStatus === "no_changes"));
2895
+ }
2713
2896
  function emptyTurnStatus(status) {
2714
2897
  switch (status) {
2715
2898
  case "cancelled":
@@ -2755,16 +2938,10 @@ function localUnbuiltReason(turn, pending) {
2755
2938
  return status
2756
2939
  ? {
2757
2940
  kind: "empty_turn",
2758
- ...(turn.retryAction
2759
- ? { pendingAction: turn.retryAction }
2760
- : turn.retryable === false
2761
- ? turn.recoveryAction
2762
- ? { pendingAction: turn.recoveryAction }
2763
- : {}
2764
- : turn.generationStatus === "detached"
2765
- ? {}
2766
- : { pendingAction: "retry_build" }),
2767
- ...(turn.reply ? { reply: turn.reply } : {}),
2941
+ ...(planQuestionOutcome(turn) ?? {
2942
+ ...emptyTurnRecovery(turn),
2943
+ ...(turn.reply ? { reply: turn.reply } : {}),
2944
+ }),
2768
2945
  status,
2769
2946
  }
2770
2947
  : undefined;
@@ -2823,52 +3000,64 @@ async function unbuiltApp(ctx, principal, applicationId, requestedBranch) {
2823
3000
  editUrl: editUrl(ctx, applicationId, actualBranch),
2824
3001
  ...(name ? { name } : {}),
2825
3002
  });
2826
- if (reason.kind === "awaiting_decision") {
2827
- // The remembered plan counts: a plan the caller can show the user beats
2828
- // sending them to the editor, and it can only be a plan, so reading it
2829
- // back cannot mislabel a question as one (ENG-5910).
2830
- const decision = decisionToPutToTheUser(ctx, applicationId, actualBranch, reason.decision);
2831
- return {
2832
- kind: "ok",
2833
- value: {
2834
- ...app,
3003
+ return {
3004
+ kind: "ok",
3005
+ value: {
3006
+ ...app,
3007
+ ...unbuiltStatus(ctx, applicationId, actualBranch, reason),
3008
+ },
3009
+ };
3010
+ }
3011
+ /**
3012
+ * How an app that cannot be previewed reads as a status. `get_app` reports
3013
+ * this, and so does a `check_app_progress` that has no turn to follow, so the
3014
+ * two reads cannot disagree about the same editor state.
3015
+ */
3016
+ function unbuiltStatus(ctx, applicationId, branch, reason) {
3017
+ switch (reason.kind) {
3018
+ case "awaiting_decision": {
3019
+ // The remembered plan counts: a plan the caller can show the user beats
3020
+ // sending them to the editor, and it can only be a plan, so reading it
3021
+ // back cannot mislabel a question as one (ENG-5910).
3022
+ const decision = decisionToPutToTheUser(ctx, applicationId, branch, reason.decision);
3023
+ return {
2835
3024
  ...(decision ? { decision } : {}),
2836
3025
  pendingAction: pendingActionForDecision(decision),
2837
3026
  status: "needs_decision",
2838
- },
2839
- };
2840
- }
2841
- if (reason.kind === "checkpoint_failed") {
2842
- return {
2843
- kind: "ok",
2844
- value: {
2845
- ...app,
3027
+ };
3028
+ }
3029
+ case "checkpoint_failed":
3030
+ return {
2846
3031
  checkpointFailure: reason.failure,
2847
3032
  pendingAction: "open_editor",
2848
3033
  status: "checkpoint_failed",
2849
- },
2850
- };
2851
- }
2852
- if (reason.kind === "empty_turn") {
2853
- return {
2854
- kind: "ok",
2855
- value: {
2856
- ...app,
3034
+ };
3035
+ case "empty_turn":
3036
+ return {
2857
3037
  ...(reason.pendingAction
2858
3038
  ? { pendingAction: reason.pendingAction }
2859
3039
  : {}),
2860
3040
  ...(reason.reply ? { reply: reason.reply } : {}),
2861
3041
  status: reason.status,
2862
- },
2863
- };
3042
+ };
3043
+ case "running":
3044
+ return { pendingAction: "wait_for_build", status: "building" };
3045
+ }
3046
+ }
3047
+ /**
3048
+ * What `check_app_progress` reports when this process has no turn to follow:
3049
+ * the editor state the read just refreshed, read as `get_app` reads it. An app
3050
+ * with nothing to follow and nothing unbuilt is ready.
3051
+ */
3052
+ async function untrackedStatus(ctx, principal, applicationId, requestedBranch) {
3053
+ const state = await unbuiltReason(ctx, principal, applicationId, requestedBranch);
3054
+ if (!state) {
3055
+ return { status: "ready" };
2864
3056
  }
3057
+ const branch = state.branch ?? requestedBranch;
2865
3058
  return {
2866
- kind: "ok",
2867
- value: {
2868
- ...app,
2869
- pendingAction: "wait_for_build",
2870
- status: "building",
2871
- },
3059
+ ...(branch ? { branch } : {}),
3060
+ ...unbuiltStatus(ctx, applicationId, branch, state.reason),
2872
3061
  };
2873
3062
  }
2874
3063
  /**
@@ -2903,6 +3092,7 @@ function decisionToPutToTheUser(ctx, applicationId, branch, decision) {
2903
3092
  * this on the poll loop would flash a blank form through a whole build.
2904
3093
  */
2905
3094
  export async function askUser(ctx, input) {
3095
+ const hostDeadlineAtMs = hostCallDeadlineAtMs(ctx);
2906
3096
  const principalResult = await ensurePrincipal(ctx);
2907
3097
  if (principalResult.kind !== "ok") {
2908
3098
  return principalResult;
@@ -2913,6 +3103,10 @@ export async function askUser(ctx, input) {
2913
3103
  return resolved;
2914
3104
  }
2915
3105
  const applicationId = resolved.value;
3106
+ const refreshed = await ensureLiveState(ctx, principal, applicationId, currentTurnBranch(ctx, principal, applicationId, input.branch), hostDeadlineAtMs);
3107
+ if (refreshed.kind !== "ok") {
3108
+ return refreshed;
3109
+ }
2916
3110
  const unbuilt = await unbuiltApp(ctx, principal, applicationId, input.branch);
2917
3111
  const branch = unbuilt?.value.branch ??
2918
3112
  currentTurnBranch(ctx, principal, applicationId, input.branch);
@@ -2954,6 +3148,7 @@ export async function askUser(ctx, input) {
2954
3148
  * Read the app's current live editor view without committing or building it.
2955
3149
  */
2956
3150
  export async function getApp(ctx, input) {
3151
+ const hostDeadlineAtMs = hostCallDeadlineAtMs(ctx);
2957
3152
  const principalResult = await ensurePrincipal(ctx);
2958
3153
  if (principalResult.kind !== "ok") {
2959
3154
  return principalResult;
@@ -2965,31 +3160,38 @@ export async function getApp(ctx, input) {
2965
3160
  }
2966
3161
  const applicationId = resolved.value;
2967
3162
  const includeScreenshot = input.includeScreenshot !== false;
3163
+ const refreshed = await ensureLiveState(ctx, principal, applicationId, currentTurnBranch(ctx, principal, applicationId, input.branch), hostDeadlineAtMs);
3164
+ if (refreshed.kind !== "ok") {
3165
+ return refreshed;
3166
+ }
2968
3167
  const unbuilt = await unbuiltApp(ctx, principal, applicationId, input.branch);
2969
3168
  const branch = unbuilt?.value.branch ??
2970
3169
  currentTurnBranch(ctx, principal, applicationId, input.branch);
2971
3170
  const mayCapture = !unbuilt || unbuilt.value.status === "building";
2972
3171
  const live = includeScreenshot && mayCapture
2973
- ? await ensureLibraryCaptureInput(ctx, principal, applicationId, branch)
3172
+ ? await libraryCaptureInput(ctx, applicationId, branch)
2974
3173
  : undefined;
2975
- const currentUnbuilt = includeScreenshot && mayCapture
2976
- ? await unbuiltApp(ctx, principal, applicationId, input.branch)
2977
- : unbuilt;
2978
- if (currentUnbuilt) {
3174
+ if (includeScreenshot && mayCapture && !live) {
3175
+ console.error("gateway live canvas skipped: no_live_context", {
3176
+ applicationId,
3177
+ held: heldSocketFor(ctx, applicationId, branch),
3178
+ });
3179
+ }
3180
+ if (unbuilt) {
2979
3181
  // Plan, question, and empty-starter stays fail-closed: photographing the
2980
3182
  // untouched template is the bug unbuiltApp exists to prevent. A running
2981
3183
  // turn is different: the held canvas is the thing being written, so a
2982
3184
  // live shot is worth returning alongside wait_for_build.
2983
- if (currentUnbuilt.value.status !== "building" ||
3185
+ if (unbuilt.value.status !== "building" ||
2984
3186
  !live ||
2985
- (currentUnbuilt.value.branch ?? normalizeBranch(input.branch)) !== branch) {
2986
- return currentUnbuilt;
3187
+ (unbuilt.value.branch ?? normalizeBranch(input.branch)) !== branch) {
3188
+ return unbuilt;
2987
3189
  }
2988
3190
  const liveShot = await captureLiveLibraryIfNeeded(ctx, applicationId, live);
2989
3191
  return {
2990
3192
  kind: "ok",
2991
3193
  value: {
2992
- ...currentUnbuilt.value,
3194
+ ...unbuilt.value,
2993
3195
  ...(liveShot.needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2994
3196
  ...(liveShot.screenshotBase64
2995
3197
  ? { screenshotBase64: liveShot.screenshotBase64 }