openmausbot 0.1.67 → 0.1.69

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 (41) hide show
  1. package/dist/assets/{index-BwpNqgCm.js → index-BmHupgVJ.js} +1 -1
  2. package/dist/assets/index-COp5lMnv.js +279 -0
  3. package/dist/assets/index-Jk7TIeyW.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +40 -2
  6. package/dist-server/drivers/agents-proxy.js +112 -7
  7. package/dist-server/enterprise/server/index.js +14587 -0
  8. package/dist-server/index.js +1732 -681
  9. package/dist-server/openmausbot.js +939 -381
  10. package/dist-server/pair-cli.js +939 -381
  11. package/dist-server/server/avatar-image.js +86 -34
  12. package/dist-server/server/bot-package.js +60 -14
  13. package/dist-server/server/brand.js +7 -0
  14. package/dist-server/server/cli.js +23 -1
  15. package/dist-server/server/config.js +30 -3
  16. package/dist-server/server/delegations.js +56 -11
  17. package/dist-server/server/drivers/acp/qwen.js +142 -5
  18. package/dist-server/server/drivers/agents-proxy.js +135 -8
  19. package/dist-server/server/drivers/codex-device-auth.js +102 -3
  20. package/dist-server/server/drivers/codex-identity.js +115 -0
  21. package/dist-server/server/drivers/codex.js +6 -0
  22. package/dist-server/server/harness/registry.js +2 -0
  23. package/dist-server/server/index.js +220 -65
  24. package/dist-server/server/package-export.js +7 -0
  25. package/dist-server/server/provider-auth-sessions.js +24 -0
  26. package/dist-server/server/routine-requests.js +270 -25
  27. package/dist-server/server/routines.js +215 -21
  28. package/dist-server/server/service-cli.js +63 -0
  29. package/dist-server/server/service-unit.js +129 -0
  30. package/dist-server/server/steer-queue.js +30 -1
  31. package/dist-server/server/store.js +30 -4
  32. package/dist-server/server/system-prompt.js +1 -0
  33. package/dist-server/server/turn-dispatch-guard.js +6 -0
  34. package/dist-server/shared/image-generation.js +28 -0
  35. package/dist-server/vps-container-mcp.js +40 -2
  36. package/enterprise/LICENSE +32 -0
  37. package/enterprise/README.md +89 -0
  38. package/enterprise/server/index.js +14587 -0
  39. package/package.json +2 -1
  40. package/dist/assets/index-A3CBTxwl.css +0 -1
  41. package/dist/assets/index-CDuyoVPJ.js +0 -278
@@ -21,7 +21,7 @@ import { appendDecision, readDecisions } from "./decision-log.js";
21
21
  import { validateBotCwd } from "./bot-cwd.js";
22
22
  import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
23
23
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
24
- import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, snapshotAvatarGenerationState, } from "./avatar-image.js";
24
+ import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, avatarImageStatus, snapshotAvatarGenerationState, } from "./avatar-image.js";
25
25
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
26
26
  import { groupTurnCwd } from "./room-cwd.js";
27
27
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
@@ -33,7 +33,7 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
33
33
  import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
34
34
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
35
35
  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, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
+ import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
37
37
  import { ComputerControl } from "./computer-control.js";
38
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -54,7 +54,7 @@ import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.j
54
54
  const SESSION_READ_MAX_CHARS = 8_000;
55
55
  import { promptWithReply, transcriptText } from "./replies.js";
56
56
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DelegationWakeBudget, discardDelegations, drainDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
57
- import { cancelSteeredMessage, drainSteeredMessages, queuedSteeredMessage, queueSteeredMessage, } from "./steer-queue.js";
57
+ import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queueSteeredMessage, } from "./steer-queue.js";
58
58
  import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, } from "./channel-queue.js";
59
59
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
60
60
  import { EventBus } from "./harness/bus.js";
@@ -63,7 +63,7 @@ import { selectDefaultModelSelection } from "./default-model-selection.js";
63
63
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
64
64
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
65
65
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
66
- import { mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
66
+ import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
67
67
  import * as tts from "./tts/index.js";
68
68
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
69
69
  import { buildTurnContext, engineIsFresh } from "./turn-context.js";
@@ -78,7 +78,7 @@ import { fetchSkillFromSource } from "./skill-fetch.js";
78
78
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
79
79
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
80
80
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
81
- import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
81
+ import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
82
82
  import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
83
83
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
84
84
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -116,7 +116,7 @@ import { createTeamBackup, importTeamBackup } from "./team-backup.js";
116
116
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
117
117
  import { shouldMountLocalComputer } from "./local-routing.js";
118
118
  import { resolveSurface } from "./surface.js";
119
- import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
119
+ import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
120
120
  import { createGracefulShutdown } from "./graceful-shutdown.js";
121
121
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
122
122
  import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
@@ -448,7 +448,6 @@ const directTurnGenerationByThread = new Map();
448
448
  // Keep the exact provider/profile settings that own a running conversation.
449
449
  // Selecting another thread or changing a default must not retarget its tools.
450
450
  const directTurnBots = new Map();
451
- const MAX_CONCURRENT_BOT_THREADS = 3;
452
451
  let providerFleetReloading = false;
453
452
  const turnResources = new TurnResources();
454
453
  const turnResourceOwners = new Map();
@@ -484,6 +483,11 @@ function botForThread(botId, threadId) {
484
483
  function threadBusy(botId, threadId) {
485
484
  return store.taskByThread(botId, threadId)?.busy === true || directTurnDispatchClaims.has(threadId);
486
485
  }
486
+ function botAtThreadCapacity(botId) {
487
+ // Setup/dispatch reservations still occupy a slot even if an early
488
+ // completion event has already cleared the stored busy flag.
489
+ return store.tasks(botId).filter((task) => threadBusy(botId, task.threadId)).length >= maxConcurrentBotThreads(cfg);
490
+ }
487
491
  function hasDirectDispatch(botId) {
488
492
  return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
489
493
  }
@@ -1364,6 +1368,7 @@ const publicBot = (bot) => ({
1364
1368
  activeLeafId: store.activeLeaf(bot.threadId),
1365
1369
  tasks: store.tasks(bot.id).map(wireTask),
1366
1370
  });
1371
+ const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
1367
1372
  // busyBotId names only the speaker that currently owns the provider process.
1368
1373
  // A room turn is wider: it also includes async setup and every responder still
1369
1374
  // queued behind that speaker. Keep that operation visible for its whole
@@ -1494,6 +1499,10 @@ function finishGroupGoalRun(groupId, operation, status, detail) {
1494
1499
  // A calendar-triggered team goal reuses its RoutineRun id for this card.
1495
1500
  // Manual goals have unrelated ids, so the manager safely ignores them.
1496
1501
  const routineRun = routines?.finishGoalRun(run.runId, status, safeDetail);
1502
+ if (routineRun?.status === "cancelled") {
1503
+ pendingDelegationWakes.delete(operation.threadId);
1504
+ discardDelegations(commsBus, operation.threadId);
1505
+ }
1497
1506
  // Member-level turn completions are intentionally private/intermediate for
1498
1507
  // a team goal, so the normal direct-routine notification path never fires.
1499
1508
  // Notify once from the correlated terminal receipt instead.
@@ -1502,13 +1511,15 @@ function finishGroupGoalRun(groupId, operation, status, detail) {
1502
1511
  if (routineRun?.status === "waiting") {
1503
1512
  const coordinator = store.bot(routineRun.botId);
1504
1513
  if (coordinator) {
1505
- notify(buildNotification("question", coordinator, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || `${routineRun.routineName} needs your input`, { avatarUrl: coordinator.avatarUrl }));
1514
+ const notificationBot = routineSourceOwner(routineRun)?.bot ?? coordinator;
1515
+ notify(buildNotification("question", notificationBot, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || `${routineRun.routineName} needs your input`, { avatarUrl: notificationBot.avatarUrl }));
1506
1516
  }
1507
1517
  }
1508
1518
  if (routineRun?.status === "completed") {
1509
1519
  const coordinator = store.bot(routineRun.botId);
1510
1520
  if (coordinator) {
1511
- notify(buildNotification("done", coordinator, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || routineRun.routineName, { avatarUrl: coordinator.avatarUrl }));
1521
+ const notificationBot = routineSourceOwner(routineRun)?.bot ?? coordinator;
1522
+ notify(buildNotification("done", notificationBot, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || routineRun.routineName, { avatarUrl: notificationBot.avatarUrl }));
1512
1523
  }
1513
1524
  }
1514
1525
  const group = store.group(groupId);
@@ -1740,6 +1751,7 @@ store.onChange((change) => {
1740
1751
  break;
1741
1752
  case "thread.deleted":
1742
1753
  routines?.forgetRoutineRequestReceiptsForThread(change.threadId);
1754
+ broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() });
1743
1755
  break;
1744
1756
  case "bot": {
1745
1757
  const bot = store.bot(change.botId);
@@ -1883,6 +1895,7 @@ function broadcast(payload) {
1883
1895
  }
1884
1896
  }
1885
1897
  }
1898
+ onSteeredQueueChange(() => broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() }));
1886
1899
  // ── server-side event folding (upstream's ingestion worker, miniature) ──
1887
1900
  // The canonical stream is the source of truth; the persisted transcript
1888
1901
  // and every client view are projections of it.
@@ -2033,9 +2046,17 @@ const watchdog = new TurnWatchdog({
2033
2046
  revokeInternalCapabilitiesForThread(turn.threadId);
2034
2047
  repeats.settle(turn.threadId);
2035
2048
  const bot = botForThread(turn.botId, turn.threadId);
2036
- const instance = bot ? registry.get(bot.modelSelection.instanceId) : null;
2049
+ const routineRun = activeRoutineRunForThread(turn.threadId);
2050
+ const instance = routineRun?.runOn === "cloud"
2051
+ ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent")
2052
+ : bot ? registry.get(bot.modelSelection.instanceId) : null;
2037
2053
  void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
2038
2054
  const minutes = Math.round(TURN_STALL_MS / 60_000);
2055
+ if (routineRun?.target === "bot") {
2056
+ routines?.failThread(turn.threadId, `No activity for ${minutes} minutes — the routine was stopped`);
2057
+ pendingDelegationWakes.delete(turn.threadId);
2058
+ discardDelegations(commsBus, turn.threadId);
2059
+ }
2039
2060
  store.appendMessage(turn.threadId, {
2040
2061
  role: "bot",
2041
2062
  kind: "activity",
@@ -2826,7 +2847,8 @@ bus.subscribe((event) => {
2826
2847
  store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
2827
2848
  else if (asker.busy)
2828
2849
  store.setActivity(asker.id, "waiting-on-you");
2829
- notify(buildNotification(permission ? "approval" : "question", asker, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
2850
+ const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
2851
+ notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
2830
2852
  };
2831
2853
  if (reviewTask && reviewMode === "enforce") {
2832
2854
  // Avoid buzzing the owner for a card the reviewer is about to answer.
@@ -2991,13 +3013,14 @@ bus.subscribe((event) => {
2991
3013
  // A failed peer turn stays a chip too. The bot that delegated is woken
2992
3014
  // with the failure and answers the person in its own thread — buzzing
2993
3015
  // here as well would ring twice for one piece of news.
2994
- if (routineRun?.status !== "failed" && !internal) {
3016
+ if ((!routineRun || routineRun.status === "completed") && !internal) {
2995
3017
  // the frame carries the bot's avatar so every desktop client can
2996
3018
  // show the notification under that bot's own face
2997
3019
  const completionDetail = routineRun
2998
3020
  ? reply || routineRun.output || routineRun.routineName
2999
3021
  : reply;
3000
- notify(buildNotification("done", bot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: bot.avatarUrl }));
3022
+ const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || bot;
3023
+ notify(buildNotification("done", notificationBot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: notificationBot.avatarUrl }));
3001
3024
  }
3002
3025
  if (screenPollers.has(event.threadId)) {
3003
3026
  if (resourceOwner)
@@ -3064,9 +3087,22 @@ const delegationWatch = new Map();
3064
3087
  // the cardContinuation resume pattern used for connector/credential cards.
3065
3088
  const delegationWakeBudget = new DelegationWakeBudget();
3066
3089
  const pendingDelegationWakes = new Map();
3067
- function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3090
+ function activeRoutineRunForThread(threadId) {
3091
+ const run = routines?.runForThread(threadId);
3092
+ return run && ["running", "waiting"].includes(run.status) ? run : null;
3093
+ }
3094
+ function routineDelegationCanResume(threadId, routineRunId) {
3095
+ return !routineRunId || activeRoutineRunForThread(threadId)?.id === routineRunId;
3096
+ }
3097
+ function dispatchDelegationWake(botId, threadId, targetName, failureReason, routineRunId, budgetAcquired = false) {
3068
3098
  if (!store.taskByThread(botId, threadId))
3069
3099
  return;
3100
+ if (!routineDelegationCanResume(threadId, routineRunId))
3101
+ return;
3102
+ if (!budgetAcquired && !delegationWakeBudget.tryAcquire(threadId)) {
3103
+ routines?.failThread(threadId, "Delegation follow-up limit reached; review the run before retrying");
3104
+ return;
3105
+ }
3070
3106
  const prompt = failureReason
3071
3107
  ? buildDelegationFailurePrompt(targetName, failureReason)
3072
3108
  : buildDelegationRevivalPrompt(targetName);
@@ -3081,8 +3117,9 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3081
3117
  return;
3082
3118
  const message = error instanceof Error ? error.message : String(error);
3083
3119
  // Raced with a user turn claiming the bot — retry once it settles.
3084
- if (/already working/i.test(message)) {
3085
- pendingDelegationWakes.set(threadId, { botId, targetName, failureReason });
3120
+ // This is the same logical wake, so keep its original budget charge.
3121
+ if (isTurnAdmissionBlocked(error)) {
3122
+ pendingDelegationWakes.set(threadId, { botId, targetName, failureReason, routineRunId, budgetAcquired: true });
3086
3123
  return;
3087
3124
  }
3088
3125
  store.appendMessage(threadId, {
@@ -3093,37 +3130,43 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3093
3130
  ok: false,
3094
3131
  },
3095
3132
  });
3133
+ routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
3096
3134
  });
3097
3135
  }
3098
- function wakeDelegationSource(source, threadId, targetName, failureReason) {
3136
+ function wakeDelegationSource(source, threadId, targetName, failureReason, routineRunId) {
3099
3137
  if (!store.taskByThread(source.id, threadId))
3100
3138
  return;
3139
+ if (!routineDelegationCanResume(threadId, routineRunId))
3140
+ return;
3101
3141
  // Busy? Hold the wake until the source settles, then drain it — the
3102
3142
  // delegated reply is already in the thread, so nothing is lost, and the
3103
3143
  // source processes it the moment it is free rather than only on a later
3104
3144
  // user nudge.
3105
3145
  if (threadBusy(source.id, threadId) || activeGroupTurnForBot(source.id)) {
3106
- pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason });
3146
+ pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason, routineRunId });
3107
3147
  return;
3108
3148
  }
3109
- if (!delegationWakeBudget.tryAcquire(threadId))
3110
- return;
3111
- dispatchDelegationWake(source.id, threadId, targetName, failureReason);
3149
+ dispatchDelegationWake(source.id, threadId, targetName, failureReason, routineRunId);
3112
3150
  }
3113
3151
  function drainDelegationWakes() {
3114
3152
  for (const [threadId, entry] of pendingDelegationWakes) {
3115
- if (!store.taskByThread(entry.botId, threadId)) {
3153
+ if (!store.taskByThread(entry.botId, threadId) || !routineDelegationCanResume(threadId, entry.routineRunId)) {
3116
3154
  pendingDelegationWakes.delete(threadId);
3117
3155
  continue;
3118
3156
  }
3119
3157
  if (threadBusy(entry.botId, threadId) || activeGroupTurnForBot(entry.botId))
3120
3158
  continue;
3121
3159
  pendingDelegationWakes.delete(threadId);
3122
- if (!delegationWakeBudget.tryAcquire(threadId))
3123
- continue;
3124
- dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason);
3160
+ dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason, entry.routineRunId, entry.budgetAcquired);
3125
3161
  }
3126
3162
  }
3163
+ function wakeUndispatchedDelegation(receipt, routineRunId) {
3164
+ const source = store.botByThread(receipt.sourceThreadId);
3165
+ if (!source)
3166
+ return;
3167
+ markTaskContextExternallyUpdated(source, receipt.sourceThreadId);
3168
+ wakeDelegationSource(source, receipt.sourceThreadId, receipt.toBotName, receipt.result || "the handoff did not run", routineRunId);
3169
+ }
3127
3170
  // Provider-native sessions only know about messages produced inside their
3128
3171
  // own turns. A delegated result is appended later by the harness, so mark the
3129
3172
  // source task with a persisted, impossible-to-resume owner. Its next turn
@@ -3255,11 +3298,11 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
3255
3298
  // silent" gap). Failures wake it too — the user must hear the task did
3256
3299
  // not finish. Idle-checked and burst-capped so a busy source or a
3257
3300
  // re-delegating loop cannot spin up runs.
3258
- if (ok && reply.trim()) {
3259
- wakeDelegationSource(source, watched.sourceThreadId, targetName);
3301
+ if (ok) {
3302
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
3260
3303
  }
3261
3304
  else if (!ok) {
3262
- wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish");
3305
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish", watched.routineRunId);
3263
3306
  }
3264
3307
  }
3265
3308
  }
@@ -3329,6 +3372,7 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3329
3372
  taskId,
3330
3373
  sourceThreadId,
3331
3374
  sourceBotId,
3375
+ routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
3332
3376
  startedAtMs: Date.now(),
3333
3377
  });
3334
3378
  }
@@ -3365,6 +3409,10 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3365
3409
  reportStartFailure(err);
3366
3410
  });
3367
3411
  };
3412
+ function drainThreadDelegations(threadId) {
3413
+ const routineRunId = activeRoutineRunForThread(threadId)?.id;
3414
+ drainDelegations(commsBus, approvalBus, threadId, runDelegatedTurn, (receipt) => wakeUndispatchedDelegation(receipt, routineRunId));
3415
+ }
3368
3416
  // Most waiting handoffs retry from a target's turn.completed event. Some
3369
3417
  // setup, cancellation, room, watchdog, and provider-reload paths release a
3370
3418
  // bot without that event, so every explicit idle release calls this same
@@ -3377,10 +3425,13 @@ function retryDelegationsWaitingOn(botId) {
3377
3425
  delegationRetryBots.add(botId);
3378
3426
  queueMicrotask(() => {
3379
3427
  delegationRetryBots.delete(botId);
3428
+ // Explicit idle releases (room/setup/reload/watchdog fallbacks) may not
3429
+ // publish turn.completed. They free a waiting source continuation too.
3430
+ drainDelegationWakes();
3380
3431
  if (store.bot(botId)?.busy)
3381
3432
  return;
3382
3433
  for (const waitingThread of releaseDelegationsWaitingOn(botId)) {
3383
- drainDelegations(commsBus, approvalBus, waitingThread, runDelegatedTurn);
3434
+ drainThreadDelegations(waitingThread);
3384
3435
  }
3385
3436
  });
3386
3437
  }
@@ -3395,7 +3446,7 @@ bus.subscribe((event) => {
3395
3446
  if (!event.ok)
3396
3447
  discardDelegations(commsBus, event.threadId);
3397
3448
  else
3398
- drainDelegations(commsBus, approvalBus, event.threadId, runDelegatedTurn);
3449
+ drainThreadDelegations(event.threadId);
3399
3450
  // A settling bot frees itself as a delegation TARGET too: handoffs that
3400
3451
  // found it busy earlier were kept queued (bounded retries) on their own
3401
3452
  // source threads, and this is the moment they get their retry.
@@ -3441,7 +3492,24 @@ function drainQueuedSends() {
3441
3492
  }),
3442
3493
  // Provider completion can precede its dispatch promise: keep the queue
3443
3494
  // intact until that exact handshake releases its runtime-only claim.
3444
- (botId, threadId) => threadBusy(botId, threadId) || Boolean(activeGroupTurnForBot(botId)));
3495
+ (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId)));
3496
+ }
3497
+ /** Keep a person's words off the transcript until a direct-thread slot is
3498
+ * available. Reuse the existing cancellable, idempotent composer queue. */
3499
+ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId) {
3500
+ const capacity = botAtThreadCapacity(botId);
3501
+ if (capacity || threadBusy(botId, threadId)) {
3502
+ const reason = capacity ? "capacity" : undefined;
3503
+ const queued = queueSteeredMessage(botId, threadId, text, {
3504
+ replyToId: replyTo?.id,
3505
+ sendId,
3506
+ reason,
3507
+ prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
3508
+ });
3509
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason };
3510
+ }
3511
+ const message = await startTurn(botId, text, { threadId, replyTo, sendId });
3512
+ return { ok: true, threadId, message };
3445
3513
  }
3446
3514
  const screenPollers = new Map();
3447
3515
  /** The preview shares the box's single command endpoint with the agent's
@@ -3576,6 +3644,19 @@ async function startTurn(botId, text, opts) {
3576
3644
  if (!profile)
3577
3645
  throw Object.assign(new Error("no such bot"), { status: 404 });
3578
3646
  const threadId = opts?.threadId ?? profile.threadId;
3647
+ const continuingRoutine = opts?.cardContinuation ? activeRoutineRunForThread(threadId) : null;
3648
+ if (continuingRoutine) {
3649
+ const onDispatchError = opts?.onDispatchError;
3650
+ opts = {
3651
+ ...opts,
3652
+ runOn: continuingRoutine.runOn,
3653
+ automationSource: continuingRoutine.triggerSource ?? (continuingRoutine.manual ? "manual" : "schedule"),
3654
+ onDispatchError: (message) => {
3655
+ routines?.failThread(threadId, message);
3656
+ onDispatchError?.(message);
3657
+ },
3658
+ };
3659
+ }
3579
3660
  const bot = store.projectBotForTask(botId, threadId);
3580
3661
  if (!bot)
3581
3662
  throw Object.assign(new Error("no such task"), { status: 404 });
@@ -3600,8 +3681,8 @@ async function startTurn(botId, text, opts) {
3600
3681
  if (activeGroupTurnForBot(botId)) {
3601
3682
  throw Object.assign(new Error("the bot is already working in a channel — wait for it to finish"), { status: 409, code: "thread_busy" });
3602
3683
  }
3603
- if (store.tasks(botId).filter((task) => task.busy).length >= MAX_CONCURRENT_BOT_THREADS) {
3604
- throw Object.assign(new Error("this bot is already working on three threads — wait for one to finish"), { status: 409, code: "thread_limit" });
3684
+ if (botAtThreadCapacity(botId)) {
3685
+ throw Object.assign(new Error(`this bot has reached its limit of ${maxConcurrentBotThreads(cfg)} parallel threads — wait for one to finish`), { status: 409, code: "thread_limit" });
3605
3686
  }
3606
3687
  // Retire anything a previous turn left behind before minting this turn's
3607
3688
  // integrations. Completion and interrupt paths do the same; this is the
@@ -4169,6 +4250,7 @@ async function startTurn(botId, text, opts) {
4169
4250
  { id: "credential", label: "Credentials", text: credentialPrompt },
4170
4251
  { id: "recall", label: "Recall", text: recallPrompt },
4171
4252
  { id: "routine", label: "Routines", text: routinePrompt },
4253
+ { id: "routine-execution", label: "Routine execution", text: opts?.automationSource === "schedule" || opts?.automationSource === "manual" ? ROUTINE_EXECUTION_PROMPT : "" },
4172
4254
  { id: "profile", label: "Profile changes", text: profilePrompt },
4173
4255
  { id: "learn", label: "Skill authoring", text: learnPrompt },
4174
4256
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
@@ -4309,8 +4391,14 @@ function routineSourceOwner(run) {
4309
4391
  const bot = store.bot(run.botId);
4310
4392
  if (!bot)
4311
4393
  return null;
4312
- if (store.taskByThread(bot.id, threadId))
4313
- return { bot, group: undefined, threadId };
4394
+ // The source is stamped from the confirmed request, never calendar input.
4395
+ // A request for a teammate runs as that teammate but reports to the bot
4396
+ // whose conversation held the card. Recheck their section before sharing
4397
+ // a result, since either bot may have moved since confirmation.
4398
+ const sourceBot = store.botByThread(threadId);
4399
+ if (sourceBot && sectionKey(sourceBot.section) === sectionKey(bot.section)) {
4400
+ return { bot: sourceBot, group: undefined, threadId };
4401
+ }
4314
4402
  const group = store.groupByThread(threadId);
4315
4403
  return group?.memberIds.includes(bot.id) ? { bot, group, threadId } : null;
4316
4404
  }
@@ -4450,8 +4538,16 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4450
4538
  }
4451
4539
  await directStop;
4452
4540
  }
4541
+ // Load queued handoffs before scheduler recovery can fail an interrupted
4542
+ // run. Its failure callback can then durably drop that work immediately;
4543
+ // nothing dispatches until the listener is ready below.
4544
+ const commsBus = { store, broadcast };
4545
+ _loadPending();
4453
4546
  routines = new RoutineManager({
4454
4547
  emit: broadcast,
4548
+ hasPendingDelegations: (threadId) => pendingThreads().includes(threadId) ||
4549
+ [...delegationWatch.values()].some((watch) => watch.sourceThreadId === threadId) ||
4550
+ pendingDelegationWakes.has(threadId),
4455
4551
  botState: (botId) => {
4456
4552
  const bot = store.bot(botId);
4457
4553
  return !bot ? "missing" : bot.busy ? "busy" : "ready";
@@ -4488,6 +4584,8 @@ routines = new RoutineManager({
4488
4584
  },
4489
4585
  interruptTurn: async (botId, threadId, runOn) => {
4490
4586
  const bot = botForThread(botId, threadId);
4587
+ pendingDelegationWakes.delete(threadId);
4588
+ discardDelegations(commsBus, threadId);
4491
4589
  cancelDirectTurnDispatch(botId, threadId);
4492
4590
  revokeInternalCapabilitiesForThread(threadId);
4493
4591
  const instance = runOn === "cloud"
@@ -4505,11 +4603,16 @@ routines = new RoutineManager({
4505
4603
  interruptGoal: interruptRoutineGroupGoal,
4506
4604
  onRunChanged: syncRoutineRunToSource,
4507
4605
  onRunFailed: (run) => {
4606
+ if (run.threadId) {
4607
+ pendingDelegationWakes.delete(run.threadId);
4608
+ discardDelegations(commsBus, run.threadId);
4609
+ }
4508
4610
  const bot = store.bot(run.botId);
4509
4611
  if (!bot)
4510
4612
  return;
4511
4613
  const detail = run.error ? `${run.routineName}: ${run.error}` : run.routineName;
4512
- notify(buildNotification("routine-failed", bot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
4614
+ const notificationBot = routineSourceOwner(run)?.bot ?? bot;
4615
+ notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
4513
4616
  },
4514
4617
  });
4515
4618
  // The scheduler receipt and room transcript live in separate durable stores.
@@ -4551,7 +4654,6 @@ if (recoveryOwners.length > 0) {
4551
4654
  : [];
4552
4655
  })));
4553
4656
  }
4554
- routines.start();
4555
4657
  // Chat tools can prepare routine changes, but the harness applies them only
4556
4658
  // after the user confirms a durable card. Keeping this beside the scheduler
4557
4659
  // makes the card resolvable after an app restart without involving the model.
@@ -4639,6 +4741,13 @@ const agentRoutine = (routine, latestRun) => {
4639
4741
  type: "interval",
4640
4742
  everyMinutes: routine.schedule.everyMinutes,
4641
4743
  anchorAt: new Date(routine.schedule.anchorAt).toISOString(),
4744
+ ...(routine.schedule.weekdays === undefined
4745
+ ? {}
4746
+ : { weekdays: routine.schedule.weekdays.map((day) => ROUTINE_WEEKDAY_NAMES[day]) }),
4747
+ ...(routine.schedule.window === undefined ? {} : { window: { ...routine.schedule.window } }),
4748
+ ...(routine.schedule.endsAt === undefined
4749
+ ? {}
4750
+ : { endsAt: new Date(routine.schedule.endsAt).toISOString() }),
4642
4751
  }
4643
4752
  : {
4644
4753
  type: "weekly",
@@ -4806,10 +4915,6 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
4806
4915
  })
4807
4916
  .join("\n");
4808
4917
  }
4809
- // comms bus: passed into the visibility helpers in comms-visibility.ts so
4810
- // they can mirror messages + chips without re-deriving SSE plumbing. Same
4811
- // shape every comms entry point uses (ask_bot, delegate_bot).
4812
- const commsBus = { store, broadcast };
4813
4918
  // What each room has already taken from its bots. Keyed by room because the
4814
4919
  // loop post_to_room can start is a property of the room, not of any one
4815
4920
  // caller — three bots posting twice each is the same runaway as one bot
@@ -4830,18 +4935,6 @@ const approvalBus = { store, broadcast, notify };
4830
4935
  if (stale)
4831
4936
  console.log(`peer approvals: dismissed ${stale} card(s) left by a previous run`);
4832
4937
  }
4833
- // Handoffs a previous process queued but never ran: the source turn is
4834
- // dead (no turn survives a restart) so they would otherwise wait forever.
4835
- // Run them now, through the same drain — target and approvePeerComms are
4836
- // re-checked there as always; a source bot that no longer exists is skipped.
4837
- _loadPending();
4838
- {
4839
- const leftover = pendingThreads();
4840
- if (leftover.length)
4841
- console.log(`delegations: ${leftover.length} thread(s) with queued handoffs from a previous run — draining`);
4842
- for (const threadId of leftover)
4843
- drainDelegations(commsBus, approvalBus, threadId, runDelegatedTurn);
4844
- }
4845
4938
  async function runGroupMemberTurn(groupId, threadId, botId, hop,
4846
4939
  // bots that already spoke for this user message — "@Scout ask @Pixel"
4847
4940
  // must not run Pixel twice (once chained, once as a direct responder)
@@ -6402,7 +6495,7 @@ function dispatchConnectorResume(entry) {
6402
6495
  onDispatchError: (message) => markConnectorResumeFailed(entry.threadId, entry.resumeKey, message),
6403
6496
  }).catch((error) => {
6404
6497
  const message = error instanceof Error ? error.message : String(error);
6405
- if (/already working/i.test(message))
6498
+ if (isTurnAdmissionBlocked(error))
6406
6499
  pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
6407
6500
  else
6408
6501
  markConnectorResumeFailed(entry.threadId, entry.resumeKey, message);
@@ -6529,7 +6622,7 @@ function dispatchSecretResume(entry) {
6529
6622
  onDispatchError: (message) => markSecretResumeFailed(entry.threadId, entry.messageId, message),
6530
6623
  }).catch((error) => {
6531
6624
  const message = error instanceof Error ? error.message : String(error);
6532
- if (/already working/i.test(message)) {
6625
+ if (isTurnAdmissionBlocked(error)) {
6533
6626
  pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
6534
6627
  }
6535
6628
  else {
@@ -6707,6 +6800,7 @@ function cliProbeEnvironment() {
6707
6800
  "OMB_COMPOSIO_BROKER_TOKEN",
6708
6801
  "OMB_TTS_KEY",
6709
6802
  "OMB_OPENAI_IMAGE_KEY",
6803
+ "OMB_CUSTOM_IMAGE_KEY",
6710
6804
  "ANTHROPIC_API_KEY",
6711
6805
  "OPENAI_API_KEY",
6712
6806
  ]) {
@@ -6819,12 +6913,13 @@ function configStatus() {
6819
6913
  // the chosen voice is a setting, not a secret; the key is reported the
6820
6914
  // same configured-or-not way as every other credential
6821
6915
  tts: tts.describeVoice(cfg),
6822
- imageGen: { configured: Boolean(cfg.imageGen?.key) },
6916
+ imageGen: avatarImageStatus(cfg),
6823
6917
  // not a secret — the sidebar shows it
6824
6918
  profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" },
6825
6919
  // not a secret — the settings picker shows it; "" = follow the system
6826
6920
  language: cfg.language ?? "",
6827
6921
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
6922
+ threads: { maxConcurrentPerBot: maxConcurrentBotThreads(cfg) },
6828
6923
  localVm: {
6829
6924
  mode: localVmMode(cfg),
6830
6925
  maxInstances: localVmMaxInstances(cfg),
@@ -7186,6 +7281,11 @@ const handleRequest = async (req, res) => {
7186
7281
  if (method === "GET" && path === "/api/health" && !gate.auth) {
7187
7282
  return json(res, 200, { app: "openmausbot" });
7188
7283
  }
7284
+ // The brand is public too: the sign-in page must carry the deployment's
7285
+ // name and icon before anyone has a session, and it holds nothing secret.
7286
+ if (method === "GET" && path === "/api/brand" && !gate.auth) {
7287
+ return json(res, 200, loadBrand());
7288
+ }
7189
7289
  if (!gate.auth)
7190
7290
  return json(res, gate.status, { error: gate.error });
7191
7291
  const auth = gate.auth;
@@ -7872,6 +7972,7 @@ const handleRequest = async (req, res) => {
7872
7972
  taskId,
7873
7973
  sourceThreadId: fromThreadId,
7874
7974
  sourceBotId: currentFrom.id,
7975
+ routineRunId: activeRoutineRunForThread(fromThreadId)?.id,
7875
7976
  });
7876
7977
  store.appendMessage(fromThreadId, {
7877
7978
  role: "bot",
@@ -8642,6 +8743,7 @@ const handleRequest = async (req, res) => {
8642
8743
  return json(res, 400, { error: "messages must be a non-negative whole number" });
8643
8744
  return json(res, 200, {
8644
8745
  bots: store.bots.map((bot) => ({ ...publicBot(bot), ...messagePage(bot.threadId, limit) })),
8746
+ botQueuedMessages: publicBotQueuedMessages(),
8645
8747
  groups: store.groups.map((g) => ({ ...publicGroupState(g), ...messagePage(g.threadId, limit) })),
8646
8748
  computerControl: Object.fromEntries(store.bots.map((bot) => {
8647
8749
  const snapshot = computerControl.snapshot(bot.id);
@@ -9934,7 +10036,7 @@ const handleRequest = async (req, res) => {
9934
10036
  if (!parsed.success) {
9935
10037
  return json(res, 400, { error: `prompt must be at most 400 characters` });
9936
10038
  }
9937
- const generated = await generateAvatarImage(cfg.imageGen?.key ?? "", existing, parsed.data.prompt);
10039
+ const generated = await generateAvatarImage(cfg, existing, parsed.data.prompt);
9938
10040
  const current = store.bot(existing.id);
9939
10041
  if (!current)
9940
10042
  return json(res, 404, { error: "no such bot" });
@@ -11094,7 +11196,7 @@ const handleRequest = async (req, res) => {
11094
11196
  if (queued.text !== text || queued.replyToId !== replyTo?.id) {
11095
11197
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
11096
11198
  }
11097
- return { ok: true, queued: true, queueId: queued.id, threadId };
11199
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
11098
11200
  }
11099
11201
  }
11100
11202
  const currentAtStart = store.projectBotForTask(bot.id, threadId);
@@ -11155,8 +11257,7 @@ const handleRequest = async (req, res) => {
11155
11257
  return { ok: true, steered: true, threadId, message };
11156
11258
  }
11157
11259
  if (!current.busy) {
11158
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId });
11159
- return { ok: true, threadId, message };
11260
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
11160
11261
  }
11161
11262
  const queued = queueSteeredMessage(current.id, threadId, text, {
11162
11263
  replyToId: replyTo?.id,
@@ -11165,8 +11266,7 @@ const handleRequest = async (req, res) => {
11165
11266
  });
11166
11267
  return { ok: true, queued: true, queueId: queued.id, threadId };
11167
11268
  }
11168
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId });
11169
- return { ok: true, threadId, message };
11269
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
11170
11270
  });
11171
11271
  return json(res, 202, receipt);
11172
11272
  }
@@ -11437,6 +11537,22 @@ const handleRequest = async (req, res) => {
11437
11537
  });
11438
11538
  // Folders organize one bot's threads; they never own settings,
11439
11539
  // transcripts or working directories. The project wire names stay stable.
11540
+ m = path.match(/^\/api\/bots\/([\w-]+)\/projects\/order$/);
11541
+ if (m && method === "PATCH") {
11542
+ const bot = store.bot(m[1]);
11543
+ if (!bot)
11544
+ return json(res, 404, { error: "no such bot" });
11545
+ const body = await readBody(req);
11546
+ if (!body || typeof body !== "object" || Array.isArray(body) ||
11547
+ Object.keys(body).some((key) => key !== "projectIds") ||
11548
+ !Array.isArray(body.projectIds) || body.projectIds.some((id) => typeof id !== "string")) {
11549
+ return json(res, 400, { error: "projectIds must be an array of folder IDs" });
11550
+ }
11551
+ const projects = store.reorderProjects(bot.id, body.projectIds);
11552
+ if (!projects)
11553
+ return json(res, 400, { error: "projectIds must include each of this bot's folders exactly once" });
11554
+ return json(res, 200, { projects, bot: botWithThread(bot) });
11555
+ }
11440
11556
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
11441
11557
  if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
11442
11558
  const bot = store.bot(m[1]);
@@ -11451,18 +11567,23 @@ const handleRequest = async (req, res) => {
11451
11567
  const body = await readBody(req);
11452
11568
  if (!body || typeof body !== "object" || Array.isArray(body))
11453
11569
  return json(res, 400, { error: "body must be a JSON object" });
11454
- if (Object.keys(body).some((key) => key !== "name")) {
11570
+ if (Object.keys(body).some((key) => key !== "name" && key !== "emoji")) {
11455
11571
  return json(res, 400, { error: "unsupported folder setting" });
11456
11572
  }
11457
11573
  if ((method === "POST" || body.name !== undefined) &&
11458
11574
  (typeof body.name !== "string" || !body.name.trim() || body.name.trim().length > 80)) {
11459
11575
  return json(res, 400, { error: "folder name must be between 1 and 80 characters" });
11460
11576
  }
11577
+ if (body.emoji !== undefined && body.emoji !== null && !isProjectEmoji(body.emoji)) {
11578
+ return json(res, 400, { error: "folder emoji must be one emoji, or null to reset it" });
11579
+ }
11461
11580
  const patch = {};
11462
11581
  if (body.name !== undefined)
11463
11582
  patch.name = body.name.trim();
11583
+ if (body.emoji !== undefined)
11584
+ patch.emoji = body.emoji;
11464
11585
  const project = method === "POST"
11465
- ? store.createProject(bot.id, patch.name)
11586
+ ? store.createProject(bot.id, patch.name, patch.emoji)
11466
11587
  : store.patchProject(bot.id, m[2], patch);
11467
11588
  return json(res, method === "POST" ? 201 : 200, { project, bot: botWithThread(bot) });
11468
11589
  }
@@ -11896,7 +12017,7 @@ const handleRequest = async (req, res) => {
11896
12017
  const state = await providerAuthSessions.status(authStatus[1], auth.kind === "session" ? auth.session.id : "loopback", url.searchParams.get("flowId") ?? "");
11897
12018
  return json(res, 200, { auth: state });
11898
12019
  }
11899
- const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel)$/.exec(path);
12020
+ const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel|auth\/sign-out)$/.exec(path);
11900
12021
  if (method === "POST" && instanceAction) {
11901
12022
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
11902
12023
  return json(res, 415, { error: "content-type must be application/json" });
@@ -11929,6 +12050,13 @@ const handleRequest = async (req, res) => {
11929
12050
  }
11930
12051
  return json(res, 200, { auth: started });
11931
12052
  }
12053
+ if (action === "auth/sign-out") {
12054
+ const instance = registry.get(instanceId);
12055
+ if (!instance)
12056
+ return json(res, 404, { error: "unknown instance" });
12057
+ await providerAuthSessions.signOut(instance, owner);
12058
+ return json(res, 200, { instances: await describeInstances() });
12059
+ }
11932
12060
  if (action === "auth/complete") {
11933
12061
  const body = await readBody(req);
11934
12062
  const flowId = typeof body?.flowId === "string" ? body.flowId : "";
@@ -12504,6 +12632,8 @@ const handleRequest = async (req, res) => {
12504
12632
  persisted.tts.key = "";
12505
12633
  if (persisted.imageGen?.key !== undefined)
12506
12634
  persisted.imageGen.key = "";
12635
+ if (persisted.imageGen?.customApiKey !== undefined)
12636
+ persisted.imageGen.customApiKey = "";
12507
12637
  saveConfig(persisted);
12508
12638
  configWriteCommitted = true;
12509
12639
  syncCredentialEnv(patch);
@@ -12567,6 +12697,7 @@ const handleRequest = async (req, res) => {
12567
12697
  key !== "imageGen" &&
12568
12698
  key !== "vps" &&
12569
12699
  key !== "rooms" &&
12700
+ key !== "threads" &&
12570
12701
  key !== "localVm" &&
12571
12702
  key !== "features" &&
12572
12703
  key !== "browserProfiles");
@@ -12604,6 +12735,12 @@ const handleRequest = async (req, res) => {
12604
12735
  }
12605
12736
  const status = configStatus();
12606
12737
  broadcast({ kind: "config", ...status });
12738
+ if (patch.threads !== undefined) {
12739
+ drainQueuedSends();
12740
+ drainDelegationWakes();
12741
+ drainConnectorResumes();
12742
+ drainSecretResumes();
12743
+ }
12607
12744
  if (mandatoryError)
12608
12745
  throw mandatoryError;
12609
12746
  return status;
@@ -13048,6 +13185,24 @@ catch (error) {
13048
13185
  }
13049
13186
  server.listen(PORT, "127.0.0.1", () => {
13050
13187
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
13188
+ // Startup work uses the same turn dispatcher and local tool endpoint as
13189
+ // ordinary chat. Start only once every registry is initialized and the
13190
+ // endpoint is listening; earlier dispatch can hit uninitialized bindings.
13191
+ routines.start();
13192
+ const leftover = pendingThreads();
13193
+ if (leftover.length)
13194
+ console.log(`delegations: ${leftover.length} thread(s) with queued handoffs from a previous run — draining`);
13195
+ for (const threadId of leftover) {
13196
+ const run = routines.runForThread(threadId);
13197
+ // A person can reuse a completed run's task for unrelated work. Only
13198
+ // discard the old run's handoffs, not a later user's persisted queue.
13199
+ const reused = run?.finishedAt !== undefined && store.botByThread(threadId) &&
13200
+ store.activePath(threadId).some((message) => message.role === "user" && message.at > run.finishedAt);
13201
+ if (run && !["running", "waiting"].includes(run.status) && !reused)
13202
+ discardDelegations(commsBus, threadId);
13203
+ else
13204
+ drainThreadDelegations(threadId);
13205
+ }
13051
13206
  });
13052
13207
  // A second listener for `openmausbot serve --tunnel` (server/tunnel.ts): the
13053
13208
  // connector gateway on this machine forwards public traffic to this IPC path.