openmausbot 0.1.73 → 0.1.75

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 (42) hide show
  1. package/dist/assets/{index-BYbK7ber.js → index-BTlyRwLl.js} +1 -1
  2. package/dist/assets/index-CeMbvsgx.js +305 -0
  3. package/dist/assets/index-DH7Zax6L.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +9 -0
  6. package/dist-server/index.js +1402 -1048
  7. package/dist-server/mcp-gate.js +58 -16
  8. package/dist-server/openmausbot.js +543 -271
  9. package/dist-server/pair-cli.js +543 -271
  10. package/dist-server/permission-proxy.js +119 -14
  11. package/dist-server/server/auto-approve.js +44 -205
  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 +58 -4
  15. package/dist-server/server/drivers/acp/grok.js +15 -2
  16. package/dist-server/server/drivers/antigravity-acp.js +5 -4
  17. package/dist-server/server/drivers/antigravity.js +9 -4
  18. package/dist-server/server/drivers/claude.js +113 -13
  19. package/dist-server/server/drivers/codex.js +37 -15
  20. package/dist-server/server/drivers/retry.js +4 -0
  21. package/dist-server/server/engine-install.js +9 -5
  22. package/dist-server/server/index.js +265 -233
  23. package/dist-server/server/message-db.js +70 -3
  24. package/dist-server/server/permission-proxy.js +90 -15
  25. package/dist-server/server/procs.js +82 -23
  26. package/dist-server/server/routines.js +67 -8
  27. package/dist-server/server/setup-mode.js +6 -7
  28. package/dist-server/server/steer-queue.js +27 -7
  29. package/dist-server/server/store.js +19 -0
  30. package/dist-server/server/team-manifest.js +1 -1
  31. package/dist-server/server/thread-events.js +10 -1
  32. package/dist-server/server/webhooks.js +4 -1
  33. package/dist-server/server/workspace-backup.js +24 -9
  34. package/dist-server/server/workspace.js +13 -0
  35. package/dist-server/shared/approval-mode.js +11 -10
  36. package/dist-server/shared/ask-question.js +172 -0
  37. package/dist-server/shared/provider-safety.js +6 -0
  38. package/dist-server/vps-container-mcp.js +9 -0
  39. package/package.json +1 -1
  40. package/dist/assets/index-BLJ7byG3.js +0 -296
  41. package/dist/assets/index-De2TRLxu.css +0 -1
  42. 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";
@@ -76,7 +75,7 @@ import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-conte
76
75
  import { extractTurnImages } from "./turn-images.js";
77
76
  import { TurnWatchdog } from "./turn-watchdog.js";
78
77
  import { TurnResources, workspaceResource } from "./turn-resources.js";
79
- import { ensureWorkspace, ensureTaskWorkspace, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
78
+ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
80
79
  import { readMemoryTopic } from "./workspace.js";
81
80
  import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
82
81
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.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
@@ -1203,6 +1213,14 @@ function handleDesktopTrustedApprovalMessage(raw) {
1203
1213
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
1204
1214
  return false;
1205
1215
  const message = raw;
1216
+ const threadCanReceiveGrant = (bot) => {
1217
+ const threadId = bot.approvalGrant?.threadId;
1218
+ if (!threadId)
1219
+ return true;
1220
+ const target = store.projectBotForTask(bot.id, threadId);
1221
+ return Boolean(target && !threadBusy(bot.id, threadId) &&
1222
+ registry.cliTarget(target.modelSelection.instanceId)?.driverKind === registry.cliTarget(bot.modelSelection.instanceId)?.driverKind);
1223
+ };
1206
1224
  if (message.type === "approval-trusted-mode-commit") {
1207
1225
  const requestId = typeof message.requestId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(message.requestId)
1208
1226
  ? message.requestId
@@ -1221,7 +1239,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
1221
1239
  bot.approvalGrant.phase === "committed" &&
1222
1240
  bot.approvalMode === mode &&
1223
1241
  !bot.busy &&
1242
+ threadCanReceiveGrant(bot) &&
1224
1243
  supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1244
+ if (bot.approvalGrant.threadId) {
1245
+ store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
1246
+ }
1225
1247
  store.patchBot(botId, { approvalGrant: undefined });
1226
1248
  }
1227
1249
  else if (bot?.approvalGrant?.requestId === requestId) {
@@ -1262,7 +1284,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1262
1284
  return true;
1263
1285
  }
1264
1286
  store.patchBot(botId, {
1265
- approvalGrant: { requestId, mode, phase: "confirmed" },
1287
+ approvalGrant: { ...bot.approvalGrant, requestId, mode, phase: "confirmed" },
1266
1288
  });
1267
1289
  confirm(true);
1268
1290
  return true;
@@ -1311,7 +1333,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1311
1333
  }
1312
1334
  // Still inert: Electron must receive this acknowledgement and request
1313
1335
  // finalization before the durable mode can affect any turn.
1314
- store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "activated" } });
1336
+ store.patchBot(botId, { approvalGrant: { ...bot.approvalGrant, requestId, mode, phase: "activated" } });
1315
1337
  activate(true);
1316
1338
  return true;
1317
1339
  }
@@ -1349,10 +1371,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
1349
1371
  bot.approvalGrant.phase === "activated" &&
1350
1372
  bot.approvalMode === mode &&
1351
1373
  !bot.busy &&
1374
+ threadCanReceiveGrant(bot) &&
1352
1375
  supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1353
1376
  // Durable but still inert. Electron must observe this exact ACK before
1354
1377
  // sending the one-way commit release that clears the journal.
1355
- store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "committed" } });
1378
+ store.patchBot(botId, { approvalGrant: { ...bot.approvalGrant, requestId, mode, phase: "committed" } });
1356
1379
  finalize(true);
1357
1380
  return true;
1358
1381
  }
@@ -1396,6 +1419,20 @@ function handleDesktopTrustedApprovalMessage(raw) {
1396
1419
  return true;
1397
1420
  }
1398
1421
  const currentMode = approvalModeFor(existing);
1422
+ const threadId = message.threadId;
1423
+ if (threadId !== undefined) {
1424
+ const target = typeof threadId === "string" && /^[\w-]{1,128}$/.test(threadId)
1425
+ ? store.projectBotForTask(botId, threadId) : null;
1426
+ if (!target || (mode !== "full" && mode !== "custom") || currentMode !== mode || existing.approvalGrant) {
1427
+ respond({ ok: false, error: "Choose this bot's approval level in bot settings before applying it to an existing thread" });
1428
+ return true;
1429
+ }
1430
+ if (threadBusy(botId, threadId) ||
1431
+ registry.cliTarget(target.modelSelection.instanceId)?.driverKind !== registry.cliTarget(existing.modelSelection.instanceId)?.driverKind) {
1432
+ respond({ ok: false, error: "Stop this thread and use the bot's provider before applying its approval level" });
1433
+ return true;
1434
+ }
1435
+ }
1399
1436
  const emergencyDowngrade = existing.busy && isEmergencyApprovalDowngrade(currentMode, mode);
1400
1437
  const clearsPendingElevation = mode === "ask" && existing.approvalGrant !== undefined;
1401
1438
  if (existing.busy && !emergencyDowngrade && !clearsPendingElevation) {
@@ -1422,7 +1459,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1422
1459
  approvalMode: mode,
1423
1460
  autoApprove: mode === "auto",
1424
1461
  approvalGrant: mode === "full" || mode === "custom"
1425
- ? { requestId, mode, phase: "prepared" }
1462
+ ? { requestId, mode, phase: "prepared", ...(typeof threadId === "string" ? { threadId } : {}) }
1426
1463
  : undefined,
1427
1464
  });
1428
1465
  if (!updated) {
@@ -2162,7 +2199,9 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
2162
2199
  * the engine has no asks — is fail-closed: the action was never run. The
2163
2200
  * card is settled and a chip says so, instead of the answer vanishing into
2164
2201
  * a 500 while the card sits open forever. */
2165
- async function answerRequest(threadId, instanceId, requestId, behavior, message, decidedFor) {
2202
+ async function answerRequest(threadId, instanceId, requestId, behavior, message, decidedFor,
2203
+ /** "Always allow this session": the provider keeps the allow, not the app */
2204
+ always) {
2166
2205
  // Snapshot the card BEFORE delivering the answer: a delivered answer
2167
2206
  // resolves the request synchronously through the fold, which consumes
2168
2207
  // the askMessageByRequest entry — by the time the await returns, nobody
@@ -2179,12 +2218,21 @@ async function answerRequest(threadId, instanceId, requestId, behavior, message,
2179
2218
  let outcome = "unavailable";
2180
2219
  if (instance) {
2181
2220
  try {
2182
- outcome = await instance.adapter.respondToRequest(threadId, requestId, { behavior, message });
2221
+ outcome = await instance.adapter.respondToRequest(threadId, requestId, { behavior, message, always: always && behavior === "allow" });
2183
2222
  }
2184
2223
  catch {
2185
2224
  outcome = "unavailable";
2186
2225
  }
2187
2226
  }
2227
+ // An answered question keeps its words. `request.resolved` only records
2228
+ // the behavior, so without this the card reads "answer" forever and the
2229
+ // person can no longer see what they told the bot.
2230
+ if (outcome !== "unavailable" && behavior === "answer" && message && cardMessage) {
2231
+ const settled = store.messagesFor(threadId).find((m) => m.id === cardMessage.id)?.card;
2232
+ if (settled) {
2233
+ store.patchMessage(threadId, cardMessage.id, { card: { ...settled, answeredText: message } });
2234
+ }
2235
+ }
2188
2236
  // The human's verdict, recorded only when it actually reached the engine:
2189
2237
  // `unavailable` means the action never ran, and a "user-approved" row
2190
2238
  // over a request nothing answered would be the audit log lying. A
@@ -2248,6 +2296,16 @@ function requestBehavior(value) {
2248
2296
  // the last settled assistant text per thread, so a "finished" notification
2249
2297
  // can carry what the bot actually said
2250
2298
  const lastReply = new Map();
2299
+ /** the model each thread's provider session announced in session.started,
2300
+ * so a fallback notice can name the model Auto is unavailable for */
2301
+ const sessionModelByThread = new Map();
2302
+ /** threads already told that the provider's reviewer never started */
2303
+ const nativeReviewNoticed = new Set();
2304
+ /** a driver kind as the chat should name it: "claudeAgent" → "Claude" */
2305
+ const providerLabel = (provider) => {
2306
+ const bare = provider.replace(/Agent$/, "");
2307
+ return bare.charAt(0).toUpperCase() + bare.slice(1);
2308
+ };
2251
2309
  /** Put a notification on the wire. Clients decide what to do with it — a
2252
2310
  * desktop notification now, a push to a paired phone later. */
2253
2311
  function notify(notification) {
@@ -2315,6 +2373,7 @@ const watchdog = new TurnWatchdog({
2315
2373
  kind: "activity",
2316
2374
  tool: { name: `error: no activity for ${minutes} minutes — the turn was stopped`, ok: false },
2317
2375
  });
2376
+ settleDirectFollowup(stalledGeneration);
2318
2377
  finalizeDelegationWatch(turn.threadId, false, "", "Delegated turn stalled and was stopped");
2319
2378
  turnUsage.delete(turn.threadId);
2320
2379
  roomStallCompletions.stall(turn.threadId);
@@ -2370,66 +2429,6 @@ const watchdog = new TurnWatchdog({
2370
2429
  },
2371
2430
  });
2372
2431
  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
2432
  bus.subscribe((event) => {
2434
2433
  if (shouldIgnoreProviderEvent(event))
2435
2434
  return;
@@ -2440,6 +2439,11 @@ bus.subscribe((event) => {
2440
2439
  else if (event.type === "turn.completed") {
2441
2440
  watchdog.settle(event.threadId);
2442
2441
  revokeInternalCapabilityForProviderEvent(event);
2442
+ if (event.turnId) {
2443
+ const owner = directFollowupTurns.complete(event.threadId, event.turnId);
2444
+ if (owner)
2445
+ settleDirectFollowup(owner.generation);
2446
+ }
2443
2447
  }
2444
2448
  else if (event.type !== "session.exited")
2445
2449
  watchdog.touch(event.threadId);
@@ -2835,6 +2839,8 @@ bus.subscribe((event) => {
2835
2839
  if (bot && event.sessionId && event.providerInstanceId) {
2836
2840
  store.setResumeCursor(bot.id, event.providerInstanceId, event.sessionId, event.threadId);
2837
2841
  }
2842
+ if (typeof event.model === "string" && event.model)
2843
+ sessionModelByThread.set(event.threadId, event.model);
2838
2844
  break;
2839
2845
  case "item.completed":
2840
2846
  if (event.itemType === "assistant_text") {
@@ -2913,29 +2919,42 @@ bus.subscribe((event) => {
2913
2919
  }
2914
2920
  break;
2915
2921
  case "request.opened": {
2916
- 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.
2922
+ // A structured ask carries the model's own options and has no
2923
+ // allow/deny answer, so it is a question no matter which channel the
2924
+ // provider routed it through — and, like every question, no approval
2925
+ // mode may ever answer it for the person.
2926
+ const permission = event.requestType === "permission" && !event.questions?.length;
2927
+ // A permission request here is one the provider left for a person: its
2928
+ // own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
2929
+ // OpenMausBot decides nothing about the action itself. Only Full access
2930
+ // answers, because that is exactly what the person granted. A QUESTION
2931
+ // always reaches the human — even Full access never invents an answer.
2921
2932
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2922
2933
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
2923
- const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
2934
+ const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
2924
2935
  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
- })
2936
+ ? autoVerdict(effectiveApprovalMode, event.tool, { requiresExplicitApproval: event.requiresExplicitApproval })
2938
2937
  : null;
2938
+ // Auto's reviewer is the engine's own. Claude accepts `--permission-mode
2939
+ // auto` for any model and starts in Manual without a word when auto is
2940
+ // unavailable (Haiku 4.5, Sonnet 4.5, an org that disabled it), so the
2941
+ // bot asks about everything. Say once per session why, and what stops it.
2942
+ if (permission &&
2943
+ asker &&
2944
+ event.nativeReview === "inactive" &&
2945
+ effectiveApprovalMode === "auto" &&
2946
+ !nativeReviewNoticed.has(event.threadId)) {
2947
+ nativeReviewNoticed.add(event.threadId);
2948
+ const model = sessionModelByThread.get(event.threadId);
2949
+ pushMessage({
2950
+ role: "bot",
2951
+ kind: "activity",
2952
+ tool: {
2953
+ 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.`,
2954
+ ok: true,
2955
+ },
2956
+ });
2957
+ }
2939
2958
  if (verdict?.approve && asker && event.requestId) {
2940
2959
  const settled = verdict.approve;
2941
2960
  const instance = event.providerInstanceId
@@ -2971,7 +2990,6 @@ bus.subscribe((event) => {
2971
2990
  summary,
2972
2991
  decision: "auto-approved",
2973
2992
  source: verdict.source,
2974
- rule: verdict.rule,
2975
2993
  });
2976
2994
  }
2977
2995
  catch {
@@ -2986,17 +3004,8 @@ bus.subscribe((event) => {
2986
3004
  options: ["Allow", "Deny"],
2987
3005
  requestId,
2988
3006
  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",
3007
+ held: HELD_NOTE["approval.held.undeliveredFull"],
3008
+ heldCode: "approval.held.undeliveredFull",
3000
3009
  approvalScope: event.approvalScope,
3001
3010
  },
3002
3011
  });
@@ -3010,24 +3019,16 @@ bus.subscribe((event) => {
3010
3019
  summary,
3011
3020
  decision: "card-shown",
3012
3021
  source: "auto-fallback",
3013
- rule: verdict.rule,
3014
3022
  });
3015
3023
  }
3016
3024
  })();
3017
3025
  break;
3018
3026
  }
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
- };
3027
+ const heldContext = { source: verdict?.source, permission };
3028
+ // A structured ask (Claude's AskUserQuestion) is a question whatever
3029
+ // the provider routed it as: it has no allow/deny answer, only the
3030
+ // model's own options. The card carries them so the person can choose.
3031
+ const questions = event.questions?.length ? event.questions : undefined;
3031
3032
  const message = pushMessage({
3032
3033
  role: "bot",
3033
3034
  kind: "options",
@@ -3041,15 +3042,10 @@ bus.subscribe((event) => {
3041
3042
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
3042
3043
  requestId: event.requestId,
3043
3044
  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,
3045
+ questionRequest: questions ? { version: 1, questions } : undefined,
3046
+ // the provider can keep an allow for its session; the app keeps
3047
+ // no grant of its own for a provider's tool
3048
+ allowSession: permission && event.allowSession && !event.requiresExplicitApproval ? true : undefined,
3053
3049
  // The text stays for cards saved before heldCode existed, and for
3054
3050
  // clients that do not know the key yet.
3055
3051
  held: approvalHeldReason(heldContext),
@@ -3059,37 +3055,8 @@ bus.subscribe((event) => {
3059
3055
  });
3060
3056
  if (event.requestId)
3061
3057
  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
3058
+ // Every card that reaches a human is a decision too: "the provider
3059
+ // left this for you, in this mode". `question` marks the cards no
3093
3060
  // rule may ever answer; a permission card without a verdict (no known
3094
3061
  // asker, or no requestId to answer through) can only mean nothing was
3095
3062
  // granted.
@@ -3102,43 +3069,22 @@ bus.subscribe((event) => {
3102
3069
  summary: event.summary,
3103
3070
  decision: "card-shown",
3104
3071
  source: !permission ? "question" : verdict ? verdict.source : "no-grant",
3105
- rule: verdict?.rule,
3106
3072
  unattended: unattended || undefined,
3107
3073
  });
3108
3074
  // Notify from HERE, not from a separate subscriber on request.opened:
3109
3075
  // 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;
3076
+ // Full access answered took the early return above and never buzzes.
3077
+ if (asker) {
3114
3078
  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();
3079
+ if (card && !card.answered) {
3080
+ // the bot is not working now — it is waiting on a person
3081
+ if (bot)
3082
+ store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
3083
+ else if (asker.busy)
3084
+ store.setActivity(asker.id, "waiting-on-you");
3085
+ const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
3086
+ notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
3087
+ }
3142
3088
  }
3143
3089
  break;
3144
3090
  }
@@ -3789,6 +3735,8 @@ bus.subscribe((event) => {
3789
3735
  drainDelegationWakes();
3790
3736
  });
3791
3737
  function drainQueuedSends() {
3738
+ if (!followupsReady)
3739
+ return;
3792
3740
  drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
3793
3741
  // A plain attended turn — no automationSource, no comms depth: exactly
3794
3742
  // what typing the same words into an idle bot would run. The one
@@ -3797,15 +3745,19 @@ function drainQueuedSends() {
3797
3745
  // Drain just appended the held lines; userMessage keeps startTurn
3798
3746
  // from duplicating the last one, and excludeIds drops every drained
3799
3747
  // 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
- });
3748
+ new Promise((resolve, reject) => {
3749
+ void startTurn(botId, prompt, {
3750
+ threadId, userMessage, excludeMessageIds: excludeIds, unattended, onTurnSettled: resolve,
3751
+ }).catch((err) => {
3752
+ store.appendMessage(threadId, {
3753
+ role: "bot", kind: "activity",
3754
+ tool: {
3755
+ name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
3756
+ ok: false,
3757
+ },
3758
+ });
3759
+ resolve();
3760
+ }).catch(reject);
3809
3761
  }),
3810
3762
  // Provider completion can precede its dispatch promise: keep the queue
3811
3763
  // intact until that exact handshake releases its runtime-only claim.
@@ -4210,6 +4162,8 @@ async function startTurn(botId, text, opts) {
4210
4162
  const resourceOwner = { threadId, generation: dispatchClaimId };
4211
4163
  turnResourceOwners.set(threadId, resourceOwner);
4212
4164
  directTurnGenerationByThread.set(threadId, dispatchClaimId);
4165
+ if (opts?.onTurnSettled)
4166
+ directFollowupSettlers.set(dispatchClaimId, { threadId, settle: opts.onTurnSettled });
4213
4167
  directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
4214
4168
  directTurnBots.set(threadId, bot);
4215
4169
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -4602,6 +4556,7 @@ async function startTurn(botId, text, opts) {
4602
4556
  // to a turn whose engine actually mounted them (setupMode is already
4603
4557
  // false when they are not — see agentsMounted above)
4604
4558
  { id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
4559
+ { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
4605
4560
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
4606
4561
  { id: "plan", label: "Surface", text: plan.note },
4607
4562
  // gated on the integration, not the key: the hint only goes to a
@@ -4650,6 +4605,11 @@ async function startTurn(botId, text, opts) {
4650
4605
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
4651
4606
  }
4652
4607
  bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
4608
+ if (directFollowupSettlers.has(dispatchClaimId) && dispatch.value.turnId &&
4609
+ !directFollowupTurns.bind(threadId, dispatchClaimId, dispatch.value.turnId)) {
4610
+ // This exact queued turn completed before its dispatch ACK arrived.
4611
+ settleDirectFollowup(dispatchClaimId);
4612
+ }
4653
4613
  clearDirectTurnDispatch(threadId, dispatchClaimId);
4654
4614
  // dispatched: the rewind is spent, and the old cursors are dead
4655
4615
  if (rewound)
@@ -4683,6 +4643,7 @@ async function startTurn(botId, text, opts) {
4683
4643
  }
4684
4644
  }
4685
4645
  catch (e) {
4646
+ settleDirectFollowup(dispatchClaimId);
4686
4647
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
4687
4648
  clearDirectTurnDispatch(threadId, dispatchClaimId);
4688
4649
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -5275,6 +5236,7 @@ const webhooks = new WebhookManager({
5275
5236
  return !bot ? "missing" : bot.busy ? "busy" : "ready";
5276
5237
  },
5277
5238
  enqueue: (input) => routines.enqueueWebhook(input),
5239
+ findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
5278
5240
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
5279
5241
  pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
5280
5242
  });
@@ -5386,7 +5348,7 @@ setupRetry = 0) {
5386
5348
  }
5387
5349
  revokeInternalCapabilitiesForThread(threadId);
5388
5350
  spoken.add(botId);
5389
- const preparedApprovalMode = approvalModeForTurn(bot, false, threadId);
5351
+ const preparedApprovalMode = approvalModeForTurn(bot, false);
5390
5352
  const preparedSelection = { ...bot.modelSelection };
5391
5353
  const preparedComposio = bot.composio;
5392
5354
  const instance = registry.get(bot.modelSelection.instanceId);
@@ -5509,7 +5471,7 @@ setupRetry = 0) {
5509
5471
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
5510
5472
  return false;
5511
5473
  const setupChanged = registry.get(preparedSelection.instanceId) !== instance ||
5512
- approvalModeForTurn(readyBot, false, threadId) !== preparedApprovalMode ||
5474
+ approvalModeForTurn(readyBot, false) !== preparedApprovalMode ||
5513
5475
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
5514
5476
  readyBot.modelSelection.model !== preparedSelection.model ||
5515
5477
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -5700,6 +5662,7 @@ setupRetry = 0) {
5700
5662
  store.patchBot(bot.id, { soulDrift: drift.drift });
5701
5663
  }
5702
5664
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
5665
+ { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
5703
5666
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
5704
5667
  { id: "computer", label: "Computer", text: computerPrompt(roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
5705
5668
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -5816,7 +5779,7 @@ setupRetry = 0) {
5816
5779
  threadId,
5817
5780
  text,
5818
5781
  images: turnImages,
5819
- approvalMode: approvalModeForTurn(readyBot, false, threadId),
5782
+ approvalMode: approvalModeForTurn(readyBot, false),
5820
5783
  system: roomSystem.text,
5821
5784
  systemStable: roomSystem.stable,
5822
5785
  systemVolatile: roomSystem.volatile,
@@ -6438,6 +6401,8 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
6438
6401
  return message;
6439
6402
  }
6440
6403
  function drainQueuedChannelSends() {
6404
+ if (!followupsReady)
6405
+ return;
6441
6406
  drainChannelMessages((groupId) => {
6442
6407
  const group = store.group(groupId);
6443
6408
  return group ? groupIsWorking(group) : false;
@@ -6449,9 +6414,12 @@ function drainQueuedChannelSends() {
6449
6414
  if (!group || !ownsThread)
6450
6415
  return;
6451
6416
  try {
6452
- startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via });
6417
+ startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId });
6453
6418
  }
6454
6419
  catch (error) {
6420
+ if (!store.messagesFor(threadId).some((message) => message.queueId === id && message.role === "user")) {
6421
+ store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId, sendId, channelMode: mode, queueId: id, via });
6422
+ }
6455
6423
  store.appendMessage(threadId, {
6456
6424
  role: "bot",
6457
6425
  kind: "activity",
@@ -6464,6 +6432,7 @@ function drainQueuedChannelSends() {
6464
6432
  // A message with no eligible responder creates no operation. Continue
6465
6433
  // draining instead of leaving later user messages behind it forever.
6466
6434
  queueMicrotask(drainQueuedChannelSends);
6435
+ return groupQueues.get(groupId);
6467
6436
  });
6468
6437
  }
6469
6438
  function sameCalendarRoster(group, botIds) {
@@ -7371,6 +7340,14 @@ function configStatus() {
7371
7340
  showToolCalls: showToolCallsEnabled(cfg),
7372
7341
  browser: builtInBrowserEnabled(cfg),
7373
7342
  },
7343
+ // first-run progress — not a secret; the app decides whether to show
7344
+ // the welcome tour from this, never from browser storage
7345
+ onboarding: {
7346
+ completedAt: cfg.onboarding?.completedAt ?? "",
7347
+ version: cfg.onboarding?.version ?? 0,
7348
+ reelSeen: cfg.onboarding?.reelSeen === true,
7349
+ hintsSeen: cfg.onboarding?.hintsSeen ?? [],
7350
+ },
7374
7351
  // Which browser this server can give bots: the desktop app's surface,
7375
7352
  // the agent-browser engine, or nothing yet (with the reason).
7376
7353
  browserEngine: browserEngineSummary(),
@@ -7489,6 +7466,7 @@ async function reloadProviders() {
7489
7466
  });
7490
7467
  store.setTaskActivity(botId, threadId, "idle");
7491
7468
  }
7469
+ settleDirectFollowup(owner?.generation);
7492
7470
  retryDelegationsWaitingOn(botId);
7493
7471
  }
7494
7472
  for (const [threadId, speaker] of rooms) {
@@ -10506,6 +10484,9 @@ const handleRequest = async (req, res) => {
10506
10484
  }
10507
10485
  const receipt = await sendSequencer.run(sendId ? `group:${group.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id, channelMode), async () => {
10508
10486
  if (sendId) {
10487
+ if (cancelledChatFollowup("channel", group.id, threadId, sendId)) {
10488
+ throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
10489
+ }
10509
10490
  const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id, channelMode);
10510
10491
  if (accepted.kind === "conflict") {
10511
10492
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
@@ -11101,12 +11082,6 @@ const handleRequest = async (req, res) => {
11101
11082
  error: "This approval-level change can only be made from the packaged desktop app",
11102
11083
  });
11103
11084
  }
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
11085
  // "Auto on this Mac" hands a bot the user's real session, so the grant
11111
11086
  // must prove a human saw the warning. The desktop dialog is the only
11112
11087
  // caller that sends acknowledgeLocalAuto; without it a PATCH that would
@@ -11462,6 +11437,7 @@ const handleRequest = async (req, res) => {
11462
11437
  // staged provider images into a message, so dispose them here.
11463
11438
  for (const task of store.tasks(bot.id)) {
11464
11439
  purgeGeneratedImagesForThread(task.threadId);
11440
+ settleDirectFollowup(directTurnGenerationByThread.get(task.threadId));
11465
11441
  directTurnGenerationByThread.delete(task.threadId);
11466
11442
  directTurnBots.delete(task.threadId);
11467
11443
  }
@@ -12005,6 +11981,9 @@ const handleRequest = async (req, res) => {
12005
11981
  const replyTo = resolveReplyTarget(threadId, body.replyToId);
12006
11982
  const receipt = await sendSequencer.run(sendId ? `bot:${bot.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
12007
11983
  if (sendId) {
11984
+ if (cancelledChatFollowup("bot", bot.id, threadId, sendId)) {
11985
+ throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
11986
+ }
12008
11987
  const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
12009
11988
  if (accepted.kind === "conflict") {
12010
11989
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
@@ -12204,7 +12183,7 @@ const handleRequest = async (req, res) => {
12204
12183
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
12205
12184
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
12206
12185
  }
12207
- const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name });
12186
+ 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
12187
  return json(res, 200, { ok: true, outcome });
12209
12188
  }
12210
12189
  // Answer by THREAD, so a request raised inside a room can be answered
@@ -12289,7 +12268,7 @@ const handleRequest = async (req, res) => {
12289
12268
  if (!owner && !pending)
12290
12269
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
12291
12270
  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);
12271
+ const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true);
12293
12272
  return json(res, 200, { ok: true, outcome });
12294
12273
  }
12295
12274
  m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
@@ -12460,15 +12439,17 @@ const handleRequest = async (req, res) => {
12460
12439
  const current = store.projectBotForTask(m[1], m[2]);
12461
12440
  if (!current)
12462
12441
  return json(res, 404, { error: "no such task" });
12463
- const allowed = new Set(["title", "projectId", "modelSelection", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto"]);
12442
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto"]);
12464
12443
  if (Object.keys(body).some((key) => !allowed.has(key)))
12465
12444
  return json(res, 400, { error: "unsupported thread setting" });
12466
- for (const key of ["requireAvailableModel", "acknowledgeLocalAuto"]) {
12445
+ for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault"]) {
12467
12446
  if (body[key] !== undefined && typeof body[key] !== "boolean")
12468
12447
  return json(res, 400, { error: `${key} must be a boolean` });
12469
12448
  }
12470
12449
  if (body.requireAvailableModel === true && body.modelSelection === undefined)
12471
12450
  return json(res, 400, { error: "requireAvailableModel requires modelSelection" });
12451
+ if (body.updateBotDefault === true && body.modelSelection === undefined)
12452
+ return json(res, 400, { error: "updateBotDefault requires modelSelection" });
12472
12453
  const patch = {};
12473
12454
  if (body.projectId !== undefined) {
12474
12455
  if (body.projectId === null)
@@ -12505,8 +12486,11 @@ const handleRequest = async (req, res) => {
12505
12486
  const mode = body.approvalMode ?? (body.autoApprove ? "auto" : "ask");
12506
12487
  // Elevated modes still require the trusted desktop transition. A
12507
12488
  // thread settings PATCH cannot manufacture that grant.
12508
- if (mode !== "ask" && mode !== "auto")
12509
- return json(res, 403, { error: "Full and Custom access require confirmation in bot settings" });
12489
+ if (mode !== "ask" && mode !== "auto" && mode !== "edits")
12490
+ return json(res, 403, { error: "Full and Custom access require trusted desktop confirmation" });
12491
+ if (!supportsApprovalMode(registry.cliTarget(current.modelSelection.instanceId)?.driverKind, mode)) {
12492
+ return json(res, 400, { error: "This provider does not support the selected approval level" });
12493
+ }
12510
12494
  if (threadBusy(current.id, current.threadId))
12511
12495
  return json(res, 409, { error: "stop this thread before changing its approval mode" });
12512
12496
  if (current.approvalGrant)
@@ -12526,6 +12510,24 @@ const handleRequest = async (req, res) => {
12526
12510
  registry.cliTarget(patch.modelSelection.instanceId)?.driverKind !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
12527
12511
  return json(res, 400, { error: "Choose Ask for this thread before changing providers with elevated permissions" });
12528
12512
  }
12513
+ if (body.updateBotDefault === true && patch.modelSelection) {
12514
+ const profile = store.bot(current.id);
12515
+ const checked = checkedModelSelection(patch.modelSelection, {
12516
+ selection: profile.modelSelection, busy: Boolean(activeGroupTurnForBot(current.id)),
12517
+ });
12518
+ if (!checked.ok)
12519
+ return json(res, checked.status, { error: checked.error });
12520
+ const defaultMode = approvalModeFor(profile);
12521
+ if ((defaultMode === "full" || defaultMode === "custom") &&
12522
+ (!supportsApprovalMode(registry.cliTarget(patch.modelSelection.instanceId)?.driverKind, defaultMode) ||
12523
+ registry.cliTarget(patch.modelSelection.instanceId)?.driverKind !== registry.cliTarget(profile.modelSelection.instanceId)?.driverKind)) {
12524
+ return json(res, 400, { error: "Choose Ask in bot settings before changing its default provider with elevated permissions" });
12525
+ }
12526
+ // Validate both scopes before saving either. Existing sibling threads
12527
+ // retain their selections; only this pinned thread and future/group
12528
+ // turns adopt the new default. Do not target the currently selected tab.
12529
+ store.patchBot(current.id, { modelSelection: patch.modelSelection });
12530
+ }
12529
12531
  const task = store.patchTask(m[1], m[2], patch);
12530
12532
  const fresh = botWithThread(store.bot(m[1]));
12531
12533
  broadcast({ kind: "bot", bot: fresh });
@@ -12546,6 +12548,7 @@ const handleRequest = async (req, res) => {
12546
12548
  const updated = store.deleteTask(m[1], m[2]);
12547
12549
  if (!updated)
12548
12550
  return json(res, 400, { error: "a bot keeps at least one task" });
12551
+ settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
12549
12552
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
12550
12553
  const fresh = botWithThread(updated);
12551
12554
  broadcast({ kind: "bot", bot: fresh });
@@ -13641,19 +13644,10 @@ const handleRequest = async (req, res) => {
13641
13644
  browserReferenceCleanupError = error;
13642
13645
  }
13643
13646
  }
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");
13647
+ // Provider keys change the fleet. Profile, language, voice, VPS, room
13648
+ // timeout, and onboarding progress changes do not rebuild it: no driver
13649
+ // reads them, and they should not interrupt in-flight turns.
13650
+ const reloadKeys = providerReloadKeys(patch);
13657
13651
  // Config is already durable. A provider credential or runtime change
13658
13652
  // invalidates every old child immediately, including when browser
13659
13653
  // cleanup below has to await Electron before reloadProviders begins.
@@ -14149,8 +14143,45 @@ try {
14149
14143
  catch (error) {
14150
14144
  console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
14151
14145
  }
14146
+ // A dispatch claim is deliberately committed before transcript/provider work.
14147
+ // If we died after that point, its outcome is unknown: recover the user's words
14148
+ // and a review notice, never hand them to a model for a second execution.
14149
+ for (const row of chatFollowups()) {
14150
+ if (row.status !== "dispatching" && row.status !== "interrupted")
14151
+ continue;
14152
+ const owned = row.kind === "bot"
14153
+ ? Boolean(store.taskByThread(row.ownerId, row.threadId))
14154
+ : Boolean(store.groupByThread(row.threadId)?.id === row.ownerId);
14155
+ if (!owned) {
14156
+ settleChatFollowups([row.id], "cancelled");
14157
+ continue;
14158
+ }
14159
+ settleChatFollowups([row.id], "interrupted");
14160
+ const messages = store.messagesFor(row.threadId);
14161
+ if (!messages.some((message) => message.queueId === row.id && message.role === "user")) {
14162
+ store.appendMessage(row.threadId, {
14163
+ role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
14164
+ sendId: row.payload.sendId, queueId: row.id,
14165
+ ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
14166
+ });
14167
+ }
14168
+ if (!messages.some((message) => message.queueId === row.id && message.kind === "activity")) {
14169
+ store.appendMessage(row.threadId, {
14170
+ role: "bot", kind: "activity", queueId: row.id,
14171
+ tool: { name: "Queued follow-up interrupted by restart or restore — it may have already run. Review the result before sending it again.", ok: false },
14172
+ });
14173
+ }
14174
+ // The FULL-sync retirement also flushes both transcript writes. Retrying
14175
+ // this sendId now finds the canonical message, without a permanent journal scan.
14176
+ settleChatFollowups([row.id], null);
14177
+ }
14178
+ restoreSteeredMessages();
14179
+ restoreChannelMessages();
14152
14180
  server.listen(PORT, "127.0.0.1", () => {
14153
14181
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
14182
+ followupsReady = true;
14183
+ drainQueuedSends();
14184
+ drainQueuedChannelSends();
14154
14185
  // Startup work uses the same turn dispatcher and local tool endpoint as
14155
14186
  // ordinary chat. Start only once every registry is initialized and the
14156
14187
  // endpoint is listening; earlier dispatch can hit uninitialized bindings.
@@ -14188,6 +14219,7 @@ if (TUNNEL_SOCKET) {
14188
14219
  const gracefulShutdown = createGracefulShutdown({
14189
14220
  cleanup: [
14190
14221
  () => {
14222
+ followupsReady = false;
14191
14223
  // Child MCP processes and the HTTP listener can remain alive while the
14192
14224
  // asynchronous shutdown jobs drain. Invalidate their turn bearers before
14193
14225
  // any cleanup function reaches an await.