openmausbot 0.1.67 → 0.1.68

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.
@@ -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";
@@ -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
  }
@@ -1494,6 +1498,10 @@ function finishGroupGoalRun(groupId, operation, status, detail) {
1494
1498
  // A calendar-triggered team goal reuses its RoutineRun id for this card.
1495
1499
  // Manual goals have unrelated ids, so the manager safely ignores them.
1496
1500
  const routineRun = routines?.finishGoalRun(run.runId, status, safeDetail);
1501
+ if (routineRun?.status === "cancelled") {
1502
+ pendingDelegationWakes.delete(operation.threadId);
1503
+ discardDelegations(commsBus, operation.threadId);
1504
+ }
1497
1505
  // Member-level turn completions are intentionally private/intermediate for
1498
1506
  // a team goal, so the normal direct-routine notification path never fires.
1499
1507
  // Notify once from the correlated terminal receipt instead.
@@ -1502,13 +1510,15 @@ function finishGroupGoalRun(groupId, operation, status, detail) {
1502
1510
  if (routineRun?.status === "waiting") {
1503
1511
  const coordinator = store.bot(routineRun.botId);
1504
1512
  if (coordinator) {
1505
- notify(buildNotification("question", coordinator, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || `${routineRun.routineName} needs your input`, { avatarUrl: coordinator.avatarUrl }));
1513
+ const notificationBot = routineSourceOwner(routineRun)?.bot ?? coordinator;
1514
+ notify(buildNotification("question", notificationBot, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || `${routineRun.routineName} needs your input`, { avatarUrl: notificationBot.avatarUrl }));
1506
1515
  }
1507
1516
  }
1508
1517
  if (routineRun?.status === "completed") {
1509
1518
  const coordinator = store.bot(routineRun.botId);
1510
1519
  if (coordinator) {
1511
- notify(buildNotification("done", coordinator, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || routineRun.routineName, { avatarUrl: coordinator.avatarUrl }));
1520
+ const notificationBot = routineSourceOwner(routineRun)?.bot ?? coordinator;
1521
+ notify(buildNotification("done", notificationBot, routineSourceThread(routineRun) ?? routineRun.threadId ?? operation.threadId, safeDetail || routineRun.routineName, { avatarUrl: notificationBot.avatarUrl }));
1512
1522
  }
1513
1523
  }
1514
1524
  const group = store.group(groupId);
@@ -2033,9 +2043,17 @@ const watchdog = new TurnWatchdog({
2033
2043
  revokeInternalCapabilitiesForThread(turn.threadId);
2034
2044
  repeats.settle(turn.threadId);
2035
2045
  const bot = botForThread(turn.botId, turn.threadId);
2036
- const instance = bot ? registry.get(bot.modelSelection.instanceId) : null;
2046
+ const routineRun = activeRoutineRunForThread(turn.threadId);
2047
+ const instance = routineRun?.runOn === "cloud"
2048
+ ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent")
2049
+ : bot ? registry.get(bot.modelSelection.instanceId) : null;
2037
2050
  void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
2038
2051
  const minutes = Math.round(TURN_STALL_MS / 60_000);
2052
+ if (routineRun?.target === "bot") {
2053
+ routines?.failThread(turn.threadId, `No activity for ${minutes} minutes — the routine was stopped`);
2054
+ pendingDelegationWakes.delete(turn.threadId);
2055
+ discardDelegations(commsBus, turn.threadId);
2056
+ }
2039
2057
  store.appendMessage(turn.threadId, {
2040
2058
  role: "bot",
2041
2059
  kind: "activity",
@@ -2826,7 +2844,8 @@ bus.subscribe((event) => {
2826
2844
  store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
2827
2845
  else if (asker.busy)
2828
2846
  store.setActivity(asker.id, "waiting-on-you");
2829
- notify(buildNotification(permission ? "approval" : "question", asker, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
2847
+ const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
2848
+ notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
2830
2849
  };
2831
2850
  if (reviewTask && reviewMode === "enforce") {
2832
2851
  // Avoid buzzing the owner for a card the reviewer is about to answer.
@@ -2991,13 +3010,14 @@ bus.subscribe((event) => {
2991
3010
  // A failed peer turn stays a chip too. The bot that delegated is woken
2992
3011
  // with the failure and answers the person in its own thread — buzzing
2993
3012
  // here as well would ring twice for one piece of news.
2994
- if (routineRun?.status !== "failed" && !internal) {
3013
+ if ((!routineRun || routineRun.status === "completed") && !internal) {
2995
3014
  // the frame carries the bot's avatar so every desktop client can
2996
3015
  // show the notification under that bot's own face
2997
3016
  const completionDetail = routineRun
2998
3017
  ? reply || routineRun.output || routineRun.routineName
2999
3018
  : reply;
3000
- notify(buildNotification("done", bot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: bot.avatarUrl }));
3019
+ const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || bot;
3020
+ notify(buildNotification("done", notificationBot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: notificationBot.avatarUrl }));
3001
3021
  }
3002
3022
  if (screenPollers.has(event.threadId)) {
3003
3023
  if (resourceOwner)
@@ -3064,9 +3084,22 @@ const delegationWatch = new Map();
3064
3084
  // the cardContinuation resume pattern used for connector/credential cards.
3065
3085
  const delegationWakeBudget = new DelegationWakeBudget();
3066
3086
  const pendingDelegationWakes = new Map();
3067
- function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3087
+ function activeRoutineRunForThread(threadId) {
3088
+ const run = routines?.runForThread(threadId);
3089
+ return run && ["running", "waiting"].includes(run.status) ? run : null;
3090
+ }
3091
+ function routineDelegationCanResume(threadId, routineRunId) {
3092
+ return !routineRunId || activeRoutineRunForThread(threadId)?.id === routineRunId;
3093
+ }
3094
+ function dispatchDelegationWake(botId, threadId, targetName, failureReason, routineRunId, budgetAcquired = false) {
3068
3095
  if (!store.taskByThread(botId, threadId))
3069
3096
  return;
3097
+ if (!routineDelegationCanResume(threadId, routineRunId))
3098
+ return;
3099
+ if (!budgetAcquired && !delegationWakeBudget.tryAcquire(threadId)) {
3100
+ routines?.failThread(threadId, "Delegation follow-up limit reached; review the run before retrying");
3101
+ return;
3102
+ }
3070
3103
  const prompt = failureReason
3071
3104
  ? buildDelegationFailurePrompt(targetName, failureReason)
3072
3105
  : buildDelegationRevivalPrompt(targetName);
@@ -3081,8 +3114,9 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3081
3114
  return;
3082
3115
  const message = error instanceof Error ? error.message : String(error);
3083
3116
  // 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 });
3117
+ // This is the same logical wake, so keep its original budget charge.
3118
+ if (isTurnAdmissionBlocked(error)) {
3119
+ pendingDelegationWakes.set(threadId, { botId, targetName, failureReason, routineRunId, budgetAcquired: true });
3086
3120
  return;
3087
3121
  }
3088
3122
  store.appendMessage(threadId, {
@@ -3093,37 +3127,43 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3093
3127
  ok: false,
3094
3128
  },
3095
3129
  });
3130
+ routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
3096
3131
  });
3097
3132
  }
3098
- function wakeDelegationSource(source, threadId, targetName, failureReason) {
3133
+ function wakeDelegationSource(source, threadId, targetName, failureReason, routineRunId) {
3099
3134
  if (!store.taskByThread(source.id, threadId))
3100
3135
  return;
3136
+ if (!routineDelegationCanResume(threadId, routineRunId))
3137
+ return;
3101
3138
  // Busy? Hold the wake until the source settles, then drain it — the
3102
3139
  // delegated reply is already in the thread, so nothing is lost, and the
3103
3140
  // source processes it the moment it is free rather than only on a later
3104
3141
  // user nudge.
3105
3142
  if (threadBusy(source.id, threadId) || activeGroupTurnForBot(source.id)) {
3106
- pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason });
3143
+ pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason, routineRunId });
3107
3144
  return;
3108
3145
  }
3109
- if (!delegationWakeBudget.tryAcquire(threadId))
3110
- return;
3111
- dispatchDelegationWake(source.id, threadId, targetName, failureReason);
3146
+ dispatchDelegationWake(source.id, threadId, targetName, failureReason, routineRunId);
3112
3147
  }
3113
3148
  function drainDelegationWakes() {
3114
3149
  for (const [threadId, entry] of pendingDelegationWakes) {
3115
- if (!store.taskByThread(entry.botId, threadId)) {
3150
+ if (!store.taskByThread(entry.botId, threadId) || !routineDelegationCanResume(threadId, entry.routineRunId)) {
3116
3151
  pendingDelegationWakes.delete(threadId);
3117
3152
  continue;
3118
3153
  }
3119
3154
  if (threadBusy(entry.botId, threadId) || activeGroupTurnForBot(entry.botId))
3120
3155
  continue;
3121
3156
  pendingDelegationWakes.delete(threadId);
3122
- if (!delegationWakeBudget.tryAcquire(threadId))
3123
- continue;
3124
- dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason);
3157
+ dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason, entry.routineRunId, entry.budgetAcquired);
3125
3158
  }
3126
3159
  }
3160
+ function wakeUndispatchedDelegation(receipt, routineRunId) {
3161
+ const source = store.botByThread(receipt.sourceThreadId);
3162
+ if (!source)
3163
+ return;
3164
+ markTaskContextExternallyUpdated(source, receipt.sourceThreadId);
3165
+ wakeDelegationSource(source, receipt.sourceThreadId, receipt.toBotName, receipt.result || "the handoff did not run", routineRunId);
3166
+ }
3127
3167
  // Provider-native sessions only know about messages produced inside their
3128
3168
  // own turns. A delegated result is appended later by the harness, so mark the
3129
3169
  // source task with a persisted, impossible-to-resume owner. Its next turn
@@ -3255,11 +3295,11 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
3255
3295
  // silent" gap). Failures wake it too — the user must hear the task did
3256
3296
  // not finish. Idle-checked and burst-capped so a busy source or a
3257
3297
  // re-delegating loop cannot spin up runs.
3258
- if (ok && reply.trim()) {
3259
- wakeDelegationSource(source, watched.sourceThreadId, targetName);
3298
+ if (ok) {
3299
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
3260
3300
  }
3261
3301
  else if (!ok) {
3262
- wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish");
3302
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish", watched.routineRunId);
3263
3303
  }
3264
3304
  }
3265
3305
  }
@@ -3329,6 +3369,7 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3329
3369
  taskId,
3330
3370
  sourceThreadId,
3331
3371
  sourceBotId,
3372
+ routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
3332
3373
  startedAtMs: Date.now(),
3333
3374
  });
3334
3375
  }
@@ -3365,6 +3406,10 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3365
3406
  reportStartFailure(err);
3366
3407
  });
3367
3408
  };
3409
+ function drainThreadDelegations(threadId) {
3410
+ const routineRunId = activeRoutineRunForThread(threadId)?.id;
3411
+ drainDelegations(commsBus, approvalBus, threadId, runDelegatedTurn, (receipt) => wakeUndispatchedDelegation(receipt, routineRunId));
3412
+ }
3368
3413
  // Most waiting handoffs retry from a target's turn.completed event. Some
3369
3414
  // setup, cancellation, room, watchdog, and provider-reload paths release a
3370
3415
  // bot without that event, so every explicit idle release calls this same
@@ -3377,10 +3422,13 @@ function retryDelegationsWaitingOn(botId) {
3377
3422
  delegationRetryBots.add(botId);
3378
3423
  queueMicrotask(() => {
3379
3424
  delegationRetryBots.delete(botId);
3425
+ // Explicit idle releases (room/setup/reload/watchdog fallbacks) may not
3426
+ // publish turn.completed. They free a waiting source continuation too.
3427
+ drainDelegationWakes();
3380
3428
  if (store.bot(botId)?.busy)
3381
3429
  return;
3382
3430
  for (const waitingThread of releaseDelegationsWaitingOn(botId)) {
3383
- drainDelegations(commsBus, approvalBus, waitingThread, runDelegatedTurn);
3431
+ drainThreadDelegations(waitingThread);
3384
3432
  }
3385
3433
  });
3386
3434
  }
@@ -3395,7 +3443,7 @@ bus.subscribe((event) => {
3395
3443
  if (!event.ok)
3396
3444
  discardDelegations(commsBus, event.threadId);
3397
3445
  else
3398
- drainDelegations(commsBus, approvalBus, event.threadId, runDelegatedTurn);
3446
+ drainThreadDelegations(event.threadId);
3399
3447
  // A settling bot frees itself as a delegation TARGET too: handoffs that
3400
3448
  // found it busy earlier were kept queued (bounded retries) on their own
3401
3449
  // source threads, and this is the moment they get their retry.
@@ -3441,7 +3489,24 @@ function drainQueuedSends() {
3441
3489
  }),
3442
3490
  // Provider completion can precede its dispatch promise: keep the queue
3443
3491
  // intact until that exact handshake releases its runtime-only claim.
3444
- (botId, threadId) => threadBusy(botId, threadId) || Boolean(activeGroupTurnForBot(botId)));
3492
+ (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId)));
3493
+ }
3494
+ /** Keep a person's words off the transcript until a direct-thread slot is
3495
+ * available. Reuse the existing cancellable, idempotent composer queue. */
3496
+ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId) {
3497
+ const capacity = botAtThreadCapacity(botId);
3498
+ if (capacity || threadBusy(botId, threadId)) {
3499
+ const reason = capacity ? "capacity" : undefined;
3500
+ const queued = queueSteeredMessage(botId, threadId, text, {
3501
+ replyToId: replyTo?.id,
3502
+ sendId,
3503
+ reason,
3504
+ prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
3505
+ });
3506
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason };
3507
+ }
3508
+ const message = await startTurn(botId, text, { threadId, replyTo, sendId });
3509
+ return { ok: true, threadId, message };
3445
3510
  }
3446
3511
  const screenPollers = new Map();
3447
3512
  /** The preview shares the box's single command endpoint with the agent's
@@ -3576,6 +3641,19 @@ async function startTurn(botId, text, opts) {
3576
3641
  if (!profile)
3577
3642
  throw Object.assign(new Error("no such bot"), { status: 404 });
3578
3643
  const threadId = opts?.threadId ?? profile.threadId;
3644
+ const continuingRoutine = opts?.cardContinuation ? activeRoutineRunForThread(threadId) : null;
3645
+ if (continuingRoutine) {
3646
+ const onDispatchError = opts?.onDispatchError;
3647
+ opts = {
3648
+ ...opts,
3649
+ runOn: continuingRoutine.runOn,
3650
+ automationSource: continuingRoutine.triggerSource ?? (continuingRoutine.manual ? "manual" : "schedule"),
3651
+ onDispatchError: (message) => {
3652
+ routines?.failThread(threadId, message);
3653
+ onDispatchError?.(message);
3654
+ },
3655
+ };
3656
+ }
3579
3657
  const bot = store.projectBotForTask(botId, threadId);
3580
3658
  if (!bot)
3581
3659
  throw Object.assign(new Error("no such task"), { status: 404 });
@@ -3600,8 +3678,8 @@ async function startTurn(botId, text, opts) {
3600
3678
  if (activeGroupTurnForBot(botId)) {
3601
3679
  throw Object.assign(new Error("the bot is already working in a channel — wait for it to finish"), { status: 409, code: "thread_busy" });
3602
3680
  }
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" });
3681
+ if (botAtThreadCapacity(botId)) {
3682
+ 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
3683
  }
3606
3684
  // Retire anything a previous turn left behind before minting this turn's
3607
3685
  // integrations. Completion and interrupt paths do the same; this is the
@@ -4169,6 +4247,7 @@ async function startTurn(botId, text, opts) {
4169
4247
  { id: "credential", label: "Credentials", text: credentialPrompt },
4170
4248
  { id: "recall", label: "Recall", text: recallPrompt },
4171
4249
  { id: "routine", label: "Routines", text: routinePrompt },
4250
+ { id: "routine-execution", label: "Routine execution", text: opts?.automationSource === "schedule" || opts?.automationSource === "manual" ? ROUTINE_EXECUTION_PROMPT : "" },
4172
4251
  { id: "profile", label: "Profile changes", text: profilePrompt },
4173
4252
  { id: "learn", label: "Skill authoring", text: learnPrompt },
4174
4253
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
@@ -4309,8 +4388,14 @@ function routineSourceOwner(run) {
4309
4388
  const bot = store.bot(run.botId);
4310
4389
  if (!bot)
4311
4390
  return null;
4312
- if (store.taskByThread(bot.id, threadId))
4313
- return { bot, group: undefined, threadId };
4391
+ // The source is stamped from the confirmed request, never calendar input.
4392
+ // A request for a teammate runs as that teammate but reports to the bot
4393
+ // whose conversation held the card. Recheck their section before sharing
4394
+ // a result, since either bot may have moved since confirmation.
4395
+ const sourceBot = store.botByThread(threadId);
4396
+ if (sourceBot && sectionKey(sourceBot.section) === sectionKey(bot.section)) {
4397
+ return { bot: sourceBot, group: undefined, threadId };
4398
+ }
4314
4399
  const group = store.groupByThread(threadId);
4315
4400
  return group?.memberIds.includes(bot.id) ? { bot, group, threadId } : null;
4316
4401
  }
@@ -4450,8 +4535,16 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4450
4535
  }
4451
4536
  await directStop;
4452
4537
  }
4538
+ // Load queued handoffs before scheduler recovery can fail an interrupted
4539
+ // run. Its failure callback can then durably drop that work immediately;
4540
+ // nothing dispatches until the listener is ready below.
4541
+ const commsBus = { store, broadcast };
4542
+ _loadPending();
4453
4543
  routines = new RoutineManager({
4454
4544
  emit: broadcast,
4545
+ hasPendingDelegations: (threadId) => pendingThreads().includes(threadId) ||
4546
+ [...delegationWatch.values()].some((watch) => watch.sourceThreadId === threadId) ||
4547
+ pendingDelegationWakes.has(threadId),
4455
4548
  botState: (botId) => {
4456
4549
  const bot = store.bot(botId);
4457
4550
  return !bot ? "missing" : bot.busy ? "busy" : "ready";
@@ -4488,6 +4581,8 @@ routines = new RoutineManager({
4488
4581
  },
4489
4582
  interruptTurn: async (botId, threadId, runOn) => {
4490
4583
  const bot = botForThread(botId, threadId);
4584
+ pendingDelegationWakes.delete(threadId);
4585
+ discardDelegations(commsBus, threadId);
4491
4586
  cancelDirectTurnDispatch(botId, threadId);
4492
4587
  revokeInternalCapabilitiesForThread(threadId);
4493
4588
  const instance = runOn === "cloud"
@@ -4505,11 +4600,16 @@ routines = new RoutineManager({
4505
4600
  interruptGoal: interruptRoutineGroupGoal,
4506
4601
  onRunChanged: syncRoutineRunToSource,
4507
4602
  onRunFailed: (run) => {
4603
+ if (run.threadId) {
4604
+ pendingDelegationWakes.delete(run.threadId);
4605
+ discardDelegations(commsBus, run.threadId);
4606
+ }
4508
4607
  const bot = store.bot(run.botId);
4509
4608
  if (!bot)
4510
4609
  return;
4511
4610
  const detail = run.error ? `${run.routineName}: ${run.error}` : run.routineName;
4512
- notify(buildNotification("routine-failed", bot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
4611
+ const notificationBot = routineSourceOwner(run)?.bot ?? bot;
4612
+ notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
4513
4613
  },
4514
4614
  });
4515
4615
  // The scheduler receipt and room transcript live in separate durable stores.
@@ -4551,7 +4651,6 @@ if (recoveryOwners.length > 0) {
4551
4651
  : [];
4552
4652
  })));
4553
4653
  }
4554
- routines.start();
4555
4654
  // Chat tools can prepare routine changes, but the harness applies them only
4556
4655
  // after the user confirms a durable card. Keeping this beside the scheduler
4557
4656
  // makes the card resolvable after an app restart without involving the model.
@@ -4806,10 +4905,6 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
4806
4905
  })
4807
4906
  .join("\n");
4808
4907
  }
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
4908
  // What each room has already taken from its bots. Keyed by room because the
4814
4909
  // loop post_to_room can start is a property of the room, not of any one
4815
4910
  // caller — three bots posting twice each is the same runaway as one bot
@@ -4830,18 +4925,6 @@ const approvalBus = { store, broadcast, notify };
4830
4925
  if (stale)
4831
4926
  console.log(`peer approvals: dismissed ${stale} card(s) left by a previous run`);
4832
4927
  }
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
4928
  async function runGroupMemberTurn(groupId, threadId, botId, hop,
4846
4929
  // bots that already spoke for this user message — "@Scout ask @Pixel"
4847
4930
  // must not run Pixel twice (once chained, once as a direct responder)
@@ -6402,7 +6485,7 @@ function dispatchConnectorResume(entry) {
6402
6485
  onDispatchError: (message) => markConnectorResumeFailed(entry.threadId, entry.resumeKey, message),
6403
6486
  }).catch((error) => {
6404
6487
  const message = error instanceof Error ? error.message : String(error);
6405
- if (/already working/i.test(message))
6488
+ if (isTurnAdmissionBlocked(error))
6406
6489
  pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
6407
6490
  else
6408
6491
  markConnectorResumeFailed(entry.threadId, entry.resumeKey, message);
@@ -6529,7 +6612,7 @@ function dispatchSecretResume(entry) {
6529
6612
  onDispatchError: (message) => markSecretResumeFailed(entry.threadId, entry.messageId, message),
6530
6613
  }).catch((error) => {
6531
6614
  const message = error instanceof Error ? error.message : String(error);
6532
- if (/already working/i.test(message)) {
6615
+ if (isTurnAdmissionBlocked(error)) {
6533
6616
  pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
6534
6617
  }
6535
6618
  else {
@@ -6825,6 +6908,7 @@ function configStatus() {
6825
6908
  // not a secret — the settings picker shows it; "" = follow the system
6826
6909
  language: cfg.language ?? "",
6827
6910
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
6911
+ threads: { maxConcurrentPerBot: maxConcurrentBotThreads(cfg) },
6828
6912
  localVm: {
6829
6913
  mode: localVmMode(cfg),
6830
6914
  maxInstances: localVmMaxInstances(cfg),
@@ -7186,6 +7270,11 @@ const handleRequest = async (req, res) => {
7186
7270
  if (method === "GET" && path === "/api/health" && !gate.auth) {
7187
7271
  return json(res, 200, { app: "openmausbot" });
7188
7272
  }
7273
+ // The brand is public too: the sign-in page must carry the deployment's
7274
+ // name and icon before anyone has a session, and it holds nothing secret.
7275
+ if (method === "GET" && path === "/api/brand" && !gate.auth) {
7276
+ return json(res, 200, loadBrand());
7277
+ }
7189
7278
  if (!gate.auth)
7190
7279
  return json(res, gate.status, { error: gate.error });
7191
7280
  const auth = gate.auth;
@@ -7872,6 +7961,7 @@ const handleRequest = async (req, res) => {
7872
7961
  taskId,
7873
7962
  sourceThreadId: fromThreadId,
7874
7963
  sourceBotId: currentFrom.id,
7964
+ routineRunId: activeRoutineRunForThread(fromThreadId)?.id,
7875
7965
  });
7876
7966
  store.appendMessage(fromThreadId, {
7877
7967
  role: "bot",
@@ -11094,7 +11184,7 @@ const handleRequest = async (req, res) => {
11094
11184
  if (queued.text !== text || queued.replyToId !== replyTo?.id) {
11095
11185
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
11096
11186
  }
11097
- return { ok: true, queued: true, queueId: queued.id, threadId };
11187
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
11098
11188
  }
11099
11189
  }
11100
11190
  const currentAtStart = store.projectBotForTask(bot.id, threadId);
@@ -11155,8 +11245,7 @@ const handleRequest = async (req, res) => {
11155
11245
  return { ok: true, steered: true, threadId, message };
11156
11246
  }
11157
11247
  if (!current.busy) {
11158
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId });
11159
- return { ok: true, threadId, message };
11248
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
11160
11249
  }
11161
11250
  const queued = queueSteeredMessage(current.id, threadId, text, {
11162
11251
  replyToId: replyTo?.id,
@@ -11165,8 +11254,7 @@ const handleRequest = async (req, res) => {
11165
11254
  });
11166
11255
  return { ok: true, queued: true, queueId: queued.id, threadId };
11167
11256
  }
11168
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId });
11169
- return { ok: true, threadId, message };
11257
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
11170
11258
  });
11171
11259
  return json(res, 202, receipt);
11172
11260
  }
@@ -11437,6 +11525,22 @@ const handleRequest = async (req, res) => {
11437
11525
  });
11438
11526
  // Folders organize one bot's threads; they never own settings,
11439
11527
  // transcripts or working directories. The project wire names stay stable.
11528
+ m = path.match(/^\/api\/bots\/([\w-]+)\/projects\/order$/);
11529
+ if (m && method === "PATCH") {
11530
+ const bot = store.bot(m[1]);
11531
+ if (!bot)
11532
+ return json(res, 404, { error: "no such bot" });
11533
+ const body = await readBody(req);
11534
+ if (!body || typeof body !== "object" || Array.isArray(body) ||
11535
+ Object.keys(body).some((key) => key !== "projectIds") ||
11536
+ !Array.isArray(body.projectIds) || body.projectIds.some((id) => typeof id !== "string")) {
11537
+ return json(res, 400, { error: "projectIds must be an array of folder IDs" });
11538
+ }
11539
+ const projects = store.reorderProjects(bot.id, body.projectIds);
11540
+ if (!projects)
11541
+ return json(res, 400, { error: "projectIds must include each of this bot's folders exactly once" });
11542
+ return json(res, 200, { projects, bot: botWithThread(bot) });
11543
+ }
11440
11544
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
11441
11545
  if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
11442
11546
  const bot = store.bot(m[1]);
@@ -11451,18 +11555,23 @@ const handleRequest = async (req, res) => {
11451
11555
  const body = await readBody(req);
11452
11556
  if (!body || typeof body !== "object" || Array.isArray(body))
11453
11557
  return json(res, 400, { error: "body must be a JSON object" });
11454
- if (Object.keys(body).some((key) => key !== "name")) {
11558
+ if (Object.keys(body).some((key) => key !== "name" && key !== "emoji")) {
11455
11559
  return json(res, 400, { error: "unsupported folder setting" });
11456
11560
  }
11457
11561
  if ((method === "POST" || body.name !== undefined) &&
11458
11562
  (typeof body.name !== "string" || !body.name.trim() || body.name.trim().length > 80)) {
11459
11563
  return json(res, 400, { error: "folder name must be between 1 and 80 characters" });
11460
11564
  }
11565
+ if (body.emoji !== undefined && body.emoji !== null && !isProjectEmoji(body.emoji)) {
11566
+ return json(res, 400, { error: "folder emoji must be one emoji, or null to reset it" });
11567
+ }
11461
11568
  const patch = {};
11462
11569
  if (body.name !== undefined)
11463
11570
  patch.name = body.name.trim();
11571
+ if (body.emoji !== undefined)
11572
+ patch.emoji = body.emoji;
11464
11573
  const project = method === "POST"
11465
- ? store.createProject(bot.id, patch.name)
11574
+ ? store.createProject(bot.id, patch.name, patch.emoji)
11466
11575
  : store.patchProject(bot.id, m[2], patch);
11467
11576
  return json(res, method === "POST" ? 201 : 200, { project, bot: botWithThread(bot) });
11468
11577
  }
@@ -12567,6 +12676,7 @@ const handleRequest = async (req, res) => {
12567
12676
  key !== "imageGen" &&
12568
12677
  key !== "vps" &&
12569
12678
  key !== "rooms" &&
12679
+ key !== "threads" &&
12570
12680
  key !== "localVm" &&
12571
12681
  key !== "features" &&
12572
12682
  key !== "browserProfiles");
@@ -12604,6 +12714,12 @@ const handleRequest = async (req, res) => {
12604
12714
  }
12605
12715
  const status = configStatus();
12606
12716
  broadcast({ kind: "config", ...status });
12717
+ if (patch.threads !== undefined) {
12718
+ drainQueuedSends();
12719
+ drainDelegationWakes();
12720
+ drainConnectorResumes();
12721
+ drainSecretResumes();
12722
+ }
12607
12723
  if (mandatoryError)
12608
12724
  throw mandatoryError;
12609
12725
  return status;
@@ -13048,6 +13164,24 @@ catch (error) {
13048
13164
  }
13049
13165
  server.listen(PORT, "127.0.0.1", () => {
13050
13166
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
13167
+ // Startup work uses the same turn dispatcher and local tool endpoint as
13168
+ // ordinary chat. Start only once every registry is initialized and the
13169
+ // endpoint is listening; earlier dispatch can hit uninitialized bindings.
13170
+ routines.start();
13171
+ const leftover = pendingThreads();
13172
+ if (leftover.length)
13173
+ console.log(`delegations: ${leftover.length} thread(s) with queued handoffs from a previous run — draining`);
13174
+ for (const threadId of leftover) {
13175
+ const run = routines.runForThread(threadId);
13176
+ // A person can reuse a completed run's task for unrelated work. Only
13177
+ // discard the old run's handoffs, not a later user's persisted queue.
13178
+ const reused = run?.finishedAt !== undefined && store.botByThread(threadId) &&
13179
+ store.activePath(threadId).some((message) => message.role === "user" && message.at > run.finishedAt);
13180
+ if (run && !["running", "waiting"].includes(run.status) && !reused)
13181
+ discardDelegations(commsBus, threadId);
13182
+ else
13183
+ drainThreadDelegations(threadId);
13184
+ }
13051
13185
  });
13052
13186
  // A second listener for `openmausbot serve --tunnel` (server/tunnel.ts): the
13053
13187
  // connector gateway on this machine forwards public traffic to this IPC path.