openmausbot 0.1.73 → 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 (33) 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-BYbK7ber.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/index.js +592 -453
  7. package/dist-server/mcp-gate.js +58 -16
  8. package/dist-server/openmausbot.js +186 -64
  9. package/dist-server/pair-cli.js +186 -64
  10. package/dist-server/server/auto-approve.js +30 -206
  11. package/dist-server/server/channel-queue.js +20 -2
  12. package/dist-server/server/config.js +36 -1
  13. package/dist-server/server/drivers/acp/core.js +56 -3
  14. package/dist-server/server/drivers/acp/grok.js +3 -1
  15. package/dist-server/server/drivers/antigravity-acp.js +5 -4
  16. package/dist-server/server/drivers/antigravity.js +9 -4
  17. package/dist-server/server/drivers/claude.js +54 -11
  18. package/dist-server/server/drivers/codex.js +22 -11
  19. package/dist-server/server/engine-install.js +9 -5
  20. package/dist-server/server/index.js +185 -223
  21. package/dist-server/server/message-db.js +70 -3
  22. package/dist-server/server/procs.js +82 -23
  23. package/dist-server/server/routines.js +67 -8
  24. package/dist-server/server/steer-queue.js +27 -7
  25. package/dist-server/server/team-manifest.js +1 -1
  26. package/dist-server/server/webhooks.js +4 -1
  27. package/dist-server/server/workspace-backup.js +24 -9
  28. package/dist-server/shared/approval-mode.js +11 -10
  29. package/dist-server/vps-container-mcp.js +9 -0
  30. package/package.json +1 -1
  31. package/dist/assets/index-BLJ7byG3.js +0 -296
  32. package/dist/assets/index-De2TRLxu.css +0 -1
  33. 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);
@@ -2835,6 +2803,8 @@ bus.subscribe((event) => {
2835
2803
  if (bot && event.sessionId && event.providerInstanceId) {
2836
2804
  store.setResumeCursor(bot.id, event.providerInstanceId, event.sessionId, event.threadId);
2837
2805
  }
2806
+ if (typeof event.model === "string" && event.model)
2807
+ sessionModelByThread.set(event.threadId, event.model);
2838
2808
  break;
2839
2809
  case "item.completed":
2840
2810
  if (event.itemType === "assistant_text") {
@@ -2914,28 +2884,37 @@ bus.subscribe((event) => {
2914
2884
  break;
2915
2885
  case "request.opened": {
2916
2886
  const permission = event.requestType === "permission";
2917
- // Approval modes and always-allow can answer permission requests for
2918
- // the bot so it keeps working. A QUESTION always reaches the human —
2919
- // even Full access never invents a person's answer. Safe Auto stops on
2920
- // 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.
2921
2892
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2922
2893
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
2923
- const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
2894
+ const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
2924
2895
  const verdict = permission && asker && event.requestId
2925
- ? autoVerdict({
2926
- // Use the same receiving-bot mode as the provider dispatch.
2927
- approvalMode: effectiveApprovalMode,
2928
- autoApprove: false,
2929
- alwaysAllow: asker.alwaysAllow,
2930
- }, event.tool, event.summary, {
2931
- unattended,
2932
- scope: event.approvalScope,
2933
- requiresExplicitApproval: event.requiresExplicitApproval,
2934
- // Match the dispatched mode for every provider, including
2935
- // delegated Custom and unattended Auto downgrades.
2936
- nativeApproval: requiresNativeApproval(event.provider, effectiveApprovalMode),
2937
- })
2896
+ ? autoVerdict(effectiveApprovalMode, event.tool, { requiresExplicitApproval: event.requiresExplicitApproval })
2938
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
+ }
2939
2918
  if (verdict?.approve && asker && event.requestId) {
2940
2919
  const settled = verdict.approve;
2941
2920
  const instance = event.providerInstanceId
@@ -2971,7 +2950,6 @@ bus.subscribe((event) => {
2971
2950
  summary,
2972
2951
  decision: "auto-approved",
2973
2952
  source: verdict.source,
2974
- rule: verdict.rule,
2975
2953
  });
2976
2954
  }
2977
2955
  catch {
@@ -2986,17 +2964,8 @@ bus.subscribe((event) => {
2986
2964
  options: ["Allow", "Deny"],
2987
2965
  requestId,
2988
2966
  tool,
2989
- allowKey: rememberableApprovalKey(asker, tool, summary, {
2990
- source: verdict.source,
2991
- scope: event.approvalScope,
2992
- requiresExplicitApproval: event.requiresExplicitApproval,
2993
- }),
2994
- held: HELD_NOTE[verdict.source === "full-access"
2995
- ? "approval.held.undeliveredFull"
2996
- : "approval.held.undelivered"],
2997
- heldCode: verdict.source === "full-access"
2998
- ? "approval.held.undeliveredFull"
2999
- : "approval.held.undelivered",
2967
+ held: HELD_NOTE["approval.held.undeliveredFull"],
2968
+ heldCode: "approval.held.undeliveredFull",
3000
2969
  approvalScope: event.approvalScope,
3001
2970
  },
3002
2971
  });
@@ -3010,24 +2979,12 @@ bus.subscribe((event) => {
3010
2979
  summary,
3011
2980
  decision: "card-shown",
3012
2981
  source: "auto-fallback",
3013
- rule: verdict.rule,
3014
2982
  });
3015
2983
  }
3016
2984
  })();
3017
2985
  break;
3018
2986
  }
3019
- // A card can outlive the bot record that raised it. Without one there is
3020
- // no mode to explain, but the sandbox note still applies.
3021
- const heldContext = {
3022
- source: verdict?.source,
3023
- permission,
3024
- requiresExplicitApproval: event.requiresExplicitApproval,
3025
- mode: asker ? approvalModeForOrigin(approvalModeFor(asker), { peerInitiated: isInternalTurn(event.threadId) }) : "ask",
3026
- unattended: Boolean(unattended),
3027
- fullAccessAvailable: asker && !isInternalTurn(event.threadId)
3028
- ? supportsApprovalMode(registry.cliTarget(asker.modelSelection.instanceId)?.driverKind, "full")
3029
- : false,
3030
- };
2987
+ const heldContext = { source: verdict?.source, permission };
3031
2988
  const message = pushMessage({
3032
2989
  role: "bot",
3033
2990
  kind: "options",
@@ -3041,15 +2998,9 @@ bus.subscribe((event) => {
3041
2998
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
3042
2999
  requestId: event.requestId,
3043
3000
  tool: permission ? event.tool : undefined,
3044
- // the exact grant "always allow" would remember, decided here so
3045
- // client and server can never derive it differently
3046
- allowKey: permission
3047
- ? rememberableApprovalKey(asker, event.tool, event.summary, {
3048
- source: verdict?.source,
3049
- scope: event.approvalScope,
3050
- requiresExplicitApproval: event.requiresExplicitApproval,
3051
- })
3052
- : 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,
3053
3004
  // The text stays for cards saved before heldCode existed, and for
3054
3005
  // clients that do not know the key yet.
3055
3006
  held: approvalHeldReason(heldContext),
@@ -3059,37 +3010,8 @@ bus.subscribe((event) => {
3059
3010
  });
3060
3011
  if (event.requestId)
3061
3012
  askMessageByRequest.set(`${event.threadId}:${event.requestId}`, message.id);
3062
- const reviewMode = resolveAutoReviewMode(asker?.autoReview);
3063
- let reviewTask;
3064
- if (permission &&
3065
- !event.requiresExplicitApproval &&
3066
- asker &&
3067
- event.requestId &&
3068
- shouldReview({
3069
- source: verdict?.source,
3070
- mode: reviewMode,
3071
- approvalMode: approvalModeFor(asker),
3072
- unattended: Boolean(unattended),
3073
- approvalScope: event.approvalScope,
3074
- })) {
3075
- // Review stays on the provider boundary that opened the request.
3076
- // Falling back to an arbitrary sibling could disclose action details
3077
- // to a provider the user did not choose for this bot.
3078
- const instance = registry.get(event.providerInstanceId ?? asker.modelSelection.instanceId);
3079
- if (instance?.reviewPermission) {
3080
- reviewTask = reviewPermissionCard({
3081
- instance,
3082
- asker,
3083
- threadId: event.threadId,
3084
- requestId: event.requestId,
3085
- messageId: message.id,
3086
- tool: event.tool,
3087
- summary: event.summary,
3088
- });
3089
- }
3090
- }
3091
- // Every card that reaches a human is a decision too — "a rule sent
3092
- // 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
3093
3015
  // rule may ever answer; a permission card without a verdict (no known
3094
3016
  // asker, or no requestId to answer through) can only mean nothing was
3095
3017
  // granted.
@@ -3102,43 +3024,22 @@ bus.subscribe((event) => {
3102
3024
  summary: event.summary,
3103
3025
  decision: "card-shown",
3104
3026
  source: !permission ? "question" : verdict ? verdict.source : "no-grant",
3105
- rule: verdict?.rule,
3106
3027
  unattended: unattended || undefined,
3107
3028
  });
3108
3029
  // Notify from HERE, not from a separate subscriber on request.opened:
3109
3030
  // this is the branch where a card actually reached a human. Anything
3110
- // auto mode answered took the early return above and never buzzes.
3111
- const notifyHuman = () => {
3112
- if (!asker)
3113
- return;
3031
+ // Full access answered took the early return above and never buzzes.
3032
+ if (asker) {
3114
3033
  const card = store.messagesFor(event.threadId).find((candidate) => candidate.id === message.id)?.card;
3115
- if (!card || card.answered)
3116
- return;
3117
- // the bot is not working now — it is waiting on a person
3118
- if (bot)
3119
- store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
3120
- else if (asker.busy)
3121
- store.setActivity(asker.id, "waiting-on-you");
3122
- const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
3123
- notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
3124
- };
3125
- if (reviewTask && reviewMode === "enforce") {
3126
- // Avoid buzzing the owner for a card the reviewer is about to answer.
3127
- // A deny, failure, or timeout falls back to the normal notification;
3128
- // if the human already answered meanwhile, notifyHuman is a no-op.
3129
- void reviewTask
3130
- .catch(() => false)
3131
- .then((approved) => {
3132
- if (!approved)
3133
- notifyHuman();
3134
- });
3135
- }
3136
- else {
3137
- // Watch mode notifies immediately, but its background audit must not
3138
- // become an unhandled rejection if an unexpected store error occurs.
3139
- if (reviewTask)
3140
- void reviewTask.catch(() => false);
3141
- 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
+ }
3142
3043
  }
3143
3044
  break;
3144
3045
  }
@@ -3789,6 +3690,8 @@ bus.subscribe((event) => {
3789
3690
  drainDelegationWakes();
3790
3691
  });
3791
3692
  function drainQueuedSends() {
3693
+ if (!followupsReady)
3694
+ return;
3792
3695
  drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
3793
3696
  // A plain attended turn — no automationSource, no comms depth: exactly
3794
3697
  // what typing the same words into an idle bot would run. The one
@@ -3797,15 +3700,19 @@ function drainQueuedSends() {
3797
3700
  // Drain just appended the held lines; userMessage keeps startTurn
3798
3701
  // from duplicating the last one, and excludeIds drops every drained
3799
3702
  // line from the transcript-replay so they are not also in `prompt`.
3800
- startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds, unattended }).then(() => undefined).catch((err) => {
3801
- store.appendMessage(threadId, {
3802
- role: "bot",
3803
- kind: "activity",
3804
- tool: {
3805
- name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
3806
- ok: false,
3807
- },
3808
- });
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);
3809
3716
  }),
3810
3717
  // Provider completion can precede its dispatch promise: keep the queue
3811
3718
  // intact until that exact handshake releases its runtime-only claim.
@@ -4210,6 +4117,8 @@ async function startTurn(botId, text, opts) {
4210
4117
  const resourceOwner = { threadId, generation: dispatchClaimId };
4211
4118
  turnResourceOwners.set(threadId, resourceOwner);
4212
4119
  directTurnGenerationByThread.set(threadId, dispatchClaimId);
4120
+ if (opts?.onTurnSettled)
4121
+ directFollowupSettlers.set(dispatchClaimId, { threadId, settle: opts.onTurnSettled });
4213
4122
  directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
4214
4123
  directTurnBots.set(threadId, bot);
4215
4124
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -4650,6 +4559,11 @@ async function startTurn(botId, text, opts) {
4650
4559
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
4651
4560
  }
4652
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
+ }
4653
4567
  clearDirectTurnDispatch(threadId, dispatchClaimId);
4654
4568
  // dispatched: the rewind is spent, and the old cursors are dead
4655
4569
  if (rewound)
@@ -4683,6 +4597,7 @@ async function startTurn(botId, text, opts) {
4683
4597
  }
4684
4598
  }
4685
4599
  catch (e) {
4600
+ settleDirectFollowup(dispatchClaimId);
4686
4601
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
4687
4602
  clearDirectTurnDispatch(threadId, dispatchClaimId);
4688
4603
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -5275,6 +5190,7 @@ const webhooks = new WebhookManager({
5275
5190
  return !bot ? "missing" : bot.busy ? "busy" : "ready";
5276
5191
  },
5277
5192
  enqueue: (input) => routines.enqueueWebhook(input),
5193
+ findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
5278
5194
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
5279
5195
  pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
5280
5196
  });
@@ -5386,7 +5302,7 @@ setupRetry = 0) {
5386
5302
  }
5387
5303
  revokeInternalCapabilitiesForThread(threadId);
5388
5304
  spoken.add(botId);
5389
- const preparedApprovalMode = approvalModeForTurn(bot, false, threadId);
5305
+ const preparedApprovalMode = approvalModeForTurn(bot, false);
5390
5306
  const preparedSelection = { ...bot.modelSelection };
5391
5307
  const preparedComposio = bot.composio;
5392
5308
  const instance = registry.get(bot.modelSelection.instanceId);
@@ -5509,7 +5425,7 @@ setupRetry = 0) {
5509
5425
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
5510
5426
  return false;
5511
5427
  const setupChanged = registry.get(preparedSelection.instanceId) !== instance ||
5512
- approvalModeForTurn(readyBot, false, threadId) !== preparedApprovalMode ||
5428
+ approvalModeForTurn(readyBot, false) !== preparedApprovalMode ||
5513
5429
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
5514
5430
  readyBot.modelSelection.model !== preparedSelection.model ||
5515
5431
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -5816,7 +5732,7 @@ setupRetry = 0) {
5816
5732
  threadId,
5817
5733
  text,
5818
5734
  images: turnImages,
5819
- approvalMode: approvalModeForTurn(readyBot, false, threadId),
5735
+ approvalMode: approvalModeForTurn(readyBot, false),
5820
5736
  system: roomSystem.text,
5821
5737
  systemStable: roomSystem.stable,
5822
5738
  systemVolatile: roomSystem.volatile,
@@ -6438,6 +6354,8 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
6438
6354
  return message;
6439
6355
  }
6440
6356
  function drainQueuedChannelSends() {
6357
+ if (!followupsReady)
6358
+ return;
6441
6359
  drainChannelMessages((groupId) => {
6442
6360
  const group = store.group(groupId);
6443
6361
  return group ? groupIsWorking(group) : false;
@@ -6449,9 +6367,12 @@ function drainQueuedChannelSends() {
6449
6367
  if (!group || !ownsThread)
6450
6368
  return;
6451
6369
  try {
6452
- startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via });
6370
+ startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId });
6453
6371
  }
6454
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
+ }
6455
6376
  store.appendMessage(threadId, {
6456
6377
  role: "bot",
6457
6378
  kind: "activity",
@@ -6464,6 +6385,7 @@ function drainQueuedChannelSends() {
6464
6385
  // A message with no eligible responder creates no operation. Continue
6465
6386
  // draining instead of leaving later user messages behind it forever.
6466
6387
  queueMicrotask(drainQueuedChannelSends);
6388
+ return groupQueues.get(groupId);
6467
6389
  });
6468
6390
  }
6469
6391
  function sameCalendarRoster(group, botIds) {
@@ -7371,6 +7293,14 @@ function configStatus() {
7371
7293
  showToolCalls: showToolCallsEnabled(cfg),
7372
7294
  browser: builtInBrowserEnabled(cfg),
7373
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
+ },
7374
7304
  // Which browser this server can give bots: the desktop app's surface,
7375
7305
  // the agent-browser engine, or nothing yet (with the reason).
7376
7306
  browserEngine: browserEngineSummary(),
@@ -7489,6 +7419,7 @@ async function reloadProviders() {
7489
7419
  });
7490
7420
  store.setTaskActivity(botId, threadId, "idle");
7491
7421
  }
7422
+ settleDirectFollowup(owner?.generation);
7492
7423
  retryDelegationsWaitingOn(botId);
7493
7424
  }
7494
7425
  for (const [threadId, speaker] of rooms) {
@@ -10506,6 +10437,9 @@ const handleRequest = async (req, res) => {
10506
10437
  }
10507
10438
  const receipt = await sendSequencer.run(sendId ? `group:${group.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id, channelMode), async () => {
10508
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
+ }
10509
10443
  const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id, channelMode);
10510
10444
  if (accepted.kind === "conflict") {
10511
10445
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
@@ -11101,12 +11035,6 @@ const handleRequest = async (req, res) => {
11101
11035
  error: "This approval-level change can only be made from the packaged desktop app",
11102
11036
  });
11103
11037
  }
11104
- if (body.autoReview !== undefined) {
11105
- if (body.autoReview !== "off" && body.autoReview !== "shadow" && body.autoReview !== "enforce") {
11106
- return json(res, 400, { error: "autoReview must be off, shadow, or enforce" });
11107
- }
11108
- patch.autoReview = body.autoReview;
11109
- }
11110
11038
  // "Auto on this Mac" hands a bot the user's real session, so the grant
11111
11039
  // must prove a human saw the warning. The desktop dialog is the only
11112
11040
  // caller that sends acknowledgeLocalAuto; without it a PATCH that would
@@ -11462,6 +11390,7 @@ const handleRequest = async (req, res) => {
11462
11390
  // staged provider images into a message, so dispose them here.
11463
11391
  for (const task of store.tasks(bot.id)) {
11464
11392
  purgeGeneratedImagesForThread(task.threadId);
11393
+ settleDirectFollowup(directTurnGenerationByThread.get(task.threadId));
11465
11394
  directTurnGenerationByThread.delete(task.threadId);
11466
11395
  directTurnBots.delete(task.threadId);
11467
11396
  }
@@ -12005,6 +11934,9 @@ const handleRequest = async (req, res) => {
12005
11934
  const replyTo = resolveReplyTarget(threadId, body.replyToId);
12006
11935
  const receipt = await sendSequencer.run(sendId ? `bot:${bot.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
12007
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
+ }
12008
11940
  const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
12009
11941
  if (accepted.kind === "conflict") {
12010
11942
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
@@ -12204,7 +12136,7 @@ const handleRequest = async (req, res) => {
12204
12136
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
12205
12137
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
12206
12138
  }
12207
- 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);
12208
12140
  return json(res, 200, { ok: true, outcome });
12209
12141
  }
12210
12142
  // Answer by THREAD, so a request raised inside a room can be answered
@@ -12289,7 +12221,7 @@ const handleRequest = async (req, res) => {
12289
12221
  if (!owner && !pending)
12290
12222
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
12291
12223
  const requestOwner = owner ? botForThread(owner.id, threadId) : null;
12292
- 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);
12293
12225
  return json(res, 200, { ok: true, outcome });
12294
12226
  }
12295
12227
  m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
@@ -12546,6 +12478,7 @@ const handleRequest = async (req, res) => {
12546
12478
  const updated = store.deleteTask(m[1], m[2]);
12547
12479
  if (!updated)
12548
12480
  return json(res, 400, { error: "a bot keeps at least one task" });
12481
+ settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
12549
12482
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
12550
12483
  const fresh = botWithThread(updated);
12551
12484
  broadcast({ kind: "bot", bot: fresh });
@@ -13641,19 +13574,10 @@ const handleRequest = async (req, res) => {
13641
13574
  browserReferenceCleanupError = error;
13642
13575
  }
13643
13576
  }
13644
- // Provider keys change the fleet. Profile, language, voice, VPS, and
13645
- // room timeout changes do not rebuild it: no driver reads them, and they
13646
- // should not interrupt in-flight turns.
13647
- const reloadKeys = Object.keys(patch).filter((key) => key !== "profile" &&
13648
- key !== "language" &&
13649
- key !== "tts" &&
13650
- key !== "imageGen" &&
13651
- key !== "vps" &&
13652
- key !== "rooms" &&
13653
- key !== "threads" &&
13654
- key !== "localVm" &&
13655
- key !== "features" &&
13656
- 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);
13657
13581
  // Config is already durable. A provider credential or runtime change
13658
13582
  // invalidates every old child immediately, including when browser
13659
13583
  // cleanup below has to await Electron before reloadProviders begins.
@@ -14149,8 +14073,45 @@ try {
14149
14073
  catch (error) {
14150
14074
  console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
14151
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();
14152
14110
  server.listen(PORT, "127.0.0.1", () => {
14153
14111
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
14112
+ followupsReady = true;
14113
+ drainQueuedSends();
14114
+ drainQueuedChannelSends();
14154
14115
  // Startup work uses the same turn dispatcher and local tool endpoint as
14155
14116
  // ordinary chat. Start only once every registry is initialized and the
14156
14117
  // endpoint is listening; earlier dispatch can hit uninitialized bindings.
@@ -14188,6 +14149,7 @@ if (TUNNEL_SOCKET) {
14188
14149
  const gracefulShutdown = createGracefulShutdown({
14189
14150
  cleanup: [
14190
14151
  () => {
14152
+ followupsReady = false;
14191
14153
  // Child MCP processes and the HTTP listener can remain alive while the
14192
14154
  // asynchronous shutdown jobs drain. Invalidate their turn bearers before
14193
14155
  // any cleanup function reaches an await.