openmausbot 0.1.66 → 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.
Files changed (53) hide show
  1. package/dist/assets/index-CbaejjQA.js +278 -0
  2. package/dist/assets/{index-kW5L1QeO.js → index-HqooKFTS.js} +1 -1
  3. package/dist/assets/index-aJ5EWZOQ.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/computer-proxy.js +14 -3
  6. package/dist-server/container-mcp.js +44 -19
  7. package/dist-server/drivers/agents-proxy.js +59 -3
  8. package/dist-server/enterprise/server/index.js +14587 -0
  9. package/dist-server/index.js +2675 -1055
  10. package/dist-server/local-computer-proxy.js +433 -0
  11. package/dist-server/local-computer.js +48 -5
  12. package/dist-server/mcp-server.js +38 -17
  13. package/dist-server/openmausbot.js +1560 -745
  14. package/dist-server/pair-cli.js +1560 -745
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/account-signin.js +103 -0
  17. package/dist-server/server/brand.js +7 -0
  18. package/dist-server/server/caddy.js +203 -0
  19. package/dist-server/server/cli.js +185 -22
  20. package/dist-server/server/computer-proxy.js +11 -2
  21. package/dist-server/server/config.js +22 -1
  22. package/dist-server/server/control-client.js +7 -1
  23. package/dist-server/server/delegations.js +56 -11
  24. package/dist-server/server/drivers/agents-proxy.js +75 -5
  25. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  26. package/dist-server/server/drivers/claude.js +8 -0
  27. package/dist-server/server/environment.js +1 -0
  28. package/dist-server/server/harness/registry.js +7 -1
  29. package/dist-server/server/index.js +965 -363
  30. package/dist-server/server/local-computer-proxy.js +28 -0
  31. package/dist-server/server/local-computer.js +17 -0
  32. package/dist-server/server/mcp-bridge.js +27 -11
  33. package/dist-server/server/peer-approval.js +19 -6
  34. package/dist-server/server/proxy-paths.js +1 -0
  35. package/dist-server/server/routines.js +36 -9
  36. package/dist-server/server/service-cli.js +63 -0
  37. package/dist-server/server/service-unit.js +129 -0
  38. package/dist-server/server/sessions.js +42 -1
  39. package/dist-server/server/steer-queue.js +12 -9
  40. package/dist-server/server/store.js +260 -34
  41. package/dist-server/server/system-prompt.js +1 -0
  42. package/dist-server/server/team-backup.js +1 -0
  43. package/dist-server/server/tunnel.js +27 -1
  44. package/dist-server/server/turn-dispatch-guard.js +6 -0
  45. package/dist-server/server/turn-resources.js +50 -0
  46. package/dist-server/server/workspace.js +53 -3
  47. package/dist-server/vps-container-mcp.js +44 -19
  48. package/enterprise/LICENSE +32 -0
  49. package/enterprise/README.md +89 -0
  50. package/enterprise/server/index.js +14587 -0
  51. package/package.json +2 -1
  52. package/dist/assets/index-BftlaHAF.css +0 -1
  53. package/dist/assets/index-DuR3H-Hf.js +0 -278
@@ -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,13 +63,14 @@ 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";
70
70
  import { extractTurnImages } from "./turn-images.js";
71
71
  import { TurnWatchdog } from "./turn-watchdog.js";
72
- import { ensureWorkspace, listMemoryTopics, isMemoryTopicName, memorySystemPrompt, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
72
+ import { TurnResources, workspaceResource } from "./turn-resources.js";
73
+ import { ensureWorkspace, ensureTaskWorkspace, updateMemory, listMemoryTopics, isMemoryTopicName, memorySystemPrompt, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
73
74
  import { readMemoryFile, readMemoryTopic, writeMemoryFile, MEMORY_FILE_MAX_BYTES, } from "./workspace.js";
74
75
  import { readSectionContext, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
75
76
  import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
@@ -77,8 +78,8 @@ import { fetchSkillFromSource } from "./skill-fetch.js";
77
78
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
78
79
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
79
80
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
80
- import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
81
- import { readCuaConnection } from "./local-computer.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
+ import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
82
83
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
83
84
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
84
85
  import { LocalVmLeasePool } from "./local-vm-lease.js";
@@ -115,12 +116,13 @@ import { createTeamBackup, importTeamBackup } from "./team-backup.js";
115
116
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
116
117
  import { shouldMountLocalComputer } from "./local-routing.js";
117
118
  import { resolveSurface } from "./surface.js";
118
- import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
119
+ import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
119
120
  import { createGracefulShutdown } from "./graceful-shutdown.js";
120
121
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
121
122
  import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
122
123
  import { environmentDescriptor, loadEnvironmentId } from "./environment.js";
123
124
  import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
125
+ import { createEmailSignIn, parseAllowList } from "./account-signin.js";
124
126
  import { ProviderAuthSessions } from "./provider-auth-sessions.js";
125
127
  import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
126
128
  import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
@@ -178,6 +180,14 @@ let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
178
180
  const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
179
181
  const cfg = loadConfig();
180
182
  const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
183
+ // "Sign in with your email" on /pair: the allow-list is read per call so a
184
+ // Settings change or an env bootstrap applies without a restart.
185
+ const emailSignIn = createEmailSignIn({
186
+ allow: () => {
187
+ const current = loadConfig().signIn;
188
+ return { admins: parseAllowList(current?.admins?.join(",")), members: parseAllowList(current?.members?.join(",")) };
189
+ },
190
+ });
181
191
  let customDomainRevision = 0;
182
192
  function savedCustomDomain() {
183
193
  if (DESKTOP_MANAGED || !cfg.customDomain)
@@ -434,7 +444,82 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation) {
434
444
  class DirectTurnSetupCancelled extends Error {
435
445
  }
436
446
  const directTurnDispatchClaims = new Map();
437
- const directTurnGenerationByBot = new Map();
447
+ const directTurnGenerationByThread = new Map();
448
+ // Keep the exact provider/profile settings that own a running conversation.
449
+ // Selecting another thread or changing a default must not retarget its tools.
450
+ const directTurnBots = new Map();
451
+ let providerFleetReloading = false;
452
+ const turnResources = new TurnResources();
453
+ const turnResourceOwners = new Map();
454
+ const turnComputerResources = new Map();
455
+ const settlingResourceOwners = new Map();
456
+ function claimTurnResource(owner, resource) {
457
+ if (!turnResources.claim(resource, owner))
458
+ return false;
459
+ turnResourceOwners.set(owner.threadId, owner);
460
+ return true;
461
+ }
462
+ function releaseTurnResources(owner) {
463
+ if (!owner)
464
+ return;
465
+ if (settlingResourceOwners.get(owner.threadId) === owner.generation)
466
+ settlingResourceOwners.delete(owner.threadId);
467
+ turnResources.release(owner);
468
+ if (turnResourceOwners.get(owner.threadId)?.generation === owner.generation)
469
+ turnResourceOwners.delete(owner.threadId);
470
+ if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
471
+ turnComputerResources.delete(owner.threadId);
472
+ }
473
+ function bindTurnComputer(owner, resource, exclusive = false) {
474
+ if (exclusive && !claimTurnResource(owner, resource)) {
475
+ throw Object.assign(new Error("another thread is using this computer — wait for it to finish"), { status: 409, code: "computer_busy" });
476
+ }
477
+ turnResourceOwners.set(owner.threadId, owner);
478
+ turnComputerResources.set(owner.threadId, { owner, resource });
479
+ }
480
+ function botForThread(botId, threadId) {
481
+ return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
482
+ }
483
+ function threadBusy(botId, threadId) {
484
+ return store.taskByThread(botId, threadId)?.busy === true || directTurnDispatchClaims.has(threadId);
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
+ }
491
+ function hasDirectDispatch(botId) {
492
+ return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
493
+ }
494
+ function requestedTaskBot(botId, rawThreadId) {
495
+ const profile = store.bot(botId);
496
+ if (!profile)
497
+ throw Object.assign(new Error("no such bot"), { status: 404 });
498
+ if (rawThreadId !== undefined && (typeof rawThreadId !== "string" || !/^[\w-]+$/.test(rawThreadId))) {
499
+ throw Object.assign(new Error("threadId must be a task id"), { status: 400 });
500
+ }
501
+ const threadId = typeof rawThreadId === "string" ? rawThreadId : profile.threadId;
502
+ const task = store.projectBotForTask(botId, threadId);
503
+ if (!task)
504
+ throw Object.assign(new Error("no such task"), { status: 404 });
505
+ return task;
506
+ }
507
+ async function interruptDirectThread(botId, threadId) {
508
+ const owner = botForThread(botId, threadId);
509
+ cancelDirectTurnDispatch(botId, threadId);
510
+ revokeInternalCapabilitiesForThread(threadId);
511
+ await (owner ? registry.get(owner.modelSelection.instanceId) : undefined)?.adapter.interruptTurn(threadId);
512
+ closeOpenApprovals(threadId);
513
+ }
514
+ async function interruptAllDirectThreads(botId) {
515
+ const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId));
516
+ // Revoke every sibling before yielding to any provider teardown.
517
+ for (const task of threads) {
518
+ cancelDirectTurnDispatch(botId, task.threadId);
519
+ revokeInternalCapabilitiesForThread(task.threadId);
520
+ }
521
+ await Promise.all(threads.map((task) => interruptDirectThread(botId, task.threadId)));
522
+ }
438
523
  const retiredProviderTurns = new RetiredTurnRegistry();
439
524
  const pendingCancelledProviderHandshakes = new PendingTurnCancellations();
440
525
  const generatedImagesByTurn = new Map();
@@ -481,32 +566,35 @@ function shouldIgnoreProviderEvent(event) {
481
566
  return true;
482
567
  if (event.type !== "session.exited" || event.turnId !== undefined)
483
568
  return false;
484
- return store.botByThread(event.threadId)?.busy === true || Boolean(store.groupByThread(event.threadId)?.busyBotId);
569
+ const bot = store.botByThread(event.threadId);
570
+ return Boolean(bot && threadBusy(bot.id, event.threadId)) || Boolean(store.groupByThread(event.threadId)?.busyBotId);
485
571
  }
486
572
  function directTurnClaimIsCurrent(botId, claimId, threadId) {
487
- const claim = directTurnDispatchClaims.get(botId);
488
- const bot = store.bot(botId);
489
- return claim?.id === claimId && claim.threadId === threadId && bot?.busy === true;
573
+ const claim = directTurnDispatchClaims.get(threadId);
574
+ return claim?.id === claimId && claim.botId === botId && store.taskByThread(botId, threadId)?.busy === true;
490
575
  }
491
576
  function directTurnClaimExists(botId, claimId, threadId) {
492
- const claim = directTurnDispatchClaims.get(botId);
493
- return claim?.id === claimId && claim.threadId === threadId;
577
+ const claim = directTurnDispatchClaims.get(threadId);
578
+ return claim?.id === claimId && claim.botId === botId;
494
579
  }
495
580
  function markDirectTurnDispatching(botId, claimId, threadId) {
496
581
  if (!directTurnClaimIsCurrent(botId, claimId, threadId))
497
582
  return false;
498
- directTurnDispatchClaims.set(botId, { id: claimId, threadId, phase: "dispatching" });
583
+ directTurnDispatchClaims.set(threadId, { id: claimId, botId, threadId, phase: "dispatching" });
499
584
  return true;
500
585
  }
501
- function clearDirectTurnDispatch(botId, claimId) {
502
- if (directTurnDispatchClaims.get(botId)?.id === claimId)
503
- directTurnDispatchClaims.delete(botId);
586
+ function clearDirectTurnDispatch(threadId, claimId) {
587
+ if (directTurnDispatchClaims.get(threadId)?.id === claimId)
588
+ directTurnDispatchClaims.delete(threadId);
504
589
  }
505
590
  function cancelDirectTurnDispatch(botId, expectedThreadId) {
506
- const claim = directTurnDispatchClaims.get(botId);
507
- if (!claim || (expectedThreadId !== undefined && claim.threadId !== expectedThreadId))
591
+ const threadId = expectedThreadId ?? store.bot(botId)?.threadId;
592
+ if (!threadId)
508
593
  return null;
509
- directTurnDispatchClaims.delete(botId);
594
+ const claim = directTurnDispatchClaims.get(threadId);
595
+ if (!claim || claim.botId !== botId)
596
+ return null;
597
+ directTurnDispatchClaims.delete(threadId);
510
598
  // Setup has not called the adapter yet, so there is no provider handshake
511
599
  // (and no unknown turn id) to quarantine. Dispatching is the only phase in
512
600
  // which a late provider event can exist.
@@ -675,11 +763,11 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
675
763
  }),
676
764
  };
677
765
  }
678
- function askBotAndWait(targetBotId, message, depth, fromBotId) {
766
+ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
679
767
  const target = store.bot(targetBotId);
680
768
  if (!target)
681
769
  return Promise.resolve({ status: "error", text: "(no such bot)" });
682
- const threadId = target.threadId;
770
+ const threadId = targetThreadId ?? target.threadId;
683
771
  return new Promise((resolve) => {
684
772
  let text = "";
685
773
  let done = false;
@@ -716,8 +804,9 @@ function askBotAndWait(targetBotId, message, depth, fromBotId) {
716
804
  // prefixed to it: the wording is for the model reading this turn, the
717
805
  // field is for anything that reads the transcript later.
718
806
  const asker = fromBotId ? store.bot(fromBotId) : undefined;
719
- const unattended = isUnattended(fromBotId);
807
+ const unattended = isUnattended(fromBotId, fromThreadId);
720
808
  startTurn(targetBotId, message, {
809
+ threadId,
721
810
  commsDepth: depth + 1,
722
811
  unattended,
723
812
  peerAsk: asker
@@ -1008,14 +1097,14 @@ async function botOverview(bot) {
1008
1097
  * this too, but no provider dispatch or later permission callback relies on
1009
1098
  * persistence having been produced exclusively by that route. Delegation
1010
1099
  * uses the receiving bot's grant, never the sender's — see approvalModeForOrigin. */
1011
- const approvalModeForTurn = (bot, peerInitiated = false) => {
1100
+ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
1012
1101
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
1013
1102
  if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1014
1103
  return "ask";
1015
1104
  }
1016
1105
  // Native reviewers can approve before a permission reaches this process.
1017
1106
  // Unattended Auto must therefore downgrade before spawning the provider.
1018
- if (mode === "auto" && isUnattended(bot.id))
1107
+ if (mode === "auto" && isUnattended(bot.id, threadId))
1019
1108
  return "ask";
1020
1109
  return mode;
1021
1110
  };
@@ -1409,6 +1498,10 @@ function finishGroupGoalRun(groupId, operation, status, detail) {
1409
1498
  // A calendar-triggered team goal reuses its RoutineRun id for this card.
1410
1499
  // Manual goals have unrelated ids, so the manager safely ignores them.
1411
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
+ }
1412
1505
  // Member-level turn completions are intentionally private/intermediate for
1413
1506
  // a team goal, so the normal direct-routine notification path never fires.
1414
1507
  // Notify once from the correlated terminal receipt instead.
@@ -1417,13 +1510,15 @@ function finishGroupGoalRun(groupId, operation, status, detail) {
1417
1510
  if (routineRun?.status === "waiting") {
1418
1511
  const coordinator = store.bot(routineRun.botId);
1419
1512
  if (coordinator) {
1420
- 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 }));
1421
1515
  }
1422
1516
  }
1423
1517
  if (routineRun?.status === "completed") {
1424
1518
  const coordinator = store.bot(routineRun.botId);
1425
1519
  if (coordinator) {
1426
- 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 }));
1427
1522
  }
1428
1523
  }
1429
1524
  const group = store.group(groupId);
@@ -1940,15 +2035,25 @@ const watchdog = new TurnWatchdog({
1940
2035
  stallMs: TURN_STALL_MS,
1941
2036
  checkMs: 60_000,
1942
2037
  onStall: (turn) => {
2038
+ const stalledResourceOwner = turnResourceOwners.get(turn.threadId);
2039
+ const stalledGeneration = directTurnGenerationByThread.get(turn.threadId);
1943
2040
  // Room targets carry an invocation identity; only those claims belong
1944
2041
  // to the room grace cleanup added here.
1945
2042
  const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
1946
2043
  revokeInternalCapabilitiesForThread(turn.threadId);
1947
2044
  repeats.settle(turn.threadId);
1948
- const bot = store.bot(turn.botId);
1949
- const instance = bot ? registry.get(bot.modelSelection.instanceId) : null;
2045
+ const bot = botForThread(turn.botId, turn.threadId);
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;
1950
2050
  void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
1951
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
+ }
1952
2057
  store.appendMessage(turn.threadId, {
1953
2058
  role: "bot",
1954
2059
  kind: "activity",
@@ -1962,6 +2067,10 @@ const watchdog = new TurnWatchdog({
1962
2067
  // overlap the process we are stopping. The normal turn.completed fold
1963
2068
  // clears it first when the adapter responds.
1964
2069
  const releaseOwnership = () => {
2070
+ if (stalledGeneration && directTurnGenerationByThread.get(turn.threadId) !== stalledGeneration)
2071
+ return;
2072
+ if (stalledResourceOwner && turnResourceOwners.get(turn.threadId)?.generation !== stalledResourceOwner.generation)
2073
+ return;
1965
2074
  // A goal coordinator can stall before sendTurn reveals its provider
1966
2075
  // turn id. Reusing the room during that ambiguous pre-id window would
1967
2076
  // make old and replacement events indistinguishable. Keep ownership
@@ -1982,10 +2091,15 @@ const watchdog = new TurnWatchdog({
1982
2091
  }
1983
2092
  const currentBot = store.bot(turn.botId);
1984
2093
  if (currentBot?.busy) {
1985
- stopScreenPoller(currentBot.id);
2094
+ stopScreenPoller(currentBot.id, turn.threadId);
2095
+ releaseTurnResources(stalledResourceOwner);
1986
2096
  if (activeVpsThreads.get(currentBot.id) === turn.threadId)
1987
2097
  activeVpsThreads.delete(currentBot.id);
1988
- store.setActivity(currentBot.id, "idle");
2098
+ if (store.taskByThread(currentBot.id, turn.threadId))
2099
+ store.setTaskActivity(currentBot.id, turn.threadId, "idle");
2100
+ else
2101
+ store.setActivity(currentBot.id, "idle");
2102
+ directTurnBots.delete(turn.threadId);
1989
2103
  retryDelegationsWaitingOn(currentBot.id);
1990
2104
  // The grace fallback replaces a missing turn.completed event. Release
1991
2105
  // every kind of work that may have queued behind this bot, including
@@ -2071,16 +2185,7 @@ bus.subscribe((event) => {
2071
2185
  watchdog.settle(event.threadId);
2072
2186
  revokeInternalCapabilityForProviderEvent(event);
2073
2187
  }
2074
- else if (event.type === "session.exited") {
2075
- // A retained provider session can exit after a newer turn reused the same
2076
- // thread. An unscoped session event must never revoke that newer turn's
2077
- // capability; its turn completion or watchdog owns release instead.
2078
- const directBotBusy = store.botByThread(event.threadId)?.busy === true;
2079
- const roomBusy = Boolean(store.groupByThread(event.threadId)?.busyBotId);
2080
- if (!directBotBusy && !roomBusy) {
2081
- }
2082
- }
2083
- else
2188
+ else if (event.type !== "session.exited")
2084
2189
  watchdog.touch(event.threadId);
2085
2190
  });
2086
2191
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
@@ -2089,35 +2194,38 @@ bus.subscribe((event) => {
2089
2194
  // the guard behind auto mode is a pattern list, not a security boundary, and
2090
2195
  // it must not stand in for a human at 3am.
2091
2196
  //
2092
- // Keyed by BOT rather than thread because a bot runs one turn at a time, so
2093
- // the identity is exact, and because the peer-comms paths know who is asking
2094
- // but not always from which thread. Idle marks expire rather than clearing on
2197
+ // Keyed by conversation: typing in one thread never makes a sibling webhook
2198
+ // run attended. Idle marks expire rather than clearing on
2095
2199
  // turn.completed: bus subscribers fire in registration order, and the
2096
2200
  // delegation drain runs AFTER the main fold — clearing there would blank the
2097
2201
  // flag before the hop that needs to read it. A busy bot never ages out, and a
2098
2202
  // stale mark only ever means "ask a human", so this fails closed.
2099
- const unattendedBots = new Map();
2203
+ const unattendedThreads = new Map();
2100
2204
  const UNATTENDED_TTL_MS = 30 * 60_000;
2101
- function markUnattended(botId) {
2102
- unattendedBots.set(botId, Date.now());
2205
+ function markUnattended(botId, threadId) {
2206
+ unattendedThreads.set(threadId, { botId, at: Date.now() });
2103
2207
  }
2104
- function clearUnattended(botId) {
2105
- unattendedBots.delete(botId);
2208
+ function clearUnattended(threadId) {
2209
+ unattendedThreads.delete(threadId);
2106
2210
  }
2107
- function isUnattended(botId) {
2211
+ function isUnattended(botId, threadId) {
2108
2212
  if (!botId)
2109
2213
  return false;
2110
- const at = unattendedBots.get(botId);
2111
- if (at === undefined)
2214
+ // Legacy peer callers without a thread fail closed if any of the bot's
2215
+ // work is unattended. Capability/event callers always pass the exact id.
2216
+ if (!threadId)
2217
+ return [...unattendedThreads].some(([id, mark]) => mark.botId === botId && isUnattended(botId, id));
2218
+ const mark = unattendedThreads.get(threadId);
2219
+ if (!mark || mark.botId !== botId)
2112
2220
  return false;
2113
2221
  // A long-running turn is still unattended even if its next approval comes
2114
2222
  // more than 30 minutes after the previous one. Only an idle bot may age
2115
2223
  // out; every positive read refreshes the inactivity window.
2116
- if (Date.now() - at > UNATTENDED_TTL_MS && !store.bot(botId)?.busy) {
2117
- unattendedBots.delete(botId);
2224
+ if (Date.now() - mark.at > UNATTENDED_TTL_MS && !threadBusy(botId, threadId) && !groupSpeakers.has(threadId)) {
2225
+ unattendedThreads.delete(threadId);
2118
2226
  return false;
2119
2227
  }
2120
- unattendedBots.set(botId, Date.now());
2228
+ mark.at = Date.now();
2121
2229
  return true;
2122
2230
  }
2123
2231
  // Threads whose turn in flight was started by another BOT — an ask_bot hop,
@@ -2184,7 +2292,7 @@ function managedBoxOwners() {
2184
2292
  // control lease still names its owner. This is deliberately conservative
2185
2293
  // across destination changes: an old Box may still contain valuable state.
2186
2294
  inUse: bot.busy === true ||
2187
- directTurnDispatchClaims.has(bot.id) ||
2295
+ hasDirectDispatch(bot.id) ||
2188
2296
  activeGroupTurnForBot(bot.id) !== null ||
2189
2297
  Boolean(routines?.activeRunForBot(bot.id)) ||
2190
2298
  activeVpsThreads.has(bot.id) ||
@@ -2194,7 +2302,7 @@ function managedBoxOwners() {
2194
2302
  function botHasActiveTurn(botId) {
2195
2303
  const bot = store.bot(botId);
2196
2304
  return bot?.busy === true ||
2197
- directTurnDispatchClaims.has(botId) ||
2305
+ hasDirectDispatch(botId) ||
2198
2306
  activeGroupTurnForBot(botId) !== null;
2199
2307
  }
2200
2308
  function providerTransitionMessage(provider) {
@@ -2378,7 +2486,7 @@ bus.subscribe((event) => {
2378
2486
  localVmLeaseFor(localVmTarget).touch(event.threadId);
2379
2487
  localVmIdleFor(localVmTarget).touch();
2380
2488
  }
2381
- if (event.type === "turn.completed") {
2489
+ if (event.type === "turn.completed" && !store.botByThread(event.threadId)) {
2382
2490
  releaseLocalVmThread(event.threadId);
2383
2491
  }
2384
2492
  const coordinatorTurnsForThread = groupGoalCoordinatorTurns.get(event.threadId);
@@ -2433,7 +2541,8 @@ bus.subscribe((event) => {
2433
2541
  if (!privateImageEvent)
2434
2542
  broadcast({ kind: "runtime", event });
2435
2543
  const routineRun = privateImageEvent ? null : (routines?.handleRuntimeEvent(event) ?? null);
2436
- const bot = store.botByThread(event.threadId);
2544
+ const ownerBot = store.botByThread(event.threadId);
2545
+ const bot = ownerBot ? botForThread(ownerBot.id, event.threadId) ?? undefined : undefined;
2437
2546
  const group = bot ? undefined : store.groupByThread(event.threadId);
2438
2547
  if (!bot && !group)
2439
2548
  return;
@@ -2504,7 +2613,7 @@ bus.subscribe((event) => {
2504
2613
  if (bot) {
2505
2614
  const touches = screenTouchingTool(toolName);
2506
2615
  if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
2507
- pokeScreenPoller(bot.id, touches, screenSurfaceForTool(toolName));
2616
+ pokeScreenPoller(event.threadId, touches, screenSurfaceForTool(toolName));
2508
2617
  }
2509
2618
  }
2510
2619
  }
@@ -2535,8 +2644,8 @@ bus.subscribe((event) => {
2535
2644
  // even Full access never invents a person's answer. Safe Auto stops on
2536
2645
  // the guards in auto-approve.ts; explicitly acknowledged Full does not.
2537
2646
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2538
- const unattended = permission && asker && event.requestId ? isUnattended(asker.id) : false;
2539
- const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
2647
+ const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
2648
+ const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
2540
2649
  const verdict = permission && asker && event.requestId
2541
2650
  ? autoVerdict({
2542
2651
  // Use the same receiving-bot mode as the provider dispatch.
@@ -2731,9 +2840,12 @@ bus.subscribe((event) => {
2731
2840
  if (!card || card.answered)
2732
2841
  return;
2733
2842
  // the bot is not working now — it is waiting on a person
2734
- if (asker.busy)
2843
+ if (bot)
2844
+ store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
2845
+ else if (asker.busy)
2735
2846
  store.setActivity(asker.id, "waiting-on-you");
2736
- 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));
2737
2849
  };
2738
2850
  if (reviewTask && reviewMode === "enforce") {
2739
2851
  // Avoid buzzing the owner for a card the reviewer is about to answer.
@@ -2758,7 +2870,10 @@ bus.subscribe((event) => {
2758
2870
  case "request.resolved": {
2759
2871
  // answered (by whoever): the turn is working again, unless it settled
2760
2872
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2761
- if (waiting?.activity === "waiting-on-you")
2873
+ if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
2874
+ store.setTaskActivity(bot.id, event.threadId, "working");
2875
+ }
2876
+ else if (!bot && waiting?.activity === "waiting-on-you")
2762
2877
  store.setActivity(waiting.id, "working");
2763
2878
  const messageId = event.requestId ? askMessageByRequest.get(`${event.threadId}:${event.requestId}`) : null;
2764
2879
  if (messageId) {
@@ -2793,7 +2908,7 @@ bus.subscribe((event) => {
2793
2908
  // dispatch moves it to working; turn.completed (which follows a setup
2794
2909
  // failure) is told to leave "dead" alone.
2795
2910
  if (event.setup && bot)
2796
- store.setActivity(bot.id, "dead");
2911
+ store.setTaskActivity(bot.id, event.threadId, "dead");
2797
2912
  break;
2798
2913
  case "thread.token-usage.updated":
2799
2914
  // running totals for the turn in flight; folded into the task's
@@ -2841,10 +2956,36 @@ bus.subscribe((event) => {
2841
2956
  // tally is not the right home for a shared room's spend, so only
2842
2957
  // 1:1 task turns are tallied for now.
2843
2958
  if (bot) {
2844
- const vpsTurn = activeVpsThreads.get(bot.id) === event.threadId;
2845
- const clearVpsTurn = () => {
2846
- if (activeVpsThreads.get(bot.id) === event.threadId)
2847
- activeVpsThreads.delete(bot.id);
2959
+ const resourceOwner = turnResourceOwners.get(event.threadId);
2960
+ const generation = directTurnGenerationByThread.get(event.threadId);
2961
+ const isCurrent = () => directTurnGenerationByThread.get(event.threadId) === generation &&
2962
+ Boolean(store.taskByThread(bot.id, event.threadId));
2963
+ const settleDirectTurn = (resumeQueued = false) => {
2964
+ // Resource claims can outlive a deleted thread, but a stale capture
2965
+ // must never release a replacement generation's VM or busy state.
2966
+ const ownsResources = resourceOwner &&
2967
+ turnResourceOwners.get(event.threadId)?.generation === resourceOwner.generation;
2968
+ if (ownsResources) {
2969
+ if (activeVpsThreads.get(bot.id) === event.threadId)
2970
+ activeVpsThreads.delete(bot.id);
2971
+ releaseLocalVmThread(event.threadId);
2972
+ }
2973
+ releaseTurnResources(resourceOwner);
2974
+ if (!isCurrent())
2975
+ return;
2976
+ if (store.taskByThread(bot.id, event.threadId)?.activity !== "dead") {
2977
+ store.setTaskActivity(bot.id, event.threadId, "idle");
2978
+ }
2979
+ directTurnBots.delete(event.threadId);
2980
+ // Ordinary completion still drains from the existing bus subscribers.
2981
+ // An asynchronous screenshot finishes after those subscribers ran.
2982
+ if (resumeQueued) {
2983
+ retryDelegationsWaitingOn(bot.id);
2984
+ drainQueuedSends();
2985
+ drainConnectorResumes();
2986
+ drainSecretResumes();
2987
+ drainDelegationWakes();
2988
+ }
2848
2989
  };
2849
2990
  // bank what this turn spent before the bot broadcast carries the
2850
2991
  // task list to every window. The driver's own per-turn figure
@@ -2857,9 +2998,6 @@ bus.subscribe((event) => {
2857
2998
  cachedInput: tokens?.cachedInput,
2858
2999
  costUsd: event.cost ?? null,
2859
3000
  });
2860
- // settled → idle; a setup failure already marked it dead, keep that
2861
- if (store.bot(bot.id)?.activity !== "dead")
2862
- store.setActivity(bot.id, "idle");
2863
3001
  const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
2864
3002
  const routineReportGroup = routineReportThread ? store.groupByThread(routineReportThread) : undefined;
2865
3003
  // Group-origin routines belong to that channel's unread state. Their
@@ -2868,48 +3006,52 @@ bus.subscribe((event) => {
2868
3006
  // pair channel and chipped into both threads, which is the whole of
2869
3007
  // what the person needs to be able to find it.
2870
3008
  if (!routineReportGroup && !internal)
2871
- store.patchBot(bot.id, { unread: true });
3009
+ store.patchTask(bot.id, event.threadId, { unread: true });
2872
3010
  // A failed peer turn stays a chip too. The bot that delegated is woken
2873
3011
  // with the failure and answers the person in its own thread — buzzing
2874
3012
  // here as well would ring twice for one piece of news.
2875
- if (routineRun?.status !== "failed" && !internal) {
3013
+ if ((!routineRun || routineRun.status === "completed") && !internal) {
2876
3014
  // the frame carries the bot's avatar so every desktop client can
2877
3015
  // show the notification under that bot's own face
2878
3016
  const completionDetail = routineRun
2879
3017
  ? reply || routineRun.output || routineRun.routineName
2880
3018
  : reply;
2881
- 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 }));
2882
3021
  }
2883
- if (screenPollers.has(bot.id)) {
3022
+ if (screenPollers.has(event.threadId)) {
3023
+ if (resourceOwner)
3024
+ settlingResourceOwners.set(resourceOwner.threadId, resourceOwner.generation);
2884
3025
  // the last live frame becomes a settled inline screen message —
2885
3026
  // the screenshot-in-chat moment. One fresh capture first, so the
2886
3027
  // frame shows the turn's END state (the final tool's poke may
2887
3028
  // still be in flight).
2888
3029
  //
2889
- // The capture is slow (a real screenshot round trip) and the bot
2890
- // is already idle, so a fast follow-up send or the steer-queue
2891
- // drain can land BEFORE the frame does. Anchor the frame to the
2892
- // turn's actual last message now, and chain-insert it there when
2893
- // it arrives — otherwise the user's next message ends up stranded
2894
- // above the screenshot (the browser-mode ordering bug).
3030
+ // Keep the thread busy until its bounded final capture releases
3031
+ // the screen AND workspace. Otherwise an accepted follow-up races
3032
+ // these claims and fails as though another thread owned its folder.
2895
3033
  const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
2896
- void finalScreenFrame(bot.id, event.threadId).then((frame) => {
2897
- // the bot may have been deleted while the capture ran
2898
- if (frame && store.bot(bot.id)) {
2899
- if (group)
2900
- pushMessage({ role: "bot", kind: "screen", png: frame.png, mime: frame.mime });
2901
- else
2902
- store.insertMessageAfter(event.threadId, settleLeafId, { role: "bot", kind: "screen", png: frame.png, mime: frame.mime });
3034
+ let timeout;
3035
+ void Promise.race([
3036
+ finalScreenFrame(bot.id, event.threadId),
3037
+ new Promise((resolve) => { timeout = setTimeout(() => resolve(null), SCREEN_SETTLE_TIMEOUT_MS); }),
3038
+ ]).then((frame) => {
3039
+ if (frame && isCurrent()) {
3040
+ store.insertMessageAfter(event.threadId, settleLeafId, { role: "bot", kind: "screen", png: frame.png, mime: frame.mime });
2903
3041
  }
2904
- }).finally(clearVpsTurn);
3042
+ }).catch(() => { }).finally(() => {
3043
+ clearTimeout(timeout);
3044
+ settleDirectTurn(true);
3045
+ });
2905
3046
  }
2906
- else if (vpsTurn) {
2907
- clearVpsTurn();
3047
+ else {
3048
+ settleDirectTurn();
2908
3049
  }
2909
3050
  }
2910
3051
  const speaker = groupSpeakers.get(event.threadId);
2911
3052
  const group = store.groupByThread(event.threadId);
2912
3053
  if (speaker && group?.busyBotId === speaker.botId) {
3054
+ releaseTurnResources(turnResourceOwners.get(event.threadId));
2913
3055
  groupSpeakers.delete(event.threadId);
2914
3056
  store.patchGroup(group.id, { busyBotId: null, unread: true });
2915
3057
  const speakingBot = store.bot(speaker.botId);
@@ -2942,21 +3084,39 @@ const delegationWatch = new Map();
2942
3084
  // the cardContinuation resume pattern used for connector/credential cards.
2943
3085
  const delegationWakeBudget = new DelegationWakeBudget();
2944
3086
  const pendingDelegationWakes = new Map();
2945
- 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) {
3095
+ if (!store.taskByThread(botId, threadId))
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
+ }
2946
3103
  const prompt = failureReason
2947
3104
  ? buildDelegationFailurePrompt(targetName, failureReason)
2948
3105
  : buildDelegationRevivalPrompt(targetName);
2949
3106
  void startTurn(botId, prompt, {
2950
3107
  threadId,
2951
3108
  cardContinuation: true,
2952
- unattended: isUnattended(botId),
3109
+ unattended: isUnattended(botId, threadId),
2953
3110
  })
2954
3111
  .then(() => undefined)
2955
3112
  .catch((error) => {
3113
+ if (!store.taskByThread(botId, threadId))
3114
+ return;
2956
3115
  const message = error instanceof Error ? error.message : String(error);
2957
3116
  // Raced with a user turn claiming the bot — retry once it settles.
2958
- if (/already working/i.test(message)) {
2959
- 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 });
2960
3120
  return;
2961
3121
  }
2962
3122
  store.appendMessage(threadId, {
@@ -2967,31 +3127,43 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
2967
3127
  ok: false,
2968
3128
  },
2969
3129
  });
3130
+ routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
2970
3131
  });
2971
3132
  }
2972
- function wakeDelegationSource(source, threadId, targetName, failureReason) {
3133
+ function wakeDelegationSource(source, threadId, targetName, failureReason, routineRunId) {
3134
+ if (!store.taskByThread(source.id, threadId))
3135
+ return;
3136
+ if (!routineDelegationCanResume(threadId, routineRunId))
3137
+ return;
2973
3138
  // Busy? Hold the wake until the source settles, then drain it — the
2974
3139
  // delegated reply is already in the thread, so nothing is lost, and the
2975
3140
  // source processes it the moment it is free rather than only on a later
2976
3141
  // user nudge.
2977
- if (store.bot(source.id)?.busy) {
2978
- pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason });
3142
+ if (threadBusy(source.id, threadId) || activeGroupTurnForBot(source.id)) {
3143
+ pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason, routineRunId });
2979
3144
  return;
2980
3145
  }
2981
- if (!delegationWakeBudget.tryAcquire(threadId))
2982
- return;
2983
- dispatchDelegationWake(source.id, threadId, targetName, failureReason);
3146
+ dispatchDelegationWake(source.id, threadId, targetName, failureReason, routineRunId);
2984
3147
  }
2985
3148
  function drainDelegationWakes() {
2986
3149
  for (const [threadId, entry] of pendingDelegationWakes) {
2987
- if (store.bot(entry.botId)?.busy)
3150
+ if (!store.taskByThread(entry.botId, threadId) || !routineDelegationCanResume(threadId, entry.routineRunId)) {
3151
+ pendingDelegationWakes.delete(threadId);
2988
3152
  continue;
2989
- pendingDelegationWakes.delete(threadId);
2990
- if (!delegationWakeBudget.tryAcquire(threadId))
3153
+ }
3154
+ if (threadBusy(entry.botId, threadId) || activeGroupTurnForBot(entry.botId))
2991
3155
  continue;
2992
- dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason);
3156
+ pendingDelegationWakes.delete(threadId);
3157
+ dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason, entry.routineRunId, entry.budgetAcquired);
2993
3158
  }
2994
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
+ }
2995
3167
  // Provider-native sessions only know about messages produced inside their
2996
3168
  // own turns. A delegated result is appended later by the harness, so mark the
2997
3169
  // source task with a persisted, impossible-to-resume owner. Its next turn
@@ -3007,14 +3179,11 @@ function markTaskContextExternallyUpdated(bot, threadId) {
3007
3179
  const task = store.taskByThread(bot.id, threadId);
3008
3180
  if (!task)
3009
3181
  return;
3010
- task.resumeCursors = {};
3011
- task.lastInstanceId = `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`;
3012
- // patchBot persists the task mutation and broadcasts unread/context state.
3013
- // The legacy cursor mirror follows only the task currently open in chat.
3014
- const patch = { unread: true };
3015
- if (bot.threadId === threadId)
3016
- patch.resumeCursors = {};
3017
- store.patchBot(bot.id, patch);
3182
+ store.patchTask(bot.id, threadId, {
3183
+ resumeCursors: {},
3184
+ lastInstanceId: `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`,
3185
+ unread: true,
3186
+ });
3018
3187
  }
3019
3188
  /** Consume one delegated-turn watch and mirror exactly one terminal state.
3020
3189
  * Some harness paths settle a busy bot without a provider turn.completed
@@ -3095,7 +3264,7 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
3095
3264
  // The delegated result is already in the shared transcript; a group
3096
3265
  // continuation is the responsibility of the room's own turn engine.
3097
3266
  }
3098
- else {
3267
+ else if (store.taskByThread(source.id, watched.sourceThreadId)) {
3099
3268
  // 1:1 source: the source thread is the delegating bot's own task.
3100
3269
  if (ok && reply.trim()) {
3101
3270
  const sourceReply = {
@@ -3126,11 +3295,11 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
3126
3295
  // silent" gap). Failures wake it too — the user must hear the task did
3127
3296
  // not finish. Idle-checked and burst-capped so a busy source or a
3128
3297
  // re-delegating loop cannot spin up runs.
3129
- if (ok && reply.trim()) {
3130
- wakeDelegationSource(source, watched.sourceThreadId, targetName);
3298
+ if (ok) {
3299
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
3131
3300
  }
3132
3301
  else if (!ok) {
3133
- 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);
3134
3303
  }
3135
3304
  }
3136
3305
  }
@@ -3200,6 +3369,7 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3200
3369
  taskId,
3201
3370
  sourceThreadId,
3202
3371
  sourceBotId,
3372
+ routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
3203
3373
  startedAtMs: Date.now(),
3204
3374
  });
3205
3375
  }
@@ -3225,8 +3395,9 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3225
3395
  });
3226
3396
  };
3227
3397
  return startTurn(toBotId, text, {
3398
+ threadId: targetThreadId,
3228
3399
  commsDepth,
3229
- unattended: isUnattended(sourceBotId),
3400
+ unattended: isUnattended(sourceBotId, sourceThreadId),
3230
3401
  // startTurn schedules provider/integration setup after marking the bot
3231
3402
  // busy. Those asynchronous setup failures do not emit turn.completed,
3232
3403
  // so clear the watch and report them through this callback too.
@@ -3235,6 +3406,10 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3235
3406
  reportStartFailure(err);
3236
3407
  });
3237
3408
  };
3409
+ function drainThreadDelegations(threadId) {
3410
+ const routineRunId = activeRoutineRunForThread(threadId)?.id;
3411
+ drainDelegations(commsBus, approvalBus, threadId, runDelegatedTurn, (receipt) => wakeUndispatchedDelegation(receipt, routineRunId));
3412
+ }
3238
3413
  // Most waiting handoffs retry from a target's turn.completed event. Some
3239
3414
  // setup, cancellation, room, watchdog, and provider-reload paths release a
3240
3415
  // bot without that event, so every explicit idle release calls this same
@@ -3247,10 +3422,13 @@ function retryDelegationsWaitingOn(botId) {
3247
3422
  delegationRetryBots.add(botId);
3248
3423
  queueMicrotask(() => {
3249
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();
3250
3428
  if (store.bot(botId)?.busy)
3251
3429
  return;
3252
3430
  for (const waitingThread of releaseDelegationsWaitingOn(botId)) {
3253
- drainDelegations(commsBus, approvalBus, waitingThread, runDelegatedTurn);
3431
+ drainThreadDelegations(waitingThread);
3254
3432
  }
3255
3433
  });
3256
3434
  }
@@ -3265,7 +3443,7 @@ bus.subscribe((event) => {
3265
3443
  if (!event.ok)
3266
3444
  discardDelegations(commsBus, event.threadId);
3267
3445
  else
3268
- drainDelegations(commsBus, approvalBus, event.threadId, runDelegatedTurn);
3446
+ drainThreadDelegations(event.threadId);
3269
3447
  // A settling bot frees itself as a delegation TARGET too: handoffs that
3270
3448
  // found it busy earlier were kept queued (bounded retries) on their own
3271
3449
  // source threads, and this is the moment they get their retry.
@@ -3308,7 +3486,27 @@ function drainQueuedSends() {
3308
3486
  ok: false,
3309
3487
  },
3310
3488
  });
3311
- }));
3489
+ }),
3490
+ // Provider completion can precede its dispatch promise: keep the queue
3491
+ // intact until that exact handshake releases its runtime-only claim.
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 };
3312
3510
  }
3313
3511
  const screenPollers = new Map();
3314
3512
  /** The preview shares the box's single command endpoint with the agent's
@@ -3317,38 +3515,56 @@ const screenPollers = new Map();
3317
3515
  * and never two in flight. */
3318
3516
  const SCREEN_POLL_MS = 6000;
3319
3517
  const SCREEN_MIN_GAP_MS = 3000;
3518
+ const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
3320
3519
  /** `screenIsTheWork` starts the turn already counting as screen usage: a
3321
3520
  * boxAgent's whole session runs ON the box, so every tool it calls acts on
3322
3521
  * that screen even though none of them is named like a computer tool. Its
3323
3522
  * shell-only turns are kept honest by the settle-time hash gate instead. */
3324
- function startScreenPoller(botId, captures, { screenIsTheWork = false } = {}) {
3523
+ function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
3325
3524
  if (!captures.computer && !captures.browser)
3326
3525
  return;
3327
- if (screenPollers.has(botId))
3526
+ if (screenPollers.has(threadId))
3328
3527
  return;
3528
+ const owner = turnResourceOwners.get(threadId);
3529
+ const computer = turnComputerResources.get(threadId);
3530
+ const browserSession = currentBrowserSession(botId, botForThread(botId, threadId)?.browserProfile);
3531
+ const guarded = (capture, resource) => capture && owner && resource ? async () => {
3532
+ const isCurrent = () => turnResourceOwners.get(threadId)?.generation === owner.generation &&
3533
+ turnResources.owns(resource, owner) && Boolean(store.taskByThread(botId, threadId) || store.groupByThread(threadId));
3534
+ if (!isCurrent())
3535
+ throw new Error("this thread does not own that screen");
3536
+ const frame = await capture();
3537
+ if (!isCurrent())
3538
+ throw new Error("this thread no longer owns that screen");
3539
+ return frame;
3540
+ } : undefined;
3329
3541
  // Assign rather than spread: the source's last-frame getter deliberately
3330
3542
  // hides a stale frame as soon as the selected surface changes.
3331
3543
  const entry = Object.assign(createScreenFrameSource({
3332
- captures,
3544
+ captures: {
3545
+ computer: guarded(captures.computer, computer?.resource),
3546
+ browser: guarded(captures.browser, browserSession ? `browser:${browserSession}` : undefined),
3547
+ },
3333
3548
  control: () => ({
3334
3549
  held: computerControl.snapshot(botId).held,
3335
3550
  revision: computerControlRevision.get(botId) ?? 0,
3336
3551
  }),
3337
- onFrame: (frame) => broadcast({ kind: "screen", botId, ...frame }),
3552
+ onFrame: (frame) => broadcast({ kind: "screen", botId, threadId, ...frame }),
3338
3553
  minGapMs: SCREEN_MIN_GAP_MS,
3339
3554
  }), {
3340
3555
  timer: null,
3556
+ botId,
3341
3557
  touched: screenIsTheWork,
3342
3558
  });
3343
3559
  entry.timer = setInterval(() => void entry.capture(), SCREEN_POLL_MS);
3344
- screenPollers.set(botId, entry);
3560
+ screenPollers.set(threadId, entry);
3345
3561
  }
3346
3562
  /** Event-driven refresh: capture NOW (the bot just acted on its screen)
3347
3563
  * instead of waiting for the next interval tick. Rate-limited inside
3348
3564
  * capture() — a tool-heavy turn used to fire one full REST chain per
3349
3565
  * completed tool, competing with the agent for the same endpoint. */
3350
- function pokeScreenPoller(botId, touches, surface) {
3351
- const entry = screenPollers.get(botId);
3566
+ function pokeScreenPoller(threadId, touches, surface) {
3567
+ const entry = screenPollers.get(threadId);
3352
3568
  if (!entry)
3353
3569
  return;
3354
3570
  // the same signal, read twice: a completed computer tool is both the
@@ -3367,13 +3583,14 @@ function pokeScreenPoller(botId, touches, surface) {
3367
3583
  entry.surface = surface;
3368
3584
  void entry.capture();
3369
3585
  }
3370
- function stopScreenPoller(botId) {
3371
- const entry = screenPollers.get(botId);
3372
- if (!entry)
3373
- return;
3374
- if (entry.timer)
3375
- clearInterval(entry.timer);
3376
- screenPollers.delete(botId);
3586
+ function stopScreenPoller(botId, threadId) {
3587
+ for (const [id, entry] of screenPollers) {
3588
+ if (entry.botId !== botId || (threadId && id !== threadId))
3589
+ continue;
3590
+ if (entry.timer)
3591
+ clearInterval(entry.timer);
3592
+ screenPollers.delete(id);
3593
+ }
3377
3594
  }
3378
3595
  /** sha256 of the frame each bot last settled into a transcript — the
3379
3596
  * comparison the hash gate needs is "this turn's end state against what
@@ -3382,8 +3599,8 @@ function stopScreenPoller(botId) {
3382
3599
  * newest screen message so a restart does not re-picture the same idle
3383
3600
  * desktop either. */
3384
3601
  const settledScreenHashes = new Map();
3385
- function shownScreenHash(botId, threadId) {
3386
- const known = settledScreenHashes.get(botId);
3602
+ function shownScreenHash(threadId) {
3603
+ const known = settledScreenHashes.get(threadId);
3387
3604
  if (known)
3388
3605
  return known;
3389
3606
  const shown = store.messagesFor(threadId).findLast((m) => m.kind === "screen" && Boolean(m.png));
@@ -3398,33 +3615,56 @@ function shownScreenHash(botId, threadId) {
3398
3615
  * screen work, so without this its shell-only replies would all end in the
3399
3616
  * same idle desktop. Either way the poller is torn down here, so no
3400
3617
  * per-turn state survives the turn. */
3401
- async function finalScreenFrame(botId, threadId) {
3402
- const entry = screenPollers.get(botId);
3618
+ async function finalScreenFrame(_botId, threadId) {
3619
+ const entry = screenPollers.get(threadId);
3620
+ const owner = turnResourceOwners.get(threadId);
3403
3621
  if (!entry)
3404
3622
  return null;
3405
3623
  if (entry.timer)
3406
3624
  clearInterval(entry.timer);
3407
- screenPollers.delete(botId);
3625
+ screenPollers.delete(threadId);
3408
3626
  if (!entry.touched)
3409
3627
  return null;
3410
3628
  await entry.capture(true);
3629
+ if (!owner || turnResourceOwners.get(threadId)?.generation !== owner.generation ||
3630
+ !store.taskByThread(_botId, threadId))
3631
+ return null;
3411
3632
  const frame = entry.last;
3412
- if (!frame || !settledFrameIsNews(shownScreenHash(botId, threadId), frame.png))
3633
+ if (!frame || !settledFrameIsNews(shownScreenHash(threadId), frame.png))
3413
3634
  return null;
3414
- settledScreenHashes.set(botId, screenFrameHash(frame.png));
3635
+ settledScreenHashes.set(threadId, screenFrameHash(frame.png));
3415
3636
  return frame;
3416
3637
  }
3417
3638
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
3418
3639
  async function startTurn(botId, text, opts) {
3419
- const bot = store.bot(botId);
3420
- if (!bot)
3640
+ const profile = store.bot(botId);
3641
+ if (!profile)
3421
3642
  throw Object.assign(new Error("no such bot"), { status: 404 });
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
+ }
3657
+ const bot = store.projectBotForTask(botId, threadId);
3658
+ if (!bot)
3659
+ throw Object.assign(new Error("no such task"), { status: 404 });
3422
3660
  if (bot.approvalGrant) {
3423
3661
  throw Object.assign(new Error("this bot's approval level is still being confirmed — try again"), { status: 409 });
3424
3662
  }
3425
3663
  const transitionError = providerTransitionForTurn(bot, opts?.runOn);
3426
3664
  if (transitionError)
3427
3665
  throw Object.assign(new Error(transitionError), { status: 409 });
3666
+ if (providerFleetReloading)
3667
+ throw Object.assign(new Error("provider settings are being updated — try again shortly"), { status: 409 });
3428
3668
  if (checkpointRestoreLeases.has(botId)) {
3429
3669
  throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
3430
3670
  status: 409,
@@ -3433,19 +3673,24 @@ async function startTurn(botId, text, opts) {
3433
3673
  if (boxLifecycleBusyBots.has(botId)) {
3434
3674
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
3435
3675
  }
3436
- if (bot.busy)
3437
- throw Object.assign(new Error("the bot is already working — interrupt it first"), { status: 409 });
3438
- const threadId = opts?.threadId ?? bot.threadId;
3676
+ if (threadBusy(botId, threadId))
3677
+ throw Object.assign(new Error("this thread is already working — interrupt it first"), { status: 409, code: "thread_busy" });
3678
+ if (activeGroupTurnForBot(botId)) {
3679
+ throw Object.assign(new Error("the bot is already working in a channel — wait for it to finish"), { status: 409, code: "thread_busy" });
3680
+ }
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" });
3683
+ }
3439
3684
  // Retire anything a previous turn left behind before minting this turn's
3440
3685
  // integrations. Completion and interrupt paths do the same; this is the
3441
3686
  // final backstop against a retained proxy process.
3442
3687
  revokeInternalCapabilitiesForThread(threadId);
3443
3688
  // a webhook turn, or one inherited from a bot already running unattended
3444
3689
  if (opts?.automationSource === "webhook" || opts?.unattended)
3445
- markUnattended(bot.id);
3690
+ markUnattended(bot.id, threadId);
3446
3691
  // a person typing into this bot ends the unattended window immediately
3447
3692
  else if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
3448
- clearUnattended(bot.id);
3693
+ clearUnattended(threadId);
3449
3694
  delegationWakeBudget.reset(threadId);
3450
3695
  }
3451
3696
  const task = store.taskByThread(bot.id, threadId);
@@ -3494,6 +3739,13 @@ async function startTurn(botId, text, opts) {
3494
3739
  }
3495
3740
  // an edit hands us its already-branched user message; a plain send appends
3496
3741
  let userMessage = opts?.userMessage;
3742
+ if (opts?.editedMessageId) {
3743
+ const edited = store.branchMessage(threadId, opts.editedMessageId, text);
3744
+ if (!edited)
3745
+ throw Object.assign(new Error("only a user text message can be edited"), { status: 400 });
3746
+ store.patchTask(bot.id, threadId, { rewound: true });
3747
+ userMessage = edited;
3748
+ }
3497
3749
  if (!userMessage) {
3498
3750
  userMessage = opts?.cardContinuation
3499
3751
  ? { id: `card-${randomUUID()}`, at: Date.now(), role: "user", kind: "text", text }
@@ -3537,7 +3789,7 @@ async function startTurn(botId, text, opts) {
3537
3789
  // inline (transcript-replay drivers get it via transcript). The flag is
3538
3790
  // cleared only once the turn is actually dispatched — clearing it here
3539
3791
  // would cost the next attempt its history if this dispatch fails.
3540
- const rewound = threadId === bot.threadId && Boolean(bot.rewound);
3792
+ const rewound = Boolean(task.rewound);
3541
3793
  // A fresh engine — the user switched this bot's model mid-thread — has no
3542
3794
  // current session here either, so it gets the same replay. Distinct from
3543
3795
  // rewound: the OTHER instances' cursors are left alone (a rewind wipes
@@ -3593,16 +3845,19 @@ async function startTurn(botId, text, opts) {
3593
3845
  // in the background — box provisioning can take ~90s and must never
3594
3846
  // hang the HTTP request
3595
3847
  const dispatchClaimId = randomUUID();
3596
- directTurnGenerationByBot.set(bot.id, dispatchClaimId);
3597
- directTurnDispatchClaims.set(bot.id, { id: dispatchClaimId, threadId, phase: "setup" });
3848
+ const resourceOwner = { threadId, generation: dispatchClaimId };
3849
+ turnResourceOwners.set(threadId, resourceOwner);
3850
+ directTurnGenerationByThread.set(threadId, dispatchClaimId);
3851
+ directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
3852
+ directTurnBots.set(threadId, bot);
3598
3853
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
3599
- store.setActivity(bot.id, "working");
3854
+ store.setTaskActivity(bot.id, threadId, "working");
3600
3855
  // The badge is "this bot answered you, and you have not looked yet". A
3601
3856
  // person starting a turn has looked; a teammate's hop has not — the fold
3602
3857
  // never re-marks an internal turn, so clearing here would silently spend
3603
3858
  // a signal the person still owes a glance to.
3604
3859
  if (commsDepth === 0)
3605
- store.patchBot(bot.id, { unread: false });
3860
+ store.patchTask(bot.id, threadId, { unread: false });
3606
3861
  turnUsage.delete(threadId);
3607
3862
  void (async () => {
3608
3863
  try {
@@ -3613,6 +3868,8 @@ async function startTurn(botId, text, opts) {
3613
3868
  ...(skillAuthoring ? ["skillAuthoring"] : []),
3614
3869
  ], availableSkills());
3615
3870
  if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
3871
+ if (!claimTurnResource(resourceOwner, "computer:phone"))
3872
+ throw new Error("another thread is using the phone — wait for it to finish");
3616
3873
  integrations.phone = phoneIntegration();
3617
3874
  }
3618
3875
  // the user's connected apps, but only to a driver that can mount
@@ -3637,7 +3894,9 @@ async function startTurn(botId, text, opts) {
3637
3894
  // desk, not the whole house — and the workspace is where its
3638
3895
  // MEMORY.md lives. API/box engines have no local filesystem story.
3639
3896
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
3640
- const privateWorkspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
3897
+ if (worksInWorkspace)
3898
+ ensureWorkspace(bot.id);
3899
+ const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
3641
3900
  const skillInstructions = renderSkillInstructions(selectedSkills, {
3642
3901
  includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
3643
3902
  });
@@ -3655,6 +3914,9 @@ async function startTurn(botId, text, opts) {
3655
3914
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace)
3656
3915
  : null;
3657
3916
  const cwd = pinnedCwd ?? undefined;
3917
+ if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
3918
+ throw Object.assign(new Error("another thread is working in this project folder — wait for it to finish or choose a separate folder"), { status: 409, code: "workspace_busy" });
3919
+ }
3658
3920
  // Checkpoint explicit project folders, where a bot can overwrite the
3659
3921
  // user's work. Its private OpenMaus workspace is app-owned and changes
3660
3922
  // on nearly every ordinary chat; snapshotting it would add hidden disk
@@ -3698,6 +3960,7 @@ async function startTurn(botId, text, opts) {
3698
3960
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
3699
3961
  }
3700
3962
  const localVmTarget = localVmTargetForBot(bot.id);
3963
+ bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
3701
3964
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
3702
3965
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
3703
3966
  }
@@ -3745,7 +4008,8 @@ async function startTurn(botId, text, opts) {
3745
4008
  const cua = readCuaConnection();
3746
4009
  if (!cua)
3747
4010
  throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
3748
- integrations.localComputer = cua;
4011
+ bindTurnComputer(resourceOwner, "computer:host");
4012
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
3749
4013
  computerKind = "local";
3750
4014
  }
3751
4015
  // A VPS is a local-agent computer mount, never a remote agent runner.
@@ -3758,6 +4022,9 @@ async function startTurn(botId, text, opts) {
3758
4022
  if (unsupported && wants === undefined)
3759
4023
  autoVpsProblem = unsupported;
3760
4024
  if (!unsupported) {
4025
+ // The remote lifecycle and container are shared by this bot. Keep
4026
+ // its explicit computer turns serialized; ordinary threads still run.
4027
+ bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
3761
4028
  activeVpsThreads.set(bot.id, threadId);
3762
4029
  const remote = wants === "cloud" || bot.autoStartVps
3763
4030
  ? await vps.vpsComputerAction("provision", cfg, bot.id)
@@ -3785,6 +4052,10 @@ async function startTurn(botId, text, opts) {
3785
4052
  // Cloud is also strict when explicitly selected. Auto (unset) reuses an
3786
4053
  // existing cloud box, then falls back to host CUA without provisioning.
3787
4054
  if ((wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
4055
+ // Explicit cloud turns can provision/wake the same bot's Box. Claim
4056
+ // before any network await so setup itself cannot race another turn.
4057
+ if (wants === "cloud")
4058
+ bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
3788
4059
  if (!mountsCloudComputer && wants === "cloud") {
3789
4060
  throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
3790
4061
  }
@@ -3818,6 +4089,7 @@ async function startTurn(botId, text, opts) {
3818
4089
  });
3819
4090
  }
3820
4091
  if (b && lifecycle === "attach") {
4092
+ bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
3821
4093
  previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
3822
4094
  if (mountsCloudComputer) {
3823
4095
  integrations.computer = {
@@ -3848,7 +4120,8 @@ async function startTurn(botId, text, opts) {
3848
4120
  })) {
3849
4121
  const cua = readCuaConnection();
3850
4122
  if (cua) {
3851
- integrations.localComputer = cua;
4123
+ bindTurnComputer(resourceOwner, "computer:host");
4124
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
3852
4125
  computerKind = "local";
3853
4126
  }
3854
4127
  }
@@ -3974,10 +4247,11 @@ async function startTurn(botId, text, opts) {
3974
4247
  { id: "credential", label: "Credentials", text: credentialPrompt },
3975
4248
  { id: "recall", label: "Recall", text: recallPrompt },
3976
4249
  { id: "routine", label: "Routines", text: routinePrompt },
4250
+ { id: "routine-execution", label: "Routine execution", text: opts?.automationSource === "schedule" || opts?.automationSource === "manual" ? ROUTINE_EXECUTION_PROMPT : "" },
3977
4251
  { id: "profile", label: "Profile changes", text: profilePrompt },
3978
4252
  { id: "learn", label: "Skill authoring", text: learnPrompt },
3979
4253
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3980
- { id: "memory", label: "Memory", text: privateWorkspace ? memorySystemPrompt(bot.id) : "" },
4254
+ { id: "memory", label: "Memory", text: privateWorkspace ? memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents) }) : "" },
3981
4255
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
3982
4256
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
3983
4257
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
@@ -3988,7 +4262,7 @@ async function startTurn(botId, text, opts) {
3988
4262
  threadId,
3989
4263
  text: turnText,
3990
4264
  images: turnImages,
3991
- approvalMode: approvalModeForTurn(liveBot ?? bot, commsDepth > 0),
4265
+ approvalMode: approvalModeForTurn(bot, commsDepth > 0),
3992
4266
  model,
3993
4267
  effort,
3994
4268
  // a rewound thread never resumes the abandoned branch's session
@@ -4007,10 +4281,10 @@ async function startTurn(botId, text, opts) {
4007
4281
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
4008
4282
  }
4009
4283
  bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
4010
- clearDirectTurnDispatch(bot.id, dispatchClaimId);
4284
+ clearDirectTurnDispatch(threadId, dispatchClaimId);
4011
4285
  // dispatched: the rewind is spent, and the old cursors are dead
4012
4286
  if (rewound)
4013
- store.patchBot(bot.id, { rewound: false, resumeCursors: {} });
4287
+ store.patchTask(bot.id, threadId, { rewound: false, resumeCursors: {} });
4014
4288
  // and this engine now owns the thread's most recent turn
4015
4289
  // Consume exactly the external-update generation this turn replayed.
4016
4290
  // If a newer delegated result landed during setup, its unique marker
@@ -4022,21 +4296,29 @@ async function startTurn(botId, text, opts) {
4022
4296
  // after its own turn.completed would never be torn down — it would
4023
4297
  // keep polling the box forever, carrying dead per-turn state. busy
4024
4298
  // is flipped false in the fold, so it is the honest "still running".
4025
- if ((previewCapture || browserCapture) && store.bot(bot.id)?.busy) {
4026
- startScreenPoller(bot.id, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.driverKind === "boxAgent" });
4299
+ if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
4300
+ startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.driverKind === "boxAgent" });
4027
4301
  }
4028
4302
  // An adapter may publish completion synchronously just before its
4029
4303
  // dispatch promise resolves. The event could not use the turn-id map
4030
4304
  // above yet, so close this exact generation from durable busy state.
4031
- if (store.bot(bot.id)?.busy !== true) {
4305
+ if (!threadBusy(bot.id, threadId) && directTurnGenerationByThread.get(threadId) === dispatchClaimId) {
4032
4306
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
4307
+ if (settlingResourceOwners.get(threadId) !== dispatchClaimId)
4308
+ releaseTurnResources(resourceOwner);
4309
+ retryDelegationsWaitingOn(bot.id);
4310
+ drainQueuedSends();
4311
+ drainConnectorResumes();
4312
+ drainSecretResumes();
4313
+ drainDelegationWakes();
4033
4314
  }
4034
4315
  }
4035
4316
  catch (e) {
4036
4317
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
4037
- clearDirectTurnDispatch(bot.id, dispatchClaimId);
4318
+ clearDirectTurnDispatch(threadId, dispatchClaimId);
4038
4319
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
4039
- const ownsLatestGeneration = directTurnGenerationByBot.get(bot.id) === dispatchClaimId;
4320
+ const ownsLatestGeneration = directTurnGenerationByThread.get(threadId) === dispatchClaimId;
4321
+ releaseTurnResources(resourceOwner);
4040
4322
  if (ownsLatestGeneration) {
4041
4323
  releaseLocalVmThread(threadId);
4042
4324
  if (activeVpsThreads.get(bot.id) === threadId)
@@ -4046,8 +4328,9 @@ async function startTurn(botId, text, opts) {
4046
4328
  }
4047
4329
  if (e instanceof DirectTurnSetupCancelled) {
4048
4330
  opts?.onDispatchError?.(e.message);
4049
- if (ownsLatestGeneration && store.bot(bot.id)?.busy) {
4050
- store.setActivity(bot.id, "idle");
4331
+ if (ownsLatestGeneration && threadBusy(bot.id, threadId)) {
4332
+ store.setTaskActivity(bot.id, threadId, "idle");
4333
+ directTurnBots.delete(threadId);
4051
4334
  retryDelegationsWaitingOn(bot.id);
4052
4335
  }
4053
4336
  if (ownsLatestGeneration) {
@@ -4079,7 +4362,8 @@ async function startTurn(botId, text, opts) {
4079
4362
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
4080
4363
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
4081
4364
  }
4082
- store.setActivity(bot.id, "idle");
4365
+ store.setTaskActivity(bot.id, threadId, "idle");
4366
+ directTurnBots.delete(threadId);
4083
4367
  retryDelegationsWaitingOn(bot.id);
4084
4368
  opts?.onDispatchError?.(message);
4085
4369
  // a dispatch failure never emits turn.completed, so the settle-driven
@@ -4104,8 +4388,14 @@ function routineSourceOwner(run) {
4104
4388
  const bot = store.bot(run.botId);
4105
4389
  if (!bot)
4106
4390
  return null;
4107
- if (store.taskByThread(bot.id, threadId))
4108
- 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
+ }
4109
4399
  const group = store.groupByThread(threadId);
4110
4400
  return group?.memberIds.includes(bot.id) ? { bot, group, threadId } : null;
4111
4401
  }
@@ -4190,7 +4480,7 @@ function syncRoutineRunToSource(run) {
4190
4480
  if (source.group)
4191
4481
  store.patchGroup(source.group.id, { unread: true });
4192
4482
  else
4193
- store.patchBot(source.bot.id, { unread: true });
4483
+ store.patchTask(source.bot.id, sourceThreadId, { unread: true });
4194
4484
  }
4195
4485
  return sourceThreadId;
4196
4486
  }
@@ -4212,6 +4502,12 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4212
4502
  const bot = store.bot(botId);
4213
4503
  if (!bot)
4214
4504
  return;
4505
+ for (const task of store.tasks(botId)) {
4506
+ if (task.approvalMode === "full" || task.approvalMode === "custom") {
4507
+ store.patchTask(botId, task.threadId, { approvalMode: "ask", autoApprove: false, alwaysAllow: [] });
4508
+ }
4509
+ }
4510
+ const directStop = interruptAllDirectThreads(botId);
4215
4511
  const routineRun = routines?.activeBotRunForBot(bot.id);
4216
4512
  if (routineRun) {
4217
4513
  if (routineRun.threadId)
@@ -4220,6 +4516,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4220
4516
  await routines.cancelRun(routineRun.id);
4221
4517
  if (routineRun.threadId)
4222
4518
  closeOpenApprovals(routineRun.threadId);
4519
+ await directStop;
4223
4520
  return;
4224
4521
  }
4225
4522
  const groupTurn = activeGroupTurnForBot(bot.id);
@@ -4227,6 +4524,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4227
4524
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
4228
4525
  cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
4229
4526
  const results = await Promise.allSettled([
4527
+ directStop,
4230
4528
  registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(groupTurn.threadId),
4231
4529
  ]);
4232
4530
  closeOpenApprovals(groupTurn.threadId);
@@ -4235,19 +4533,18 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4235
4533
  throw failure.reason;
4236
4534
  return;
4237
4535
  }
4238
- const directClaim = cancelDirectTurnDispatch(bot.id);
4239
- const threadId = directClaim?.threadId ?? bot.threadId;
4240
- revokeInternalCapabilitiesForThread(threadId);
4241
- const results = await Promise.allSettled([
4242
- registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(threadId),
4243
- ]);
4244
- closeOpenApprovals(threadId);
4245
- const failure = results.find((result) => result.status === "rejected");
4246
- if (failure)
4247
- throw failure.reason;
4536
+ await directStop;
4248
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();
4249
4543
  routines = new RoutineManager({
4250
4544
  emit: broadcast,
4545
+ hasPendingDelegations: (threadId) => pendingThreads().includes(threadId) ||
4546
+ [...delegationWatch.values()].some((watch) => watch.sourceThreadId === threadId) ||
4547
+ pendingDelegationWakes.has(threadId),
4251
4548
  botState: (botId) => {
4252
4549
  const bot = store.bot(botId);
4253
4550
  return !bot ? "missing" : bot.busy ? "busy" : "ready";
@@ -4283,7 +4580,9 @@ routines = new RoutineManager({
4283
4580
  });
4284
4581
  },
4285
4582
  interruptTurn: async (botId, threadId, runOn) => {
4286
- const bot = store.bot(botId);
4583
+ const bot = botForThread(botId, threadId);
4584
+ pendingDelegationWakes.delete(threadId);
4585
+ discardDelegations(commsBus, threadId);
4287
4586
  cancelDirectTurnDispatch(botId, threadId);
4288
4587
  revokeInternalCapabilitiesForThread(threadId);
4289
4588
  const instance = runOn === "cloud"
@@ -4301,11 +4600,16 @@ routines = new RoutineManager({
4301
4600
  interruptGoal: interruptRoutineGroupGoal,
4302
4601
  onRunChanged: syncRoutineRunToSource,
4303
4602
  onRunFailed: (run) => {
4603
+ if (run.threadId) {
4604
+ pendingDelegationWakes.delete(run.threadId);
4605
+ discardDelegations(commsBus, run.threadId);
4606
+ }
4304
4607
  const bot = store.bot(run.botId);
4305
4608
  if (!bot)
4306
4609
  return;
4307
4610
  const detail = run.error ? `${run.routineName}: ${run.error}` : run.routineName;
4308
- 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));
4309
4613
  },
4310
4614
  });
4311
4615
  // The scheduler receipt and room transcript live in separate durable stores.
@@ -4347,7 +4651,6 @@ if (recoveryOwners.length > 0) {
4347
4651
  : [];
4348
4652
  })));
4349
4653
  }
4350
- routines.start();
4351
4654
  // Chat tools can prepare routine changes, but the harness applies them only
4352
4655
  // after the user confirms a durable card. Keeping this beside the scheduler
4353
4656
  // makes the card resolvable after an app restart without involving the model.
@@ -4602,10 +4905,6 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
4602
4905
  })
4603
4906
  .join("\n");
4604
4907
  }
4605
- // comms bus: passed into the visibility helpers in comms-visibility.ts so
4606
- // they can mirror messages + chips without re-deriving SSE plumbing. Same
4607
- // shape every comms entry point uses (ask_bot, delegate_bot).
4608
- const commsBus = { store, broadcast };
4609
4908
  // What each room has already taken from its bots. Keyed by room because the
4610
4909
  // loop post_to_room can start is a property of the room, not of any one
4611
4910
  // caller — three bots posting twice each is the same runaway as one bot
@@ -4626,18 +4925,6 @@ const approvalBus = { store, broadcast, notify };
4626
4925
  if (stale)
4627
4926
  console.log(`peer approvals: dismissed ${stale} card(s) left by a previous run`);
4628
4927
  }
4629
- // Handoffs a previous process queued but never ran: the source turn is
4630
- // dead (no turn survives a restart) so they would otherwise wait forever.
4631
- // Run them now, through the same drain — target and approvePeerComms are
4632
- // re-checked there as always; a source bot that no longer exists is skipped.
4633
- _loadPending();
4634
- {
4635
- const leftover = pendingThreads();
4636
- if (leftover.length)
4637
- console.log(`delegations: ${leftover.length} thread(s) with queued handoffs from a previous run — draining`);
4638
- for (const threadId of leftover)
4639
- drainDelegations(commsBus, approvalBus, threadId, runDelegatedTurn);
4640
- }
4641
4928
  async function runGroupMemberTurn(groupId, threadId, botId, hop,
4642
4929
  // bots that already spoke for this user message — "@Scout ask @Pixel"
4643
4930
  // must not run Pixel twice (once chained, once as a direct responder)
@@ -4651,6 +4938,10 @@ operation,
4651
4938
  setupRetry = 0) {
4652
4939
  if (isCancelled?.())
4653
4940
  return false;
4941
+ if (providerFleetReloading) {
4942
+ onDispatchError?.("provider settings are being updated — try again shortly");
4943
+ return false;
4944
+ }
4654
4945
  const group = store.group(groupId);
4655
4946
  const bot = store.bot(botId);
4656
4947
  const ownsThread = group?.dm
@@ -4664,7 +4955,7 @@ setupRetry = 0) {
4664
4955
  }
4665
4956
  revokeInternalCapabilitiesForThread(threadId);
4666
4957
  spoken.add(botId);
4667
- const preparedApprovalMode = approvalModeForTurn(bot);
4958
+ const preparedApprovalMode = approvalModeForTurn(bot, false, threadId);
4668
4959
  const preparedSelection = { ...bot.modelSelection };
4669
4960
  const preparedComposio = bot.composio;
4670
4961
  const instance = registry.get(bot.modelSelection.instanceId);
@@ -4707,6 +4998,8 @@ setupRetry = 0) {
4707
4998
  return true;
4708
4999
  }
4709
5000
  const internalGeneration = beginInternalCapabilityGeneration(threadId);
5001
+ const resourceOwner = { threadId, generation: internalGeneration };
5002
+ turnResourceOwners.set(threadId, resourceOwner);
4710
5003
  let roomVmTarget = null;
4711
5004
  let retainRoomVmLease = false;
4712
5005
  let roomSpeaker;
@@ -4715,6 +5008,7 @@ setupRetry = 0) {
4715
5008
  if (roomVmTarget && localVmThreadTargets.get(threadId) === roomVmTarget)
4716
5009
  releaseLocalVmThread(threadId);
4717
5010
  roomVmTarget = null;
5011
+ releaseTurnResources(resourceOwner);
4718
5012
  };
4719
5013
  try {
4720
5014
  const integrations = {};
@@ -4738,6 +5032,8 @@ setupRetry = 0) {
4738
5032
  const skills = availableSkills();
4739
5033
  const selectedSkills = mergeSkills(selectBundledSkills(roomContext, instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], skills), selectBundledSkills(latestUser?.text ?? "", skillAuthoring ? ["skillAuthoring"] : [], skills));
4740
5034
  if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
5035
+ if (!claimTurnResource(resourceOwner, "computer:phone"))
5036
+ throw new Error("another thread is using the phone — wait for it to finish");
4741
5037
  integrations.phone = phoneIntegration();
4742
5038
  }
4743
5039
  try {
@@ -4782,7 +5078,7 @@ setupRetry = 0) {
4782
5078
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
4783
5079
  return false;
4784
5080
  const setupChanged = registry.get(preparedSelection.instanceId) !== instance ||
4785
- approvalModeForTurn(readyBot) !== preparedApprovalMode ||
5081
+ approvalModeForTurn(readyBot, false, threadId) !== preparedApprovalMode ||
4786
5082
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
4787
5083
  readyBot.modelSelection.model !== preparedSelection.model ||
4788
5084
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -4891,6 +5187,7 @@ setupRetry = 0) {
4891
5187
  }
4892
5188
  // A distinct identity fences cleanup even in shared mode on the same room thread.
4893
5189
  const target = { ...localVmTargetForBot(readyBot.id) };
5190
+ bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
4894
5191
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
4895
5192
  throw new Error("this Local VM is being started, stopped, or replaced");
4896
5193
  }
@@ -5082,7 +5379,7 @@ setupRetry = 0) {
5082
5379
  threadId,
5083
5380
  text,
5084
5381
  images: turnImages,
5085
- approvalMode: approvalModeForTurn(readyBot),
5382
+ approvalMode: approvalModeForTurn(readyBot, false, threadId),
5086
5383
  system: roomSystem,
5087
5384
  cwd,
5088
5385
  integrations,
@@ -5167,6 +5464,8 @@ setupRetry = 0) {
5167
5464
  // same bounded cleanup as the stall watchdog. An unbound goal handshake
5168
5465
  // keeps the room closed until attribution becomes safe.
5169
5466
  const releaseOwnership = () => {
5467
+ if (turnResourceOwners.get(threadId)?.generation !== resourceOwner.generation)
5468
+ return;
5170
5469
  if (hasUnboundDiscardedGroupGoalTurn(threadId)) {
5171
5470
  const retry = setTimeout(releaseOwnership, 1_000);
5172
5471
  retry.unref?.();
@@ -6154,7 +6453,7 @@ function dispatchConnectorResume(entry) {
6154
6453
  return;
6155
6454
  const names = entry.labels.join(", ");
6156
6455
  const prompt = `OpenMausBot connection update: the user securely connected ${names}. Continue the task that paused for this connection. Do not ask them to connect it again.`;
6157
- if (owner.bot.busy) {
6456
+ if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
6158
6457
  pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
6159
6458
  return;
6160
6459
  }
@@ -6186,7 +6485,7 @@ function dispatchConnectorResume(entry) {
6186
6485
  onDispatchError: (message) => markConnectorResumeFailed(entry.threadId, entry.resumeKey, message),
6187
6486
  }).catch((error) => {
6188
6487
  const message = error instanceof Error ? error.message : String(error);
6189
- if (/already working/i.test(message))
6488
+ if (isTurnAdmissionBlocked(error))
6190
6489
  pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
6191
6490
  else
6192
6491
  markConnectorResumeFailed(entry.threadId, entry.resumeKey, message);
@@ -6207,7 +6506,8 @@ function maybeResumeConnectors(botId, threadId, resumeKey) {
6207
6506
  }
6208
6507
  function drainConnectorResumes() {
6209
6508
  for (const [key, entry] of pendingConnectorResumes) {
6210
- if (store.bot(entry.botId)?.busy)
6509
+ const owner = connectorThread(entry.botId, entry.threadId);
6510
+ if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
6211
6511
  continue;
6212
6512
  pendingConnectorResumes.delete(key);
6213
6513
  dispatchConnectorResume(entry);
@@ -6280,7 +6580,7 @@ function dispatchSecretResume(entry) {
6280
6580
  const prompt = entry.outcome === "provided"
6281
6581
  ? `OpenMausBot credential update: the user securely provided ${entry.label}. Continue the task that paused for it. You do not receive the secret and must not ask them to paste it into chat.`
6282
6582
  : `OpenMausBot credential update: the user declined to provide ${entry.label}. Continue without it if possible, or briefly explain the limitation. Do not ask them to paste it into chat.`;
6283
- if (owner.bot.busy) {
6583
+ if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
6284
6584
  pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
6285
6585
  return;
6286
6586
  }
@@ -6312,7 +6612,7 @@ function dispatchSecretResume(entry) {
6312
6612
  onDispatchError: (message) => markSecretResumeFailed(entry.threadId, entry.messageId, message),
6313
6613
  }).catch((error) => {
6314
6614
  const message = error instanceof Error ? error.message : String(error);
6315
- if (/already working/i.test(message)) {
6615
+ if (isTurnAdmissionBlocked(error)) {
6316
6616
  pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
6317
6617
  }
6318
6618
  else {
@@ -6425,7 +6725,8 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
6425
6725
  }
6426
6726
  function drainSecretResumes() {
6427
6727
  for (const [key, entry] of pendingSecretResumes) {
6428
- if (store.bot(entry.botId)?.busy)
6728
+ const owner = connectorThread(entry.botId, entry.threadId);
6729
+ if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
6429
6730
  continue;
6430
6731
  pendingSecretResumes.delete(key);
6431
6732
  dispatchSecretResume(entry);
@@ -6607,6 +6908,7 @@ function configStatus() {
6607
6908
  // not a secret — the settings picker shows it; "" = follow the system
6608
6909
  language: cfg.language ?? "",
6609
6910
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
6911
+ threads: { maxConcurrentPerBot: maxConcurrentBotThreads(cfg) },
6610
6912
  localVm: {
6611
6913
  mode: localVmMode(cfg),
6612
6914
  maxInstances: localVmMaxInstances(cfg),
@@ -6623,15 +6925,19 @@ function configStatus() {
6623
6925
  // show the same durable session as an agent, but config PATCH validation
6624
6926
  // keeps it read-only and rejects callers that try to choose it.
6625
6927
  browserProfiles: cfg.browserProfiles ?? [],
6928
+ // who may sign in with an emailed code (server/account-signin.ts)
6929
+ signIn: { admins: cfg.signIn?.admins ?? [], members: cfg.signIn?.members ?? [] },
6626
6930
  };
6627
6931
  }
6628
6932
  function configForAccess(status, admin) {
6629
6933
  if (admin)
6630
6934
  return status;
6631
- // Configured-or-not is fine; an SSH alias, an email, and a browser
6632
- // partition id are not a client's business. Preserve the source objects.
6935
+ // Configured-or-not is fine; an SSH alias, an email, a browser partition
6936
+ // id, and the sign-in list are not a client's business. Preserve the
6937
+ // source objects.
6633
6938
  return {
6634
6939
  ...status,
6940
+ signIn: { admins: [], members: [] },
6635
6941
  vps: { configured: status.vps.configured, sshAlias: "" },
6636
6942
  profile: { name: status.profile.name, email: "" },
6637
6943
  browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
@@ -6686,32 +6992,66 @@ async function persistProviderInstance(instanceId, instances) {
6686
6992
  /** Rebuild the provider fleet after a config change so new keys take
6687
6993
  * effect without a server restart (kills any in-flight turns). */
6688
6994
  async function reloadProviders() {
6995
+ providerFleetReloading = true;
6689
6996
  providerAuthSessions.clear();
6690
6997
  // Every provider process is about to die. Revoke all turn capabilities in
6691
6998
  // one synchronous step before the first teardown await, including room/task
6692
6999
  // threads that are not a bot's default DM.
6693
7000
  revokeAllInternalCapabilities();
7001
+ const direct = store.bots.flatMap((bot) => store.tasks(bot.id)
7002
+ .filter((task) => threadBusy(bot.id, task.threadId))
7003
+ .map((task) => ({ botId: bot.id, threadId: task.threadId, owner: turnResourceOwners.get(task.threadId) })));
7004
+ const rooms = [...groupSpeakers.entries()];
7005
+ for (const task of direct)
7006
+ cancelDirectTurnDispatch(task.botId, task.threadId);
7007
+ for (const [threadId] of rooms) {
7008
+ const group = store.groupByThread(threadId);
7009
+ if (group)
7010
+ cancelGroupTurnOperations(group.id, threadId);
7011
+ }
6694
7012
  bus.detachAll();
6695
- await registry.disposeAll();
6696
- await registry.load(instanceConfigs(cfg));
6697
- bus.attach(registry.instances());
6698
- // A killed turn's terminal events can die with the old fleet (dispose is
6699
- // async under the hood), stranding the bot busy — and its screen poller —
6700
- // forever. Settle anything still marked busy.
6701
- for (const b of store.bots.filter((b) => b.busy)) {
6702
- const vmThread = [...localVmThreadTargets.entries()].find(([, target]) => localVmLeaseFor(target).current(localVmOwnerBusy)?.botId === b.id)?.[0];
6703
- if (vmThread)
6704
- releaseLocalVmThread(vmThread);
6705
- stopScreenPoller(b.id);
6706
- activeVpsThreads.delete(b.id);
6707
- finalizeDelegationWatch(b.threadId, false, "", "Delegated turn did not finish — provider settings changed");
6708
- store.appendMessage(b.threadId, {
6709
- role: "bot",
6710
- kind: "activity",
6711
- tool: { name: "error: turn interrupted — provider settings changed", ok: false },
6712
- });
6713
- store.setActivity(b.id, "idle");
6714
- retryDelegationsWaitingOn(b.id);
7013
+ try {
7014
+ await registry.disposeAll();
7015
+ await registry.load(instanceConfigs(cfg));
7016
+ bus.attach(registry.instances());
7017
+ }
7018
+ finally {
7019
+ // Settle every exact conversation, not whichever one is selected now.
7020
+ // Teardown can swallow terminal events; no task may remain busy forever.
7021
+ for (const { botId, threadId, owner } of direct) {
7022
+ stopScreenPoller(botId, threadId);
7023
+ releaseLocalVmThread(threadId);
7024
+ releaseTurnResources(owner);
7025
+ if (activeVpsThreads.get(botId) === threadId)
7026
+ activeVpsThreads.delete(botId);
7027
+ watchdog.settle(threadId);
7028
+ closeOpenApprovals(threadId);
7029
+ directTurnBots.delete(threadId);
7030
+ finalizeDelegationWatch(threadId, false, "", "Delegated turn did not finish — provider settings changed");
7031
+ routines?.failThread(threadId, "Provider settings changed while this thread was running");
7032
+ if (store.taskByThread(botId, threadId)) {
7033
+ store.appendMessage(threadId, {
7034
+ role: "bot", kind: "activity",
7035
+ tool: { name: "error: turn interrupted — provider settings changed", ok: false },
7036
+ });
7037
+ store.setTaskActivity(botId, threadId, "idle");
7038
+ }
7039
+ retryDelegationsWaitingOn(botId);
7040
+ }
7041
+ for (const [threadId, speaker] of rooms) {
7042
+ releaseLocalVmThread(threadId);
7043
+ releaseTurnResources(turnResourceOwners.get(threadId));
7044
+ watchdog.settle(threadId);
7045
+ closeOpenApprovals(threadId);
7046
+ if (groupSpeakers.get(threadId) !== speaker)
7047
+ continue;
7048
+ groupSpeakers.delete(threadId);
7049
+ const group = store.groupByThread(threadId);
7050
+ if (group)
7051
+ store.patchGroup(group.id, { busyBotId: null });
7052
+ store.setActivity(speaker.botId, "idle");
7053
+ }
7054
+ providerFleetReloading = false;
6715
7055
  }
6716
7056
  // killed turns settle here without a turn.completed event, so anything
6717
7057
  // queued behind them drains now — onto the freshly loaded fleet
@@ -6829,7 +7169,7 @@ const handleRequest = async (req, res) => {
6829
7169
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
6830
7170
  return;
6831
7171
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
6832
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }));
7172
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled() }));
6833
7173
  }
6834
7174
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
6835
7175
  if (method === "GET" && domainCheck) {
@@ -6837,6 +7177,46 @@ const handleRequest = async (req, res) => {
6837
7177
  const challenge = customDomainVerifier.challenge(domainCheck[1]);
6838
7178
  return json(res, challenge ? 200 : 404, challenge ?? { error: "No active domain check." });
6839
7179
  }
7180
+ // Sign in with an emailed code (server/account-signin.ts). Public like
7181
+ // /api/auth/pair, JSON-only for the same reason, and counted against the
7182
+ // same per-source lockout so a code cannot be guessed.
7183
+ if (method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
7184
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
7185
+ return json(res, 415, { error: "send the sign-in request as JSON (content-type: application/json)" });
7186
+ }
7187
+ if (!emailSignIn.enabled())
7188
+ return json(res, 404, { error: "email sign-in is not set up on this server; use a pairing code" });
7189
+ const source = requestSource(req);
7190
+ const allowed = sessions.attemptAllowed(source);
7191
+ if (!allowed.ok)
7192
+ return json(res, 429, { error: `too many failed sign-in attempts from your address; try again in ${Math.ceil(allowed.retryAfterMs / 1000)}s` });
7193
+ const body = await readBody(req);
7194
+ const email = typeof body?.email === "string" ? body.email : "";
7195
+ if (path === "/api/auth/email/start") {
7196
+ const started = await emailSignIn.start(email);
7197
+ if (!started.ok) {
7198
+ if (started.status === 403)
7199
+ sessions.noteFailure(source);
7200
+ return json(res, started.status, { error: started.error });
7201
+ }
7202
+ return json(res, 200, { ok: true });
7203
+ }
7204
+ const code = typeof body?.code === "string" ? body.code : "";
7205
+ const label = typeof body?.label === "string" ? body.label : "";
7206
+ const verified = await emailSignIn.verify(email, code);
7207
+ if (!verified.ok) {
7208
+ if (verified.status === 401 || verified.status === 403)
7209
+ sessions.noteFailure(source);
7210
+ console.warn(`email sign-in refused from ${source}: ${verified.error}`);
7211
+ return json(res, verified.status, { error: verified.error });
7212
+ }
7213
+ sessions.clearFailures(source);
7214
+ const issued = sessions.issue({ label: label.trim() || labelFromUserAgent(req.headers["user-agent"]), scopes: verified.scopes, userId: verified.userId, email: verified.email });
7215
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true });
7216
+ const secure = requestOrigin(req)?.startsWith("https://") === true;
7217
+ res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, issued.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(issued.session) }));
7218
+ return json(res, 200, { session: issued.session, environment });
7219
+ }
6840
7220
  if (method === "POST" && path === "/api/auth/pair") {
6841
7221
  // JSON only: a cross-site HTML form cannot send this content type
6842
7222
  // without a preflight, so a stray unused code cannot be planted as a
@@ -6854,7 +7234,7 @@ const handleRequest = async (req, res) => {
6854
7234
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
6855
7235
  return json(res, result.status, { error: result.error });
6856
7236
  }
6857
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED });
7237
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled() });
6858
7238
  if (wantsCookie) {
6859
7239
  const secure = requestOrigin(req)?.startsWith("https://") === true;
6860
7240
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
@@ -6890,6 +7270,11 @@ const handleRequest = async (req, res) => {
6890
7270
  if (method === "GET" && path === "/api/health" && !gate.auth) {
6891
7271
  return json(res, 200, { app: "openmausbot" });
6892
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
+ }
6893
7278
  if (!gate.auth)
6894
7279
  return json(res, gate.status, { error: gate.error });
6895
7280
  const auth = gate.auth;
@@ -6905,6 +7290,8 @@ const handleRequest = async (req, res) => {
6905
7290
  expiresAt: auth.session.expiresAt,
6906
7291
  via: auth.via,
6907
7292
  environmentId: ENVIRONMENT_ID,
7293
+ // the account behind the session, when it came from a sign-in
7294
+ ...(auth.session.email ? { email: auth.session.email } : {}),
6908
7295
  });
6909
7296
  }
6910
7297
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -7084,6 +7471,11 @@ const handleRequest = async (req, res) => {
7084
7471
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
7085
7472
  }
7086
7473
  };
7474
+ if (method === "POST" && path === "/api/internal/memory") {
7475
+ const body = await readInternalBody();
7476
+ const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText });
7477
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "too-large" ? 413 : 400, result);
7478
+ }
7087
7479
  if (method === "POST" && path === "/api/internal/browser/mcp") {
7088
7480
  const body = await readInternalBody();
7089
7481
  const bot = store.bot(internalCapability.botId);
@@ -7104,6 +7496,9 @@ const handleRequest = async (req, res) => {
7104
7496
  currentBrowserSession(current.id, current.browserProfile) !== browser.session) {
7105
7497
  throw Object.assign(new Error("Browser access changed while connecting."), { status: 409 });
7106
7498
  }
7499
+ if (body.method === "tools/call" && !claimTurnResource(internalCapability, `browser:${browser.session}`)) {
7500
+ throw Object.assign(new Error("another thread is using this browser — pause browser work until that thread finishes"), { status: 409 });
7501
+ }
7107
7502
  });
7108
7503
  requireActiveInternalCapability();
7109
7504
  return json(res, 200, { result });
@@ -7299,7 +7694,7 @@ const handleRequest = async (req, res) => {
7299
7694
  const from = store.bot(fromBotId);
7300
7695
  if (!from)
7301
7696
  return json(res, 403, { error: "unknown sender" });
7302
- const fromThreadId = String(url.searchParams.get("fromThreadId") ?? from.threadId);
7697
+ const fromThreadId = internalCapability.threadId;
7303
7698
  if (!connectorThread(from.id, fromThreadId)) {
7304
7699
  return json(res, 403, { error: "source conversation does not belong to sender" });
7305
7700
  }
@@ -7331,7 +7726,7 @@ const handleRequest = async (req, res) => {
7331
7726
  const from = store.bot(fromBotId);
7332
7727
  if (!from)
7333
7728
  return json(res, 403, { error: "unknown sender" });
7334
- const fromThreadId = String(url.searchParams.get("fromThreadId") ?? from.threadId);
7729
+ const fromThreadId = internalCapability.threadId;
7335
7730
  if (!connectorThread(from.id, fromThreadId)) {
7336
7731
  return json(res, 403, { error: "source conversation does not belong to sender" });
7337
7732
  }
@@ -7546,11 +7941,12 @@ const handleRequest = async (req, res) => {
7546
7941
  const prefixed = withPeerProvenance(message, {
7547
7942
  botName: currentFrom.name,
7548
7943
  delivery: "ask_bot",
7549
- unattended: isUnattended(currentFrom.id),
7944
+ unattended: isUnattended(currentFrom.id, fromThreadId),
7550
7945
  });
7551
- const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId);
7946
+ const targetThreadId = currentTarget.threadId;
7947
+ const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId, fromThreadId, targetThreadId);
7552
7948
  requireActiveInternalCapability();
7553
- if (outcome.status === "timeout" && !delegationWatch.has(currentTarget.threadId)) {
7949
+ if (outcome.status === "timeout" && !delegationWatch.has(targetThreadId)) {
7554
7950
  // The peer's turn is still running — only the wait ended. Convert
7555
7951
  // the ask into a delegation claim ticket: the watch mirrors the
7556
7952
  // terminal state into the channel AND the asker's thread when the
@@ -7558,13 +7954,14 @@ const handleRequest = async (req, res) => {
7558
7954
  // Losing the reply was the old behavior, and it read as "the bots
7559
7955
  // don't respond to each other".
7560
7956
  const taskId = newId();
7561
- delegationWatch.set(currentTarget.threadId, {
7957
+ delegationWatch.set(targetThreadId, {
7562
7958
  channelId: channel.id,
7563
7959
  toBotId,
7564
7960
  toBotName: currentTarget.name,
7565
7961
  taskId,
7566
7962
  sourceThreadId: fromThreadId,
7567
7963
  sourceBotId: currentFrom.id,
7964
+ routineRunId: activeRoutineRunForThread(fromThreadId)?.id,
7568
7965
  });
7569
7966
  store.appendMessage(fromThreadId, {
7570
7967
  role: "bot",
@@ -7797,7 +8194,7 @@ const handleRequest = async (req, res) => {
7797
8194
  // Unattended inheritance: the mark rides the sender, and reading it
7798
8195
  // here is also what keeps its window alive through a turn that only
7799
8196
  // posts — an aged-out mark would hand the next hop to auto-approve.
7800
- const unattended = isUnattended(poster.id);
8197
+ const unattended = isUnattended(poster.id, internalCapability.threadId);
7801
8198
  const posted = store.appendMessage(room.threadId, {
7802
8199
  role: "bot",
7803
8200
  kind: "text",
@@ -7955,6 +8352,14 @@ const handleRequest = async (req, res) => {
7955
8352
  return json(res, 404, { error: "no such bot" });
7956
8353
  if (method === "GET") {
7957
8354
  const snapshot = computerControl.snapshot(botId);
8355
+ const computer = turnComputerResources.get(internalCapability.threadId);
8356
+ if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
8357
+ !claimTurnResource(computer.owner, computer.resource)) {
8358
+ return json(res, 200, {
8359
+ held: true, helpOpen: false,
8360
+ blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
8361
+ });
8362
+ }
7958
8363
  return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null });
7959
8364
  }
7960
8365
  if (method === "POST") {
@@ -7966,7 +8371,7 @@ const handleRequest = async (req, res) => {
7966
8371
  // screen it needs hands on are in the room — so send the person
7967
8372
  // where the work is, and say which room it was.
7968
8373
  const roomTurn = activeGroupTurnForBot(bot.id);
7969
- const target = blockedTarget(bot, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
8374
+ const target = blockedTarget({ ...bot, threadId: internalCapability.threadId }, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
7970
8375
  notify(buildNotification("takeover", bot, target.threadId, snapshot.helpReason ?? "asked you to take over", {
7971
8376
  group: target.group,
7972
8377
  }));
@@ -8311,6 +8716,16 @@ const handleRequest = async (req, res) => {
8311
8716
  return;
8312
8717
  }
8313
8718
  // ── bots ──
8719
+ // Paired sessions are authenticated above. The companion marker may
8720
+ // only narrow behavior (including its capability-free local dev proxy);
8721
+ // it never grants authority or replaces the existing request gate.
8722
+ const requirePinnedClientThread = (botId, threadId) => {
8723
+ if (threadId === undefined &&
8724
+ (auth.kind === "session" || req.headers["x-openmausbot-companion"] === "1") &&
8725
+ store.tasks(botId).length > 1) {
8726
+ throw Object.assign(new Error("This bot has multiple threads. Update this client and choose a thread before sending this action."), { status: 409 });
8727
+ }
8728
+ };
8314
8729
  if (method === "GET" && path === "/api/bots") {
8315
8730
  const limit = pageSize(url.searchParams.get("messages"));
8316
8731
  if (limit === null)
@@ -8342,14 +8757,14 @@ const handleRequest = async (req, res) => {
8342
8757
  const window = messageWindow(threadId, around, limit ?? DEFAULT_PAGE);
8343
8758
  if (!window)
8344
8759
  return json(res, 404, { error: "no such message" });
8345
- return json(res, 200, window);
8760
+ return json(res, 200, { ...window, activeLeafId: store.activeLeaf(threadId) });
8346
8761
  }
8347
8762
  // An unknown cursor must not silently answer with the newest page —
8348
8763
  // the client would paginate in a circle and never reach the top.
8349
8764
  if (before && !store.messagesFor(threadId).some((msg) => msg.id === before)) {
8350
8765
  return json(res, 404, { error: "no such message" });
8351
8766
  }
8352
- return json(res, 200, messagePage(threadId, limit ?? DEFAULT_PAGE, before));
8767
+ return json(res, 200, { ...messagePage(threadId, limit ?? DEFAULT_PAGE, before), activeLeafId: store.activeLeaf(threadId) });
8353
8768
  }
8354
8769
  // the pixels of one screen message, fetched only when something shows it
8355
8770
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages\/([\w-]+)\/image$/);
@@ -9664,16 +10079,25 @@ const handleRequest = async (req, res) => {
9664
10079
  const existing = store.bot(m[1]);
9665
10080
  if (!existing)
9666
10081
  return json(res, 404, { error: "no such bot" });
10082
+ requirePinnedClientThread(existing.id, undefined);
10083
+ const selected = requestedTaskBot(existing.id, undefined);
9667
10084
  if (existing.approvalGrant) {
9668
10085
  return json(res, 409, { error: "wait for the approval-level change to finish before changing models" });
9669
10086
  }
9670
- const checked = checkedModelSelection(body, { selection: existing.modelSelection, busy: Boolean(existing.busy) }, true);
10087
+ const checked = checkedModelSelection(body, { selection: selected.modelSelection, busy: threadBusy(selected.id, selected.threadId) }, true);
9671
10088
  if (!checked.ok)
9672
10089
  return json(res, checked.status, { error: checked.error });
9673
- const existingApprovalMode = approvalModeFor(existing);
9674
- if ((existingApprovalMode === "full" || existingApprovalMode === "custom") &&
9675
- (!supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, existingApprovalMode) ||
9676
- registry.cliTarget(checked.selection.instanceId)?.driverKind !== registry.cliTarget(existing.modelSelection.instanceId)?.driverKind)) {
10090
+ if (activeGroupTurnForBot(existing.id)) {
10091
+ const groupChecked = checkedModelSelection(checked.selection, { selection: existing.modelSelection, busy: true });
10092
+ if (!groupChecked.ok)
10093
+ return json(res, groupChecked.status, { error: groupChecked.error });
10094
+ }
10095
+ if ([existing, selected].some((owner) => {
10096
+ const mode = approvalModeFor(owner);
10097
+ return (mode === "full" || mode === "custom") &&
10098
+ (!supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, mode) ||
10099
+ registry.cliTarget(checked.selection.instanceId)?.driverKind !== registry.cliTarget(owner.modelSelection.instanceId)?.driverKind);
10100
+ })) {
9677
10101
  return json(res, 400, {
9678
10102
  error: "Changing providers with elevated permissions requires choosing Ask first",
9679
10103
  });
@@ -9683,13 +10107,16 @@ const handleRequest = async (req, res) => {
9683
10107
  const bot = store.patchBot(existing.id, { modelSelection: checked.selection });
9684
10108
  if (!bot)
9685
10109
  return json(res, 404, { error: "no such bot" });
10110
+ store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection });
9686
10111
  return json(res, 200, { bot: wireBot(bot) });
9687
10112
  }
9688
10113
  m = path.match(/^\/api\/bots\/([\w-]+)\/read$/);
9689
10114
  if (m && method === "POST") {
9690
- const bot = store.patchBot(m[1], { unread: false });
9691
- if (!bot)
9692
- return json(res, 404, { error: "no such bot" });
10115
+ const body = await readBody(req);
10116
+ requirePinnedClientThread(m[1], body?.threadId);
10117
+ const current = requestedTaskBot(m[1], body?.threadId);
10118
+ store.patchTask(current.id, current.threadId, { unread: false });
10119
+ const bot = store.bot(current.id);
9693
10120
  const visible = wireBot(bot);
9694
10121
  broadcast({ kind: "bot", bot: visible });
9695
10122
  return json(res, 200, { bot: visible });
@@ -9697,10 +10124,9 @@ const handleRequest = async (req, res) => {
9697
10124
  m = path.match(/^\/api\/bots\/([\w-]+)\/always-allow$/);
9698
10125
  if (m && method === "POST") {
9699
10126
  const body = await readBody(req);
10127
+ requirePinnedClientThread(m[1], body?.threadId);
9700
10128
  const allowKey = typeof body.allowKey === "string" ? body.allowKey : "";
9701
- const bot = store.bot(m[1]);
9702
- if (!bot)
9703
- return json(res, 404, { error: "no such bot" });
10129
+ const bot = requestedTaskBot(m[1], body.threadId);
9704
10130
  if (!allowKey)
9705
10131
  return json(res, 400, { error: "allowKey required" });
9706
10132
  const pending = store.messagesFor(bot.threadId).some((message) => message.card?.requestId &&
@@ -9710,9 +10136,13 @@ const handleRequest = async (req, res) => {
9710
10136
  if (!pending) {
9711
10137
  return json(res, 409, { error: "that grant is not on a pending approval for this bot" });
9712
10138
  }
9713
- const updated = store.patchBot(bot.id, {
10139
+ store.patchTask(bot.id, bot.threadId, {
9714
10140
  alwaysAllow: [...new Set([...(bot.alwaysAllow ?? []), allowKey])].slice(0, 200),
9715
10141
  });
10142
+ const live = directTurnBots.get(bot.threadId);
10143
+ if (live)
10144
+ live.alwaysAllow = [...new Set([...(live.alwaysAllow ?? []), allowKey])].slice(0, 200);
10145
+ const updated = store.bot(bot.id);
9716
10146
  const visible = wireBot(updated);
9717
10147
  broadcast({ kind: "bot", bot: visible });
9718
10148
  return json(res, 200, { bot: visible });
@@ -9729,6 +10159,7 @@ const handleRequest = async (req, res) => {
9729
10159
  return json(res, 403, { error: `forbidden: this session may change how a bot looks, not "${field}" (needs the admin scope)` });
9730
10160
  }
9731
10161
  const existingBot = store.bot(m[1]);
10162
+ const selectedTask = existingBot ? requestedTaskBot(existingBot.id, undefined) : null;
9732
10163
  const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
9733
10164
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
9734
10165
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
@@ -9747,6 +10178,8 @@ const handleRequest = async (req, res) => {
9747
10178
  // safe — startTurn refuses to run a turn on an unavailable instance
9748
10179
  // anyway, so an unverifiable level never reaches a CLI.
9749
10180
  const rawSelection = body.modelSelection;
10181
+ if (rawSelection !== undefined)
10182
+ requirePinnedClientThread(m[1], undefined);
9750
10183
  if (existingBot?.approvalGrant &&
9751
10184
  (rawSelection !== undefined || body.approvalMode !== undefined || body.autoApprove !== undefined)) {
9752
10185
  return json(res, 409, { error: "wait for the approval-level change to finish before changing this setting" });
@@ -9756,10 +10189,15 @@ const handleRequest = async (req, res) => {
9756
10189
  }
9757
10190
  let normalizedSelection;
9758
10191
  if (rawSelection !== undefined) {
9759
- const checked = checkedModelSelection(rawSelection, existingBot ? { selection: existingBot.modelSelection, busy: Boolean(existingBot.busy) } : undefined, body.requireAvailableModel === true);
10192
+ const checked = checkedModelSelection(rawSelection, selectedTask ? { selection: selectedTask.modelSelection, busy: threadBusy(selectedTask.id, selectedTask.threadId) } : undefined, body.requireAvailableModel === true);
9760
10193
  if (!checked.ok)
9761
10194
  return json(res, checked.status, { error: checked.error });
9762
10195
  normalizedSelection = checked.selection;
10196
+ if (existingBot && activeGroupTurnForBot(existingBot.id)) {
10197
+ const groupChecked = checkedModelSelection(normalizedSelection, { selection: existingBot.modelSelection, busy: true });
10198
+ if (!groupChecked.ok)
10199
+ return json(res, groupChecked.status, { error: groupChecked.error });
10200
+ }
9763
10201
  }
9764
10202
  // Persona/profile fields reach prompts and paired clients. Both this
9765
10203
  // broad desktop endpoint and the paired-safe profile endpoint pass
@@ -9928,6 +10366,14 @@ const handleRequest = async (req, res) => {
9928
10366
  patch.autoApprove = requestedApprovalMode === "auto";
9929
10367
  }
9930
10368
  const targetSelection = normalizedSelection ?? existingBot?.modelSelection;
10369
+ if (normalizedSelection && selectedTask) {
10370
+ const mode = approvalModeFor(selectedTask);
10371
+ if ((mode === "full" || mode === "custom") &&
10372
+ (!supportsApprovalMode(registry.cliTarget(normalizedSelection.instanceId)?.driverKind, mode) ||
10373
+ registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(selectedTask.modelSelection.instanceId)?.driverKind)) {
10374
+ return json(res, 400, { error: "Choose Ask for the selected thread before changing providers with elevated permissions" });
10375
+ }
10376
+ }
9931
10377
  if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") &&
9932
10378
  (body.approvalMode !== undefined || normalizedSelection !== undefined) &&
9933
10379
  (!targetSelection || !supportsApprovalMode(registry.cliTarget(targetSelection.instanceId)?.driverKind, requestedApprovalMode) ||
@@ -10052,15 +10498,17 @@ const handleRequest = async (req, res) => {
10052
10498
  console.warn(`bot ${m[1]}: ${loosened.join(", ")} loosened by ${requestSource(req)} through the local API with no paired session`);
10053
10499
  }
10054
10500
  if (existingBot?.computer === "local" && computerSpecified && requestedComputer !== "local") {
10055
- const routineThread = routines.activeBotRunForBot(existingBot.id)?.threadId;
10056
- const groupThread = activeGroupTurnForBot(existingBot.id)?.threadId;
10057
- const activeThread = routineThread || groupThread || existingBot.threadId;
10058
- cancelDirectTurnDispatch(existingBot.id, activeThread);
10059
- revokeInternalCapabilitiesForThread(activeThread);
10060
- await registry
10061
- .get(existingBot.modelSelection.instanceId)
10062
- ?.adapter.interruptTurn(activeThread)
10063
- .catch(() => { });
10501
+ await interruptAllDirectThreads(existingBot.id);
10502
+ const routine = routines.activeBotRunForBot(existingBot.id);
10503
+ if (routine)
10504
+ await routines.cancelRun(routine.id);
10505
+ const groupTurn = activeGroupTurnForBot(existingBot.id);
10506
+ if (groupTurn) {
10507
+ cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
10508
+ revokeInternalCapabilitiesForThread(groupTurn.threadId);
10509
+ await registry.get(existingBot.modelSelection.instanceId)?.adapter.interruptTurn(groupTurn.threadId).catch(() => { });
10510
+ closeOpenApprovals(groupTurn.threadId);
10511
+ }
10064
10512
  }
10065
10513
  const chiefMovedSections = Boolean(existingBot?.chiefOfStaff) &&
10066
10514
  body.chiefOfStaff !== false &&
@@ -10068,6 +10516,19 @@ const handleRequest = async (req, res) => {
10068
10516
  sectionKey(existingBot?.section) !== sectionKey(section);
10069
10517
  let bot;
10070
10518
  const freshBrowserBot = store.bot(m[1]);
10519
+ if (normalizedSelection && selectedTask) {
10520
+ const current = store.projectBotForTask(selectedTask.id, selectedTask.threadId);
10521
+ if (!current)
10522
+ return json(res, 404, { error: "no such task" });
10523
+ const checked = checkedModelSelection(normalizedSelection, { selection: current.modelSelection, busy: threadBusy(current.id, current.threadId) });
10524
+ if (!checked.ok)
10525
+ return json(res, checked.status, { error: checked.error });
10526
+ if (freshBrowserBot && activeGroupTurnForBot(freshBrowserBot.id)) {
10527
+ const groupChecked = checkedModelSelection(normalizedSelection, { selection: freshBrowserBot.modelSelection, busy: true });
10528
+ if (!groupChecked.ok)
10529
+ return json(res, groupChecked.status, { error: groupChecked.error });
10530
+ }
10531
+ }
10071
10532
  if (freshBrowserBot && body.browserProfile !== undefined &&
10072
10533
  patch.browserProfile !== freshBrowserBot.browserProfile &&
10073
10534
  (freshBrowserBot.busy || browserRuntime.heldBy(currentBrowserSession(freshBrowserBot.id, freshBrowserBot.browserProfile)))) {
@@ -10089,6 +10550,8 @@ const handleRequest = async (req, res) => {
10089
10550
  }
10090
10551
  if (!bot)
10091
10552
  return json(res, 404, { error: "no such bot" });
10553
+ if (normalizedSelection && selectedTask)
10554
+ store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection });
10092
10555
  if (existingBot && (bot.browserProfile !== beforeBrowserProfile || bot.browser !== beforeBrowserEnabled)) {
10093
10556
  browserLive.closeForBot(bot.id);
10094
10557
  if (beforeBrowserProfile === "guest" && (bot.browserProfile !== "guest" || bot.browser === false)) {
@@ -10113,6 +10576,7 @@ const handleRequest = async (req, res) => {
10113
10576
  await Promise.allSettled(store.bots
10114
10577
  .filter((bot) => bot.computer === "local")
10115
10578
  .map(async (bot) => {
10579
+ await interruptAllDirectThreads(bot.id);
10116
10580
  const routineRun = routines.activeBotRunForBot(bot.id);
10117
10581
  if (routineRun) {
10118
10582
  cancelDirectTurnDispatch(bot.id, routineRun.threadId);
@@ -10120,7 +10584,6 @@ const handleRequest = async (req, res) => {
10120
10584
  revokeInternalCapabilitiesForThread(routineRun.threadId);
10121
10585
  }
10122
10586
  await routines.cancelRun(routineRun.id);
10123
- return;
10124
10587
  }
10125
10588
  const instance = registry.get(bot.modelSelection.instanceId);
10126
10589
  const groupTurn = activeGroupTurnForBot(bot.id);
@@ -10129,13 +10592,7 @@ const handleRequest = async (req, res) => {
10129
10592
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
10130
10593
  await instance?.adapter.interruptTurn(groupTurn.threadId).catch(() => { });
10131
10594
  closeOpenApprovals(groupTurn.threadId);
10132
- return;
10133
10595
  }
10134
- const directClaim = cancelDirectTurnDispatch(bot.id);
10135
- const threadId = directClaim?.threadId ?? bot.threadId;
10136
- revokeInternalCapabilitiesForThread(threadId);
10137
- await instance?.adapter.interruptTurn(threadId).catch(() => { });
10138
- closeOpenApprovals(threadId);
10139
10596
  }));
10140
10597
  return json(res, 200, { ok: true });
10141
10598
  }
@@ -10165,7 +10622,7 @@ const handleRequest = async (req, res) => {
10165
10622
  // A direct turn that has already claimed the bot can provision a Box in
10166
10623
  // its background setup. Do not let deletion race that work while a Box
10167
10624
  // account is configured; the person can stop the turn and retry.
10168
- if ((box.boxConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || directTurnDispatchClaims.has(bot.id))) {
10625
+ if ((box.boxConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || hasDirectDispatch(bot.id))) {
10169
10626
  return json(res, 409, { error: "stop this bot's work before checking and deleting its cloud computer" });
10170
10627
  }
10171
10628
  const botBoxRecovery = boxCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
@@ -10263,18 +10720,19 @@ const handleRequest = async (req, res) => {
10263
10720
  const browserCleanupRequest = browserCleanup.prepare("bot", bot.id);
10264
10721
  try {
10265
10722
  // a running turn dies with its bot
10266
- const directClaim = cancelDirectTurnDispatch(bot.id);
10267
- const directThreadId = directClaim?.threadId ?? bot.threadId;
10268
10723
  // Invalidate every bot-callable bearer before the first asynchronous
10269
10724
  // teardown step. A request that already passed its initial header
10270
10725
  // check is revalidated after its body arrives and must fail closed.
10271
- revokeInternalCapabilitiesForThread(directThreadId);
10272
- directTurnGenerationByBot.delete(bot.id);
10273
- await registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(directThreadId).catch(() => { });
10274
- closeOpenApprovals(directThreadId);
10726
+ for (const task of store.tasks(bot.id))
10727
+ revokeInternalCapabilitiesForThread(task.threadId);
10728
+ await interruptAllDirectThreads(bot.id);
10275
10729
  // Deletion removes the thread before a late turn.completed can fold
10276
10730
  // staged provider images into a message, so dispose them here.
10277
- purgeGeneratedImagesForThread(directThreadId);
10731
+ for (const task of store.tasks(bot.id)) {
10732
+ purgeGeneratedImagesForThread(task.threadId);
10733
+ directTurnGenerationByThread.delete(task.threadId);
10734
+ directTurnBots.delete(task.threadId);
10735
+ }
10278
10736
  stopScreenPoller(bot.id);
10279
10737
  activeVpsThreads.delete(bot.id);
10280
10738
  routines.disableForBot(bot.id);
@@ -10651,20 +11109,18 @@ const handleRequest = async (req, res) => {
10651
11109
  // onboarding/ask cards persist their answered/dismissed state
10652
11110
  m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
10653
11111
  if (m && method === "PATCH") {
10654
- const bot = store.bot(m[1]);
10655
- if (!bot)
10656
- return json(res, 404, { error: "no such bot" });
11112
+ const body = await readBody(req);
11113
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
11114
+ return json(res, 400, { error: "body must be a JSON object" });
11115
+ }
11116
+ const bot = requestedTaskBot(m[1], body.threadId);
10657
11117
  const existing = store.messagesFor(bot.threadId).find((msg) => msg.id === m[2]);
10658
11118
  if (!existing?.card)
10659
11119
  return json(res, 404, { error: "no such card" });
10660
11120
  if (existing.card.requestId) {
10661
11121
  return json(res, 409, { error: "request cards must be answered through the approval endpoint" });
10662
11122
  }
10663
- const body = await readBody(req);
10664
- if (!body || typeof body !== "object" || Array.isArray(body)) {
10665
- return json(res, 400, { error: "body must be a JSON object" });
10666
- }
10667
- if (Object.keys(body).some((key) => key !== "answered" && key !== "dismissed")) {
11123
+ if (Object.keys(body).some((key) => key !== "answered" && key !== "dismissed" && key !== "threadId")) {
10668
11124
  return json(res, 400, { error: "only answered and dismissed may be changed" });
10669
11125
  }
10670
11126
  if (body.answered !== undefined && typeof body.answered !== "string") {
@@ -10694,6 +11150,7 @@ const handleRequest = async (req, res) => {
10694
11150
  const bot = store.bot(m[1]);
10695
11151
  if (!bot)
10696
11152
  return json(res, 404, { error: "no such bot" });
11153
+ requirePinnedClientThread(bot.id, body.threadId);
10697
11154
  if (body.threadId !== undefined && (typeof body.threadId !== "string" || !/^[\w-]+$/.test(body.threadId))) {
10698
11155
  return json(res, 400, { error: "threadId must be a task id" });
10699
11156
  }
@@ -10727,20 +11184,15 @@ const handleRequest = async (req, res) => {
10727
11184
  if (queued.text !== text || queued.replyToId !== replyTo?.id) {
10728
11185
  throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
10729
11186
  }
10730
- return { ok: true, queued: true, queueId: queued.id, threadId };
11187
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
10731
11188
  }
10732
11189
  }
10733
- const currentAtStart = store.bot(bot.id);
11190
+ const currentAtStart = store.projectBotForTask(bot.id, threadId);
10734
11191
  if (!currentAtStart)
10735
11192
  throw Object.assign(new Error("no such bot"), { status: 404 });
10736
11193
  if (!store.taskByThread(currentAtStart.id, threadId)) {
10737
11194
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
10738
11195
  }
10739
- if (currentAtStart.threadId !== threadId) {
10740
- throw Object.assign(new Error("the bot switched tasks before it could receive the message"), {
10741
- status: 409,
10742
- });
10743
- }
10744
11196
  // Claude can accept the message inside its live turn. If the write
10745
11197
  // loses a race with turn settlement, or the engine cannot steer, the
10746
11198
  // existing server-side queue records it atomically for the next turn.
@@ -10762,17 +11214,12 @@ const handleRequest = async (req, res) => {
10762
11214
  // successful steer; otherwise that late acknowledgement writes a user
10763
11215
  // message into a task the bot no longer owns. A conflict leaves the
10764
11216
  // text in the client's composer/outbox to resend deliberately.
10765
- const current = store.bot(bot.id);
11217
+ const current = store.projectBotForTask(bot.id, threadId);
10766
11218
  if (!current)
10767
11219
  throw Object.assign(new Error("no such bot"), { status: 404 });
10768
11220
  if (!store.taskByThread(bot.id, threadId)) {
10769
11221
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
10770
11222
  }
10771
- if (current.threadId !== threadId) {
10772
- throw Object.assign(new Error("the bot switched tasks before it could receive the message"), {
10773
- status: 409,
10774
- });
10775
- }
10776
11223
  if (steered) {
10777
11224
  if (!current.busy) {
10778
11225
  throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
@@ -10786,7 +11233,7 @@ const handleRequest = async (req, res) => {
10786
11233
  // desktop's owner capability, which every mutation there has
10787
11234
  // already shown) clears the mark.
10788
11235
  if (auth.kind === "session" || DESKTOP_MANAGED)
10789
- clearUnattended(current.id);
11236
+ clearUnattended(threadId);
10790
11237
  const message = store.appendMessage(threadId, {
10791
11238
  role: "user",
10792
11239
  kind: "text",
@@ -10798,8 +11245,7 @@ const handleRequest = async (req, res) => {
10798
11245
  return { ok: true, steered: true, threadId, message };
10799
11246
  }
10800
11247
  if (!current.busy) {
10801
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId });
10802
- return { ok: true, threadId, message };
11248
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
10803
11249
  }
10804
11250
  const queued = queueSteeredMessage(current.id, threadId, text, {
10805
11251
  replyToId: replyTo?.id,
@@ -10808,18 +11254,17 @@ const handleRequest = async (req, res) => {
10808
11254
  });
10809
11255
  return { ok: true, queued: true, queueId: queued.id, threadId };
10810
11256
  }
10811
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId });
10812
- return { ok: true, threadId, message };
11257
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
10813
11258
  });
10814
11259
  return json(res, 202, receipt);
10815
11260
  }
10816
11261
  m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)$/);
10817
11262
  if (m && method === "DELETE") {
10818
- const bot = store.bot(m[1]);
10819
- if (!bot)
10820
- return json(res, 404, { error: "no such bot" });
11263
+ const body = await readBody(req);
11264
+ requirePinnedClientThread(m[1], body?.threadId);
11265
+ const bot = requestedTaskBot(m[1], body?.threadId);
10821
11266
  const queueId = m[2];
10822
- if (!cancelSteeredMessage(bot.id, queueId)) {
11267
+ if (!cancelSteeredMessage(bot.id, queueId, bot.threadId)) {
10823
11268
  return json(res, 404, { error: "no such queued message" });
10824
11269
  }
10825
11270
  return json(res, 200, { ok: true });
@@ -10831,18 +11276,17 @@ const handleRequest = async (req, res) => {
10831
11276
  m = path.match(/^\/api\/bots\/([\w-]+)\/messages\/([\w-]+)\/edit$/);
10832
11277
  if (m && method === "POST") {
10833
11278
  const messageId = m[2];
10834
- const bot = store.bot(m[1]);
10835
- if (!bot)
10836
- return json(res, 404, { error: "no such bot" });
10837
11279
  const body = await readBody(req);
11280
+ requirePinnedClientThread(m[1], body?.threadId);
11281
+ const bot = requestedTaskBot(m[1], body.threadId);
10838
11282
  const text = String(body.text ?? "").trim();
10839
11283
  if (!text)
10840
11284
  return json(res, 400, { error: "text required" });
10841
11285
  // everything from here down is synchronous, so two racing edits can
10842
11286
  // never both get past this check: startTurn flips busy before the
10843
11287
  // next request is handled
10844
- if (bot.busy)
10845
- return json(res, 409, { error: "the bot is working — stop it before editing" });
11288
+ if (threadBusy(bot.id, bot.threadId))
11289
+ return json(res, 409, { error: "the thread is working — stop it before editing" });
10846
11290
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
10847
11291
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
10848
11292
  }
@@ -10855,23 +11299,20 @@ const handleRequest = async (req, res) => {
10855
11299
  error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
10856
11300
  });
10857
11301
  }
10858
- const message = store.branchMessage(bot.threadId, messageId, text);
10859
- if (!message)
10860
- return json(res, 404, { error: "no such message" });
10861
- store.patchBot(bot.id, { rewound: true });
10862
- const replyTo = message.replyToId ? resolveReplyTarget(bot.threadId, message.replyToId) : undefined;
10863
- await startTurn(bot.id, text, { userMessage: message, replyTo });
10864
- return json(res, 202, { ok: true });
11302
+ // startTurn admits the rerun before branching. A shared-resource or
11303
+ // concurrency-limit refusal must leave the original transcript intact.
11304
+ const replyTo = source.replyToId ? resolveReplyTarget(bot.threadId, source.replyToId) : undefined;
11305
+ const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo });
11306
+ return json(res, 202, { ok: true, message });
10865
11307
  }
10866
11308
  // switch which fork of the conversation is visible (no new turn)
10867
11309
  m = path.match(/^\/api\/bots\/([\w-]+)\/active-branch$/);
10868
11310
  if (m && method === "POST") {
10869
11311
  const body = await readBody(req);
10870
- const bot = store.bot(m[1]);
10871
- if (!bot)
10872
- return json(res, 404, { error: "no such bot" });
10873
- if (bot.busy)
10874
- return json(res, 409, { error: "the bot is working — stop it before switching versions" });
11312
+ requirePinnedClientThread(m[1], body?.threadId);
11313
+ const bot = requestedTaskBot(m[1], body.threadId);
11314
+ if (threadBusy(bot.id, bot.threadId))
11315
+ return json(res, 409, { error: "the thread is working — stop it before switching versions" });
10875
11316
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
10876
11317
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
10877
11318
  }
@@ -10879,15 +11320,14 @@ const handleRequest = async (req, res) => {
10879
11320
  if (!leaf)
10880
11321
  return json(res, 404, { error: "no such message" });
10881
11322
  // provider sessions still hold the other branch — next turn replays
10882
- store.patchBot(bot.id, { rewound: true });
11323
+ store.patchTask(bot.id, bot.threadId, { rewound: true });
10883
11324
  return json(res, 200, { activeLeafId: leaf });
10884
11325
  }
10885
11326
  m = path.match(/^\/api\/bots\/([\w-]+)\/respond$/);
10886
11327
  if (m && method === "POST") {
10887
- const bot = store.bot(m[1]);
10888
- if (!bot)
10889
- return json(res, 404, { error: "no such bot" });
10890
11328
  const body = await readBody(req);
11329
+ const selected = requestedTaskBot(m[1], body.threadId);
11330
+ const bot = botForThread(selected.id, selected.threadId);
10891
11331
  const behavior = requestBehavior(body.behavior);
10892
11332
  const reviewedSha256 = typeof body.reviewedSha256 === "string" ? body.reviewedSha256 : undefined;
10893
11333
  if (!behavior)
@@ -10920,7 +11360,8 @@ const handleRequest = async (req, res) => {
10920
11360
  // peer-approval intercept: harness-native cards carry a requestId
10921
11361
  // that lives in peer-approval's pending map. Resolve them here so
10922
11362
  // the provider adapter never sees a request it didn't raise.
10923
- if (resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
11363
+ if (store.messagesFor(bot.threadId).some((message) => message.card?.requestId === String(body.requestId)) &&
11364
+ resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
10924
11365
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
10925
11366
  }
10926
11367
  const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name });
@@ -10990,7 +11431,8 @@ const handleRequest = async (req, res) => {
10990
11431
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
10991
11432
  // belongs to the bus rather than to a speaker, so resolve it before we go
10992
11433
  // looking for one — a room between turns has no speaker to find.
10993
- if (resolvePeerComms(approvalBus, requestId, behavior)) {
11434
+ if (store.messagesFor(threadId).some((message) => message.card?.requestId === requestId) &&
11435
+ resolvePeerComms(approvalBus, requestId, behavior)) {
10994
11436
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
10995
11437
  }
10996
11438
  const group = store.groupByThread(threadId);
@@ -11006,7 +11448,8 @@ const handleRequest = async (req, res) => {
11006
11448
  : store.botByThread(threadId);
11007
11449
  if (!owner && !pending)
11008
11450
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
11009
- const outcome = await answerRequest(threadId, owner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined);
11451
+ const requestOwner = owner ? botForThread(owner.id, threadId) : null;
11452
+ const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined);
11010
11453
  return json(res, 200, { ok: true, outcome });
11011
11454
  }
11012
11455
  m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
@@ -11019,11 +11462,22 @@ const handleRequest = async (req, res) => {
11019
11462
  return json(res, 400, { error: "body must be a JSON object" });
11020
11463
  }
11021
11464
  const body = rawBody ?? {};
11465
+ requirePinnedClientThread(bot.id, body.threadId);
11022
11466
  const expectedThreadId = body.threadId;
11023
11467
  if (expectedThreadId !== undefined && (typeof expectedThreadId !== "string" || !/^[\w-]+$/.test(expectedThreadId))) {
11024
11468
  return json(res, 400, { error: "threadId must be a task id" });
11025
11469
  }
11026
- const directClaim = directTurnDispatchClaims.get(bot.id);
11470
+ // Explicit thread targets never fall through to another routine or
11471
+ // channel just because it belongs to the same bot.
11472
+ if (typeof expectedThreadId === "string" && store.taskByThread(bot.id, expectedThreadId)) {
11473
+ const routine = routines.activeBotRunForBot(bot.id);
11474
+ if (routine?.threadId === expectedThreadId)
11475
+ await routines.cancelRun(routine.id);
11476
+ else
11477
+ await interruptDirectThread(bot.id, expectedThreadId);
11478
+ return json(res, 200, { ok: true });
11479
+ }
11480
+ const directClaim = directTurnDispatchClaims.get(bot.threadId);
11027
11481
  const routineRun = routines.activeBotRunForBot(bot.id);
11028
11482
  if (routineRun) {
11029
11483
  if (expectedThreadId !== undefined && routineRun.threadId !== expectedThreadId) {
@@ -11036,7 +11490,7 @@ const handleRequest = async (req, res) => {
11036
11490
  await routines.cancelRun(routineRun.id);
11037
11491
  return json(res, 200, { ok: true });
11038
11492
  }
11039
- const instance = registry.get(bot.modelSelection.instanceId);
11493
+ const instance = registry.get((botForThread(bot.id, expectedThreadId ?? bot.threadId) ?? bot).modelSelection.instanceId);
11040
11494
  // a bot busy in a ROOM is running on the room's thread — stopping it
11041
11495
  // from its own chat must reach that turn, not just the 1:1 thread
11042
11496
  const busyGroup = activeGroupTurnForBot(bot.id);
@@ -11056,11 +11510,7 @@ const handleRequest = async (req, res) => {
11056
11510
  directClaim?.threadId !== expectedThreadId) {
11057
11511
  return json(res, 409, { error: "the bot switched tasks before it could be interrupted" });
11058
11512
  }
11059
- const cancelledDirect = cancelDirectTurnDispatch(bot.id, expectedThreadId);
11060
- const directThreadId = cancelledDirect?.threadId ?? bot.threadId;
11061
- revokeInternalCapabilitiesForThread(directThreadId);
11062
- await instance?.adapter.interruptTurn(directThreadId).catch(() => { });
11063
- closeOpenApprovals(directThreadId);
11513
+ await interruptDirectThread(bot.id, expectedThreadId ?? bot.threadId);
11064
11514
  return json(res, 200, { ok: true });
11065
11515
  }
11066
11516
  // ── tasks: a bot's separate contexts ────────────────────────────────
@@ -11073,18 +11523,71 @@ const handleRequest = async (req, res) => {
11073
11523
  activeLeafId: store.activeLeaf(bot.threadId),
11074
11524
  tasks: store.tasks(bot.id).map(wireTask),
11075
11525
  });
11526
+ // Folders organize one bot's threads; they never own settings,
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
+ }
11544
+ m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
11545
+ if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
11546
+ const bot = store.bot(m[1]);
11547
+ if (!bot)
11548
+ return json(res, 404, { error: "no such bot" });
11549
+ if (m[2] && !store.project(bot.id, m[2]))
11550
+ return json(res, 404, { error: "no such folder" });
11551
+ if (method === "DELETE") {
11552
+ const updated = store.deleteProject(bot.id, m[2]);
11553
+ return json(res, 200, { bot: botWithThread(updated) });
11554
+ }
11555
+ const body = await readBody(req);
11556
+ if (!body || typeof body !== "object" || Array.isArray(body))
11557
+ return json(res, 400, { error: "body must be a JSON object" });
11558
+ if (Object.keys(body).some((key) => key !== "name" && key !== "emoji")) {
11559
+ return json(res, 400, { error: "unsupported folder setting" });
11560
+ }
11561
+ if ((method === "POST" || body.name !== undefined) &&
11562
+ (typeof body.name !== "string" || !body.name.trim() || body.name.trim().length > 80)) {
11563
+ return json(res, 400, { error: "folder name must be between 1 and 80 characters" });
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
+ }
11568
+ const patch = {};
11569
+ if (body.name !== undefined)
11570
+ patch.name = body.name.trim();
11571
+ if (body.emoji !== undefined)
11572
+ patch.emoji = body.emoji;
11573
+ const project = method === "POST"
11574
+ ? store.createProject(bot.id, patch.name, patch.emoji)
11575
+ : store.patchProject(bot.id, m[2], patch);
11576
+ return json(res, method === "POST" ? 201 : 200, { project, bot: botWithThread(bot) });
11577
+ }
11076
11578
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
11077
11579
  if (m && method === "POST") {
11078
11580
  const body = await readBody(req);
11079
11581
  const bot = store.bot(m[1]);
11080
11582
  if (!bot)
11081
11583
  return json(res, 404, { error: "no such bot" });
11082
- if (bot.busy)
11083
- return json(res, 409, { error: "this bot is working — let it finish before starting a task" });
11084
11584
  if (phoneSecretSubmissions.hasBot(bot.id)) {
11085
11585
  return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
11086
11586
  }
11087
- const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined);
11587
+ if (body.projectId !== undefined && (typeof body.projectId !== "string" || !store.project(bot.id, body.projectId))) {
11588
+ return json(res, 400, { error: "projectId must belong to this bot" });
11589
+ }
11590
+ const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined, true, body.projectId);
11088
11591
  if (!task)
11089
11592
  return json(res, 500, { error: "couldn't create that task" });
11090
11593
  const fresh = botWithThread(store.bot(bot.id));
@@ -11099,12 +11602,7 @@ const handleRequest = async (req, res) => {
11099
11602
  if (phoneSecretSubmissions.hasBot(bot.id)) {
11100
11603
  return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
11101
11604
  }
11102
- // Switching the active thread while its provider turn is still running
11103
- // loses ownership of the process and can make a later interrupt target
11104
- // the wrong task. Keep this mutation atomic at the HTTP boundary; an
11105
- // MCP client cannot make a safe check-then-switch across two requests.
11106
- if (bot.busy)
11107
- return json(res, 409, { error: "this bot is working — stop it before switching tasks" });
11605
+ // Navigation only: execution owns its thread, never this selected id.
11108
11606
  const switched = store.switchTask(bot.id, m[2]);
11109
11607
  if (!switched)
11110
11608
  return json(res, 404, { error: "no such task" });
@@ -11117,12 +11615,81 @@ const handleRequest = async (req, res) => {
11117
11615
  }
11118
11616
  if (m && method === "PATCH") {
11119
11617
  const body = await readBody(req);
11120
- const task = store.renameTask(m[1], m[2], String(body.title ?? ""));
11121
- if (!task)
11618
+ if (!body || typeof body !== "object" || Array.isArray(body))
11619
+ return json(res, 400, { error: "body must be a JSON object" });
11620
+ const current = store.projectBotForTask(m[1], m[2]);
11621
+ if (!current)
11122
11622
  return json(res, 404, { error: "no such task" });
11623
+ const allowed = new Set(["title", "projectId", "modelSelection", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto"]);
11624
+ if (Object.keys(body).some((key) => !allowed.has(key)))
11625
+ return json(res, 400, { error: "unsupported thread setting" });
11626
+ for (const key of ["requireAvailableModel", "acknowledgeLocalAuto"]) {
11627
+ if (body[key] !== undefined && typeof body[key] !== "boolean")
11628
+ return json(res, 400, { error: `${key} must be a boolean` });
11629
+ }
11630
+ if (body.requireAvailableModel === true && body.modelSelection === undefined)
11631
+ return json(res, 400, { error: "requireAvailableModel requires modelSelection" });
11632
+ const patch = {};
11633
+ if (body.projectId !== undefined) {
11634
+ if (body.projectId === null)
11635
+ patch.projectId = undefined;
11636
+ else if (typeof body.projectId === "string" && store.project(current.id, body.projectId))
11637
+ patch.projectId = body.projectId;
11638
+ else
11639
+ return json(res, 400, { error: "projectId must belong to this bot, or null to ungroup the thread" });
11640
+ }
11641
+ if (body.title !== undefined) {
11642
+ if (typeof body.title !== "string")
11643
+ return json(res, 400, { error: "title must be a string" });
11644
+ patch.title = body.title;
11645
+ }
11646
+ if (body.pinnedMessageId !== undefined) {
11647
+ if (body.pinnedMessageId === null || body.pinnedMessageId === "")
11648
+ patch.pinnedMessageId = undefined;
11649
+ else if (typeof body.pinnedMessageId === "string" && store.messagesFor(current.threadId).some((message) => message.id === body.pinnedMessageId))
11650
+ patch.pinnedMessageId = body.pinnedMessageId;
11651
+ else
11652
+ return json(res, 400, { error: "pinnedMessageId must belong to this thread" });
11653
+ }
11654
+ if (body.modelSelection !== undefined) {
11655
+ if (current.approvalGrant)
11656
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
11657
+ const checked = checkedModelSelection(body.modelSelection, { selection: current.modelSelection, busy: threadBusy(current.id, current.threadId) }, body.requireAvailableModel === true);
11658
+ if (!checked.ok)
11659
+ return json(res, checked.status, { error: checked.error });
11660
+ patch.modelSelection = checked.selection;
11661
+ }
11662
+ if (body.approvalMode !== undefined || body.autoApprove !== undefined) {
11663
+ if (body.autoApprove !== undefined && typeof body.autoApprove !== "boolean")
11664
+ return json(res, 400, { error: "autoApprove must be a boolean" });
11665
+ const mode = body.approvalMode ?? (body.autoApprove ? "auto" : "ask");
11666
+ // Elevated modes still require the trusted desktop transition. A
11667
+ // thread settings PATCH cannot manufacture that grant.
11668
+ if (mode !== "ask" && mode !== "auto")
11669
+ return json(res, 403, { error: "Full and Custom access require confirmation in bot settings" });
11670
+ if (threadBusy(current.id, current.threadId))
11671
+ return json(res, 409, { error: "stop this thread before changing its approval mode" });
11672
+ if (current.approvalGrant)
11673
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
11674
+ if (mode === "auto" && approvalModeFor(current) !== "auto" && auth.kind === "loopback" && !DESKTOP_MANAGED && !req.headers.origin && store.bots.some((bot) => bot.busy)) {
11675
+ return json(res, 409, { error: "Change approval mode from the app or a paired device while bots are working." });
11676
+ }
11677
+ if (mode === "auto" && current.computer === "local" && approvalModeFor(current) !== "auto" && body.acknowledgeLocalAuto !== true) {
11678
+ return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
11679
+ }
11680
+ patch.approvalMode = mode;
11681
+ patch.autoApprove = mode === "auto";
11682
+ }
11683
+ const mode = patch.approvalMode ?? approvalModeFor(current);
11684
+ if (patch.modelSelection && (mode === "full" || mode === "custom") &&
11685
+ (!supportsApprovalMode(registry.cliTarget(patch.modelSelection.instanceId)?.driverKind, mode) ||
11686
+ registry.cliTarget(patch.modelSelection.instanceId)?.driverKind !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
11687
+ return json(res, 400, { error: "Choose Ask for this thread before changing providers with elevated permissions" });
11688
+ }
11689
+ const task = store.patchTask(m[1], m[2], patch);
11123
11690
  const fresh = botWithThread(store.bot(m[1]));
11124
11691
  broadcast({ kind: "bot", bot: fresh });
11125
- return json(res, 200, { task: wireTask(task) });
11692
+ return json(res, 200, { task: wireTask(task), bot: fresh });
11126
11693
  }
11127
11694
  if (m && method === "DELETE") {
11128
11695
  const bot = store.bot(m[1]);
@@ -11132,7 +11699,7 @@ const handleRequest = async (req, res) => {
11132
11699
  if (phoneSecretSubmissions.hasThread(m[2])) {
11133
11700
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
11134
11701
  }
11135
- if (bot?.busy && (bot.threadId === m[2] || routines.isActiveThread(m[2]))) {
11702
+ if (threadBusy(bot.id, m[2]) || routines.isActiveThread(m[2])) {
11136
11703
  return json(res, 409, { error: "this task is running — stop it first" });
11137
11704
  }
11138
11705
  const stagedSkillCleanups = stagedSkillCleanupsForThread(m[2]);
@@ -11474,9 +12041,10 @@ const handleRequest = async (req, res) => {
11474
12041
  if (action === "auth/complete") {
11475
12042
  const body = await readBody(req);
11476
12043
  const flowId = typeof body?.flowId === "string" ? body.flowId : "";
11477
- const callbackUrl = typeof body?.callbackUrl === "string" ? body.callbackUrl : "";
12044
+ // `code` for a pasted sign-in code (Claude), `callbackUrl` for a browser callback
12045
+ const callbackUrl = typeof body?.callbackUrl === "string" ? body.callbackUrl : typeof body?.code === "string" ? body.code : "";
11478
12046
  if (!flowId || !callbackUrl)
11479
- return json(res, 400, { error: "flowId and callbackUrl are required" });
12047
+ return json(res, 400, { error: "flowId and a code or callbackUrl are required" });
11480
12048
  await providerAuthSessions.complete(instanceId, owner, flowId, callbackUrl);
11481
12049
  return json(res, 200, { ok: true });
11482
12050
  }
@@ -11527,6 +12095,14 @@ const handleRequest = async (req, res) => {
11527
12095
  // executable already configured for this Claude instance. The JSON gate
11528
12096
  // keeps a hostile page from triggering a local process with a simple
11529
12097
  // cross-origin form request.
12098
+ const busyProviderSelections = () => store.bots.flatMap((bot) => {
12099
+ const busyTasks = store.tasks(bot.id).filter((task) => threadBusy(bot.id, task.threadId));
12100
+ const selections = busyTasks.map((task) => botForThread(bot.id, task.threadId).modelSelection);
12101
+ // Rooms still run from the profile default; a direct thread does not.
12102
+ if (activeGroupTurnForBot(bot.id) || (bot.busy && busyTasks.length === 0))
12103
+ selections.push(bot.modelSelection);
12104
+ return selections;
12105
+ });
11530
12106
  const claudeUpdate = /^\/api\/instances\/([\w.-]+)\/claude-update$/.exec(path);
11531
12107
  if (method === "POST" && claudeUpdate) {
11532
12108
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
@@ -11544,7 +12120,7 @@ const handleRequest = async (req, res) => {
11544
12120
  if (claudeUpdatesInFlight.has(target.cli)) {
11545
12121
  return json(res, 409, { error: "this Claude installation is already updating" });
11546
12122
  }
11547
- const active = store.bots.some((bot) => bot.busy && registry.cliTarget(bot.modelSelection.instanceId)?.cli === target.cli);
12123
+ const active = busyProviderSelections().some((selection) => registry.cliTarget(selection.instanceId)?.cli === target.cli);
11548
12124
  if (active) {
11549
12125
  return json(res, 409, { error: "wait for running Claude tasks to finish before updating" });
11550
12126
  }
@@ -11577,7 +12153,7 @@ const handleRequest = async (req, res) => {
11577
12153
  const instanceId = instancePatch[1];
11578
12154
  if (providerConfigBusy)
11579
12155
  return json(res, 409, { error: "provider settings are already being updated" });
11580
- if (store.bots.some((bot) => bot.busy && bot.modelSelection.instanceId === instanceId)) {
12156
+ if (busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
11581
12157
  return json(res, 409, { error: "Wait for bots using this account to finish before changing its settings." });
11582
12158
  }
11583
12159
  providerConfigBusy = true;
@@ -11601,7 +12177,7 @@ const handleRequest = async (req, res) => {
11601
12177
  catch { /* Allow repairing an unused malformed account. */ }
11602
12178
  entry.config = { ...entry.config, configDir: body.configDir };
11603
12179
  if (previousDir !== configuredAccountDirectory(entry)) {
11604
- const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
12180
+ const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
11605
12181
  if (used)
11606
12182
  return json(res, 409, { error: "This account is used by bots or conversation history. Add another account and select it for the bot instead." });
11607
12183
  assertSeparateClaudeAccount(instances, instanceId, entry);
@@ -11625,7 +12201,8 @@ const handleRequest = async (req, res) => {
11625
12201
  if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
11626
12202
  return json(res, 400, { error: "Only added Claude accounts can be removed here." });
11627
12203
  }
11628
- if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId)) {
12204
+ if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
12205
+ busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
11629
12206
  return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
11630
12207
  }
11631
12208
  providerConfigBusy = true;
@@ -12099,6 +12676,7 @@ const handleRequest = async (req, res) => {
12099
12676
  key !== "imageGen" &&
12100
12677
  key !== "vps" &&
12101
12678
  key !== "rooms" &&
12679
+ key !== "threads" &&
12102
12680
  key !== "localVm" &&
12103
12681
  key !== "features" &&
12104
12682
  key !== "browserProfiles");
@@ -12136,6 +12714,12 @@ const handleRequest = async (req, res) => {
12136
12714
  }
12137
12715
  const status = configStatus();
12138
12716
  broadcast({ kind: "config", ...status });
12717
+ if (patch.threads !== undefined) {
12718
+ drainQueuedSends();
12719
+ drainDelegationWakes();
12720
+ drainConnectorResumes();
12721
+ drainSecretResumes();
12722
+ }
12139
12723
  if (mandatoryError)
12140
12724
  throw mandatoryError;
12141
12725
  return status;
@@ -12580,6 +13164,24 @@ catch (error) {
12580
13164
  }
12581
13165
  server.listen(PORT, "127.0.0.1", () => {
12582
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
+ }
12583
13185
  });
12584
13186
  // A second listener for `openmausbot serve --tunnel` (server/tunnel.ts): the
12585
13187
  // connector gateway on this machine forwards public traffic to this IPC path.