openmausbot 0.1.72 → 0.1.74

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 (36) hide show
  1. package/dist/assets/index-BRLHsQ5g.js +298 -0
  2. package/dist/assets/index-ByLQ7a3F.css +1 -0
  3. package/dist/assets/{index-Bpai86ua.js → index-CwoQdM0z.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +9 -0
  6. package/dist-server/drivers/agents-proxy.js +2 -2
  7. package/dist-server/index.js +721 -484
  8. package/dist-server/mcp-gate.js +58 -16
  9. package/dist-server/openmausbot.js +236 -80
  10. package/dist-server/pair-cli.js +236 -80
  11. package/dist-server/server/auto-approve.js +30 -206
  12. package/dist-server/server/channel-queue.js +20 -2
  13. package/dist-server/server/config.js +36 -1
  14. package/dist-server/server/drivers/acp/core.js +56 -3
  15. package/dist-server/server/drivers/acp/grok.js +3 -1
  16. package/dist-server/server/drivers/agents-proxy.js +2 -2
  17. package/dist-server/server/drivers/antigravity-acp.js +5 -4
  18. package/dist-server/server/drivers/antigravity.js +9 -4
  19. package/dist-server/server/drivers/claude.js +54 -11
  20. package/dist-server/server/drivers/codex.js +86 -27
  21. package/dist-server/server/engine-install.js +9 -5
  22. package/dist-server/server/index.js +210 -231
  23. package/dist-server/server/message-db.js +70 -3
  24. package/dist-server/server/procs.js +82 -23
  25. package/dist-server/server/routines.js +67 -8
  26. package/dist-server/server/steer-queue.js +27 -7
  27. package/dist-server/server/team-manifest.js +1 -1
  28. package/dist-server/server/vps-computer.js +81 -12
  29. package/dist-server/server/webhooks.js +4 -1
  30. package/dist-server/server/workspace-backup.js +24 -9
  31. package/dist-server/shared/approval-mode.js +11 -10
  32. package/dist-server/vps-container-mcp.js +9 -0
  33. package/package.json +1 -1
  34. package/dist/assets/index-BFPYw6P-.js +0 -296
  35. package/dist/assets/index-D1odUdTi.css +0 -1
  36. package/dist-server/server/auto-review.js +0 -79
@@ -8,11 +8,10 @@ import { extname, join } from "node:path";
8
8
  import { z } from "zod";
9
9
  import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
10
10
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
11
- import { approvalModeFor, supportsApprovalMode, requiresNativeApproval, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
11
+ import { approvalModeFor, supportsApprovalMode, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
12
12
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
13
13
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
14
- import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, rememberableApprovalKey } from "./auto-approve.js";
15
- import { requestReview, resolveAutoReviewMode, shouldReview } from "./auto-review.js";
14
+ import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict } from "./auto-approve.js";
16
15
  import { updateClaudeCli } from "./claude-update.js";
17
16
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
18
17
  import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
@@ -33,7 +32,7 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
33
32
  import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
34
33
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
35
34
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
36
- import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
35
+ import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
37
36
  import { ComputerControl } from "./computer-control.js";
38
37
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
38
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -53,15 +52,15 @@ import { probeMcpServer } from "./mcp-probe.js";
53
52
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
54
53
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
55
54
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
56
- import { readMessageText, recallMessages, searchMessages, closeMessageDb } from "./message-db.js";
55
+ import { readMessageText, recallMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
57
56
  import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
58
57
  /** A session_read answer competes with the transcript for the context
59
58
  * window; a computer-use turn's output can run to hundreds of KB. */
60
59
  const SESSION_READ_MAX_CHARS = 8_000;
61
60
  import { promptWithReply, transcriptText } from "./replies.js";
62
61
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DelegationWakeBudget, discardDelegations, drainDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
63
- import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, } from "./steer-queue.js";
64
- import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, } from "./channel-queue.js";
62
+ import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreSteeredMessages, } from "./steer-queue.js";
63
+ import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, } from "./channel-queue.js";
65
64
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
66
65
  import { EventBus } from "./harness/bus.js";
67
66
  import { ProviderRegistry } from "./harness/registry.js";
@@ -481,6 +480,20 @@ class DirectTurnSetupCancelled extends Error {
481
480
  }
482
481
  const directTurnDispatchClaims = new Map();
483
482
  const directTurnGenerationByThread = new Map();
483
+ // Stop revokes credentials before completion, but the receipt must retain its
484
+ // exact provider-turn owner until that completion or explicit failure cleanup.
485
+ const directFollowupTurns = new ProviderTurnGenerationRegistry();
486
+ const directFollowupSettlers = new Map();
487
+ function settleDirectFollowup(generation) {
488
+ if (!generation)
489
+ return;
490
+ const pending = directFollowupSettlers.get(generation);
491
+ if (!pending)
492
+ return;
493
+ directFollowupSettlers.delete(generation);
494
+ directFollowupTurns.deleteGeneration(pending.threadId, generation);
495
+ pending.settle();
496
+ }
484
497
  // Keep the exact provider/profile settings that own a running conversation.
485
498
  // Selecting another thread or changing a default must not retarget its tools.
486
499
  const directTurnBots = new Map();
@@ -1001,6 +1014,7 @@ function checkedMemberIds(value) {
1001
1014
  }
1002
1015
  let bootSelection = { instanceId: "", model: "" };
1003
1016
  const store = new Store(() => bootSelection);
1017
+ let followupsReady = false;
1004
1018
  const sendSequencer = new SendSequencer();
1005
1019
  bootSelection = await defaultSelection();
1006
1020
  store.seedIfEmpty();
@@ -1184,15 +1198,11 @@ async function botOverview(bot) {
1184
1198
  * this too, but no provider dispatch or later permission callback relies on
1185
1199
  * persistence having been produced exclusively by that route. Delegation
1186
1200
  * uses the receiving bot's grant, never the sender's — see approvalModeForOrigin. */
1187
- const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
1201
+ const approvalModeForTurn = (bot, peerInitiated = false) => {
1188
1202
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
1189
1203
  if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1190
1204
  return "ask";
1191
1205
  }
1192
- // Native reviewers can approve before a permission reaches this process.
1193
- // Unattended Auto must therefore downgrade before spawning the provider.
1194
- if (mode === "auto" && isUnattended(bot.id, threadId))
1195
- return "ask";
1196
1206
  return mode;
1197
1207
  };
1198
1208
  /** Privileged approval-mode transitions are deliberately absent from the
@@ -2162,7 +2172,9 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
2162
2172
  * the engine has no asks — is fail-closed: the action was never run. The
2163
2173
  * card is settled and a chip says so, instead of the answer vanishing into
2164
2174
  * a 500 while the card sits open forever. */
2165
- async function answerRequest(threadId, instanceId, requestId, behavior, message, decidedFor) {
2175
+ async function answerRequest(threadId, instanceId, requestId, behavior, message, decidedFor,
2176
+ /** "Always allow this session": the provider keeps the allow, not the app */
2177
+ always) {
2166
2178
  // Snapshot the card BEFORE delivering the answer: a delivered answer
2167
2179
  // resolves the request synchronously through the fold, which consumes
2168
2180
  // the askMessageByRequest entry — by the time the await returns, nobody
@@ -2179,7 +2191,7 @@ async function answerRequest(threadId, instanceId, requestId, behavior, message,
2179
2191
  let outcome = "unavailable";
2180
2192
  if (instance) {
2181
2193
  try {
2182
- outcome = await instance.adapter.respondToRequest(threadId, requestId, { behavior, message });
2194
+ outcome = await instance.adapter.respondToRequest(threadId, requestId, { behavior, message, always: always && behavior === "allow" });
2183
2195
  }
2184
2196
  catch {
2185
2197
  outcome = "unavailable";
@@ -2248,6 +2260,16 @@ function requestBehavior(value) {
2248
2260
  // the last settled assistant text per thread, so a "finished" notification
2249
2261
  // can carry what the bot actually said
2250
2262
  const lastReply = new Map();
2263
+ /** the model each thread's provider session announced in session.started,
2264
+ * so a fallback notice can name the model Auto is unavailable for */
2265
+ const sessionModelByThread = new Map();
2266
+ /** threads already told that the provider's reviewer never started */
2267
+ const nativeReviewNoticed = new Set();
2268
+ /** a driver kind as the chat should name it: "claudeAgent" → "Claude" */
2269
+ const providerLabel = (provider) => {
2270
+ const bare = provider.replace(/Agent$/, "");
2271
+ return bare.charAt(0).toUpperCase() + bare.slice(1);
2272
+ };
2251
2273
  /** Put a notification on the wire. Clients decide what to do with it — a
2252
2274
  * desktop notification now, a push to a paired phone later. */
2253
2275
  function notify(notification) {
@@ -2315,6 +2337,7 @@ const watchdog = new TurnWatchdog({
2315
2337
  kind: "activity",
2316
2338
  tool: { name: `error: no activity for ${minutes} minutes — the turn was stopped`, ok: false },
2317
2339
  });
2340
+ settleDirectFollowup(stalledGeneration);
2318
2341
  finalizeDelegationWatch(turn.threadId, false, "", "Delegated turn stalled and was stopped");
2319
2342
  turnUsage.delete(turn.threadId);
2320
2343
  roomStallCompletions.stall(turn.threadId);
@@ -2370,66 +2393,6 @@ const watchdog = new TurnWatchdog({
2370
2393
  },
2371
2394
  });
2372
2395
  watchdog.start();
2373
- async function reviewPermissionCard(args) {
2374
- const mode = resolveAutoReviewMode(args.asker.autoReview);
2375
- if (mode === "off" || !args.instance.reviewPermission)
2376
- return false;
2377
- const persona = [args.asker.name, args.asker.title, args.asker.description].filter(Boolean).join(" — ");
2378
- const reviewed = await requestReview(args.instance.reviewPermission.bind(args.instance), {
2379
- tool: args.tool,
2380
- summary: args.summary,
2381
- persona,
2382
- });
2383
- if (!reviewed)
2384
- return false;
2385
- if (mode === "shadow") {
2386
- appendDecision(DATA_DIR, {
2387
- threadId: args.threadId,
2388
- requestId: args.requestId,
2389
- botId: args.asker.id,
2390
- botName: args.asker.name,
2391
- tool: args.tool,
2392
- summary: args.summary,
2393
- decision: reviewed.allow ? "review-would-approve" : "review-would-deny",
2394
- source: "auto-review-shadow",
2395
- rule: reviewed.reason,
2396
- });
2397
- return false;
2398
- }
2399
- if (!reviewed.allow)
2400
- return false;
2401
- // The human can answer while review is running. Their click wins before
2402
- // the provider receives anything and before the audit log claims approval.
2403
- const card = store.messagesFor(args.threadId).find((message) => message.id === args.messageId)?.card;
2404
- if (!card || card.answered)
2405
- return false;
2406
- let outcome = "unavailable";
2407
- try {
2408
- outcome = await args.instance.adapter.respondToRequest(args.threadId, args.requestId, { behavior: "allow" });
2409
- }
2410
- catch {
2411
- return false;
2412
- }
2413
- if (outcome === "unavailable")
2414
- return false;
2415
- store.appendMessage(args.threadId, {
2416
- role: "bot",
2417
- kind: "activity",
2418
- tool: { name: `review approved ${args.tool}: ${reviewed.reason}`, ok: true },
2419
- });
2420
- appendDecision(DATA_DIR, {
2421
- threadId: args.threadId,
2422
- requestId: args.requestId,
2423
- botId: args.asker.id,
2424
- botName: args.asker.name,
2425
- tool: args.tool,
2426
- summary: args.summary,
2427
- decision: "auto-approved",
2428
- source: "auto-review",
2429
- rule: reviewed.reason,
2430
- });
2431
- return true;
2432
- }
2433
2396
  bus.subscribe((event) => {
2434
2397
  if (shouldIgnoreProviderEvent(event))
2435
2398
  return;
@@ -2440,6 +2403,11 @@ bus.subscribe((event) => {
2440
2403
  else if (event.type === "turn.completed") {
2441
2404
  watchdog.settle(event.threadId);
2442
2405
  revokeInternalCapabilityForProviderEvent(event);
2406
+ if (event.turnId) {
2407
+ const owner = directFollowupTurns.complete(event.threadId, event.turnId);
2408
+ if (owner)
2409
+ settleDirectFollowup(owner.generation);
2410
+ }
2443
2411
  }
2444
2412
  else if (event.type !== "session.exited")
2445
2413
  watchdog.touch(event.threadId);
@@ -2540,6 +2508,9 @@ let localVmProvisionBusy = false;
2540
2508
  let localVmModeChangeBusy = false;
2541
2509
  const activeVpsThreads = new Map();
2542
2510
  const boxLifecycleBusyBots = new Set();
2511
+ // A refresh is a reader, not a lifecycle change. Keep its reservation until
2512
+ // the provider settles even if the HTTP client leaves, and share it on retry.
2513
+ const vpsPreviewRequests = new Map();
2543
2514
  const orphanBoxLifecycleBusyIds = new Set();
2544
2515
  const boxInventoryRequestsBusyIds = new Set();
2545
2516
  const computerProviderConfigTransitions = new Set();
@@ -2588,7 +2559,7 @@ function providerOperationConflict(provider) {
2588
2559
  if (managedBoxOwners().some((owner) => owner.inUse)) {
2589
2560
  return `stop active bot work and computer control before changing ${provider === "box" ? "the Box account" : "the VPS connection"}`;
2590
2561
  }
2591
- if (boxLifecycleBusyBots.size > 0) {
2562
+ if (boxLifecycleBusyBots.size > 0 || vpsPreviewRequests.size > 0) {
2592
2563
  return "wait for cloud computer actions to finish before changing provider settings";
2593
2564
  }
2594
2565
  if (provider === "box") {
@@ -2642,13 +2613,17 @@ function claimManagedBoxMutation(instance) {
2642
2613
  }
2643
2614
  return claimBotComputerLifecycle(ownerBotId);
2644
2615
  }
2645
- /** One synchronous lane for every Box lifecycle consumer. Both Settings and
2616
+ /** One synchronous lane for cloud lifecycle consumers. Both Settings and
2646
2617
  * bot-scoped actions use it, so whichever operation starts first excludes the
2647
- * other instead of relying on a stale check made before a provider await. */
2648
- function claimBotComputerLifecycle(botId) {
2618
+ * other instead of relying on a stale check made before a provider await.
2619
+ * Only opening an existing VPS viewer may coexist with its pending preview. */
2620
+ function claimBotComputerLifecycle(botId, allowPreview = false) {
2649
2621
  if (boxLifecycleBusyBots.has(botId)) {
2650
2622
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
2651
2623
  }
2624
+ if (!allowPreview && vpsPreviewRequests.has(botId)) {
2625
+ throw Object.assign(new Error("a screen preview is still refreshing — wait before changing this computer"), { status: 409 });
2626
+ }
2652
2627
  boxLifecycleBusyBots.add(botId);
2653
2628
  return () => boxLifecycleBusyBots.delete(botId);
2654
2629
  }
@@ -2828,6 +2803,8 @@ bus.subscribe((event) => {
2828
2803
  if (bot && event.sessionId && event.providerInstanceId) {
2829
2804
  store.setResumeCursor(bot.id, event.providerInstanceId, event.sessionId, event.threadId);
2830
2805
  }
2806
+ if (typeof event.model === "string" && event.model)
2807
+ sessionModelByThread.set(event.threadId, event.model);
2831
2808
  break;
2832
2809
  case "item.completed":
2833
2810
  if (event.itemType === "assistant_text") {
@@ -2907,28 +2884,37 @@ bus.subscribe((event) => {
2907
2884
  break;
2908
2885
  case "request.opened": {
2909
2886
  const permission = event.requestType === "permission";
2910
- // Approval modes and always-allow can answer permission requests for
2911
- // the bot so it keeps working. A QUESTION always reaches the human —
2912
- // even Full access never invents a person's answer. Safe Auto stops on
2913
- // the guards in auto-approve.ts; explicitly acknowledged Full does not.
2887
+ // A permission request here is one the provider left for a person: its
2888
+ // own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
2889
+ // OpenMausBot decides nothing about the action itself. Only Full access
2890
+ // answers, because that is exactly what the person granted. A QUESTION
2891
+ // always reaches the human — even Full access never invents an answer.
2914
2892
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2915
2893
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
2916
- const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
2894
+ const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
2917
2895
  const verdict = permission && asker && event.requestId
2918
- ? autoVerdict({
2919
- // Use the same receiving-bot mode as the provider dispatch.
2920
- approvalMode: effectiveApprovalMode,
2921
- autoApprove: false,
2922
- alwaysAllow: asker.alwaysAllow,
2923
- }, event.tool, event.summary, {
2924
- unattended,
2925
- scope: event.approvalScope,
2926
- requiresExplicitApproval: event.requiresExplicitApproval,
2927
- // Match the dispatched mode for every provider, including
2928
- // delegated Custom and unattended Auto downgrades.
2929
- nativeApproval: requiresNativeApproval(event.provider, effectiveApprovalMode),
2930
- })
2896
+ ? autoVerdict(effectiveApprovalMode, event.tool, { requiresExplicitApproval: event.requiresExplicitApproval })
2931
2897
  : null;
2898
+ // Auto's reviewer is the engine's own. Claude accepts `--permission-mode
2899
+ // auto` for any model and starts in Manual without a word when auto is
2900
+ // unavailable (Haiku 4.5, Sonnet 4.5, an org that disabled it), so the
2901
+ // bot asks about everything. Say once per session why, and what stops it.
2902
+ if (permission &&
2903
+ asker &&
2904
+ event.nativeReview === "inactive" &&
2905
+ effectiveApprovalMode === "auto" &&
2906
+ !nativeReviewNoticed.has(event.threadId)) {
2907
+ nativeReviewNoticed.add(event.threadId);
2908
+ const model = sessionModelByThread.get(event.threadId);
2909
+ pushMessage({
2910
+ role: "bot",
2911
+ kind: "activity",
2912
+ tool: {
2913
+ name: `Approve for me: ${providerLabel(event.provider)}'s automatic reviewer is not available${model ? ` for ${model}` : ""}, so this bot asks before each action. Choose a model it supports, or Full access, to stop the prompts.`,
2914
+ ok: true,
2915
+ },
2916
+ });
2917
+ }
2932
2918
  if (verdict?.approve && asker && event.requestId) {
2933
2919
  const settled = verdict.approve;
2934
2920
  const instance = event.providerInstanceId
@@ -2964,7 +2950,6 @@ bus.subscribe((event) => {
2964
2950
  summary,
2965
2951
  decision: "auto-approved",
2966
2952
  source: verdict.source,
2967
- rule: verdict.rule,
2968
2953
  });
2969
2954
  }
2970
2955
  catch {
@@ -2979,17 +2964,8 @@ bus.subscribe((event) => {
2979
2964
  options: ["Allow", "Deny"],
2980
2965
  requestId,
2981
2966
  tool,
2982
- allowKey: rememberableApprovalKey(asker, tool, summary, {
2983
- source: verdict.source,
2984
- scope: event.approvalScope,
2985
- requiresExplicitApproval: event.requiresExplicitApproval,
2986
- }),
2987
- held: HELD_NOTE[verdict.source === "full-access"
2988
- ? "approval.held.undeliveredFull"
2989
- : "approval.held.undelivered"],
2990
- heldCode: verdict.source === "full-access"
2991
- ? "approval.held.undeliveredFull"
2992
- : "approval.held.undelivered",
2967
+ held: HELD_NOTE["approval.held.undeliveredFull"],
2968
+ heldCode: "approval.held.undeliveredFull",
2993
2969
  approvalScope: event.approvalScope,
2994
2970
  },
2995
2971
  });
@@ -3003,24 +2979,12 @@ bus.subscribe((event) => {
3003
2979
  summary,
3004
2980
  decision: "card-shown",
3005
2981
  source: "auto-fallback",
3006
- rule: verdict.rule,
3007
2982
  });
3008
2983
  }
3009
2984
  })();
3010
2985
  break;
3011
2986
  }
3012
- // A card can outlive the bot record that raised it. Without one there is
3013
- // no mode to explain, but the sandbox note still applies.
3014
- const heldContext = {
3015
- source: verdict?.source,
3016
- permission,
3017
- requiresExplicitApproval: event.requiresExplicitApproval,
3018
- mode: asker ? approvalModeForOrigin(approvalModeFor(asker), { peerInitiated: isInternalTurn(event.threadId) }) : "ask",
3019
- unattended: Boolean(unattended),
3020
- fullAccessAvailable: asker && !isInternalTurn(event.threadId)
3021
- ? supportsApprovalMode(registry.cliTarget(asker.modelSelection.instanceId)?.driverKind, "full")
3022
- : false,
3023
- };
2987
+ const heldContext = { source: verdict?.source, permission };
3024
2988
  const message = pushMessage({
3025
2989
  role: "bot",
3026
2990
  kind: "options",
@@ -3034,15 +2998,9 @@ bus.subscribe((event) => {
3034
2998
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
3035
2999
  requestId: event.requestId,
3036
3000
  tool: permission ? event.tool : undefined,
3037
- // the exact grant "always allow" would remember, decided here so
3038
- // client and server can never derive it differently
3039
- allowKey: permission
3040
- ? rememberableApprovalKey(asker, event.tool, event.summary, {
3041
- source: verdict?.source,
3042
- scope: event.approvalScope,
3043
- requiresExplicitApproval: event.requiresExplicitApproval,
3044
- })
3045
- : undefined,
3001
+ // the provider can keep an allow for its session; the app keeps
3002
+ // no grant of its own for a provider's tool
3003
+ allowSession: permission && event.allowSession && !event.requiresExplicitApproval ? true : undefined,
3046
3004
  // The text stays for cards saved before heldCode existed, and for
3047
3005
  // clients that do not know the key yet.
3048
3006
  held: approvalHeldReason(heldContext),
@@ -3052,37 +3010,8 @@ bus.subscribe((event) => {
3052
3010
  });
3053
3011
  if (event.requestId)
3054
3012
  askMessageByRequest.set(`${event.threadId}:${event.requestId}`, message.id);
3055
- const reviewMode = resolveAutoReviewMode(asker?.autoReview);
3056
- let reviewTask;
3057
- if (permission &&
3058
- !event.requiresExplicitApproval &&
3059
- asker &&
3060
- event.requestId &&
3061
- shouldReview({
3062
- source: verdict?.source,
3063
- mode: reviewMode,
3064
- approvalMode: approvalModeFor(asker),
3065
- unattended: Boolean(unattended),
3066
- approvalScope: event.approvalScope,
3067
- })) {
3068
- // Review stays on the provider boundary that opened the request.
3069
- // Falling back to an arbitrary sibling could disclose action details
3070
- // to a provider the user did not choose for this bot.
3071
- const instance = registry.get(event.providerInstanceId ?? asker.modelSelection.instanceId);
3072
- if (instance?.reviewPermission) {
3073
- reviewTask = reviewPermissionCard({
3074
- instance,
3075
- asker,
3076
- threadId: event.threadId,
3077
- requestId: event.requestId,
3078
- messageId: message.id,
3079
- tool: event.tool,
3080
- summary: event.summary,
3081
- });
3082
- }
3083
- }
3084
- // Every card that reaches a human is a decision too — "a rule sent
3085
- // this to you, and here is which one". `question` marks the cards no
3013
+ // Every card that reaches a human is a decision too: "the provider
3014
+ // left this for you, in this mode". `question` marks the cards no
3086
3015
  // rule may ever answer; a permission card without a verdict (no known
3087
3016
  // asker, or no requestId to answer through) can only mean nothing was
3088
3017
  // granted.
@@ -3095,43 +3024,22 @@ bus.subscribe((event) => {
3095
3024
  summary: event.summary,
3096
3025
  decision: "card-shown",
3097
3026
  source: !permission ? "question" : verdict ? verdict.source : "no-grant",
3098
- rule: verdict?.rule,
3099
3027
  unattended: unattended || undefined,
3100
3028
  });
3101
3029
  // Notify from HERE, not from a separate subscriber on request.opened:
3102
3030
  // this is the branch where a card actually reached a human. Anything
3103
- // auto mode answered took the early return above and never buzzes.
3104
- const notifyHuman = () => {
3105
- if (!asker)
3106
- return;
3031
+ // Full access answered took the early return above and never buzzes.
3032
+ if (asker) {
3107
3033
  const card = store.messagesFor(event.threadId).find((candidate) => candidate.id === message.id)?.card;
3108
- if (!card || card.answered)
3109
- return;
3110
- // the bot is not working now — it is waiting on a person
3111
- if (bot)
3112
- store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
3113
- else if (asker.busy)
3114
- store.setActivity(asker.id, "waiting-on-you");
3115
- const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
3116
- notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
3117
- };
3118
- if (reviewTask && reviewMode === "enforce") {
3119
- // Avoid buzzing the owner for a card the reviewer is about to answer.
3120
- // A deny, failure, or timeout falls back to the normal notification;
3121
- // if the human already answered meanwhile, notifyHuman is a no-op.
3122
- void reviewTask
3123
- .catch(() => false)
3124
- .then((approved) => {
3125
- if (!approved)
3126
- notifyHuman();
3127
- });
3128
- }
3129
- else {
3130
- // Watch mode notifies immediately, but its background audit must not
3131
- // become an unhandled rejection if an unexpected store error occurs.
3132
- if (reviewTask)
3133
- void reviewTask.catch(() => false);
3134
- notifyHuman();
3034
+ if (card && !card.answered) {
3035
+ // the bot is not working now — it is waiting on a person
3036
+ if (bot)
3037
+ store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
3038
+ else if (asker.busy)
3039
+ store.setActivity(asker.id, "waiting-on-you");
3040
+ const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
3041
+ notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
3042
+ }
3135
3043
  }
3136
3044
  break;
3137
3045
  }
@@ -3782,6 +3690,8 @@ bus.subscribe((event) => {
3782
3690
  drainDelegationWakes();
3783
3691
  });
3784
3692
  function drainQueuedSends() {
3693
+ if (!followupsReady)
3694
+ return;
3785
3695
  drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
3786
3696
  // A plain attended turn — no automationSource, no comms depth: exactly
3787
3697
  // what typing the same words into an idle bot would run. The one
@@ -3790,15 +3700,19 @@ function drainQueuedSends() {
3790
3700
  // Drain just appended the held lines; userMessage keeps startTurn
3791
3701
  // from duplicating the last one, and excludeIds drops every drained
3792
3702
  // line from the transcript-replay so they are not also in `prompt`.
3793
- startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds, unattended }).then(() => undefined).catch((err) => {
3794
- store.appendMessage(threadId, {
3795
- role: "bot",
3796
- kind: "activity",
3797
- tool: {
3798
- name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
3799
- ok: false,
3800
- },
3801
- });
3703
+ new Promise((resolve, reject) => {
3704
+ void startTurn(botId, prompt, {
3705
+ threadId, userMessage, excludeMessageIds: excludeIds, unattended, onTurnSettled: resolve,
3706
+ }).catch((err) => {
3707
+ store.appendMessage(threadId, {
3708
+ role: "bot", kind: "activity",
3709
+ tool: {
3710
+ name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
3711
+ ok: false,
3712
+ },
3713
+ });
3714
+ resolve();
3715
+ }).catch(reject);
3802
3716
  }),
3803
3717
  // Provider completion can precede its dispatch promise: keep the queue
3804
3718
  // intact until that exact handshake releases its runtime-only claim.
@@ -4203,6 +4117,8 @@ async function startTurn(botId, text, opts) {
4203
4117
  const resourceOwner = { threadId, generation: dispatchClaimId };
4204
4118
  turnResourceOwners.set(threadId, resourceOwner);
4205
4119
  directTurnGenerationByThread.set(threadId, dispatchClaimId);
4120
+ if (opts?.onTurnSettled)
4121
+ directFollowupSettlers.set(dispatchClaimId, { threadId, settle: opts.onTurnSettled });
4206
4122
  directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
4207
4123
  directTurnBots.set(threadId, bot);
4208
4124
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -4643,6 +4559,11 @@ async function startTurn(botId, text, opts) {
4643
4559
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
4644
4560
  }
4645
4561
  bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
4562
+ if (directFollowupSettlers.has(dispatchClaimId) && dispatch.value.turnId &&
4563
+ !directFollowupTurns.bind(threadId, dispatchClaimId, dispatch.value.turnId)) {
4564
+ // This exact queued turn completed before its dispatch ACK arrived.
4565
+ settleDirectFollowup(dispatchClaimId);
4566
+ }
4646
4567
  clearDirectTurnDispatch(threadId, dispatchClaimId);
4647
4568
  // dispatched: the rewind is spent, and the old cursors are dead
4648
4569
  if (rewound)
@@ -4676,6 +4597,7 @@ async function startTurn(botId, text, opts) {
4676
4597
  }
4677
4598
  }
4678
4599
  catch (e) {
4600
+ settleDirectFollowup(dispatchClaimId);
4679
4601
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
4680
4602
  clearDirectTurnDispatch(threadId, dispatchClaimId);
4681
4603
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -5268,6 +5190,7 @@ const webhooks = new WebhookManager({
5268
5190
  return !bot ? "missing" : bot.busy ? "busy" : "ready";
5269
5191
  },
5270
5192
  enqueue: (input) => routines.enqueueWebhook(input),
5193
+ findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
5271
5194
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
5272
5195
  pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
5273
5196
  });
@@ -5379,7 +5302,7 @@ setupRetry = 0) {
5379
5302
  }
5380
5303
  revokeInternalCapabilitiesForThread(threadId);
5381
5304
  spoken.add(botId);
5382
- const preparedApprovalMode = approvalModeForTurn(bot, false, threadId);
5305
+ const preparedApprovalMode = approvalModeForTurn(bot, false);
5383
5306
  const preparedSelection = { ...bot.modelSelection };
5384
5307
  const preparedComposio = bot.composio;
5385
5308
  const instance = registry.get(bot.modelSelection.instanceId);
@@ -5502,7 +5425,7 @@ setupRetry = 0) {
5502
5425
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
5503
5426
  return false;
5504
5427
  const setupChanged = registry.get(preparedSelection.instanceId) !== instance ||
5505
- approvalModeForTurn(readyBot, false, threadId) !== preparedApprovalMode ||
5428
+ approvalModeForTurn(readyBot, false) !== preparedApprovalMode ||
5506
5429
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
5507
5430
  readyBot.modelSelection.model !== preparedSelection.model ||
5508
5431
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -5809,7 +5732,7 @@ setupRetry = 0) {
5809
5732
  threadId,
5810
5733
  text,
5811
5734
  images: turnImages,
5812
- approvalMode: approvalModeForTurn(readyBot, false, threadId),
5735
+ approvalMode: approvalModeForTurn(readyBot, false),
5813
5736
  system: roomSystem.text,
5814
5737
  systemStable: roomSystem.stable,
5815
5738
  systemVolatile: roomSystem.volatile,
@@ -6431,6 +6354,8 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
6431
6354
  return message;
6432
6355
  }
6433
6356
  function drainQueuedChannelSends() {
6357
+ if (!followupsReady)
6358
+ return;
6434
6359
  drainChannelMessages((groupId) => {
6435
6360
  const group = store.group(groupId);
6436
6361
  return group ? groupIsWorking(group) : false;
@@ -6442,9 +6367,12 @@ function drainQueuedChannelSends() {
6442
6367
  if (!group || !ownsThread)
6443
6368
  return;
6444
6369
  try {
6445
- startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via });
6370
+ startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId });
6446
6371
  }
6447
6372
  catch (error) {
6373
+ if (!store.messagesFor(threadId).some((message) => message.queueId === id && message.role === "user")) {
6374
+ store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId, sendId, channelMode: mode, queueId: id, via });
6375
+ }
6448
6376
  store.appendMessage(threadId, {
6449
6377
  role: "bot",
6450
6378
  kind: "activity",
@@ -6457,6 +6385,7 @@ function drainQueuedChannelSends() {
6457
6385
  // A message with no eligible responder creates no operation. Continue
6458
6386
  // draining instead of leaving later user messages behind it forever.
6459
6387
  queueMicrotask(drainQueuedChannelSends);
6388
+ return groupQueues.get(groupId);
6460
6389
  });
6461
6390
  }
6462
6391
  function sameCalendarRoster(group, botIds) {
@@ -7364,6 +7293,14 @@ function configStatus() {
7364
7293
  showToolCalls: showToolCallsEnabled(cfg),
7365
7294
  browser: builtInBrowserEnabled(cfg),
7366
7295
  },
7296
+ // first-run progress — not a secret; the app decides whether to show
7297
+ // the welcome tour from this, never from browser storage
7298
+ onboarding: {
7299
+ completedAt: cfg.onboarding?.completedAt ?? "",
7300
+ version: cfg.onboarding?.version ?? 0,
7301
+ reelSeen: cfg.onboarding?.reelSeen === true,
7302
+ hintsSeen: cfg.onboarding?.hintsSeen ?? [],
7303
+ },
7367
7304
  // Which browser this server can give bots: the desktop app's surface,
7368
7305
  // the agent-browser engine, or nothing yet (with the reason).
7369
7306
  browserEngine: browserEngineSummary(),
@@ -7482,6 +7419,7 @@ async function reloadProviders() {
7482
7419
  });
7483
7420
  store.setTaskActivity(botId, threadId, "idle");
7484
7421
  }
7422
+ settleDirectFollowup(owner?.generation);
7485
7423
  retryDelegationsWaitingOn(botId);
7486
7424
  }
7487
7425
  for (const [threadId, speaker] of rooms) {
@@ -7634,7 +7572,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
7634
7572
  idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall &&
7635
7573
  !routines?.isTicking && !calendarCalls?.isTicking &&
7636
7574
  !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
7637
- !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !orphanBoxLifecycleBusyIds.size &&
7575
+ !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoxLifecycleBusyIds.size &&
7638
7576
  !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
7639
7577
  store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !computerControl.snapshot(bot.id).held) &&
7640
7578
  store.groups.every((group) => !groupIsWorking(group)),
@@ -10499,6 +10437,9 @@ const handleRequest = async (req, res) => {
10499
10437
  }
10500
10438
  const receipt = await sendSequencer.run(sendId ? `group:${group.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id, channelMode), async () => {
10501
10439
  if (sendId) {
10440
+ if (cancelledChatFollowup("channel", group.id, threadId, sendId)) {
10441
+ throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
10442
+ }
10502
10443
  const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id, channelMode);
10503
10444
  if (accepted.kind === "conflict") {
10504
10445
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
@@ -11094,12 +11035,6 @@ const handleRequest = async (req, res) => {
11094
11035
  error: "This approval-level change can only be made from the packaged desktop app",
11095
11036
  });
11096
11037
  }
11097
- if (body.autoReview !== undefined) {
11098
- if (body.autoReview !== "off" && body.autoReview !== "shadow" && body.autoReview !== "enforce") {
11099
- return json(res, 400, { error: "autoReview must be off, shadow, or enforce" });
11100
- }
11101
- patch.autoReview = body.autoReview;
11102
- }
11103
11038
  // "Auto on this Mac" hands a bot the user's real session, so the grant
11104
11039
  // must prove a human saw the warning. The desktop dialog is the only
11105
11040
  // caller that sends acknowledgeLocalAuto; without it a PATCH that would
@@ -11455,6 +11390,7 @@ const handleRequest = async (req, res) => {
11455
11390
  // staged provider images into a message, so dispose them here.
11456
11391
  for (const task of store.tasks(bot.id)) {
11457
11392
  purgeGeneratedImagesForThread(task.threadId);
11393
+ settleDirectFollowup(directTurnGenerationByThread.get(task.threadId));
11458
11394
  directTurnGenerationByThread.delete(task.threadId);
11459
11395
  directTurnBots.delete(task.threadId);
11460
11396
  }
@@ -11998,6 +11934,9 @@ const handleRequest = async (req, res) => {
11998
11934
  const replyTo = resolveReplyTarget(threadId, body.replyToId);
11999
11935
  const receipt = await sendSequencer.run(sendId ? `bot:${bot.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
12000
11936
  if (sendId) {
11937
+ if (cancelledChatFollowup("bot", bot.id, threadId, sendId)) {
11938
+ throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
11939
+ }
12001
11940
  const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
12002
11941
  if (accepted.kind === "conflict") {
12003
11942
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
@@ -12197,7 +12136,7 @@ const handleRequest = async (req, res) => {
12197
12136
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
12198
12137
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
12199
12138
  }
12200
- const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name });
12139
+ const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }, body.always === true);
12201
12140
  return json(res, 200, { ok: true, outcome });
12202
12141
  }
12203
12142
  // Answer by THREAD, so a request raised inside a room can be answered
@@ -12282,7 +12221,7 @@ const handleRequest = async (req, res) => {
12282
12221
  if (!owner && !pending)
12283
12222
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
12284
12223
  const requestOwner = owner ? botForThread(owner.id, threadId) : null;
12285
- const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined);
12224
+ const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true);
12286
12225
  return json(res, 200, { ok: true, outcome });
12287
12226
  }
12288
12227
  m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
@@ -12539,6 +12478,7 @@ const handleRequest = async (req, res) => {
12539
12478
  const updated = store.deleteTask(m[1], m[2]);
12540
12479
  if (!updated)
12541
12480
  return json(res, 400, { error: "a bot keeps at least one task" });
12481
+ settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
12542
12482
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
12543
12483
  const fresh = botWithThread(updated);
12544
12484
  broadcast({ kind: "bot", bot: fresh });
@@ -13634,19 +13574,10 @@ const handleRequest = async (req, res) => {
13634
13574
  browserReferenceCleanupError = error;
13635
13575
  }
13636
13576
  }
13637
- // Provider keys change the fleet. Profile, language, voice, VPS, and
13638
- // room timeout changes do not rebuild it: no driver reads them, and they
13639
- // should not interrupt in-flight turns.
13640
- const reloadKeys = Object.keys(patch).filter((key) => key !== "profile" &&
13641
- key !== "language" &&
13642
- key !== "tts" &&
13643
- key !== "imageGen" &&
13644
- key !== "vps" &&
13645
- key !== "rooms" &&
13646
- key !== "threads" &&
13647
- key !== "localVm" &&
13648
- key !== "features" &&
13649
- key !== "browserProfiles");
13577
+ // Provider keys change the fleet. Profile, language, voice, VPS, room
13578
+ // timeout, and onboarding progress changes do not rebuild it: no driver
13579
+ // reads them, and they should not interrupt in-flight turns.
13580
+ const reloadKeys = providerReloadKeys(patch);
13650
13581
  // Config is already durable. A provider credential or runtime change
13651
13582
  // invalidates every old child immediately, including when browser
13652
13583
  // cleanup below has to await Electron before reloadProviders begins.
@@ -14032,7 +13963,19 @@ const handleRequest = async (req, res) => {
14032
13963
  return json(res, 409, { error: "this bot's cloud computer is being changed — wait for it to finish" });
14033
13964
  }
14034
13965
  if (bot.cloudBackend === "vps") {
14035
- const releaseComputerLifecycle = claimBotComputerLifecycle(botId);
13966
+ if (m[2] === "screenshot") {
13967
+ let preview = vpsPreviewRequests.get(botId);
13968
+ if (!preview) {
13969
+ preview = vps.vpsComputerScreenshot(cfg, botId).finally(() => {
13970
+ vpsPreviewRequests.delete(botId);
13971
+ });
13972
+ vpsPreviewRequests.set(botId, preview);
13973
+ }
13974
+ return json(res, 200, await preview);
13975
+ }
13976
+ // Opening the existing SSH viewer can coexist with a capture. Start,
13977
+ // stop, remove and Settings deletion still exclude pending previews.
13978
+ const releaseComputerLifecycle = claimBotComputerLifecycle(botId, m[2] === "join");
14036
13979
  try {
14037
13980
  if (m[2] === "exec") {
14038
13981
  return json(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" });
@@ -14046,8 +13989,6 @@ const handleRequest = async (req, res) => {
14046
13989
  if (m[2] === "join") {
14047
13990
  return json(res, 200, await vps.vpsComputerJoin(cfg, botId));
14048
13991
  }
14049
- if (m[2] === "screenshot")
14050
- return json(res, 200, await vps.vpsComputerScreenshot(cfg, botId));
14051
13992
  const action = m[2] === "provision" ? "provision" : m[2] === "remove" ? "remove" : "stop";
14052
13993
  return json(res, 200, await vps.vpsComputerAction(action, cfg, botId));
14053
13994
  }
@@ -14132,8 +14073,45 @@ try {
14132
14073
  catch (error) {
14133
14074
  console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
14134
14075
  }
14076
+ // A dispatch claim is deliberately committed before transcript/provider work.
14077
+ // If we died after that point, its outcome is unknown: recover the user's words
14078
+ // and a review notice, never hand them to a model for a second execution.
14079
+ for (const row of chatFollowups()) {
14080
+ if (row.status !== "dispatching" && row.status !== "interrupted")
14081
+ continue;
14082
+ const owned = row.kind === "bot"
14083
+ ? Boolean(store.taskByThread(row.ownerId, row.threadId))
14084
+ : Boolean(store.groupByThread(row.threadId)?.id === row.ownerId);
14085
+ if (!owned) {
14086
+ settleChatFollowups([row.id], "cancelled");
14087
+ continue;
14088
+ }
14089
+ settleChatFollowups([row.id], "interrupted");
14090
+ const messages = store.messagesFor(row.threadId);
14091
+ if (!messages.some((message) => message.queueId === row.id && message.role === "user")) {
14092
+ store.appendMessage(row.threadId, {
14093
+ role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
14094
+ sendId: row.payload.sendId, queueId: row.id,
14095
+ ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
14096
+ });
14097
+ }
14098
+ if (!messages.some((message) => message.queueId === row.id && message.kind === "activity")) {
14099
+ store.appendMessage(row.threadId, {
14100
+ role: "bot", kind: "activity", queueId: row.id,
14101
+ tool: { name: "Queued follow-up interrupted by restart or restore — it may have already run. Review the result before sending it again.", ok: false },
14102
+ });
14103
+ }
14104
+ // The FULL-sync retirement also flushes both transcript writes. Retrying
14105
+ // this sendId now finds the canonical message, without a permanent journal scan.
14106
+ settleChatFollowups([row.id], null);
14107
+ }
14108
+ restoreSteeredMessages();
14109
+ restoreChannelMessages();
14135
14110
  server.listen(PORT, "127.0.0.1", () => {
14136
14111
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
14112
+ followupsReady = true;
14113
+ drainQueuedSends();
14114
+ drainQueuedChannelSends();
14137
14115
  // Startup work uses the same turn dispatcher and local tool endpoint as
14138
14116
  // ordinary chat. Start only once every registry is initialized and the
14139
14117
  // endpoint is listening; earlier dispatch can hit uninitialized bindings.
@@ -14171,6 +14149,7 @@ if (TUNNEL_SOCKET) {
14171
14149
  const gracefulShutdown = createGracefulShutdown({
14172
14150
  cleanup: [
14173
14151
  () => {
14152
+ followupsReady = false;
14174
14153
  // Child MCP processes and the HTTP listener can remain alive while the
14175
14154
  // asynchronous shutdown jobs drain. Invalidate their turn bearers before
14176
14155
  // any cleanup function reaches an await.