openmausbot 0.1.66 → 0.1.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/assets/index-A3CBTxwl.css +1 -0
  2. package/dist/assets/{index-kW5L1QeO.js → index-BwpNqgCm.js} +1 -1
  3. package/dist/assets/index-CDuyoVPJ.js +278 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/computer-proxy.js +14 -3
  6. package/dist-server/container-mcp.js +42 -19
  7. package/dist-server/drivers/agents-proxy.js +31 -0
  8. package/dist-server/index.js +2416 -993
  9. package/dist-server/local-computer-proxy.js +433 -0
  10. package/dist-server/local-computer.js +48 -5
  11. package/dist-server/mcp-server.js +38 -17
  12. package/dist-server/openmausbot.js +1346 -726
  13. package/dist-server/pair-cli.js +1346 -726
  14. package/dist-server/proxy-paths.js +1 -0
  15. package/dist-server/server/account-signin.js +103 -0
  16. package/dist-server/server/caddy.js +203 -0
  17. package/dist-server/server/cli.js +163 -22
  18. package/dist-server/server/computer-proxy.js +11 -2
  19. package/dist-server/server/config.js +15 -0
  20. package/dist-server/server/control-client.js +7 -1
  21. package/dist-server/server/drivers/agents-proxy.js +34 -0
  22. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  23. package/dist-server/server/drivers/claude.js +8 -0
  24. package/dist-server/server/environment.js +1 -0
  25. package/dist-server/server/harness/registry.js +7 -1
  26. package/dist-server/server/index.js +778 -310
  27. package/dist-server/server/local-computer-proxy.js +28 -0
  28. package/dist-server/server/local-computer.js +17 -0
  29. package/dist-server/server/mcp-bridge.js +27 -11
  30. package/dist-server/server/peer-approval.js +19 -6
  31. package/dist-server/server/proxy-paths.js +1 -0
  32. package/dist-server/server/sessions.js +42 -1
  33. package/dist-server/server/steer-queue.js +10 -8
  34. package/dist-server/server/store.js +234 -34
  35. package/dist-server/server/team-backup.js +1 -0
  36. package/dist-server/server/tunnel.js +27 -1
  37. package/dist-server/server/turn-resources.js +50 -0
  38. package/dist-server/server/workspace.js +53 -3
  39. package/dist-server/vps-container-mcp.js +42 -19
  40. package/package.json +1 -1
  41. package/dist/assets/index-BftlaHAF.css +0 -1
  42. package/dist/assets/index-DuR3H-Hf.js +0 -278
@@ -69,7 +69,8 @@ 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";
@@ -78,7 +79,7 @@ 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
81
  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";
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";
@@ -121,6 +122,7 @@ 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,78 @@ 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
+ const MAX_CONCURRENT_BOT_THREADS = 3;
452
+ let providerFleetReloading = false;
453
+ const turnResources = new TurnResources();
454
+ const turnResourceOwners = new Map();
455
+ const turnComputerResources = new Map();
456
+ const settlingResourceOwners = new Map();
457
+ function claimTurnResource(owner, resource) {
458
+ if (!turnResources.claim(resource, owner))
459
+ return false;
460
+ turnResourceOwners.set(owner.threadId, owner);
461
+ return true;
462
+ }
463
+ function releaseTurnResources(owner) {
464
+ if (!owner)
465
+ return;
466
+ if (settlingResourceOwners.get(owner.threadId) === owner.generation)
467
+ settlingResourceOwners.delete(owner.threadId);
468
+ turnResources.release(owner);
469
+ if (turnResourceOwners.get(owner.threadId)?.generation === owner.generation)
470
+ turnResourceOwners.delete(owner.threadId);
471
+ if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
472
+ turnComputerResources.delete(owner.threadId);
473
+ }
474
+ function bindTurnComputer(owner, resource, exclusive = false) {
475
+ if (exclusive && !claimTurnResource(owner, resource)) {
476
+ throw Object.assign(new Error("another thread is using this computer — wait for it to finish"), { status: 409, code: "computer_busy" });
477
+ }
478
+ turnResourceOwners.set(owner.threadId, owner);
479
+ turnComputerResources.set(owner.threadId, { owner, resource });
480
+ }
481
+ function botForThread(botId, threadId) {
482
+ return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
483
+ }
484
+ function threadBusy(botId, threadId) {
485
+ return store.taskByThread(botId, threadId)?.busy === true || directTurnDispatchClaims.has(threadId);
486
+ }
487
+ function hasDirectDispatch(botId) {
488
+ return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
489
+ }
490
+ function requestedTaskBot(botId, rawThreadId) {
491
+ const profile = store.bot(botId);
492
+ if (!profile)
493
+ throw Object.assign(new Error("no such bot"), { status: 404 });
494
+ if (rawThreadId !== undefined && (typeof rawThreadId !== "string" || !/^[\w-]+$/.test(rawThreadId))) {
495
+ throw Object.assign(new Error("threadId must be a task id"), { status: 400 });
496
+ }
497
+ const threadId = typeof rawThreadId === "string" ? rawThreadId : profile.threadId;
498
+ const task = store.projectBotForTask(botId, threadId);
499
+ if (!task)
500
+ throw Object.assign(new Error("no such task"), { status: 404 });
501
+ return task;
502
+ }
503
+ async function interruptDirectThread(botId, threadId) {
504
+ const owner = botForThread(botId, threadId);
505
+ cancelDirectTurnDispatch(botId, threadId);
506
+ revokeInternalCapabilitiesForThread(threadId);
507
+ await (owner ? registry.get(owner.modelSelection.instanceId) : undefined)?.adapter.interruptTurn(threadId);
508
+ closeOpenApprovals(threadId);
509
+ }
510
+ async function interruptAllDirectThreads(botId) {
511
+ const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId));
512
+ // Revoke every sibling before yielding to any provider teardown.
513
+ for (const task of threads) {
514
+ cancelDirectTurnDispatch(botId, task.threadId);
515
+ revokeInternalCapabilitiesForThread(task.threadId);
516
+ }
517
+ await Promise.all(threads.map((task) => interruptDirectThread(botId, task.threadId)));
518
+ }
438
519
  const retiredProviderTurns = new RetiredTurnRegistry();
439
520
  const pendingCancelledProviderHandshakes = new PendingTurnCancellations();
440
521
  const generatedImagesByTurn = new Map();
@@ -481,32 +562,35 @@ function shouldIgnoreProviderEvent(event) {
481
562
  return true;
482
563
  if (event.type !== "session.exited" || event.turnId !== undefined)
483
564
  return false;
484
- return store.botByThread(event.threadId)?.busy === true || Boolean(store.groupByThread(event.threadId)?.busyBotId);
565
+ const bot = store.botByThread(event.threadId);
566
+ return Boolean(bot && threadBusy(bot.id, event.threadId)) || Boolean(store.groupByThread(event.threadId)?.busyBotId);
485
567
  }
486
568
  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;
569
+ const claim = directTurnDispatchClaims.get(threadId);
570
+ return claim?.id === claimId && claim.botId === botId && store.taskByThread(botId, threadId)?.busy === true;
490
571
  }
491
572
  function directTurnClaimExists(botId, claimId, threadId) {
492
- const claim = directTurnDispatchClaims.get(botId);
493
- return claim?.id === claimId && claim.threadId === threadId;
573
+ const claim = directTurnDispatchClaims.get(threadId);
574
+ return claim?.id === claimId && claim.botId === botId;
494
575
  }
495
576
  function markDirectTurnDispatching(botId, claimId, threadId) {
496
577
  if (!directTurnClaimIsCurrent(botId, claimId, threadId))
497
578
  return false;
498
- directTurnDispatchClaims.set(botId, { id: claimId, threadId, phase: "dispatching" });
579
+ directTurnDispatchClaims.set(threadId, { id: claimId, botId, threadId, phase: "dispatching" });
499
580
  return true;
500
581
  }
501
- function clearDirectTurnDispatch(botId, claimId) {
502
- if (directTurnDispatchClaims.get(botId)?.id === claimId)
503
- directTurnDispatchClaims.delete(botId);
582
+ function clearDirectTurnDispatch(threadId, claimId) {
583
+ if (directTurnDispatchClaims.get(threadId)?.id === claimId)
584
+ directTurnDispatchClaims.delete(threadId);
504
585
  }
505
586
  function cancelDirectTurnDispatch(botId, expectedThreadId) {
506
- const claim = directTurnDispatchClaims.get(botId);
507
- if (!claim || (expectedThreadId !== undefined && claim.threadId !== expectedThreadId))
587
+ const threadId = expectedThreadId ?? store.bot(botId)?.threadId;
588
+ if (!threadId)
589
+ return null;
590
+ const claim = directTurnDispatchClaims.get(threadId);
591
+ if (!claim || claim.botId !== botId)
508
592
  return null;
509
- directTurnDispatchClaims.delete(botId);
593
+ directTurnDispatchClaims.delete(threadId);
510
594
  // Setup has not called the adapter yet, so there is no provider handshake
511
595
  // (and no unknown turn id) to quarantine. Dispatching is the only phase in
512
596
  // which a late provider event can exist.
@@ -675,11 +759,11 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
675
759
  }),
676
760
  };
677
761
  }
678
- function askBotAndWait(targetBotId, message, depth, fromBotId) {
762
+ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
679
763
  const target = store.bot(targetBotId);
680
764
  if (!target)
681
765
  return Promise.resolve({ status: "error", text: "(no such bot)" });
682
- const threadId = target.threadId;
766
+ const threadId = targetThreadId ?? target.threadId;
683
767
  return new Promise((resolve) => {
684
768
  let text = "";
685
769
  let done = false;
@@ -716,8 +800,9 @@ function askBotAndWait(targetBotId, message, depth, fromBotId) {
716
800
  // prefixed to it: the wording is for the model reading this turn, the
717
801
  // field is for anything that reads the transcript later.
718
802
  const asker = fromBotId ? store.bot(fromBotId) : undefined;
719
- const unattended = isUnattended(fromBotId);
803
+ const unattended = isUnattended(fromBotId, fromThreadId);
720
804
  startTurn(targetBotId, message, {
805
+ threadId,
721
806
  commsDepth: depth + 1,
722
807
  unattended,
723
808
  peerAsk: asker
@@ -1008,14 +1093,14 @@ async function botOverview(bot) {
1008
1093
  * this too, but no provider dispatch or later permission callback relies on
1009
1094
  * persistence having been produced exclusively by that route. Delegation
1010
1095
  * uses the receiving bot's grant, never the sender's — see approvalModeForOrigin. */
1011
- const approvalModeForTurn = (bot, peerInitiated = false) => {
1096
+ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
1012
1097
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
1013
1098
  if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1014
1099
  return "ask";
1015
1100
  }
1016
1101
  // Native reviewers can approve before a permission reaches this process.
1017
1102
  // Unattended Auto must therefore downgrade before spawning the provider.
1018
- if (mode === "auto" && isUnattended(bot.id))
1103
+ if (mode === "auto" && isUnattended(bot.id, threadId))
1019
1104
  return "ask";
1020
1105
  return mode;
1021
1106
  };
@@ -1940,12 +2025,14 @@ const watchdog = new TurnWatchdog({
1940
2025
  stallMs: TURN_STALL_MS,
1941
2026
  checkMs: 60_000,
1942
2027
  onStall: (turn) => {
2028
+ const stalledResourceOwner = turnResourceOwners.get(turn.threadId);
2029
+ const stalledGeneration = directTurnGenerationByThread.get(turn.threadId);
1943
2030
  // Room targets carry an invocation identity; only those claims belong
1944
2031
  // to the room grace cleanup added here.
1945
2032
  const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
1946
2033
  revokeInternalCapabilitiesForThread(turn.threadId);
1947
2034
  repeats.settle(turn.threadId);
1948
- const bot = store.bot(turn.botId);
2035
+ const bot = botForThread(turn.botId, turn.threadId);
1949
2036
  const instance = bot ? registry.get(bot.modelSelection.instanceId) : null;
1950
2037
  void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
1951
2038
  const minutes = Math.round(TURN_STALL_MS / 60_000);
@@ -1962,6 +2049,10 @@ const watchdog = new TurnWatchdog({
1962
2049
  // overlap the process we are stopping. The normal turn.completed fold
1963
2050
  // clears it first when the adapter responds.
1964
2051
  const releaseOwnership = () => {
2052
+ if (stalledGeneration && directTurnGenerationByThread.get(turn.threadId) !== stalledGeneration)
2053
+ return;
2054
+ if (stalledResourceOwner && turnResourceOwners.get(turn.threadId)?.generation !== stalledResourceOwner.generation)
2055
+ return;
1965
2056
  // A goal coordinator can stall before sendTurn reveals its provider
1966
2057
  // turn id. Reusing the room during that ambiguous pre-id window would
1967
2058
  // make old and replacement events indistinguishable. Keep ownership
@@ -1982,10 +2073,15 @@ const watchdog = new TurnWatchdog({
1982
2073
  }
1983
2074
  const currentBot = store.bot(turn.botId);
1984
2075
  if (currentBot?.busy) {
1985
- stopScreenPoller(currentBot.id);
2076
+ stopScreenPoller(currentBot.id, turn.threadId);
2077
+ releaseTurnResources(stalledResourceOwner);
1986
2078
  if (activeVpsThreads.get(currentBot.id) === turn.threadId)
1987
2079
  activeVpsThreads.delete(currentBot.id);
1988
- store.setActivity(currentBot.id, "idle");
2080
+ if (store.taskByThread(currentBot.id, turn.threadId))
2081
+ store.setTaskActivity(currentBot.id, turn.threadId, "idle");
2082
+ else
2083
+ store.setActivity(currentBot.id, "idle");
2084
+ directTurnBots.delete(turn.threadId);
1989
2085
  retryDelegationsWaitingOn(currentBot.id);
1990
2086
  // The grace fallback replaces a missing turn.completed event. Release
1991
2087
  // every kind of work that may have queued behind this bot, including
@@ -2071,16 +2167,7 @@ bus.subscribe((event) => {
2071
2167
  watchdog.settle(event.threadId);
2072
2168
  revokeInternalCapabilityForProviderEvent(event);
2073
2169
  }
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
2170
+ else if (event.type !== "session.exited")
2084
2171
  watchdog.touch(event.threadId);
2085
2172
  });
2086
2173
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
@@ -2089,35 +2176,38 @@ bus.subscribe((event) => {
2089
2176
  // the guard behind auto mode is a pattern list, not a security boundary, and
2090
2177
  // it must not stand in for a human at 3am.
2091
2178
  //
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
2179
+ // Keyed by conversation: typing in one thread never makes a sibling webhook
2180
+ // run attended. Idle marks expire rather than clearing on
2095
2181
  // turn.completed: bus subscribers fire in registration order, and the
2096
2182
  // delegation drain runs AFTER the main fold — clearing there would blank the
2097
2183
  // flag before the hop that needs to read it. A busy bot never ages out, and a
2098
2184
  // stale mark only ever means "ask a human", so this fails closed.
2099
- const unattendedBots = new Map();
2185
+ const unattendedThreads = new Map();
2100
2186
  const UNATTENDED_TTL_MS = 30 * 60_000;
2101
- function markUnattended(botId) {
2102
- unattendedBots.set(botId, Date.now());
2187
+ function markUnattended(botId, threadId) {
2188
+ unattendedThreads.set(threadId, { botId, at: Date.now() });
2103
2189
  }
2104
- function clearUnattended(botId) {
2105
- unattendedBots.delete(botId);
2190
+ function clearUnattended(threadId) {
2191
+ unattendedThreads.delete(threadId);
2106
2192
  }
2107
- function isUnattended(botId) {
2193
+ function isUnattended(botId, threadId) {
2108
2194
  if (!botId)
2109
2195
  return false;
2110
- const at = unattendedBots.get(botId);
2111
- if (at === undefined)
2196
+ // Legacy peer callers without a thread fail closed if any of the bot's
2197
+ // work is unattended. Capability/event callers always pass the exact id.
2198
+ if (!threadId)
2199
+ return [...unattendedThreads].some(([id, mark]) => mark.botId === botId && isUnattended(botId, id));
2200
+ const mark = unattendedThreads.get(threadId);
2201
+ if (!mark || mark.botId !== botId)
2112
2202
  return false;
2113
2203
  // A long-running turn is still unattended even if its next approval comes
2114
2204
  // more than 30 minutes after the previous one. Only an idle bot may age
2115
2205
  // out; every positive read refreshes the inactivity window.
2116
- if (Date.now() - at > UNATTENDED_TTL_MS && !store.bot(botId)?.busy) {
2117
- unattendedBots.delete(botId);
2206
+ if (Date.now() - mark.at > UNATTENDED_TTL_MS && !threadBusy(botId, threadId) && !groupSpeakers.has(threadId)) {
2207
+ unattendedThreads.delete(threadId);
2118
2208
  return false;
2119
2209
  }
2120
- unattendedBots.set(botId, Date.now());
2210
+ mark.at = Date.now();
2121
2211
  return true;
2122
2212
  }
2123
2213
  // Threads whose turn in flight was started by another BOT — an ask_bot hop,
@@ -2184,7 +2274,7 @@ function managedBoxOwners() {
2184
2274
  // control lease still names its owner. This is deliberately conservative
2185
2275
  // across destination changes: an old Box may still contain valuable state.
2186
2276
  inUse: bot.busy === true ||
2187
- directTurnDispatchClaims.has(bot.id) ||
2277
+ hasDirectDispatch(bot.id) ||
2188
2278
  activeGroupTurnForBot(bot.id) !== null ||
2189
2279
  Boolean(routines?.activeRunForBot(bot.id)) ||
2190
2280
  activeVpsThreads.has(bot.id) ||
@@ -2194,7 +2284,7 @@ function managedBoxOwners() {
2194
2284
  function botHasActiveTurn(botId) {
2195
2285
  const bot = store.bot(botId);
2196
2286
  return bot?.busy === true ||
2197
- directTurnDispatchClaims.has(botId) ||
2287
+ hasDirectDispatch(botId) ||
2198
2288
  activeGroupTurnForBot(botId) !== null;
2199
2289
  }
2200
2290
  function providerTransitionMessage(provider) {
@@ -2378,7 +2468,7 @@ bus.subscribe((event) => {
2378
2468
  localVmLeaseFor(localVmTarget).touch(event.threadId);
2379
2469
  localVmIdleFor(localVmTarget).touch();
2380
2470
  }
2381
- if (event.type === "turn.completed") {
2471
+ if (event.type === "turn.completed" && !store.botByThread(event.threadId)) {
2382
2472
  releaseLocalVmThread(event.threadId);
2383
2473
  }
2384
2474
  const coordinatorTurnsForThread = groupGoalCoordinatorTurns.get(event.threadId);
@@ -2433,7 +2523,8 @@ bus.subscribe((event) => {
2433
2523
  if (!privateImageEvent)
2434
2524
  broadcast({ kind: "runtime", event });
2435
2525
  const routineRun = privateImageEvent ? null : (routines?.handleRuntimeEvent(event) ?? null);
2436
- const bot = store.botByThread(event.threadId);
2526
+ const ownerBot = store.botByThread(event.threadId);
2527
+ const bot = ownerBot ? botForThread(ownerBot.id, event.threadId) ?? undefined : undefined;
2437
2528
  const group = bot ? undefined : store.groupByThread(event.threadId);
2438
2529
  if (!bot && !group)
2439
2530
  return;
@@ -2504,7 +2595,7 @@ bus.subscribe((event) => {
2504
2595
  if (bot) {
2505
2596
  const touches = screenTouchingTool(toolName);
2506
2597
  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));
2598
+ pokeScreenPoller(event.threadId, touches, screenSurfaceForTool(toolName));
2508
2599
  }
2509
2600
  }
2510
2601
  }
@@ -2535,8 +2626,8 @@ bus.subscribe((event) => {
2535
2626
  // even Full access never invents a person's answer. Safe Auto stops on
2536
2627
  // the guards in auto-approve.ts; explicitly acknowledged Full does not.
2537
2628
  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";
2629
+ const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
2630
+ const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
2540
2631
  const verdict = permission && asker && event.requestId
2541
2632
  ? autoVerdict({
2542
2633
  // Use the same receiving-bot mode as the provider dispatch.
@@ -2731,7 +2822,9 @@ bus.subscribe((event) => {
2731
2822
  if (!card || card.answered)
2732
2823
  return;
2733
2824
  // the bot is not working now — it is waiting on a person
2734
- if (asker.busy)
2825
+ if (bot)
2826
+ store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
2827
+ else if (asker.busy)
2735
2828
  store.setActivity(asker.id, "waiting-on-you");
2736
2829
  notify(buildNotification(permission ? "approval" : "question", asker, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
2737
2830
  };
@@ -2758,7 +2851,10 @@ bus.subscribe((event) => {
2758
2851
  case "request.resolved": {
2759
2852
  // answered (by whoever): the turn is working again, unless it settled
2760
2853
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2761
- if (waiting?.activity === "waiting-on-you")
2854
+ if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
2855
+ store.setTaskActivity(bot.id, event.threadId, "working");
2856
+ }
2857
+ else if (!bot && waiting?.activity === "waiting-on-you")
2762
2858
  store.setActivity(waiting.id, "working");
2763
2859
  const messageId = event.requestId ? askMessageByRequest.get(`${event.threadId}:${event.requestId}`) : null;
2764
2860
  if (messageId) {
@@ -2793,7 +2889,7 @@ bus.subscribe((event) => {
2793
2889
  // dispatch moves it to working; turn.completed (which follows a setup
2794
2890
  // failure) is told to leave "dead" alone.
2795
2891
  if (event.setup && bot)
2796
- store.setActivity(bot.id, "dead");
2892
+ store.setTaskActivity(bot.id, event.threadId, "dead");
2797
2893
  break;
2798
2894
  case "thread.token-usage.updated":
2799
2895
  // running totals for the turn in flight; folded into the task's
@@ -2841,10 +2937,36 @@ bus.subscribe((event) => {
2841
2937
  // tally is not the right home for a shared room's spend, so only
2842
2938
  // 1:1 task turns are tallied for now.
2843
2939
  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);
2940
+ const resourceOwner = turnResourceOwners.get(event.threadId);
2941
+ const generation = directTurnGenerationByThread.get(event.threadId);
2942
+ const isCurrent = () => directTurnGenerationByThread.get(event.threadId) === generation &&
2943
+ Boolean(store.taskByThread(bot.id, event.threadId));
2944
+ const settleDirectTurn = (resumeQueued = false) => {
2945
+ // Resource claims can outlive a deleted thread, but a stale capture
2946
+ // must never release a replacement generation's VM or busy state.
2947
+ const ownsResources = resourceOwner &&
2948
+ turnResourceOwners.get(event.threadId)?.generation === resourceOwner.generation;
2949
+ if (ownsResources) {
2950
+ if (activeVpsThreads.get(bot.id) === event.threadId)
2951
+ activeVpsThreads.delete(bot.id);
2952
+ releaseLocalVmThread(event.threadId);
2953
+ }
2954
+ releaseTurnResources(resourceOwner);
2955
+ if (!isCurrent())
2956
+ return;
2957
+ if (store.taskByThread(bot.id, event.threadId)?.activity !== "dead") {
2958
+ store.setTaskActivity(bot.id, event.threadId, "idle");
2959
+ }
2960
+ directTurnBots.delete(event.threadId);
2961
+ // Ordinary completion still drains from the existing bus subscribers.
2962
+ // An asynchronous screenshot finishes after those subscribers ran.
2963
+ if (resumeQueued) {
2964
+ retryDelegationsWaitingOn(bot.id);
2965
+ drainQueuedSends();
2966
+ drainConnectorResumes();
2967
+ drainSecretResumes();
2968
+ drainDelegationWakes();
2969
+ }
2848
2970
  };
2849
2971
  // bank what this turn spent before the bot broadcast carries the
2850
2972
  // task list to every window. The driver's own per-turn figure
@@ -2857,9 +2979,6 @@ bus.subscribe((event) => {
2857
2979
  cachedInput: tokens?.cachedInput,
2858
2980
  costUsd: event.cost ?? null,
2859
2981
  });
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
2982
  const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
2864
2983
  const routineReportGroup = routineReportThread ? store.groupByThread(routineReportThread) : undefined;
2865
2984
  // Group-origin routines belong to that channel's unread state. Their
@@ -2868,7 +2987,7 @@ bus.subscribe((event) => {
2868
2987
  // pair channel and chipped into both threads, which is the whole of
2869
2988
  // what the person needs to be able to find it.
2870
2989
  if (!routineReportGroup && !internal)
2871
- store.patchBot(bot.id, { unread: true });
2990
+ store.patchTask(bot.id, event.threadId, { unread: true });
2872
2991
  // A failed peer turn stays a chip too. The bot that delegated is woken
2873
2992
  // with the failure and answers the person in its own thread — buzzing
2874
2993
  // here as well would ring twice for one piece of news.
@@ -2880,36 +2999,39 @@ bus.subscribe((event) => {
2880
2999
  : reply;
2881
3000
  notify(buildNotification("done", bot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: bot.avatarUrl }));
2882
3001
  }
2883
- if (screenPollers.has(bot.id)) {
3002
+ if (screenPollers.has(event.threadId)) {
3003
+ if (resourceOwner)
3004
+ settlingResourceOwners.set(resourceOwner.threadId, resourceOwner.generation);
2884
3005
  // the last live frame becomes a settled inline screen message —
2885
3006
  // the screenshot-in-chat moment. One fresh capture first, so the
2886
3007
  // frame shows the turn's END state (the final tool's poke may
2887
3008
  // still be in flight).
2888
3009
  //
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).
3010
+ // Keep the thread busy until its bounded final capture releases
3011
+ // the screen AND workspace. Otherwise an accepted follow-up races
3012
+ // these claims and fails as though another thread owned its folder.
2895
3013
  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 });
3014
+ let timeout;
3015
+ void Promise.race([
3016
+ finalScreenFrame(bot.id, event.threadId),
3017
+ new Promise((resolve) => { timeout = setTimeout(() => resolve(null), SCREEN_SETTLE_TIMEOUT_MS); }),
3018
+ ]).then((frame) => {
3019
+ if (frame && isCurrent()) {
3020
+ store.insertMessageAfter(event.threadId, settleLeafId, { role: "bot", kind: "screen", png: frame.png, mime: frame.mime });
2903
3021
  }
2904
- }).finally(clearVpsTurn);
3022
+ }).catch(() => { }).finally(() => {
3023
+ clearTimeout(timeout);
3024
+ settleDirectTurn(true);
3025
+ });
2905
3026
  }
2906
- else if (vpsTurn) {
2907
- clearVpsTurn();
3027
+ else {
3028
+ settleDirectTurn();
2908
3029
  }
2909
3030
  }
2910
3031
  const speaker = groupSpeakers.get(event.threadId);
2911
3032
  const group = store.groupByThread(event.threadId);
2912
3033
  if (speaker && group?.busyBotId === speaker.botId) {
3034
+ releaseTurnResources(turnResourceOwners.get(event.threadId));
2913
3035
  groupSpeakers.delete(event.threadId);
2914
3036
  store.patchGroup(group.id, { busyBotId: null, unread: true });
2915
3037
  const speakingBot = store.bot(speaker.botId);
@@ -2943,16 +3065,20 @@ const delegationWatch = new Map();
2943
3065
  const delegationWakeBudget = new DelegationWakeBudget();
2944
3066
  const pendingDelegationWakes = new Map();
2945
3067
  function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3068
+ if (!store.taskByThread(botId, threadId))
3069
+ return;
2946
3070
  const prompt = failureReason
2947
3071
  ? buildDelegationFailurePrompt(targetName, failureReason)
2948
3072
  : buildDelegationRevivalPrompt(targetName);
2949
3073
  void startTurn(botId, prompt, {
2950
3074
  threadId,
2951
3075
  cardContinuation: true,
2952
- unattended: isUnattended(botId),
3076
+ unattended: isUnattended(botId, threadId),
2953
3077
  })
2954
3078
  .then(() => undefined)
2955
3079
  .catch((error) => {
3080
+ if (!store.taskByThread(botId, threadId))
3081
+ return;
2956
3082
  const message = error instanceof Error ? error.message : String(error);
2957
3083
  // Raced with a user turn claiming the bot — retry once it settles.
2958
3084
  if (/already working/i.test(message)) {
@@ -2970,11 +3096,13 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
2970
3096
  });
2971
3097
  }
2972
3098
  function wakeDelegationSource(source, threadId, targetName, failureReason) {
3099
+ if (!store.taskByThread(source.id, threadId))
3100
+ return;
2973
3101
  // Busy? Hold the wake until the source settles, then drain it — the
2974
3102
  // delegated reply is already in the thread, so nothing is lost, and the
2975
3103
  // source processes it the moment it is free rather than only on a later
2976
3104
  // user nudge.
2977
- if (store.bot(source.id)?.busy) {
3105
+ if (threadBusy(source.id, threadId) || activeGroupTurnForBot(source.id)) {
2978
3106
  pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason });
2979
3107
  return;
2980
3108
  }
@@ -2984,7 +3112,11 @@ function wakeDelegationSource(source, threadId, targetName, failureReason) {
2984
3112
  }
2985
3113
  function drainDelegationWakes() {
2986
3114
  for (const [threadId, entry] of pendingDelegationWakes) {
2987
- if (store.bot(entry.botId)?.busy)
3115
+ if (!store.taskByThread(entry.botId, threadId)) {
3116
+ pendingDelegationWakes.delete(threadId);
3117
+ continue;
3118
+ }
3119
+ if (threadBusy(entry.botId, threadId) || activeGroupTurnForBot(entry.botId))
2988
3120
  continue;
2989
3121
  pendingDelegationWakes.delete(threadId);
2990
3122
  if (!delegationWakeBudget.tryAcquire(threadId))
@@ -3007,14 +3139,11 @@ function markTaskContextExternallyUpdated(bot, threadId) {
3007
3139
  const task = store.taskByThread(bot.id, threadId);
3008
3140
  if (!task)
3009
3141
  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);
3142
+ store.patchTask(bot.id, threadId, {
3143
+ resumeCursors: {},
3144
+ lastInstanceId: `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`,
3145
+ unread: true,
3146
+ });
3018
3147
  }
3019
3148
  /** Consume one delegated-turn watch and mirror exactly one terminal state.
3020
3149
  * Some harness paths settle a busy bot without a provider turn.completed
@@ -3095,7 +3224,7 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
3095
3224
  // The delegated result is already in the shared transcript; a group
3096
3225
  // continuation is the responsibility of the room's own turn engine.
3097
3226
  }
3098
- else {
3227
+ else if (store.taskByThread(source.id, watched.sourceThreadId)) {
3099
3228
  // 1:1 source: the source thread is the delegating bot's own task.
3100
3229
  if (ok && reply.trim()) {
3101
3230
  const sourceReply = {
@@ -3225,8 +3354,9 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3225
3354
  });
3226
3355
  };
3227
3356
  return startTurn(toBotId, text, {
3357
+ threadId: targetThreadId,
3228
3358
  commsDepth,
3229
- unattended: isUnattended(sourceBotId),
3359
+ unattended: isUnattended(sourceBotId, sourceThreadId),
3230
3360
  // startTurn schedules provider/integration setup after marking the bot
3231
3361
  // busy. Those asynchronous setup failures do not emit turn.completed,
3232
3362
  // so clear the watch and report them through this callback too.
@@ -3308,7 +3438,10 @@ function drainQueuedSends() {
3308
3438
  ok: false,
3309
3439
  },
3310
3440
  });
3311
- }));
3441
+ }),
3442
+ // Provider completion can precede its dispatch promise: keep the queue
3443
+ // intact until that exact handshake releases its runtime-only claim.
3444
+ (botId, threadId) => threadBusy(botId, threadId) || Boolean(activeGroupTurnForBot(botId)));
3312
3445
  }
3313
3446
  const screenPollers = new Map();
3314
3447
  /** The preview shares the box's single command endpoint with the agent's
@@ -3317,38 +3450,56 @@ const screenPollers = new Map();
3317
3450
  * and never two in flight. */
3318
3451
  const SCREEN_POLL_MS = 6000;
3319
3452
  const SCREEN_MIN_GAP_MS = 3000;
3453
+ const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
3320
3454
  /** `screenIsTheWork` starts the turn already counting as screen usage: a
3321
3455
  * boxAgent's whole session runs ON the box, so every tool it calls acts on
3322
3456
  * that screen even though none of them is named like a computer tool. Its
3323
3457
  * shell-only turns are kept honest by the settle-time hash gate instead. */
3324
- function startScreenPoller(botId, captures, { screenIsTheWork = false } = {}) {
3458
+ function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
3325
3459
  if (!captures.computer && !captures.browser)
3326
3460
  return;
3327
- if (screenPollers.has(botId))
3461
+ if (screenPollers.has(threadId))
3328
3462
  return;
3463
+ const owner = turnResourceOwners.get(threadId);
3464
+ const computer = turnComputerResources.get(threadId);
3465
+ const browserSession = currentBrowserSession(botId, botForThread(botId, threadId)?.browserProfile);
3466
+ const guarded = (capture, resource) => capture && owner && resource ? async () => {
3467
+ const isCurrent = () => turnResourceOwners.get(threadId)?.generation === owner.generation &&
3468
+ turnResources.owns(resource, owner) && Boolean(store.taskByThread(botId, threadId) || store.groupByThread(threadId));
3469
+ if (!isCurrent())
3470
+ throw new Error("this thread does not own that screen");
3471
+ const frame = await capture();
3472
+ if (!isCurrent())
3473
+ throw new Error("this thread no longer owns that screen");
3474
+ return frame;
3475
+ } : undefined;
3329
3476
  // Assign rather than spread: the source's last-frame getter deliberately
3330
3477
  // hides a stale frame as soon as the selected surface changes.
3331
3478
  const entry = Object.assign(createScreenFrameSource({
3332
- captures,
3479
+ captures: {
3480
+ computer: guarded(captures.computer, computer?.resource),
3481
+ browser: guarded(captures.browser, browserSession ? `browser:${browserSession}` : undefined),
3482
+ },
3333
3483
  control: () => ({
3334
3484
  held: computerControl.snapshot(botId).held,
3335
3485
  revision: computerControlRevision.get(botId) ?? 0,
3336
3486
  }),
3337
- onFrame: (frame) => broadcast({ kind: "screen", botId, ...frame }),
3487
+ onFrame: (frame) => broadcast({ kind: "screen", botId, threadId, ...frame }),
3338
3488
  minGapMs: SCREEN_MIN_GAP_MS,
3339
3489
  }), {
3340
3490
  timer: null,
3491
+ botId,
3341
3492
  touched: screenIsTheWork,
3342
3493
  });
3343
3494
  entry.timer = setInterval(() => void entry.capture(), SCREEN_POLL_MS);
3344
- screenPollers.set(botId, entry);
3495
+ screenPollers.set(threadId, entry);
3345
3496
  }
3346
3497
  /** Event-driven refresh: capture NOW (the bot just acted on its screen)
3347
3498
  * instead of waiting for the next interval tick. Rate-limited inside
3348
3499
  * capture() — a tool-heavy turn used to fire one full REST chain per
3349
3500
  * completed tool, competing with the agent for the same endpoint. */
3350
- function pokeScreenPoller(botId, touches, surface) {
3351
- const entry = screenPollers.get(botId);
3501
+ function pokeScreenPoller(threadId, touches, surface) {
3502
+ const entry = screenPollers.get(threadId);
3352
3503
  if (!entry)
3353
3504
  return;
3354
3505
  // the same signal, read twice: a completed computer tool is both the
@@ -3367,13 +3518,14 @@ function pokeScreenPoller(botId, touches, surface) {
3367
3518
  entry.surface = surface;
3368
3519
  void entry.capture();
3369
3520
  }
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);
3521
+ function stopScreenPoller(botId, threadId) {
3522
+ for (const [id, entry] of screenPollers) {
3523
+ if (entry.botId !== botId || (threadId && id !== threadId))
3524
+ continue;
3525
+ if (entry.timer)
3526
+ clearInterval(entry.timer);
3527
+ screenPollers.delete(id);
3528
+ }
3377
3529
  }
3378
3530
  /** sha256 of the frame each bot last settled into a transcript — the
3379
3531
  * comparison the hash gate needs is "this turn's end state against what
@@ -3382,8 +3534,8 @@ function stopScreenPoller(botId) {
3382
3534
  * newest screen message so a restart does not re-picture the same idle
3383
3535
  * desktop either. */
3384
3536
  const settledScreenHashes = new Map();
3385
- function shownScreenHash(botId, threadId) {
3386
- const known = settledScreenHashes.get(botId);
3537
+ function shownScreenHash(threadId) {
3538
+ const known = settledScreenHashes.get(threadId);
3387
3539
  if (known)
3388
3540
  return known;
3389
3541
  const shown = store.messagesFor(threadId).findLast((m) => m.kind === "screen" && Boolean(m.png));
@@ -3398,33 +3550,43 @@ function shownScreenHash(botId, threadId) {
3398
3550
  * screen work, so without this its shell-only replies would all end in the
3399
3551
  * same idle desktop. Either way the poller is torn down here, so no
3400
3552
  * per-turn state survives the turn. */
3401
- async function finalScreenFrame(botId, threadId) {
3402
- const entry = screenPollers.get(botId);
3553
+ async function finalScreenFrame(_botId, threadId) {
3554
+ const entry = screenPollers.get(threadId);
3555
+ const owner = turnResourceOwners.get(threadId);
3403
3556
  if (!entry)
3404
3557
  return null;
3405
3558
  if (entry.timer)
3406
3559
  clearInterval(entry.timer);
3407
- screenPollers.delete(botId);
3560
+ screenPollers.delete(threadId);
3408
3561
  if (!entry.touched)
3409
3562
  return null;
3410
3563
  await entry.capture(true);
3564
+ if (!owner || turnResourceOwners.get(threadId)?.generation !== owner.generation ||
3565
+ !store.taskByThread(_botId, threadId))
3566
+ return null;
3411
3567
  const frame = entry.last;
3412
- if (!frame || !settledFrameIsNews(shownScreenHash(botId, threadId), frame.png))
3568
+ if (!frame || !settledFrameIsNews(shownScreenHash(threadId), frame.png))
3413
3569
  return null;
3414
- settledScreenHashes.set(botId, screenFrameHash(frame.png));
3570
+ settledScreenHashes.set(threadId, screenFrameHash(frame.png));
3415
3571
  return frame;
3416
3572
  }
3417
3573
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
3418
3574
  async function startTurn(botId, text, opts) {
3419
- const bot = store.bot(botId);
3420
- if (!bot)
3575
+ const profile = store.bot(botId);
3576
+ if (!profile)
3421
3577
  throw Object.assign(new Error("no such bot"), { status: 404 });
3578
+ const threadId = opts?.threadId ?? profile.threadId;
3579
+ const bot = store.projectBotForTask(botId, threadId);
3580
+ if (!bot)
3581
+ throw Object.assign(new Error("no such task"), { status: 404 });
3422
3582
  if (bot.approvalGrant) {
3423
3583
  throw Object.assign(new Error("this bot's approval level is still being confirmed — try again"), { status: 409 });
3424
3584
  }
3425
3585
  const transitionError = providerTransitionForTurn(bot, opts?.runOn);
3426
3586
  if (transitionError)
3427
3587
  throw Object.assign(new Error(transitionError), { status: 409 });
3588
+ if (providerFleetReloading)
3589
+ throw Object.assign(new Error("provider settings are being updated — try again shortly"), { status: 409 });
3428
3590
  if (checkpointRestoreLeases.has(botId)) {
3429
3591
  throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
3430
3592
  status: 409,
@@ -3433,19 +3595,24 @@ async function startTurn(botId, text, opts) {
3433
3595
  if (boxLifecycleBusyBots.has(botId)) {
3434
3596
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
3435
3597
  }
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;
3598
+ if (threadBusy(botId, threadId))
3599
+ throw Object.assign(new Error("this thread is already working — interrupt it first"), { status: 409, code: "thread_busy" });
3600
+ if (activeGroupTurnForBot(botId)) {
3601
+ throw Object.assign(new Error("the bot is already working in a channel — wait for it to finish"), { status: 409, code: "thread_busy" });
3602
+ }
3603
+ if (store.tasks(botId).filter((task) => task.busy).length >= MAX_CONCURRENT_BOT_THREADS) {
3604
+ throw Object.assign(new Error("this bot is already working on three threads — wait for one to finish"), { status: 409, code: "thread_limit" });
3605
+ }
3439
3606
  // Retire anything a previous turn left behind before minting this turn's
3440
3607
  // integrations. Completion and interrupt paths do the same; this is the
3441
3608
  // final backstop against a retained proxy process.
3442
3609
  revokeInternalCapabilitiesForThread(threadId);
3443
3610
  // a webhook turn, or one inherited from a bot already running unattended
3444
3611
  if (opts?.automationSource === "webhook" || opts?.unattended)
3445
- markUnattended(bot.id);
3612
+ markUnattended(bot.id, threadId);
3446
3613
  // a person typing into this bot ends the unattended window immediately
3447
3614
  else if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
3448
- clearUnattended(bot.id);
3615
+ clearUnattended(threadId);
3449
3616
  delegationWakeBudget.reset(threadId);
3450
3617
  }
3451
3618
  const task = store.taskByThread(bot.id, threadId);
@@ -3494,6 +3661,13 @@ async function startTurn(botId, text, opts) {
3494
3661
  }
3495
3662
  // an edit hands us its already-branched user message; a plain send appends
3496
3663
  let userMessage = opts?.userMessage;
3664
+ if (opts?.editedMessageId) {
3665
+ const edited = store.branchMessage(threadId, opts.editedMessageId, text);
3666
+ if (!edited)
3667
+ throw Object.assign(new Error("only a user text message can be edited"), { status: 400 });
3668
+ store.patchTask(bot.id, threadId, { rewound: true });
3669
+ userMessage = edited;
3670
+ }
3497
3671
  if (!userMessage) {
3498
3672
  userMessage = opts?.cardContinuation
3499
3673
  ? { id: `card-${randomUUID()}`, at: Date.now(), role: "user", kind: "text", text }
@@ -3537,7 +3711,7 @@ async function startTurn(botId, text, opts) {
3537
3711
  // inline (transcript-replay drivers get it via transcript). The flag is
3538
3712
  // cleared only once the turn is actually dispatched — clearing it here
3539
3713
  // would cost the next attempt its history if this dispatch fails.
3540
- const rewound = threadId === bot.threadId && Boolean(bot.rewound);
3714
+ const rewound = Boolean(task.rewound);
3541
3715
  // A fresh engine — the user switched this bot's model mid-thread — has no
3542
3716
  // current session here either, so it gets the same replay. Distinct from
3543
3717
  // rewound: the OTHER instances' cursors are left alone (a rewind wipes
@@ -3593,16 +3767,19 @@ async function startTurn(botId, text, opts) {
3593
3767
  // in the background — box provisioning can take ~90s and must never
3594
3768
  // hang the HTTP request
3595
3769
  const dispatchClaimId = randomUUID();
3596
- directTurnGenerationByBot.set(bot.id, dispatchClaimId);
3597
- directTurnDispatchClaims.set(bot.id, { id: dispatchClaimId, threadId, phase: "setup" });
3770
+ const resourceOwner = { threadId, generation: dispatchClaimId };
3771
+ turnResourceOwners.set(threadId, resourceOwner);
3772
+ directTurnGenerationByThread.set(threadId, dispatchClaimId);
3773
+ directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
3774
+ directTurnBots.set(threadId, bot);
3598
3775
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
3599
- store.setActivity(bot.id, "working");
3776
+ store.setTaskActivity(bot.id, threadId, "working");
3600
3777
  // The badge is "this bot answered you, and you have not looked yet". A
3601
3778
  // person starting a turn has looked; a teammate's hop has not — the fold
3602
3779
  // never re-marks an internal turn, so clearing here would silently spend
3603
3780
  // a signal the person still owes a glance to.
3604
3781
  if (commsDepth === 0)
3605
- store.patchBot(bot.id, { unread: false });
3782
+ store.patchTask(bot.id, threadId, { unread: false });
3606
3783
  turnUsage.delete(threadId);
3607
3784
  void (async () => {
3608
3785
  try {
@@ -3613,6 +3790,8 @@ async function startTurn(botId, text, opts) {
3613
3790
  ...(skillAuthoring ? ["skillAuthoring"] : []),
3614
3791
  ], availableSkills());
3615
3792
  if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
3793
+ if (!claimTurnResource(resourceOwner, "computer:phone"))
3794
+ throw new Error("another thread is using the phone — wait for it to finish");
3616
3795
  integrations.phone = phoneIntegration();
3617
3796
  }
3618
3797
  // the user's connected apps, but only to a driver that can mount
@@ -3637,7 +3816,9 @@ async function startTurn(botId, text, opts) {
3637
3816
  // desk, not the whole house — and the workspace is where its
3638
3817
  // MEMORY.md lives. API/box engines have no local filesystem story.
3639
3818
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
3640
- const privateWorkspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
3819
+ if (worksInWorkspace)
3820
+ ensureWorkspace(bot.id);
3821
+ const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
3641
3822
  const skillInstructions = renderSkillInstructions(selectedSkills, {
3642
3823
  includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
3643
3824
  });
@@ -3655,6 +3836,9 @@ async function startTurn(botId, text, opts) {
3655
3836
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace)
3656
3837
  : null;
3657
3838
  const cwd = pinnedCwd ?? undefined;
3839
+ if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
3840
+ 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" });
3841
+ }
3658
3842
  // Checkpoint explicit project folders, where a bot can overwrite the
3659
3843
  // user's work. Its private OpenMaus workspace is app-owned and changes
3660
3844
  // on nearly every ordinary chat; snapshotting it would add hidden disk
@@ -3698,6 +3882,7 @@ async function startTurn(botId, text, opts) {
3698
3882
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
3699
3883
  }
3700
3884
  const localVmTarget = localVmTargetForBot(bot.id);
3885
+ bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
3701
3886
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
3702
3887
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
3703
3888
  }
@@ -3745,7 +3930,8 @@ async function startTurn(botId, text, opts) {
3745
3930
  const cua = readCuaConnection();
3746
3931
  if (!cua)
3747
3932
  throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
3748
- integrations.localComputer = cua;
3933
+ bindTurnComputer(resourceOwner, "computer:host");
3934
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
3749
3935
  computerKind = "local";
3750
3936
  }
3751
3937
  // A VPS is a local-agent computer mount, never a remote agent runner.
@@ -3758,6 +3944,9 @@ async function startTurn(botId, text, opts) {
3758
3944
  if (unsupported && wants === undefined)
3759
3945
  autoVpsProblem = unsupported;
3760
3946
  if (!unsupported) {
3947
+ // The remote lifecycle and container are shared by this bot. Keep
3948
+ // its explicit computer turns serialized; ordinary threads still run.
3949
+ bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
3761
3950
  activeVpsThreads.set(bot.id, threadId);
3762
3951
  const remote = wants === "cloud" || bot.autoStartVps
3763
3952
  ? await vps.vpsComputerAction("provision", cfg, bot.id)
@@ -3785,6 +3974,10 @@ async function startTurn(botId, text, opts) {
3785
3974
  // Cloud is also strict when explicitly selected. Auto (unset) reuses an
3786
3975
  // existing cloud box, then falls back to host CUA without provisioning.
3787
3976
  if ((wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
3977
+ // Explicit cloud turns can provision/wake the same bot's Box. Claim
3978
+ // before any network await so setup itself cannot race another turn.
3979
+ if (wants === "cloud")
3980
+ bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
3788
3981
  if (!mountsCloudComputer && wants === "cloud") {
3789
3982
  throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
3790
3983
  }
@@ -3818,6 +4011,7 @@ async function startTurn(botId, text, opts) {
3818
4011
  });
3819
4012
  }
3820
4013
  if (b && lifecycle === "attach") {
4014
+ bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
3821
4015
  previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
3822
4016
  if (mountsCloudComputer) {
3823
4017
  integrations.computer = {
@@ -3848,7 +4042,8 @@ async function startTurn(botId, text, opts) {
3848
4042
  })) {
3849
4043
  const cua = readCuaConnection();
3850
4044
  if (cua) {
3851
- integrations.localComputer = cua;
4045
+ bindTurnComputer(resourceOwner, "computer:host");
4046
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
3852
4047
  computerKind = "local";
3853
4048
  }
3854
4049
  }
@@ -3977,7 +4172,7 @@ async function startTurn(botId, text, opts) {
3977
4172
  { id: "profile", label: "Profile changes", text: profilePrompt },
3978
4173
  { id: "learn", label: "Skill authoring", text: learnPrompt },
3979
4174
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3980
- { id: "memory", label: "Memory", text: privateWorkspace ? memorySystemPrompt(bot.id) : "" },
4175
+ { id: "memory", label: "Memory", text: privateWorkspace ? memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents) }) : "" },
3981
4176
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
3982
4177
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
3983
4178
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
@@ -3988,7 +4183,7 @@ async function startTurn(botId, text, opts) {
3988
4183
  threadId,
3989
4184
  text: turnText,
3990
4185
  images: turnImages,
3991
- approvalMode: approvalModeForTurn(liveBot ?? bot, commsDepth > 0),
4186
+ approvalMode: approvalModeForTurn(bot, commsDepth > 0),
3992
4187
  model,
3993
4188
  effort,
3994
4189
  // a rewound thread never resumes the abandoned branch's session
@@ -4007,10 +4202,10 @@ async function startTurn(botId, text, opts) {
4007
4202
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
4008
4203
  }
4009
4204
  bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
4010
- clearDirectTurnDispatch(bot.id, dispatchClaimId);
4205
+ clearDirectTurnDispatch(threadId, dispatchClaimId);
4011
4206
  // dispatched: the rewind is spent, and the old cursors are dead
4012
4207
  if (rewound)
4013
- store.patchBot(bot.id, { rewound: false, resumeCursors: {} });
4208
+ store.patchTask(bot.id, threadId, { rewound: false, resumeCursors: {} });
4014
4209
  // and this engine now owns the thread's most recent turn
4015
4210
  // Consume exactly the external-update generation this turn replayed.
4016
4211
  // If a newer delegated result landed during setup, its unique marker
@@ -4022,21 +4217,29 @@ async function startTurn(botId, text, opts) {
4022
4217
  // after its own turn.completed would never be torn down — it would
4023
4218
  // keep polling the box forever, carrying dead per-turn state. busy
4024
4219
  // 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" });
4220
+ if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
4221
+ startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.driverKind === "boxAgent" });
4027
4222
  }
4028
4223
  // An adapter may publish completion synchronously just before its
4029
4224
  // dispatch promise resolves. The event could not use the turn-id map
4030
4225
  // above yet, so close this exact generation from durable busy state.
4031
- if (store.bot(bot.id)?.busy !== true) {
4226
+ if (!threadBusy(bot.id, threadId) && directTurnGenerationByThread.get(threadId) === dispatchClaimId) {
4032
4227
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
4228
+ if (settlingResourceOwners.get(threadId) !== dispatchClaimId)
4229
+ releaseTurnResources(resourceOwner);
4230
+ retryDelegationsWaitingOn(bot.id);
4231
+ drainQueuedSends();
4232
+ drainConnectorResumes();
4233
+ drainSecretResumes();
4234
+ drainDelegationWakes();
4033
4235
  }
4034
4236
  }
4035
4237
  catch (e) {
4036
4238
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
4037
- clearDirectTurnDispatch(bot.id, dispatchClaimId);
4239
+ clearDirectTurnDispatch(threadId, dispatchClaimId);
4038
4240
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
4039
- const ownsLatestGeneration = directTurnGenerationByBot.get(bot.id) === dispatchClaimId;
4241
+ const ownsLatestGeneration = directTurnGenerationByThread.get(threadId) === dispatchClaimId;
4242
+ releaseTurnResources(resourceOwner);
4040
4243
  if (ownsLatestGeneration) {
4041
4244
  releaseLocalVmThread(threadId);
4042
4245
  if (activeVpsThreads.get(bot.id) === threadId)
@@ -4046,8 +4249,9 @@ async function startTurn(botId, text, opts) {
4046
4249
  }
4047
4250
  if (e instanceof DirectTurnSetupCancelled) {
4048
4251
  opts?.onDispatchError?.(e.message);
4049
- if (ownsLatestGeneration && store.bot(bot.id)?.busy) {
4050
- store.setActivity(bot.id, "idle");
4252
+ if (ownsLatestGeneration && threadBusy(bot.id, threadId)) {
4253
+ store.setTaskActivity(bot.id, threadId, "idle");
4254
+ directTurnBots.delete(threadId);
4051
4255
  retryDelegationsWaitingOn(bot.id);
4052
4256
  }
4053
4257
  if (ownsLatestGeneration) {
@@ -4079,7 +4283,8 @@ async function startTurn(botId, text, opts) {
4079
4283
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
4080
4284
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
4081
4285
  }
4082
- store.setActivity(bot.id, "idle");
4286
+ store.setTaskActivity(bot.id, threadId, "idle");
4287
+ directTurnBots.delete(threadId);
4083
4288
  retryDelegationsWaitingOn(bot.id);
4084
4289
  opts?.onDispatchError?.(message);
4085
4290
  // a dispatch failure never emits turn.completed, so the settle-driven
@@ -4190,7 +4395,7 @@ function syncRoutineRunToSource(run) {
4190
4395
  if (source.group)
4191
4396
  store.patchGroup(source.group.id, { unread: true });
4192
4397
  else
4193
- store.patchBot(source.bot.id, { unread: true });
4398
+ store.patchTask(source.bot.id, sourceThreadId, { unread: true });
4194
4399
  }
4195
4400
  return sourceThreadId;
4196
4401
  }
@@ -4212,6 +4417,12 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4212
4417
  const bot = store.bot(botId);
4213
4418
  if (!bot)
4214
4419
  return;
4420
+ for (const task of store.tasks(botId)) {
4421
+ if (task.approvalMode === "full" || task.approvalMode === "custom") {
4422
+ store.patchTask(botId, task.threadId, { approvalMode: "ask", autoApprove: false, alwaysAllow: [] });
4423
+ }
4424
+ }
4425
+ const directStop = interruptAllDirectThreads(botId);
4215
4426
  const routineRun = routines?.activeBotRunForBot(bot.id);
4216
4427
  if (routineRun) {
4217
4428
  if (routineRun.threadId)
@@ -4220,6 +4431,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4220
4431
  await routines.cancelRun(routineRun.id);
4221
4432
  if (routineRun.threadId)
4222
4433
  closeOpenApprovals(routineRun.threadId);
4434
+ await directStop;
4223
4435
  return;
4224
4436
  }
4225
4437
  const groupTurn = activeGroupTurnForBot(bot.id);
@@ -4227,6 +4439,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4227
4439
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
4228
4440
  cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
4229
4441
  const results = await Promise.allSettled([
4442
+ directStop,
4230
4443
  registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(groupTurn.threadId),
4231
4444
  ]);
4232
4445
  closeOpenApprovals(groupTurn.threadId);
@@ -4235,16 +4448,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4235
4448
  throw failure.reason;
4236
4449
  return;
4237
4450
  }
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;
4451
+ await directStop;
4248
4452
  }
4249
4453
  routines = new RoutineManager({
4250
4454
  emit: broadcast,
@@ -4283,7 +4487,7 @@ routines = new RoutineManager({
4283
4487
  });
4284
4488
  },
4285
4489
  interruptTurn: async (botId, threadId, runOn) => {
4286
- const bot = store.bot(botId);
4490
+ const bot = botForThread(botId, threadId);
4287
4491
  cancelDirectTurnDispatch(botId, threadId);
4288
4492
  revokeInternalCapabilitiesForThread(threadId);
4289
4493
  const instance = runOn === "cloud"
@@ -4651,6 +4855,10 @@ operation,
4651
4855
  setupRetry = 0) {
4652
4856
  if (isCancelled?.())
4653
4857
  return false;
4858
+ if (providerFleetReloading) {
4859
+ onDispatchError?.("provider settings are being updated — try again shortly");
4860
+ return false;
4861
+ }
4654
4862
  const group = store.group(groupId);
4655
4863
  const bot = store.bot(botId);
4656
4864
  const ownsThread = group?.dm
@@ -4664,7 +4872,7 @@ setupRetry = 0) {
4664
4872
  }
4665
4873
  revokeInternalCapabilitiesForThread(threadId);
4666
4874
  spoken.add(botId);
4667
- const preparedApprovalMode = approvalModeForTurn(bot);
4875
+ const preparedApprovalMode = approvalModeForTurn(bot, false, threadId);
4668
4876
  const preparedSelection = { ...bot.modelSelection };
4669
4877
  const preparedComposio = bot.composio;
4670
4878
  const instance = registry.get(bot.modelSelection.instanceId);
@@ -4707,6 +4915,8 @@ setupRetry = 0) {
4707
4915
  return true;
4708
4916
  }
4709
4917
  const internalGeneration = beginInternalCapabilityGeneration(threadId);
4918
+ const resourceOwner = { threadId, generation: internalGeneration };
4919
+ turnResourceOwners.set(threadId, resourceOwner);
4710
4920
  let roomVmTarget = null;
4711
4921
  let retainRoomVmLease = false;
4712
4922
  let roomSpeaker;
@@ -4715,6 +4925,7 @@ setupRetry = 0) {
4715
4925
  if (roomVmTarget && localVmThreadTargets.get(threadId) === roomVmTarget)
4716
4926
  releaseLocalVmThread(threadId);
4717
4927
  roomVmTarget = null;
4928
+ releaseTurnResources(resourceOwner);
4718
4929
  };
4719
4930
  try {
4720
4931
  const integrations = {};
@@ -4738,6 +4949,8 @@ setupRetry = 0) {
4738
4949
  const skills = availableSkills();
4739
4950
  const selectedSkills = mergeSkills(selectBundledSkills(roomContext, instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], skills), selectBundledSkills(latestUser?.text ?? "", skillAuthoring ? ["skillAuthoring"] : [], skills));
4740
4951
  if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
4952
+ if (!claimTurnResource(resourceOwner, "computer:phone"))
4953
+ throw new Error("another thread is using the phone — wait for it to finish");
4741
4954
  integrations.phone = phoneIntegration();
4742
4955
  }
4743
4956
  try {
@@ -4782,7 +4995,7 @@ setupRetry = 0) {
4782
4995
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
4783
4996
  return false;
4784
4997
  const setupChanged = registry.get(preparedSelection.instanceId) !== instance ||
4785
- approvalModeForTurn(readyBot) !== preparedApprovalMode ||
4998
+ approvalModeForTurn(readyBot, false, threadId) !== preparedApprovalMode ||
4786
4999
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
4787
5000
  readyBot.modelSelection.model !== preparedSelection.model ||
4788
5001
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -4891,6 +5104,7 @@ setupRetry = 0) {
4891
5104
  }
4892
5105
  // A distinct identity fences cleanup even in shared mode on the same room thread.
4893
5106
  const target = { ...localVmTargetForBot(readyBot.id) };
5107
+ bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
4894
5108
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
4895
5109
  throw new Error("this Local VM is being started, stopped, or replaced");
4896
5110
  }
@@ -5082,7 +5296,7 @@ setupRetry = 0) {
5082
5296
  threadId,
5083
5297
  text,
5084
5298
  images: turnImages,
5085
- approvalMode: approvalModeForTurn(readyBot),
5299
+ approvalMode: approvalModeForTurn(readyBot, false, threadId),
5086
5300
  system: roomSystem,
5087
5301
  cwd,
5088
5302
  integrations,
@@ -5167,6 +5381,8 @@ setupRetry = 0) {
5167
5381
  // same bounded cleanup as the stall watchdog. An unbound goal handshake
5168
5382
  // keeps the room closed until attribution becomes safe.
5169
5383
  const releaseOwnership = () => {
5384
+ if (turnResourceOwners.get(threadId)?.generation !== resourceOwner.generation)
5385
+ return;
5170
5386
  if (hasUnboundDiscardedGroupGoalTurn(threadId)) {
5171
5387
  const retry = setTimeout(releaseOwnership, 1_000);
5172
5388
  retry.unref?.();
@@ -6154,7 +6370,7 @@ function dispatchConnectorResume(entry) {
6154
6370
  return;
6155
6371
  const names = entry.labels.join(", ");
6156
6372
  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) {
6373
+ if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
6158
6374
  pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
6159
6375
  return;
6160
6376
  }
@@ -6207,7 +6423,8 @@ function maybeResumeConnectors(botId, threadId, resumeKey) {
6207
6423
  }
6208
6424
  function drainConnectorResumes() {
6209
6425
  for (const [key, entry] of pendingConnectorResumes) {
6210
- if (store.bot(entry.botId)?.busy)
6426
+ const owner = connectorThread(entry.botId, entry.threadId);
6427
+ if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
6211
6428
  continue;
6212
6429
  pendingConnectorResumes.delete(key);
6213
6430
  dispatchConnectorResume(entry);
@@ -6280,7 +6497,7 @@ function dispatchSecretResume(entry) {
6280
6497
  const prompt = entry.outcome === "provided"
6281
6498
  ? `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
6499
  : `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) {
6500
+ if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
6284
6501
  pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
6285
6502
  return;
6286
6503
  }
@@ -6425,7 +6642,8 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
6425
6642
  }
6426
6643
  function drainSecretResumes() {
6427
6644
  for (const [key, entry] of pendingSecretResumes) {
6428
- if (store.bot(entry.botId)?.busy)
6645
+ const owner = connectorThread(entry.botId, entry.threadId);
6646
+ if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
6429
6647
  continue;
6430
6648
  pendingSecretResumes.delete(key);
6431
6649
  dispatchSecretResume(entry);
@@ -6623,15 +6841,19 @@ function configStatus() {
6623
6841
  // show the same durable session as an agent, but config PATCH validation
6624
6842
  // keeps it read-only and rejects callers that try to choose it.
6625
6843
  browserProfiles: cfg.browserProfiles ?? [],
6844
+ // who may sign in with an emailed code (server/account-signin.ts)
6845
+ signIn: { admins: cfg.signIn?.admins ?? [], members: cfg.signIn?.members ?? [] },
6626
6846
  };
6627
6847
  }
6628
6848
  function configForAccess(status, admin) {
6629
6849
  if (admin)
6630
6850
  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.
6851
+ // Configured-or-not is fine; an SSH alias, an email, a browser partition
6852
+ // id, and the sign-in list are not a client's business. Preserve the
6853
+ // source objects.
6633
6854
  return {
6634
6855
  ...status,
6856
+ signIn: { admins: [], members: [] },
6635
6857
  vps: { configured: status.vps.configured, sshAlias: "" },
6636
6858
  profile: { name: status.profile.name, email: "" },
6637
6859
  browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
@@ -6686,32 +6908,66 @@ async function persistProviderInstance(instanceId, instances) {
6686
6908
  /** Rebuild the provider fleet after a config change so new keys take
6687
6909
  * effect without a server restart (kills any in-flight turns). */
6688
6910
  async function reloadProviders() {
6911
+ providerFleetReloading = true;
6689
6912
  providerAuthSessions.clear();
6690
6913
  // Every provider process is about to die. Revoke all turn capabilities in
6691
6914
  // one synchronous step before the first teardown await, including room/task
6692
6915
  // threads that are not a bot's default DM.
6693
6916
  revokeAllInternalCapabilities();
6917
+ const direct = store.bots.flatMap((bot) => store.tasks(bot.id)
6918
+ .filter((task) => threadBusy(bot.id, task.threadId))
6919
+ .map((task) => ({ botId: bot.id, threadId: task.threadId, owner: turnResourceOwners.get(task.threadId) })));
6920
+ const rooms = [...groupSpeakers.entries()];
6921
+ for (const task of direct)
6922
+ cancelDirectTurnDispatch(task.botId, task.threadId);
6923
+ for (const [threadId] of rooms) {
6924
+ const group = store.groupByThread(threadId);
6925
+ if (group)
6926
+ cancelGroupTurnOperations(group.id, threadId);
6927
+ }
6694
6928
  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);
6929
+ try {
6930
+ await registry.disposeAll();
6931
+ await registry.load(instanceConfigs(cfg));
6932
+ bus.attach(registry.instances());
6933
+ }
6934
+ finally {
6935
+ // Settle every exact conversation, not whichever one is selected now.
6936
+ // Teardown can swallow terminal events; no task may remain busy forever.
6937
+ for (const { botId, threadId, owner } of direct) {
6938
+ stopScreenPoller(botId, threadId);
6939
+ releaseLocalVmThread(threadId);
6940
+ releaseTurnResources(owner);
6941
+ if (activeVpsThreads.get(botId) === threadId)
6942
+ activeVpsThreads.delete(botId);
6943
+ watchdog.settle(threadId);
6944
+ closeOpenApprovals(threadId);
6945
+ directTurnBots.delete(threadId);
6946
+ finalizeDelegationWatch(threadId, false, "", "Delegated turn did not finish — provider settings changed");
6947
+ routines?.failThread(threadId, "Provider settings changed while this thread was running");
6948
+ if (store.taskByThread(botId, threadId)) {
6949
+ store.appendMessage(threadId, {
6950
+ role: "bot", kind: "activity",
6951
+ tool: { name: "error: turn interrupted — provider settings changed", ok: false },
6952
+ });
6953
+ store.setTaskActivity(botId, threadId, "idle");
6954
+ }
6955
+ retryDelegationsWaitingOn(botId);
6956
+ }
6957
+ for (const [threadId, speaker] of rooms) {
6958
+ releaseLocalVmThread(threadId);
6959
+ releaseTurnResources(turnResourceOwners.get(threadId));
6960
+ watchdog.settle(threadId);
6961
+ closeOpenApprovals(threadId);
6962
+ if (groupSpeakers.get(threadId) !== speaker)
6963
+ continue;
6964
+ groupSpeakers.delete(threadId);
6965
+ const group = store.groupByThread(threadId);
6966
+ if (group)
6967
+ store.patchGroup(group.id, { busyBotId: null });
6968
+ store.setActivity(speaker.botId, "idle");
6969
+ }
6970
+ providerFleetReloading = false;
6715
6971
  }
6716
6972
  // killed turns settle here without a turn.completed event, so anything
6717
6973
  // queued behind them drains now — onto the freshly loaded fleet
@@ -6829,7 +7085,7 @@ const handleRequest = async (req, res) => {
6829
7085
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
6830
7086
  return;
6831
7087
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
6832
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }));
7088
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled() }));
6833
7089
  }
6834
7090
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
6835
7091
  if (method === "GET" && domainCheck) {
@@ -6837,6 +7093,46 @@ const handleRequest = async (req, res) => {
6837
7093
  const challenge = customDomainVerifier.challenge(domainCheck[1]);
6838
7094
  return json(res, challenge ? 200 : 404, challenge ?? { error: "No active domain check." });
6839
7095
  }
7096
+ // Sign in with an emailed code (server/account-signin.ts). Public like
7097
+ // /api/auth/pair, JSON-only for the same reason, and counted against the
7098
+ // same per-source lockout so a code cannot be guessed.
7099
+ if (method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
7100
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
7101
+ return json(res, 415, { error: "send the sign-in request as JSON (content-type: application/json)" });
7102
+ }
7103
+ if (!emailSignIn.enabled())
7104
+ return json(res, 404, { error: "email sign-in is not set up on this server; use a pairing code" });
7105
+ const source = requestSource(req);
7106
+ const allowed = sessions.attemptAllowed(source);
7107
+ if (!allowed.ok)
7108
+ return json(res, 429, { error: `too many failed sign-in attempts from your address; try again in ${Math.ceil(allowed.retryAfterMs / 1000)}s` });
7109
+ const body = await readBody(req);
7110
+ const email = typeof body?.email === "string" ? body.email : "";
7111
+ if (path === "/api/auth/email/start") {
7112
+ const started = await emailSignIn.start(email);
7113
+ if (!started.ok) {
7114
+ if (started.status === 403)
7115
+ sessions.noteFailure(source);
7116
+ return json(res, started.status, { error: started.error });
7117
+ }
7118
+ return json(res, 200, { ok: true });
7119
+ }
7120
+ const code = typeof body?.code === "string" ? body.code : "";
7121
+ const label = typeof body?.label === "string" ? body.label : "";
7122
+ const verified = await emailSignIn.verify(email, code);
7123
+ if (!verified.ok) {
7124
+ if (verified.status === 401 || verified.status === 403)
7125
+ sessions.noteFailure(source);
7126
+ console.warn(`email sign-in refused from ${source}: ${verified.error}`);
7127
+ return json(res, verified.status, { error: verified.error });
7128
+ }
7129
+ sessions.clearFailures(source);
7130
+ const issued = sessions.issue({ label: label.trim() || labelFromUserAgent(req.headers["user-agent"]), scopes: verified.scopes, userId: verified.userId, email: verified.email });
7131
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true });
7132
+ const secure = requestOrigin(req)?.startsWith("https://") === true;
7133
+ res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, issued.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(issued.session) }));
7134
+ return json(res, 200, { session: issued.session, environment });
7135
+ }
6840
7136
  if (method === "POST" && path === "/api/auth/pair") {
6841
7137
  // JSON only: a cross-site HTML form cannot send this content type
6842
7138
  // without a preflight, so a stray unused code cannot be planted as a
@@ -6854,7 +7150,7 @@ const handleRequest = async (req, res) => {
6854
7150
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
6855
7151
  return json(res, result.status, { error: result.error });
6856
7152
  }
6857
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED });
7153
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled() });
6858
7154
  if (wantsCookie) {
6859
7155
  const secure = requestOrigin(req)?.startsWith("https://") === true;
6860
7156
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
@@ -6905,6 +7201,8 @@ const handleRequest = async (req, res) => {
6905
7201
  expiresAt: auth.session.expiresAt,
6906
7202
  via: auth.via,
6907
7203
  environmentId: ENVIRONMENT_ID,
7204
+ // the account behind the session, when it came from a sign-in
7205
+ ...(auth.session.email ? { email: auth.session.email } : {}),
6908
7206
  });
6909
7207
  }
6910
7208
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -7084,6 +7382,11 @@ const handleRequest = async (req, res) => {
7084
7382
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
7085
7383
  }
7086
7384
  };
7385
+ if (method === "POST" && path === "/api/internal/memory") {
7386
+ const body = await readInternalBody();
7387
+ const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText });
7388
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "too-large" ? 413 : 400, result);
7389
+ }
7087
7390
  if (method === "POST" && path === "/api/internal/browser/mcp") {
7088
7391
  const body = await readInternalBody();
7089
7392
  const bot = store.bot(internalCapability.botId);
@@ -7104,6 +7407,9 @@ const handleRequest = async (req, res) => {
7104
7407
  currentBrowserSession(current.id, current.browserProfile) !== browser.session) {
7105
7408
  throw Object.assign(new Error("Browser access changed while connecting."), { status: 409 });
7106
7409
  }
7410
+ if (body.method === "tools/call" && !claimTurnResource(internalCapability, `browser:${browser.session}`)) {
7411
+ throw Object.assign(new Error("another thread is using this browser — pause browser work until that thread finishes"), { status: 409 });
7412
+ }
7107
7413
  });
7108
7414
  requireActiveInternalCapability();
7109
7415
  return json(res, 200, { result });
@@ -7299,7 +7605,7 @@ const handleRequest = async (req, res) => {
7299
7605
  const from = store.bot(fromBotId);
7300
7606
  if (!from)
7301
7607
  return json(res, 403, { error: "unknown sender" });
7302
- const fromThreadId = String(url.searchParams.get("fromThreadId") ?? from.threadId);
7608
+ const fromThreadId = internalCapability.threadId;
7303
7609
  if (!connectorThread(from.id, fromThreadId)) {
7304
7610
  return json(res, 403, { error: "source conversation does not belong to sender" });
7305
7611
  }
@@ -7331,7 +7637,7 @@ const handleRequest = async (req, res) => {
7331
7637
  const from = store.bot(fromBotId);
7332
7638
  if (!from)
7333
7639
  return json(res, 403, { error: "unknown sender" });
7334
- const fromThreadId = String(url.searchParams.get("fromThreadId") ?? from.threadId);
7640
+ const fromThreadId = internalCapability.threadId;
7335
7641
  if (!connectorThread(from.id, fromThreadId)) {
7336
7642
  return json(res, 403, { error: "source conversation does not belong to sender" });
7337
7643
  }
@@ -7546,11 +7852,12 @@ const handleRequest = async (req, res) => {
7546
7852
  const prefixed = withPeerProvenance(message, {
7547
7853
  botName: currentFrom.name,
7548
7854
  delivery: "ask_bot",
7549
- unattended: isUnattended(currentFrom.id),
7855
+ unattended: isUnattended(currentFrom.id, fromThreadId),
7550
7856
  });
7551
- const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId);
7857
+ const targetThreadId = currentTarget.threadId;
7858
+ const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId, fromThreadId, targetThreadId);
7552
7859
  requireActiveInternalCapability();
7553
- if (outcome.status === "timeout" && !delegationWatch.has(currentTarget.threadId)) {
7860
+ if (outcome.status === "timeout" && !delegationWatch.has(targetThreadId)) {
7554
7861
  // The peer's turn is still running — only the wait ended. Convert
7555
7862
  // the ask into a delegation claim ticket: the watch mirrors the
7556
7863
  // terminal state into the channel AND the asker's thread when the
@@ -7558,7 +7865,7 @@ const handleRequest = async (req, res) => {
7558
7865
  // Losing the reply was the old behavior, and it read as "the bots
7559
7866
  // don't respond to each other".
7560
7867
  const taskId = newId();
7561
- delegationWatch.set(currentTarget.threadId, {
7868
+ delegationWatch.set(targetThreadId, {
7562
7869
  channelId: channel.id,
7563
7870
  toBotId,
7564
7871
  toBotName: currentTarget.name,
@@ -7797,7 +8104,7 @@ const handleRequest = async (req, res) => {
7797
8104
  // Unattended inheritance: the mark rides the sender, and reading it
7798
8105
  // here is also what keeps its window alive through a turn that only
7799
8106
  // posts — an aged-out mark would hand the next hop to auto-approve.
7800
- const unattended = isUnattended(poster.id);
8107
+ const unattended = isUnattended(poster.id, internalCapability.threadId);
7801
8108
  const posted = store.appendMessage(room.threadId, {
7802
8109
  role: "bot",
7803
8110
  kind: "text",
@@ -7955,6 +8262,14 @@ const handleRequest = async (req, res) => {
7955
8262
  return json(res, 404, { error: "no such bot" });
7956
8263
  if (method === "GET") {
7957
8264
  const snapshot = computerControl.snapshot(botId);
8265
+ const computer = turnComputerResources.get(internalCapability.threadId);
8266
+ if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
8267
+ !claimTurnResource(computer.owner, computer.resource)) {
8268
+ return json(res, 200, {
8269
+ held: true, helpOpen: false,
8270
+ 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.",
8271
+ });
8272
+ }
7958
8273
  return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null });
7959
8274
  }
7960
8275
  if (method === "POST") {
@@ -7966,7 +8281,7 @@ const handleRequest = async (req, res) => {
7966
8281
  // screen it needs hands on are in the room — so send the person
7967
8282
  // where the work is, and say which room it was.
7968
8283
  const roomTurn = activeGroupTurnForBot(bot.id);
7969
- const target = blockedTarget(bot, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
8284
+ const target = blockedTarget({ ...bot, threadId: internalCapability.threadId }, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
7970
8285
  notify(buildNotification("takeover", bot, target.threadId, snapshot.helpReason ?? "asked you to take over", {
7971
8286
  group: target.group,
7972
8287
  }));
@@ -8311,6 +8626,16 @@ const handleRequest = async (req, res) => {
8311
8626
  return;
8312
8627
  }
8313
8628
  // ── bots ──
8629
+ // Paired sessions are authenticated above. The companion marker may
8630
+ // only narrow behavior (including its capability-free local dev proxy);
8631
+ // it never grants authority or replaces the existing request gate.
8632
+ const requirePinnedClientThread = (botId, threadId) => {
8633
+ if (threadId === undefined &&
8634
+ (auth.kind === "session" || req.headers["x-openmausbot-companion"] === "1") &&
8635
+ store.tasks(botId).length > 1) {
8636
+ throw Object.assign(new Error("This bot has multiple threads. Update this client and choose a thread before sending this action."), { status: 409 });
8637
+ }
8638
+ };
8314
8639
  if (method === "GET" && path === "/api/bots") {
8315
8640
  const limit = pageSize(url.searchParams.get("messages"));
8316
8641
  if (limit === null)
@@ -8342,14 +8667,14 @@ const handleRequest = async (req, res) => {
8342
8667
  const window = messageWindow(threadId, around, limit ?? DEFAULT_PAGE);
8343
8668
  if (!window)
8344
8669
  return json(res, 404, { error: "no such message" });
8345
- return json(res, 200, window);
8670
+ return json(res, 200, { ...window, activeLeafId: store.activeLeaf(threadId) });
8346
8671
  }
8347
8672
  // An unknown cursor must not silently answer with the newest page —
8348
8673
  // the client would paginate in a circle and never reach the top.
8349
8674
  if (before && !store.messagesFor(threadId).some((msg) => msg.id === before)) {
8350
8675
  return json(res, 404, { error: "no such message" });
8351
8676
  }
8352
- return json(res, 200, messagePage(threadId, limit ?? DEFAULT_PAGE, before));
8677
+ return json(res, 200, { ...messagePage(threadId, limit ?? DEFAULT_PAGE, before), activeLeafId: store.activeLeaf(threadId) });
8353
8678
  }
8354
8679
  // the pixels of one screen message, fetched only when something shows it
8355
8680
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages\/([\w-]+)\/image$/);
@@ -9664,16 +9989,25 @@ const handleRequest = async (req, res) => {
9664
9989
  const existing = store.bot(m[1]);
9665
9990
  if (!existing)
9666
9991
  return json(res, 404, { error: "no such bot" });
9992
+ requirePinnedClientThread(existing.id, undefined);
9993
+ const selected = requestedTaskBot(existing.id, undefined);
9667
9994
  if (existing.approvalGrant) {
9668
9995
  return json(res, 409, { error: "wait for the approval-level change to finish before changing models" });
9669
9996
  }
9670
- const checked = checkedModelSelection(body, { selection: existing.modelSelection, busy: Boolean(existing.busy) }, true);
9997
+ const checked = checkedModelSelection(body, { selection: selected.modelSelection, busy: threadBusy(selected.id, selected.threadId) }, true);
9671
9998
  if (!checked.ok)
9672
9999
  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)) {
10000
+ if (activeGroupTurnForBot(existing.id)) {
10001
+ const groupChecked = checkedModelSelection(checked.selection, { selection: existing.modelSelection, busy: true });
10002
+ if (!groupChecked.ok)
10003
+ return json(res, groupChecked.status, { error: groupChecked.error });
10004
+ }
10005
+ if ([existing, selected].some((owner) => {
10006
+ const mode = approvalModeFor(owner);
10007
+ return (mode === "full" || mode === "custom") &&
10008
+ (!supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, mode) ||
10009
+ registry.cliTarget(checked.selection.instanceId)?.driverKind !== registry.cliTarget(owner.modelSelection.instanceId)?.driverKind);
10010
+ })) {
9677
10011
  return json(res, 400, {
9678
10012
  error: "Changing providers with elevated permissions requires choosing Ask first",
9679
10013
  });
@@ -9683,13 +10017,16 @@ const handleRequest = async (req, res) => {
9683
10017
  const bot = store.patchBot(existing.id, { modelSelection: checked.selection });
9684
10018
  if (!bot)
9685
10019
  return json(res, 404, { error: "no such bot" });
10020
+ store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection });
9686
10021
  return json(res, 200, { bot: wireBot(bot) });
9687
10022
  }
9688
10023
  m = path.match(/^\/api\/bots\/([\w-]+)\/read$/);
9689
10024
  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" });
10025
+ const body = await readBody(req);
10026
+ requirePinnedClientThread(m[1], body?.threadId);
10027
+ const current = requestedTaskBot(m[1], body?.threadId);
10028
+ store.patchTask(current.id, current.threadId, { unread: false });
10029
+ const bot = store.bot(current.id);
9693
10030
  const visible = wireBot(bot);
9694
10031
  broadcast({ kind: "bot", bot: visible });
9695
10032
  return json(res, 200, { bot: visible });
@@ -9697,10 +10034,9 @@ const handleRequest = async (req, res) => {
9697
10034
  m = path.match(/^\/api\/bots\/([\w-]+)\/always-allow$/);
9698
10035
  if (m && method === "POST") {
9699
10036
  const body = await readBody(req);
10037
+ requirePinnedClientThread(m[1], body?.threadId);
9700
10038
  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" });
10039
+ const bot = requestedTaskBot(m[1], body.threadId);
9704
10040
  if (!allowKey)
9705
10041
  return json(res, 400, { error: "allowKey required" });
9706
10042
  const pending = store.messagesFor(bot.threadId).some((message) => message.card?.requestId &&
@@ -9710,9 +10046,13 @@ const handleRequest = async (req, res) => {
9710
10046
  if (!pending) {
9711
10047
  return json(res, 409, { error: "that grant is not on a pending approval for this bot" });
9712
10048
  }
9713
- const updated = store.patchBot(bot.id, {
10049
+ store.patchTask(bot.id, bot.threadId, {
9714
10050
  alwaysAllow: [...new Set([...(bot.alwaysAllow ?? []), allowKey])].slice(0, 200),
9715
10051
  });
10052
+ const live = directTurnBots.get(bot.threadId);
10053
+ if (live)
10054
+ live.alwaysAllow = [...new Set([...(live.alwaysAllow ?? []), allowKey])].slice(0, 200);
10055
+ const updated = store.bot(bot.id);
9716
10056
  const visible = wireBot(updated);
9717
10057
  broadcast({ kind: "bot", bot: visible });
9718
10058
  return json(res, 200, { bot: visible });
@@ -9729,6 +10069,7 @@ const handleRequest = async (req, res) => {
9729
10069
  return json(res, 403, { error: `forbidden: this session may change how a bot looks, not "${field}" (needs the admin scope)` });
9730
10070
  }
9731
10071
  const existingBot = store.bot(m[1]);
10072
+ const selectedTask = existingBot ? requestedTaskBot(existingBot.id, undefined) : null;
9732
10073
  const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
9733
10074
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
9734
10075
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
@@ -9747,6 +10088,8 @@ const handleRequest = async (req, res) => {
9747
10088
  // safe — startTurn refuses to run a turn on an unavailable instance
9748
10089
  // anyway, so an unverifiable level never reaches a CLI.
9749
10090
  const rawSelection = body.modelSelection;
10091
+ if (rawSelection !== undefined)
10092
+ requirePinnedClientThread(m[1], undefined);
9750
10093
  if (existingBot?.approvalGrant &&
9751
10094
  (rawSelection !== undefined || body.approvalMode !== undefined || body.autoApprove !== undefined)) {
9752
10095
  return json(res, 409, { error: "wait for the approval-level change to finish before changing this setting" });
@@ -9756,10 +10099,15 @@ const handleRequest = async (req, res) => {
9756
10099
  }
9757
10100
  let normalizedSelection;
9758
10101
  if (rawSelection !== undefined) {
9759
- const checked = checkedModelSelection(rawSelection, existingBot ? { selection: existingBot.modelSelection, busy: Boolean(existingBot.busy) } : undefined, body.requireAvailableModel === true);
10102
+ const checked = checkedModelSelection(rawSelection, selectedTask ? { selection: selectedTask.modelSelection, busy: threadBusy(selectedTask.id, selectedTask.threadId) } : undefined, body.requireAvailableModel === true);
9760
10103
  if (!checked.ok)
9761
10104
  return json(res, checked.status, { error: checked.error });
9762
10105
  normalizedSelection = checked.selection;
10106
+ if (existingBot && activeGroupTurnForBot(existingBot.id)) {
10107
+ const groupChecked = checkedModelSelection(normalizedSelection, { selection: existingBot.modelSelection, busy: true });
10108
+ if (!groupChecked.ok)
10109
+ return json(res, groupChecked.status, { error: groupChecked.error });
10110
+ }
9763
10111
  }
9764
10112
  // Persona/profile fields reach prompts and paired clients. Both this
9765
10113
  // broad desktop endpoint and the paired-safe profile endpoint pass
@@ -9928,6 +10276,14 @@ const handleRequest = async (req, res) => {
9928
10276
  patch.autoApprove = requestedApprovalMode === "auto";
9929
10277
  }
9930
10278
  const targetSelection = normalizedSelection ?? existingBot?.modelSelection;
10279
+ if (normalizedSelection && selectedTask) {
10280
+ const mode = approvalModeFor(selectedTask);
10281
+ if ((mode === "full" || mode === "custom") &&
10282
+ (!supportsApprovalMode(registry.cliTarget(normalizedSelection.instanceId)?.driverKind, mode) ||
10283
+ registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(selectedTask.modelSelection.instanceId)?.driverKind)) {
10284
+ return json(res, 400, { error: "Choose Ask for the selected thread before changing providers with elevated permissions" });
10285
+ }
10286
+ }
9931
10287
  if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") &&
9932
10288
  (body.approvalMode !== undefined || normalizedSelection !== undefined) &&
9933
10289
  (!targetSelection || !supportsApprovalMode(registry.cliTarget(targetSelection.instanceId)?.driverKind, requestedApprovalMode) ||
@@ -10052,15 +10408,17 @@ const handleRequest = async (req, res) => {
10052
10408
  console.warn(`bot ${m[1]}: ${loosened.join(", ")} loosened by ${requestSource(req)} through the local API with no paired session`);
10053
10409
  }
10054
10410
  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(() => { });
10411
+ await interruptAllDirectThreads(existingBot.id);
10412
+ const routine = routines.activeBotRunForBot(existingBot.id);
10413
+ if (routine)
10414
+ await routines.cancelRun(routine.id);
10415
+ const groupTurn = activeGroupTurnForBot(existingBot.id);
10416
+ if (groupTurn) {
10417
+ cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
10418
+ revokeInternalCapabilitiesForThread(groupTurn.threadId);
10419
+ await registry.get(existingBot.modelSelection.instanceId)?.adapter.interruptTurn(groupTurn.threadId).catch(() => { });
10420
+ closeOpenApprovals(groupTurn.threadId);
10421
+ }
10064
10422
  }
10065
10423
  const chiefMovedSections = Boolean(existingBot?.chiefOfStaff) &&
10066
10424
  body.chiefOfStaff !== false &&
@@ -10068,6 +10426,19 @@ const handleRequest = async (req, res) => {
10068
10426
  sectionKey(existingBot?.section) !== sectionKey(section);
10069
10427
  let bot;
10070
10428
  const freshBrowserBot = store.bot(m[1]);
10429
+ if (normalizedSelection && selectedTask) {
10430
+ const current = store.projectBotForTask(selectedTask.id, selectedTask.threadId);
10431
+ if (!current)
10432
+ return json(res, 404, { error: "no such task" });
10433
+ const checked = checkedModelSelection(normalizedSelection, { selection: current.modelSelection, busy: threadBusy(current.id, current.threadId) });
10434
+ if (!checked.ok)
10435
+ return json(res, checked.status, { error: checked.error });
10436
+ if (freshBrowserBot && activeGroupTurnForBot(freshBrowserBot.id)) {
10437
+ const groupChecked = checkedModelSelection(normalizedSelection, { selection: freshBrowserBot.modelSelection, busy: true });
10438
+ if (!groupChecked.ok)
10439
+ return json(res, groupChecked.status, { error: groupChecked.error });
10440
+ }
10441
+ }
10071
10442
  if (freshBrowserBot && body.browserProfile !== undefined &&
10072
10443
  patch.browserProfile !== freshBrowserBot.browserProfile &&
10073
10444
  (freshBrowserBot.busy || browserRuntime.heldBy(currentBrowserSession(freshBrowserBot.id, freshBrowserBot.browserProfile)))) {
@@ -10089,6 +10460,8 @@ const handleRequest = async (req, res) => {
10089
10460
  }
10090
10461
  if (!bot)
10091
10462
  return json(res, 404, { error: "no such bot" });
10463
+ if (normalizedSelection && selectedTask)
10464
+ store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection });
10092
10465
  if (existingBot && (bot.browserProfile !== beforeBrowserProfile || bot.browser !== beforeBrowserEnabled)) {
10093
10466
  browserLive.closeForBot(bot.id);
10094
10467
  if (beforeBrowserProfile === "guest" && (bot.browserProfile !== "guest" || bot.browser === false)) {
@@ -10113,6 +10486,7 @@ const handleRequest = async (req, res) => {
10113
10486
  await Promise.allSettled(store.bots
10114
10487
  .filter((bot) => bot.computer === "local")
10115
10488
  .map(async (bot) => {
10489
+ await interruptAllDirectThreads(bot.id);
10116
10490
  const routineRun = routines.activeBotRunForBot(bot.id);
10117
10491
  if (routineRun) {
10118
10492
  cancelDirectTurnDispatch(bot.id, routineRun.threadId);
@@ -10120,7 +10494,6 @@ const handleRequest = async (req, res) => {
10120
10494
  revokeInternalCapabilitiesForThread(routineRun.threadId);
10121
10495
  }
10122
10496
  await routines.cancelRun(routineRun.id);
10123
- return;
10124
10497
  }
10125
10498
  const instance = registry.get(bot.modelSelection.instanceId);
10126
10499
  const groupTurn = activeGroupTurnForBot(bot.id);
@@ -10129,13 +10502,7 @@ const handleRequest = async (req, res) => {
10129
10502
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
10130
10503
  await instance?.adapter.interruptTurn(groupTurn.threadId).catch(() => { });
10131
10504
  closeOpenApprovals(groupTurn.threadId);
10132
- return;
10133
10505
  }
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
10506
  }));
10140
10507
  return json(res, 200, { ok: true });
10141
10508
  }
@@ -10165,7 +10532,7 @@ const handleRequest = async (req, res) => {
10165
10532
  // A direct turn that has already claimed the bot can provision a Box in
10166
10533
  // its background setup. Do not let deletion race that work while a Box
10167
10534
  // account is configured; the person can stop the turn and retry.
10168
- if ((box.boxConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || directTurnDispatchClaims.has(bot.id))) {
10535
+ if ((box.boxConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || hasDirectDispatch(bot.id))) {
10169
10536
  return json(res, 409, { error: "stop this bot's work before checking and deleting its cloud computer" });
10170
10537
  }
10171
10538
  const botBoxRecovery = boxCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
@@ -10263,18 +10630,19 @@ const handleRequest = async (req, res) => {
10263
10630
  const browserCleanupRequest = browserCleanup.prepare("bot", bot.id);
10264
10631
  try {
10265
10632
  // a running turn dies with its bot
10266
- const directClaim = cancelDirectTurnDispatch(bot.id);
10267
- const directThreadId = directClaim?.threadId ?? bot.threadId;
10268
10633
  // Invalidate every bot-callable bearer before the first asynchronous
10269
10634
  // teardown step. A request that already passed its initial header
10270
10635
  // 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);
10636
+ for (const task of store.tasks(bot.id))
10637
+ revokeInternalCapabilitiesForThread(task.threadId);
10638
+ await interruptAllDirectThreads(bot.id);
10275
10639
  // Deletion removes the thread before a late turn.completed can fold
10276
10640
  // staged provider images into a message, so dispose them here.
10277
- purgeGeneratedImagesForThread(directThreadId);
10641
+ for (const task of store.tasks(bot.id)) {
10642
+ purgeGeneratedImagesForThread(task.threadId);
10643
+ directTurnGenerationByThread.delete(task.threadId);
10644
+ directTurnBots.delete(task.threadId);
10645
+ }
10278
10646
  stopScreenPoller(bot.id);
10279
10647
  activeVpsThreads.delete(bot.id);
10280
10648
  routines.disableForBot(bot.id);
@@ -10651,20 +11019,18 @@ const handleRequest = async (req, res) => {
10651
11019
  // onboarding/ask cards persist their answered/dismissed state
10652
11020
  m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
10653
11021
  if (m && method === "PATCH") {
10654
- const bot = store.bot(m[1]);
10655
- if (!bot)
10656
- return json(res, 404, { error: "no such bot" });
11022
+ const body = await readBody(req);
11023
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
11024
+ return json(res, 400, { error: "body must be a JSON object" });
11025
+ }
11026
+ const bot = requestedTaskBot(m[1], body.threadId);
10657
11027
  const existing = store.messagesFor(bot.threadId).find((msg) => msg.id === m[2]);
10658
11028
  if (!existing?.card)
10659
11029
  return json(res, 404, { error: "no such card" });
10660
11030
  if (existing.card.requestId) {
10661
11031
  return json(res, 409, { error: "request cards must be answered through the approval endpoint" });
10662
11032
  }
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")) {
11033
+ if (Object.keys(body).some((key) => key !== "answered" && key !== "dismissed" && key !== "threadId")) {
10668
11034
  return json(res, 400, { error: "only answered and dismissed may be changed" });
10669
11035
  }
10670
11036
  if (body.answered !== undefined && typeof body.answered !== "string") {
@@ -10694,6 +11060,7 @@ const handleRequest = async (req, res) => {
10694
11060
  const bot = store.bot(m[1]);
10695
11061
  if (!bot)
10696
11062
  return json(res, 404, { error: "no such bot" });
11063
+ requirePinnedClientThread(bot.id, body.threadId);
10697
11064
  if (body.threadId !== undefined && (typeof body.threadId !== "string" || !/^[\w-]+$/.test(body.threadId))) {
10698
11065
  return json(res, 400, { error: "threadId must be a task id" });
10699
11066
  }
@@ -10730,17 +11097,12 @@ const handleRequest = async (req, res) => {
10730
11097
  return { ok: true, queued: true, queueId: queued.id, threadId };
10731
11098
  }
10732
11099
  }
10733
- const currentAtStart = store.bot(bot.id);
11100
+ const currentAtStart = store.projectBotForTask(bot.id, threadId);
10734
11101
  if (!currentAtStart)
10735
11102
  throw Object.assign(new Error("no such bot"), { status: 404 });
10736
11103
  if (!store.taskByThread(currentAtStart.id, threadId)) {
10737
11104
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
10738
11105
  }
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
11106
  // Claude can accept the message inside its live turn. If the write
10745
11107
  // loses a race with turn settlement, or the engine cannot steer, the
10746
11108
  // existing server-side queue records it atomically for the next turn.
@@ -10762,17 +11124,12 @@ const handleRequest = async (req, res) => {
10762
11124
  // successful steer; otherwise that late acknowledgement writes a user
10763
11125
  // message into a task the bot no longer owns. A conflict leaves the
10764
11126
  // text in the client's composer/outbox to resend deliberately.
10765
- const current = store.bot(bot.id);
11127
+ const current = store.projectBotForTask(bot.id, threadId);
10766
11128
  if (!current)
10767
11129
  throw Object.assign(new Error("no such bot"), { status: 404 });
10768
11130
  if (!store.taskByThread(bot.id, threadId)) {
10769
11131
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
10770
11132
  }
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
11133
  if (steered) {
10777
11134
  if (!current.busy) {
10778
11135
  throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
@@ -10786,7 +11143,7 @@ const handleRequest = async (req, res) => {
10786
11143
  // desktop's owner capability, which every mutation there has
10787
11144
  // already shown) clears the mark.
10788
11145
  if (auth.kind === "session" || DESKTOP_MANAGED)
10789
- clearUnattended(current.id);
11146
+ clearUnattended(threadId);
10790
11147
  const message = store.appendMessage(threadId, {
10791
11148
  role: "user",
10792
11149
  kind: "text",
@@ -10815,11 +11172,11 @@ const handleRequest = async (req, res) => {
10815
11172
  }
10816
11173
  m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)$/);
10817
11174
  if (m && method === "DELETE") {
10818
- const bot = store.bot(m[1]);
10819
- if (!bot)
10820
- return json(res, 404, { error: "no such bot" });
11175
+ const body = await readBody(req);
11176
+ requirePinnedClientThread(m[1], body?.threadId);
11177
+ const bot = requestedTaskBot(m[1], body?.threadId);
10821
11178
  const queueId = m[2];
10822
- if (!cancelSteeredMessage(bot.id, queueId)) {
11179
+ if (!cancelSteeredMessage(bot.id, queueId, bot.threadId)) {
10823
11180
  return json(res, 404, { error: "no such queued message" });
10824
11181
  }
10825
11182
  return json(res, 200, { ok: true });
@@ -10831,18 +11188,17 @@ const handleRequest = async (req, res) => {
10831
11188
  m = path.match(/^\/api\/bots\/([\w-]+)\/messages\/([\w-]+)\/edit$/);
10832
11189
  if (m && method === "POST") {
10833
11190
  const messageId = m[2];
10834
- const bot = store.bot(m[1]);
10835
- if (!bot)
10836
- return json(res, 404, { error: "no such bot" });
10837
11191
  const body = await readBody(req);
11192
+ requirePinnedClientThread(m[1], body?.threadId);
11193
+ const bot = requestedTaskBot(m[1], body.threadId);
10838
11194
  const text = String(body.text ?? "").trim();
10839
11195
  if (!text)
10840
11196
  return json(res, 400, { error: "text required" });
10841
11197
  // everything from here down is synchronous, so two racing edits can
10842
11198
  // never both get past this check: startTurn flips busy before the
10843
11199
  // next request is handled
10844
- if (bot.busy)
10845
- return json(res, 409, { error: "the bot is working — stop it before editing" });
11200
+ if (threadBusy(bot.id, bot.threadId))
11201
+ return json(res, 409, { error: "the thread is working — stop it before editing" });
10846
11202
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
10847
11203
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
10848
11204
  }
@@ -10855,23 +11211,20 @@ const handleRequest = async (req, res) => {
10855
11211
  error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
10856
11212
  });
10857
11213
  }
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 });
11214
+ // startTurn admits the rerun before branching. A shared-resource or
11215
+ // concurrency-limit refusal must leave the original transcript intact.
11216
+ const replyTo = source.replyToId ? resolveReplyTarget(bot.threadId, source.replyToId) : undefined;
11217
+ const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo });
11218
+ return json(res, 202, { ok: true, message });
10865
11219
  }
10866
11220
  // switch which fork of the conversation is visible (no new turn)
10867
11221
  m = path.match(/^\/api\/bots\/([\w-]+)\/active-branch$/);
10868
11222
  if (m && method === "POST") {
10869
11223
  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" });
11224
+ requirePinnedClientThread(m[1], body?.threadId);
11225
+ const bot = requestedTaskBot(m[1], body.threadId);
11226
+ if (threadBusy(bot.id, bot.threadId))
11227
+ return json(res, 409, { error: "the thread is working — stop it before switching versions" });
10875
11228
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
10876
11229
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
10877
11230
  }
@@ -10879,15 +11232,14 @@ const handleRequest = async (req, res) => {
10879
11232
  if (!leaf)
10880
11233
  return json(res, 404, { error: "no such message" });
10881
11234
  // provider sessions still hold the other branch — next turn replays
10882
- store.patchBot(bot.id, { rewound: true });
11235
+ store.patchTask(bot.id, bot.threadId, { rewound: true });
10883
11236
  return json(res, 200, { activeLeafId: leaf });
10884
11237
  }
10885
11238
  m = path.match(/^\/api\/bots\/([\w-]+)\/respond$/);
10886
11239
  if (m && method === "POST") {
10887
- const bot = store.bot(m[1]);
10888
- if (!bot)
10889
- return json(res, 404, { error: "no such bot" });
10890
11240
  const body = await readBody(req);
11241
+ const selected = requestedTaskBot(m[1], body.threadId);
11242
+ const bot = botForThread(selected.id, selected.threadId);
10891
11243
  const behavior = requestBehavior(body.behavior);
10892
11244
  const reviewedSha256 = typeof body.reviewedSha256 === "string" ? body.reviewedSha256 : undefined;
10893
11245
  if (!behavior)
@@ -10920,7 +11272,8 @@ const handleRequest = async (req, res) => {
10920
11272
  // peer-approval intercept: harness-native cards carry a requestId
10921
11273
  // that lives in peer-approval's pending map. Resolve them here so
10922
11274
  // the provider adapter never sees a request it didn't raise.
10923
- if (resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
11275
+ if (store.messagesFor(bot.threadId).some((message) => message.card?.requestId === String(body.requestId)) &&
11276
+ resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
10924
11277
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
10925
11278
  }
10926
11279
  const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name });
@@ -10990,7 +11343,8 @@ const handleRequest = async (req, res) => {
10990
11343
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
10991
11344
  // belongs to the bus rather than to a speaker, so resolve it before we go
10992
11345
  // looking for one — a room between turns has no speaker to find.
10993
- if (resolvePeerComms(approvalBus, requestId, behavior)) {
11346
+ if (store.messagesFor(threadId).some((message) => message.card?.requestId === requestId) &&
11347
+ resolvePeerComms(approvalBus, requestId, behavior)) {
10994
11348
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
10995
11349
  }
10996
11350
  const group = store.groupByThread(threadId);
@@ -11006,7 +11360,8 @@ const handleRequest = async (req, res) => {
11006
11360
  : store.botByThread(threadId);
11007
11361
  if (!owner && !pending)
11008
11362
  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);
11363
+ const requestOwner = owner ? botForThread(owner.id, threadId) : null;
11364
+ const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined);
11010
11365
  return json(res, 200, { ok: true, outcome });
11011
11366
  }
11012
11367
  m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
@@ -11019,11 +11374,22 @@ const handleRequest = async (req, res) => {
11019
11374
  return json(res, 400, { error: "body must be a JSON object" });
11020
11375
  }
11021
11376
  const body = rawBody ?? {};
11377
+ requirePinnedClientThread(bot.id, body.threadId);
11022
11378
  const expectedThreadId = body.threadId;
11023
11379
  if (expectedThreadId !== undefined && (typeof expectedThreadId !== "string" || !/^[\w-]+$/.test(expectedThreadId))) {
11024
11380
  return json(res, 400, { error: "threadId must be a task id" });
11025
11381
  }
11026
- const directClaim = directTurnDispatchClaims.get(bot.id);
11382
+ // Explicit thread targets never fall through to another routine or
11383
+ // channel just because it belongs to the same bot.
11384
+ if (typeof expectedThreadId === "string" && store.taskByThread(bot.id, expectedThreadId)) {
11385
+ const routine = routines.activeBotRunForBot(bot.id);
11386
+ if (routine?.threadId === expectedThreadId)
11387
+ await routines.cancelRun(routine.id);
11388
+ else
11389
+ await interruptDirectThread(bot.id, expectedThreadId);
11390
+ return json(res, 200, { ok: true });
11391
+ }
11392
+ const directClaim = directTurnDispatchClaims.get(bot.threadId);
11027
11393
  const routineRun = routines.activeBotRunForBot(bot.id);
11028
11394
  if (routineRun) {
11029
11395
  if (expectedThreadId !== undefined && routineRun.threadId !== expectedThreadId) {
@@ -11036,7 +11402,7 @@ const handleRequest = async (req, res) => {
11036
11402
  await routines.cancelRun(routineRun.id);
11037
11403
  return json(res, 200, { ok: true });
11038
11404
  }
11039
- const instance = registry.get(bot.modelSelection.instanceId);
11405
+ const instance = registry.get((botForThread(bot.id, expectedThreadId ?? bot.threadId) ?? bot).modelSelection.instanceId);
11040
11406
  // a bot busy in a ROOM is running on the room's thread — stopping it
11041
11407
  // from its own chat must reach that turn, not just the 1:1 thread
11042
11408
  const busyGroup = activeGroupTurnForBot(bot.id);
@@ -11056,11 +11422,7 @@ const handleRequest = async (req, res) => {
11056
11422
  directClaim?.threadId !== expectedThreadId) {
11057
11423
  return json(res, 409, { error: "the bot switched tasks before it could be interrupted" });
11058
11424
  }
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);
11425
+ await interruptDirectThread(bot.id, expectedThreadId ?? bot.threadId);
11064
11426
  return json(res, 200, { ok: true });
11065
11427
  }
11066
11428
  // ── tasks: a bot's separate contexts ────────────────────────────────
@@ -11073,18 +11435,50 @@ const handleRequest = async (req, res) => {
11073
11435
  activeLeafId: store.activeLeaf(bot.threadId),
11074
11436
  tasks: store.tasks(bot.id).map(wireTask),
11075
11437
  });
11438
+ // Folders organize one bot's threads; they never own settings,
11439
+ // transcripts or working directories. The project wire names stay stable.
11440
+ m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
11441
+ if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
11442
+ const bot = store.bot(m[1]);
11443
+ if (!bot)
11444
+ return json(res, 404, { error: "no such bot" });
11445
+ if (m[2] && !store.project(bot.id, m[2]))
11446
+ return json(res, 404, { error: "no such folder" });
11447
+ if (method === "DELETE") {
11448
+ const updated = store.deleteProject(bot.id, m[2]);
11449
+ return json(res, 200, { bot: botWithThread(updated) });
11450
+ }
11451
+ const body = await readBody(req);
11452
+ if (!body || typeof body !== "object" || Array.isArray(body))
11453
+ return json(res, 400, { error: "body must be a JSON object" });
11454
+ if (Object.keys(body).some((key) => key !== "name")) {
11455
+ return json(res, 400, { error: "unsupported folder setting" });
11456
+ }
11457
+ if ((method === "POST" || body.name !== undefined) &&
11458
+ (typeof body.name !== "string" || !body.name.trim() || body.name.trim().length > 80)) {
11459
+ return json(res, 400, { error: "folder name must be between 1 and 80 characters" });
11460
+ }
11461
+ const patch = {};
11462
+ if (body.name !== undefined)
11463
+ patch.name = body.name.trim();
11464
+ const project = method === "POST"
11465
+ ? store.createProject(bot.id, patch.name)
11466
+ : store.patchProject(bot.id, m[2], patch);
11467
+ return json(res, method === "POST" ? 201 : 200, { project, bot: botWithThread(bot) });
11468
+ }
11076
11469
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
11077
11470
  if (m && method === "POST") {
11078
11471
  const body = await readBody(req);
11079
11472
  const bot = store.bot(m[1]);
11080
11473
  if (!bot)
11081
11474
  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
11475
  if (phoneSecretSubmissions.hasBot(bot.id)) {
11085
11476
  return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
11086
11477
  }
11087
- const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined);
11478
+ if (body.projectId !== undefined && (typeof body.projectId !== "string" || !store.project(bot.id, body.projectId))) {
11479
+ return json(res, 400, { error: "projectId must belong to this bot" });
11480
+ }
11481
+ const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined, true, body.projectId);
11088
11482
  if (!task)
11089
11483
  return json(res, 500, { error: "couldn't create that task" });
11090
11484
  const fresh = botWithThread(store.bot(bot.id));
@@ -11099,12 +11493,7 @@ const handleRequest = async (req, res) => {
11099
11493
  if (phoneSecretSubmissions.hasBot(bot.id)) {
11100
11494
  return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
11101
11495
  }
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" });
11496
+ // Navigation only: execution owns its thread, never this selected id.
11108
11497
  const switched = store.switchTask(bot.id, m[2]);
11109
11498
  if (!switched)
11110
11499
  return json(res, 404, { error: "no such task" });
@@ -11117,12 +11506,81 @@ const handleRequest = async (req, res) => {
11117
11506
  }
11118
11507
  if (m && method === "PATCH") {
11119
11508
  const body = await readBody(req);
11120
- const task = store.renameTask(m[1], m[2], String(body.title ?? ""));
11121
- if (!task)
11509
+ if (!body || typeof body !== "object" || Array.isArray(body))
11510
+ return json(res, 400, { error: "body must be a JSON object" });
11511
+ const current = store.projectBotForTask(m[1], m[2]);
11512
+ if (!current)
11122
11513
  return json(res, 404, { error: "no such task" });
11514
+ const allowed = new Set(["title", "projectId", "modelSelection", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto"]);
11515
+ if (Object.keys(body).some((key) => !allowed.has(key)))
11516
+ return json(res, 400, { error: "unsupported thread setting" });
11517
+ for (const key of ["requireAvailableModel", "acknowledgeLocalAuto"]) {
11518
+ if (body[key] !== undefined && typeof body[key] !== "boolean")
11519
+ return json(res, 400, { error: `${key} must be a boolean` });
11520
+ }
11521
+ if (body.requireAvailableModel === true && body.modelSelection === undefined)
11522
+ return json(res, 400, { error: "requireAvailableModel requires modelSelection" });
11523
+ const patch = {};
11524
+ if (body.projectId !== undefined) {
11525
+ if (body.projectId === null)
11526
+ patch.projectId = undefined;
11527
+ else if (typeof body.projectId === "string" && store.project(current.id, body.projectId))
11528
+ patch.projectId = body.projectId;
11529
+ else
11530
+ return json(res, 400, { error: "projectId must belong to this bot, or null to ungroup the thread" });
11531
+ }
11532
+ if (body.title !== undefined) {
11533
+ if (typeof body.title !== "string")
11534
+ return json(res, 400, { error: "title must be a string" });
11535
+ patch.title = body.title;
11536
+ }
11537
+ if (body.pinnedMessageId !== undefined) {
11538
+ if (body.pinnedMessageId === null || body.pinnedMessageId === "")
11539
+ patch.pinnedMessageId = undefined;
11540
+ else if (typeof body.pinnedMessageId === "string" && store.messagesFor(current.threadId).some((message) => message.id === body.pinnedMessageId))
11541
+ patch.pinnedMessageId = body.pinnedMessageId;
11542
+ else
11543
+ return json(res, 400, { error: "pinnedMessageId must belong to this thread" });
11544
+ }
11545
+ if (body.modelSelection !== undefined) {
11546
+ if (current.approvalGrant)
11547
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
11548
+ const checked = checkedModelSelection(body.modelSelection, { selection: current.modelSelection, busy: threadBusy(current.id, current.threadId) }, body.requireAvailableModel === true);
11549
+ if (!checked.ok)
11550
+ return json(res, checked.status, { error: checked.error });
11551
+ patch.modelSelection = checked.selection;
11552
+ }
11553
+ if (body.approvalMode !== undefined || body.autoApprove !== undefined) {
11554
+ if (body.autoApprove !== undefined && typeof body.autoApprove !== "boolean")
11555
+ return json(res, 400, { error: "autoApprove must be a boolean" });
11556
+ const mode = body.approvalMode ?? (body.autoApprove ? "auto" : "ask");
11557
+ // Elevated modes still require the trusted desktop transition. A
11558
+ // thread settings PATCH cannot manufacture that grant.
11559
+ if (mode !== "ask" && mode !== "auto")
11560
+ return json(res, 403, { error: "Full and Custom access require confirmation in bot settings" });
11561
+ if (threadBusy(current.id, current.threadId))
11562
+ return json(res, 409, { error: "stop this thread before changing its approval mode" });
11563
+ if (current.approvalGrant)
11564
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
11565
+ if (mode === "auto" && approvalModeFor(current) !== "auto" && auth.kind === "loopback" && !DESKTOP_MANAGED && !req.headers.origin && store.bots.some((bot) => bot.busy)) {
11566
+ return json(res, 409, { error: "Change approval mode from the app or a paired device while bots are working." });
11567
+ }
11568
+ if (mode === "auto" && current.computer === "local" && approvalModeFor(current) !== "auto" && body.acknowledgeLocalAuto !== true) {
11569
+ return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
11570
+ }
11571
+ patch.approvalMode = mode;
11572
+ patch.autoApprove = mode === "auto";
11573
+ }
11574
+ const mode = patch.approvalMode ?? approvalModeFor(current);
11575
+ if (patch.modelSelection && (mode === "full" || mode === "custom") &&
11576
+ (!supportsApprovalMode(registry.cliTarget(patch.modelSelection.instanceId)?.driverKind, mode) ||
11577
+ registry.cliTarget(patch.modelSelection.instanceId)?.driverKind !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
11578
+ return json(res, 400, { error: "Choose Ask for this thread before changing providers with elevated permissions" });
11579
+ }
11580
+ const task = store.patchTask(m[1], m[2], patch);
11123
11581
  const fresh = botWithThread(store.bot(m[1]));
11124
11582
  broadcast({ kind: "bot", bot: fresh });
11125
- return json(res, 200, { task: wireTask(task) });
11583
+ return json(res, 200, { task: wireTask(task), bot: fresh });
11126
11584
  }
11127
11585
  if (m && method === "DELETE") {
11128
11586
  const bot = store.bot(m[1]);
@@ -11132,7 +11590,7 @@ const handleRequest = async (req, res) => {
11132
11590
  if (phoneSecretSubmissions.hasThread(m[2])) {
11133
11591
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
11134
11592
  }
11135
- if (bot?.busy && (bot.threadId === m[2] || routines.isActiveThread(m[2]))) {
11593
+ if (threadBusy(bot.id, m[2]) || routines.isActiveThread(m[2])) {
11136
11594
  return json(res, 409, { error: "this task is running — stop it first" });
11137
11595
  }
11138
11596
  const stagedSkillCleanups = stagedSkillCleanupsForThread(m[2]);
@@ -11474,9 +11932,10 @@ const handleRequest = async (req, res) => {
11474
11932
  if (action === "auth/complete") {
11475
11933
  const body = await readBody(req);
11476
11934
  const flowId = typeof body?.flowId === "string" ? body.flowId : "";
11477
- const callbackUrl = typeof body?.callbackUrl === "string" ? body.callbackUrl : "";
11935
+ // `code` for a pasted sign-in code (Claude), `callbackUrl` for a browser callback
11936
+ const callbackUrl = typeof body?.callbackUrl === "string" ? body.callbackUrl : typeof body?.code === "string" ? body.code : "";
11478
11937
  if (!flowId || !callbackUrl)
11479
- return json(res, 400, { error: "flowId and callbackUrl are required" });
11938
+ return json(res, 400, { error: "flowId and a code or callbackUrl are required" });
11480
11939
  await providerAuthSessions.complete(instanceId, owner, flowId, callbackUrl);
11481
11940
  return json(res, 200, { ok: true });
11482
11941
  }
@@ -11527,6 +11986,14 @@ const handleRequest = async (req, res) => {
11527
11986
  // executable already configured for this Claude instance. The JSON gate
11528
11987
  // keeps a hostile page from triggering a local process with a simple
11529
11988
  // cross-origin form request.
11989
+ const busyProviderSelections = () => store.bots.flatMap((bot) => {
11990
+ const busyTasks = store.tasks(bot.id).filter((task) => threadBusy(bot.id, task.threadId));
11991
+ const selections = busyTasks.map((task) => botForThread(bot.id, task.threadId).modelSelection);
11992
+ // Rooms still run from the profile default; a direct thread does not.
11993
+ if (activeGroupTurnForBot(bot.id) || (bot.busy && busyTasks.length === 0))
11994
+ selections.push(bot.modelSelection);
11995
+ return selections;
11996
+ });
11530
11997
  const claudeUpdate = /^\/api\/instances\/([\w.-]+)\/claude-update$/.exec(path);
11531
11998
  if (method === "POST" && claudeUpdate) {
11532
11999
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
@@ -11544,7 +12011,7 @@ const handleRequest = async (req, res) => {
11544
12011
  if (claudeUpdatesInFlight.has(target.cli)) {
11545
12012
  return json(res, 409, { error: "this Claude installation is already updating" });
11546
12013
  }
11547
- const active = store.bots.some((bot) => bot.busy && registry.cliTarget(bot.modelSelection.instanceId)?.cli === target.cli);
12014
+ const active = busyProviderSelections().some((selection) => registry.cliTarget(selection.instanceId)?.cli === target.cli);
11548
12015
  if (active) {
11549
12016
  return json(res, 409, { error: "wait for running Claude tasks to finish before updating" });
11550
12017
  }
@@ -11577,7 +12044,7 @@ const handleRequest = async (req, res) => {
11577
12044
  const instanceId = instancePatch[1];
11578
12045
  if (providerConfigBusy)
11579
12046
  return json(res, 409, { error: "provider settings are already being updated" });
11580
- if (store.bots.some((bot) => bot.busy && bot.modelSelection.instanceId === instanceId)) {
12047
+ if (busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
11581
12048
  return json(res, 409, { error: "Wait for bots using this account to finish before changing its settings." });
11582
12049
  }
11583
12050
  providerConfigBusy = true;
@@ -11601,7 +12068,7 @@ const handleRequest = async (req, res) => {
11601
12068
  catch { /* Allow repairing an unused malformed account. */ }
11602
12069
  entry.config = { ...entry.config, configDir: body.configDir };
11603
12070
  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));
12071
+ 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
12072
  if (used)
11606
12073
  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
12074
  assertSeparateClaudeAccount(instances, instanceId, entry);
@@ -11625,7 +12092,8 @@ const handleRequest = async (req, res) => {
11625
12092
  if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
11626
12093
  return json(res, 400, { error: "Only added Claude accounts can be removed here." });
11627
12094
  }
11628
- if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId)) {
12095
+ if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
12096
+ busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
11629
12097
  return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
11630
12098
  }
11631
12099
  providerConfigBusy = true;