openmausbot 0.1.65 → 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 (54) hide show
  1. package/dist/assets/index-A3CBTxwl.css +1 -0
  2. package/dist/assets/{index-BZeH50da.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/browser-proxy.js +121 -0
  6. package/dist-server/computer-proxy.js +14 -3
  7. package/dist-server/container-mcp.js +43 -19
  8. package/dist-server/drivers/agents-proxy.js +31 -0
  9. package/dist-server/index.js +18985 -16059
  10. package/dist-server/local-computer-proxy.js +433 -0
  11. package/dist-server/local-computer.js +48 -5
  12. package/dist-server/mcp-server.js +38 -17
  13. package/dist-server/openmausbot.js +1666 -775
  14. package/dist-server/pair-cli.js +1666 -775
  15. package/dist-server/proxy-paths.js +2 -0
  16. package/dist-server/server/account-signin.js +103 -0
  17. package/dist-server/server/attachments.js +125 -13
  18. package/dist-server/server/browser-engine-release.js +10 -8
  19. package/dist-server/server/browser-engine.js +226 -26
  20. package/dist-server/server/browser-live.js +567 -0
  21. package/dist-server/server/browser-proxy.js +129 -0
  22. package/dist-server/server/browser-runtime.js +370 -0
  23. package/dist-server/server/caddy.js +203 -0
  24. package/dist-server/server/claude-accounts.js +103 -0
  25. package/dist-server/server/cli.js +163 -22
  26. package/dist-server/server/computer-proxy.js +11 -2
  27. package/dist-server/server/config.js +41 -19
  28. package/dist-server/server/control-client.js +7 -1
  29. package/dist-server/server/custom-domain.js +14 -0
  30. package/dist-server/server/drivers/agents-proxy.js +34 -0
  31. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  32. package/dist-server/server/drivers/claude.js +85 -19
  33. package/dist-server/server/environment.js +1 -0
  34. package/dist-server/server/harness/bus.js +9 -4
  35. package/dist-server/server/harness/registry.js +15 -1
  36. package/dist-server/server/index.js +1120 -379
  37. package/dist-server/server/local-computer-proxy.js +28 -0
  38. package/dist-server/server/local-computer.js +17 -0
  39. package/dist-server/server/mcp-bridge.js +27 -11
  40. package/dist-server/server/peer-approval.js +19 -6
  41. package/dist-server/server/provider-auth-sessions.js +7 -0
  42. package/dist-server/server/proxy-paths.js +2 -0
  43. package/dist-server/server/sessions.js +42 -1
  44. package/dist-server/server/steer-queue.js +10 -8
  45. package/dist-server/server/store.js +234 -34
  46. package/dist-server/server/team-backup.js +1 -0
  47. package/dist-server/server/tunnel.js +27 -1
  48. package/dist-server/server/turn-resources.js +50 -0
  49. package/dist-server/server/workspace.js +53 -3
  50. package/dist-server/src/lib/composer-attachments.js +3 -3
  51. package/dist-server/vps-container-mcp.js +43 -19
  52. package/package.json +1 -1
  53. package/dist/assets/index-BOrNJcfG.js +0 -273
  54. package/dist/assets/index-Dwzk199I.css +0 -1
@@ -14,11 +14,12 @@ import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, is
14
14
  import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, rememberableApprovalKey } from "./auto-approve.js";
15
15
  import { requestReview, resolveAutoReviewMode, shouldReview } from "./auto-review.js";
16
16
  import { updateClaudeCli } from "./claude-update.js";
17
+ import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
17
18
  import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
18
19
  import * as checkpoints from "./checkpoints.js";
19
20
  import { appendDecision, readDecisions } from "./decision-log.js";
20
21
  import { validateBotCwd } from "./bot-cwd.js";
21
- import { ATTACHMENTS_DIR, attachmentExists, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
22
+ import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
22
23
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
23
24
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, snapshotAvatarGenerationState, } from "./avatar-image.js";
24
25
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
@@ -32,7 +33,7 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
32
33
  import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
33
34
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
34
35
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
35
- import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
+ import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
37
  import { ComputerControl } from "./computer-control.js";
37
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
38
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -68,7 +69,8 @@ import { narrateTool, toUtterances } from "./tts/speech-text.js";
68
69
  import { buildTurnContext, engineIsFresh } from "./turn-context.js";
69
70
  import { extractTurnImages } from "./turn-images.js";
70
71
  import { TurnWatchdog } from "./turn-watchdog.js";
71
- 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";
72
74
  import { readMemoryFile, readMemoryTopic, writeMemoryFile, MEMORY_FILE_MAX_BYTES, } from "./workspace.js";
73
75
  import { readSectionContext, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
74
76
  import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
@@ -77,7 +79,7 @@ import { expandLearnTurnText, learnSource } from "./skill-learn.js";
77
79
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
78
80
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
79
81
  import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
80
- import { readCuaConnection } from "./local-computer.js";
82
+ import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
81
83
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
82
84
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
83
85
  import { LocalVmLeasePool } from "./local-vm-lease.js";
@@ -87,7 +89,9 @@ import * as vps from "./vps-computer.js";
87
89
  import { RoutineManager } from "./routines.js";
88
90
  import { CalendarCallManager } from "./calendar-calls.js";
89
91
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
90
- import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
92
+ import { BrowserRuntime } from "./browser-runtime.js";
93
+ import { BrowserLive } from "./browser-live.js";
94
+ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
91
95
  import { createScreenFrameSource } from "./screen-frame-source.js";
92
96
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
93
97
  import { RoutineRequestService } from "./routine-requests.js";
@@ -117,7 +121,8 @@ import { createGracefulShutdown } from "./graceful-shutdown.js";
117
121
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
118
122
  import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
119
123
  import { environmentDescriptor, loadEnvironmentId } from "./environment.js";
120
- import { createCustomDomainVerifier, normalizeCustomDomain } from "./custom-domain.js";
124
+ import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
125
+ import { createEmailSignIn, parseAllowList } from "./account-signin.js";
121
126
  import { ProviderAuthSessions } from "./provider-auth-sessions.js";
122
127
  import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
123
128
  import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
@@ -175,6 +180,14 @@ let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
175
180
  const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
176
181
  const cfg = loadConfig();
177
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
+ });
178
191
  let customDomainRevision = 0;
179
192
  function savedCustomDomain() {
180
193
  if (DESKTOP_MANAGED || !cfg.customDomain)
@@ -191,6 +204,7 @@ function customDomainStatus() {
191
204
  return {
192
205
  customDomain: savedCustomDomain(), publicUrl: publicUrl(), fallbackUrl: FALLBACK_PUBLIC_URL,
193
206
  supported: !DESKTOP_MANAGED, appPort: PORT, webhookPort: WEBHOOK_PORT,
207
+ serverIpv4: DESKTOP_MANAGED ? null : customDomainIpv4(),
194
208
  };
195
209
  }
196
210
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
@@ -248,7 +262,7 @@ const browserCleanup = new BrowserCleanupCoordinator({
248
262
  ? Promise.all(sessions.map(async (session) => {
249
263
  const ok = await clearBrowserSessionState(status.binaryPath, session, { encryptionKey: browserEngineEncryptionKey() });
250
264
  if (!ok)
251
- console.warn(`browser cleanup: could not clear the engine's saved state for session ${session}; clear it with \`agent-browser --session ${session} state clear --all\``);
265
+ console.warn(`browser cleanup: could not clear saved state for session ${session}; restart OpenMausBot to retry this profile's cleanup. Do not use state clear --all: it erases other profiles too.`);
252
266
  return ok;
253
267
  }))
254
268
  : Promise.resolve([true]);
@@ -430,7 +444,78 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation) {
430
444
  class DirectTurnSetupCancelled extends Error {
431
445
  }
432
446
  const directTurnDispatchClaims = new Map();
433
- 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
+ }
434
519
  const retiredProviderTurns = new RetiredTurnRegistry();
435
520
  const pendingCancelledProviderHandshakes = new PendingTurnCancellations();
436
521
  const generatedImagesByTurn = new Map();
@@ -443,10 +528,7 @@ function purgeGeneratedImagesForThread(threadId) {
443
528
  continue;
444
529
  generatedImagesByTurn.delete(key);
445
530
  for (const attachment of attachments) {
446
- try {
447
- unlinkSync(attachment.path);
448
- }
449
- catch { }
531
+ deleteAttachment(attachment.path);
450
532
  }
451
533
  }
452
534
  }
@@ -466,10 +548,7 @@ function retireProviderTurn(turnId) {
466
548
  continue;
467
549
  generatedImagesByTurn.delete(key);
468
550
  for (const attachment of attachments) {
469
- try {
470
- unlinkSync(attachment.path);
471
- }
472
- catch { }
551
+ deleteAttachment(attachment.path);
473
552
  }
474
553
  }
475
554
  }
@@ -483,32 +562,35 @@ function shouldIgnoreProviderEvent(event) {
483
562
  return true;
484
563
  if (event.type !== "session.exited" || event.turnId !== undefined)
485
564
  return false;
486
- 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);
487
567
  }
488
568
  function directTurnClaimIsCurrent(botId, claimId, threadId) {
489
- const claim = directTurnDispatchClaims.get(botId);
490
- const bot = store.bot(botId);
491
- 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;
492
571
  }
493
572
  function directTurnClaimExists(botId, claimId, threadId) {
494
- const claim = directTurnDispatchClaims.get(botId);
495
- return claim?.id === claimId && claim.threadId === threadId;
573
+ const claim = directTurnDispatchClaims.get(threadId);
574
+ return claim?.id === claimId && claim.botId === botId;
496
575
  }
497
576
  function markDirectTurnDispatching(botId, claimId, threadId) {
498
577
  if (!directTurnClaimIsCurrent(botId, claimId, threadId))
499
578
  return false;
500
- directTurnDispatchClaims.set(botId, { id: claimId, threadId, phase: "dispatching" });
579
+ directTurnDispatchClaims.set(threadId, { id: claimId, botId, threadId, phase: "dispatching" });
501
580
  return true;
502
581
  }
503
- function clearDirectTurnDispatch(botId, claimId) {
504
- if (directTurnDispatchClaims.get(botId)?.id === claimId)
505
- directTurnDispatchClaims.delete(botId);
582
+ function clearDirectTurnDispatch(threadId, claimId) {
583
+ if (directTurnDispatchClaims.get(threadId)?.id === claimId)
584
+ directTurnDispatchClaims.delete(threadId);
506
585
  }
507
586
  function cancelDirectTurnDispatch(botId, expectedThreadId) {
508
- const claim = directTurnDispatchClaims.get(botId);
509
- 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)
510
592
  return null;
511
- directTurnDispatchClaims.delete(botId);
593
+ directTurnDispatchClaims.delete(threadId);
512
594
  // Setup has not called the adapter yet, so there is no provider handshake
513
595
  // (and no unknown turn id) to quarantine. Dispatching is the only phase in
514
596
  // which a late provider event can exist.
@@ -524,7 +606,40 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
524
606
  /** The bot's browser for this turn: agent-browser, one isolated session per
525
607
  * browser profile or per bot (docs/plans/browser-engine.md). Null, with the
526
608
  * reason logged once, when the engine is not on this machine. */
527
- function browserIntegration(botId, profile) {
609
+ const browserRuntime = new BrowserRuntime();
610
+ const browserLive = new BrowserLive({ runtime: browserRuntime });
611
+ // Temporary profiles last for this server run, but are never saved to disk.
612
+ // The viewer and the agent must address the SAME temporary browser.
613
+ const temporaryBrowserSessions = new Map();
614
+ function currentBrowserSession(botId, profile) {
615
+ if (profile === "guest") {
616
+ let session = temporaryBrowserSessions.get(botId);
617
+ if (!session) {
618
+ session = browserSessionId(botId, "guest");
619
+ temporaryBrowserSessions.set(botId, session);
620
+ }
621
+ return session;
622
+ }
623
+ const target = profile ? browserProfilePartitionTarget(cfg, profile) : null;
624
+ return browserSessionId(botId, target?.partitionId ?? "");
625
+ }
626
+ async function forgetTemporaryBrowser(botId) {
627
+ const session = temporaryBrowserSessions.get(botId);
628
+ if (!session)
629
+ return;
630
+ temporaryBrowserSessions.delete(botId);
631
+ const engine = browserEngineStatus();
632
+ if (engine.kind !== "ready")
633
+ return;
634
+ const closed = await clearBrowserSessionState(engine.binaryPath, session, {
635
+ env: { PATH: augmentedPath() }, encryptionKey: browserEngineEncryptionKey(),
636
+ });
637
+ if (closed)
638
+ await browserRuntime.close(session);
639
+ else
640
+ console.warn(`temporary browser ${session}: could not close its session; run agent-browser --session ${session} close on this server`);
641
+ }
642
+ async function browserIntegration(botId, profile, turn) {
528
643
  const status = browserEngineStatus();
529
644
  if (status.kind !== "ready") {
530
645
  if (!engineUnavailableLogged) {
@@ -536,17 +651,29 @@ function browserIntegration(botId, profile) {
536
651
  // A profile that no longer exists falls back to the bot's own session.
537
652
  const profileTarget = profile && profile !== "guest" ? browserProfilePartitionTarget(cfg, profile) : null;
538
653
  const partitionId = profile === "guest" ? "guest" : (profileTarget?.partitionId ?? "");
539
- const session = browserSessionId(botId, partitionId);
540
- return {
541
- profile: partitionId,
542
- integration: agentBrowserIntegration({
543
- binaryPath: status.binaryPath,
544
- session,
545
- encryptionKey: browserEngineEncryptionKey(),
546
- persistent: profile !== "guest",
547
- env: { ...process.env, PATH: augmentedPath() },
548
- }),
549
- };
654
+ const session = currentBrowserSession(botId, profile);
655
+ const spec = agentBrowserIntegration({
656
+ binaryPath: status.binaryPath,
657
+ session,
658
+ encryptionKey: browserEngineEncryptionKey(),
659
+ persistent: profile !== "guest",
660
+ env: { ...process.env, PATH: augmentedPath() },
661
+ });
662
+ await prepareBrowserSessionState(status.binaryPath, session, { env: spec.env, persistent: profile !== "guest", isCurrent: () => {
663
+ const current = store.bot(botId);
664
+ return !!current && current.browser !== false && builtInBrowserEnabled(cfg)
665
+ && currentBrowserSession(current.id, current.browserProfile) === session
666
+ && (!turn || activeInternalGenerationByThread.get(turn.threadId) === turn.generation);
667
+ } });
668
+ if (!turn)
669
+ return { profile: partitionId, session, spec, integration: spec };
670
+ const token = mintInternalCapability({ botId, ...turn, browserSession: session,
671
+ kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0 });
672
+ return { profile: partitionId, session, spec, integration: {
673
+ command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
674
+ ...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
675
+ },
676
+ } };
550
677
  }
551
678
  let engineUnavailableLogged = false;
552
679
  let browserEngineInstall = null;
@@ -632,11 +759,11 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
632
759
  }),
633
760
  };
634
761
  }
635
- function askBotAndWait(targetBotId, message, depth, fromBotId) {
762
+ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
636
763
  const target = store.bot(targetBotId);
637
764
  if (!target)
638
765
  return Promise.resolve({ status: "error", text: "(no such bot)" });
639
- const threadId = target.threadId;
766
+ const threadId = targetThreadId ?? target.threadId;
640
767
  return new Promise((resolve) => {
641
768
  let text = "";
642
769
  let done = false;
@@ -673,8 +800,9 @@ function askBotAndWait(targetBotId, message, depth, fromBotId) {
673
800
  // prefixed to it: the wording is for the model reading this turn, the
674
801
  // field is for anything that reads the transcript later.
675
802
  const asker = fromBotId ? store.bot(fromBotId) : undefined;
676
- const unattended = isUnattended(fromBotId);
803
+ const unattended = isUnattended(fromBotId, fromThreadId);
677
804
  startTurn(targetBotId, message, {
805
+ threadId,
678
806
  commsDepth: depth + 1,
679
807
  unattended,
680
808
  peerAsk: asker
@@ -718,6 +846,9 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
718
846
  return { ok: false, status: 409, error: "the bot is working — stop it before changing models" };
719
847
  }
720
848
  const target = registry.get(selection.instanceId);
849
+ if (providerInstancesChanging.has(selection.instanceId)) {
850
+ return { ok: false, status: 409, error: "this provider account is being updated — try again shortly" };
851
+ }
721
852
  // Model IDs remain free-form at the app's general API boundary. Custom
722
853
  // engines can accept IDs that are not in their discovery catalog, and
723
854
  // several drivers only learn the final catalog when a turn starts. The
@@ -962,14 +1093,14 @@ async function botOverview(bot) {
962
1093
  * this too, but no provider dispatch or later permission callback relies on
963
1094
  * persistence having been produced exclusively by that route. Delegation
964
1095
  * uses the receiving bot's grant, never the sender's — see approvalModeForOrigin. */
965
- const approvalModeForTurn = (bot, peerInitiated = false) => {
1096
+ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
966
1097
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
967
1098
  if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
968
1099
  return "ask";
969
1100
  }
970
1101
  // Native reviewers can approve before a permission reaches this process.
971
1102
  // Unattended Auto must therefore downgrade before spawning the provider.
972
- if (mode === "auto" && isUnattended(bot.id))
1103
+ if (mode === "auto" && isUnattended(bot.id, threadId))
973
1104
  return "ask";
974
1105
  return mode;
975
1106
  };
@@ -1894,12 +2025,14 @@ const watchdog = new TurnWatchdog({
1894
2025
  stallMs: TURN_STALL_MS,
1895
2026
  checkMs: 60_000,
1896
2027
  onStall: (turn) => {
2028
+ const stalledResourceOwner = turnResourceOwners.get(turn.threadId);
2029
+ const stalledGeneration = directTurnGenerationByThread.get(turn.threadId);
1897
2030
  // Room targets carry an invocation identity; only those claims belong
1898
2031
  // to the room grace cleanup added here.
1899
2032
  const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
1900
2033
  revokeInternalCapabilitiesForThread(turn.threadId);
1901
2034
  repeats.settle(turn.threadId);
1902
- const bot = store.bot(turn.botId);
2035
+ const bot = botForThread(turn.botId, turn.threadId);
1903
2036
  const instance = bot ? registry.get(bot.modelSelection.instanceId) : null;
1904
2037
  void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
1905
2038
  const minutes = Math.round(TURN_STALL_MS / 60_000);
@@ -1916,6 +2049,10 @@ const watchdog = new TurnWatchdog({
1916
2049
  // overlap the process we are stopping. The normal turn.completed fold
1917
2050
  // clears it first when the adapter responds.
1918
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;
1919
2056
  // A goal coordinator can stall before sendTurn reveals its provider
1920
2057
  // turn id. Reusing the room during that ambiguous pre-id window would
1921
2058
  // make old and replacement events indistinguishable. Keep ownership
@@ -1936,10 +2073,15 @@ const watchdog = new TurnWatchdog({
1936
2073
  }
1937
2074
  const currentBot = store.bot(turn.botId);
1938
2075
  if (currentBot?.busy) {
1939
- stopScreenPoller(currentBot.id);
2076
+ stopScreenPoller(currentBot.id, turn.threadId);
2077
+ releaseTurnResources(stalledResourceOwner);
1940
2078
  if (activeVpsThreads.get(currentBot.id) === turn.threadId)
1941
2079
  activeVpsThreads.delete(currentBot.id);
1942
- 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);
1943
2085
  retryDelegationsWaitingOn(currentBot.id);
1944
2086
  // The grace fallback replaces a missing turn.completed event. Release
1945
2087
  // every kind of work that may have queued behind this bot, including
@@ -2025,16 +2167,7 @@ bus.subscribe((event) => {
2025
2167
  watchdog.settle(event.threadId);
2026
2168
  revokeInternalCapabilityForProviderEvent(event);
2027
2169
  }
2028
- else if (event.type === "session.exited") {
2029
- // A retained provider session can exit after a newer turn reused the same
2030
- // thread. An unscoped session event must never revoke that newer turn's
2031
- // capability; its turn completion or watchdog owns release instead.
2032
- const directBotBusy = store.botByThread(event.threadId)?.busy === true;
2033
- const roomBusy = Boolean(store.groupByThread(event.threadId)?.busyBotId);
2034
- if (!directBotBusy && !roomBusy) {
2035
- }
2036
- }
2037
- else
2170
+ else if (event.type !== "session.exited")
2038
2171
  watchdog.touch(event.threadId);
2039
2172
  });
2040
2173
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
@@ -2043,35 +2176,38 @@ bus.subscribe((event) => {
2043
2176
  // the guard behind auto mode is a pattern list, not a security boundary, and
2044
2177
  // it must not stand in for a human at 3am.
2045
2178
  //
2046
- // Keyed by BOT rather than thread because a bot runs one turn at a time, so
2047
- // the identity is exact, and because the peer-comms paths know who is asking
2048
- // 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
2049
2181
  // turn.completed: bus subscribers fire in registration order, and the
2050
2182
  // delegation drain runs AFTER the main fold — clearing there would blank the
2051
2183
  // flag before the hop that needs to read it. A busy bot never ages out, and a
2052
2184
  // stale mark only ever means "ask a human", so this fails closed.
2053
- const unattendedBots = new Map();
2185
+ const unattendedThreads = new Map();
2054
2186
  const UNATTENDED_TTL_MS = 30 * 60_000;
2055
- function markUnattended(botId) {
2056
- unattendedBots.set(botId, Date.now());
2187
+ function markUnattended(botId, threadId) {
2188
+ unattendedThreads.set(threadId, { botId, at: Date.now() });
2057
2189
  }
2058
- function clearUnattended(botId) {
2059
- unattendedBots.delete(botId);
2190
+ function clearUnattended(threadId) {
2191
+ unattendedThreads.delete(threadId);
2060
2192
  }
2061
- function isUnattended(botId) {
2193
+ function isUnattended(botId, threadId) {
2062
2194
  if (!botId)
2063
2195
  return false;
2064
- const at = unattendedBots.get(botId);
2065
- 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)
2066
2202
  return false;
2067
2203
  // A long-running turn is still unattended even if its next approval comes
2068
2204
  // more than 30 minutes after the previous one. Only an idle bot may age
2069
2205
  // out; every positive read refreshes the inactivity window.
2070
- if (Date.now() - at > UNATTENDED_TTL_MS && !store.bot(botId)?.busy) {
2071
- unattendedBots.delete(botId);
2206
+ if (Date.now() - mark.at > UNATTENDED_TTL_MS && !threadBusy(botId, threadId) && !groupSpeakers.has(threadId)) {
2207
+ unattendedThreads.delete(threadId);
2072
2208
  return false;
2073
2209
  }
2074
- unattendedBots.set(botId, Date.now());
2210
+ mark.at = Date.now();
2075
2211
  return true;
2076
2212
  }
2077
2213
  // Threads whose turn in flight was started by another BOT — an ask_bot hop,
@@ -2138,7 +2274,7 @@ function managedBoxOwners() {
2138
2274
  // control lease still names its owner. This is deliberately conservative
2139
2275
  // across destination changes: an old Box may still contain valuable state.
2140
2276
  inUse: bot.busy === true ||
2141
- directTurnDispatchClaims.has(bot.id) ||
2277
+ hasDirectDispatch(bot.id) ||
2142
2278
  activeGroupTurnForBot(bot.id) !== null ||
2143
2279
  Boolean(routines?.activeRunForBot(bot.id)) ||
2144
2280
  activeVpsThreads.has(bot.id) ||
@@ -2148,7 +2284,7 @@ function managedBoxOwners() {
2148
2284
  function botHasActiveTurn(botId) {
2149
2285
  const bot = store.bot(botId);
2150
2286
  return bot?.busy === true ||
2151
- directTurnDispatchClaims.has(botId) ||
2287
+ hasDirectDispatch(botId) ||
2152
2288
  activeGroupTurnForBot(botId) !== null;
2153
2289
  }
2154
2290
  function providerTransitionMessage(provider) {
@@ -2332,7 +2468,7 @@ bus.subscribe((event) => {
2332
2468
  localVmLeaseFor(localVmTarget).touch(event.threadId);
2333
2469
  localVmIdleFor(localVmTarget).touch();
2334
2470
  }
2335
- if (event.type === "turn.completed") {
2471
+ if (event.type === "turn.completed" && !store.botByThread(event.threadId)) {
2336
2472
  releaseLocalVmThread(event.threadId);
2337
2473
  }
2338
2474
  const coordinatorTurnsForThread = groupGoalCoordinatorTurns.get(event.threadId);
@@ -2387,7 +2523,8 @@ bus.subscribe((event) => {
2387
2523
  if (!privateImageEvent)
2388
2524
  broadcast({ kind: "runtime", event });
2389
2525
  const routineRun = privateImageEvent ? null : (routines?.handleRuntimeEvent(event) ?? null);
2390
- const bot = store.botByThread(event.threadId);
2526
+ const ownerBot = store.botByThread(event.threadId);
2527
+ const bot = ownerBot ? botForThread(ownerBot.id, event.threadId) ?? undefined : undefined;
2391
2528
  const group = bot ? undefined : store.groupByThread(event.threadId);
2392
2529
  if (!bot && !group)
2393
2530
  return;
@@ -2458,7 +2595,7 @@ bus.subscribe((event) => {
2458
2595
  if (bot) {
2459
2596
  const touches = screenTouchingTool(toolName);
2460
2597
  if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
2461
- pokeScreenPoller(bot.id, touches, screenSurfaceForTool(toolName));
2598
+ pokeScreenPoller(event.threadId, touches, screenSurfaceForTool(toolName));
2462
2599
  }
2463
2600
  }
2464
2601
  }
@@ -2489,8 +2626,8 @@ bus.subscribe((event) => {
2489
2626
  // even Full access never invents a person's answer. Safe Auto stops on
2490
2627
  // the guards in auto-approve.ts; explicitly acknowledged Full does not.
2491
2628
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2492
- const unattended = permission && asker && event.requestId ? isUnattended(asker.id) : false;
2493
- 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";
2494
2631
  const verdict = permission && asker && event.requestId
2495
2632
  ? autoVerdict({
2496
2633
  // Use the same receiving-bot mode as the provider dispatch.
@@ -2685,7 +2822,9 @@ bus.subscribe((event) => {
2685
2822
  if (!card || card.answered)
2686
2823
  return;
2687
2824
  // the bot is not working now — it is waiting on a person
2688
- if (asker.busy)
2825
+ if (bot)
2826
+ store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
2827
+ else if (asker.busy)
2689
2828
  store.setActivity(asker.id, "waiting-on-you");
2690
2829
  notify(buildNotification(permission ? "approval" : "question", asker, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
2691
2830
  };
@@ -2712,7 +2851,10 @@ bus.subscribe((event) => {
2712
2851
  case "request.resolved": {
2713
2852
  // answered (by whoever): the turn is working again, unless it settled
2714
2853
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
2715
- 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")
2716
2858
  store.setActivity(waiting.id, "working");
2717
2859
  const messageId = event.requestId ? askMessageByRequest.get(`${event.threadId}:${event.requestId}`) : null;
2718
2860
  if (messageId) {
@@ -2747,7 +2889,7 @@ bus.subscribe((event) => {
2747
2889
  // dispatch moves it to working; turn.completed (which follows a setup
2748
2890
  // failure) is told to leave "dead" alone.
2749
2891
  if (event.setup && bot)
2750
- store.setActivity(bot.id, "dead");
2892
+ store.setTaskActivity(bot.id, event.threadId, "dead");
2751
2893
  break;
2752
2894
  case "thread.token-usage.updated":
2753
2895
  // running totals for the turn in flight; folded into the task's
@@ -2795,10 +2937,36 @@ bus.subscribe((event) => {
2795
2937
  // tally is not the right home for a shared room's spend, so only
2796
2938
  // 1:1 task turns are tallied for now.
2797
2939
  if (bot) {
2798
- const vpsTurn = activeVpsThreads.get(bot.id) === event.threadId;
2799
- const clearVpsTurn = () => {
2800
- if (activeVpsThreads.get(bot.id) === event.threadId)
2801
- 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
+ }
2802
2970
  };
2803
2971
  // bank what this turn spent before the bot broadcast carries the
2804
2972
  // task list to every window. The driver's own per-turn figure
@@ -2811,9 +2979,6 @@ bus.subscribe((event) => {
2811
2979
  cachedInput: tokens?.cachedInput,
2812
2980
  costUsd: event.cost ?? null,
2813
2981
  });
2814
- // settled → idle; a setup failure already marked it dead, keep that
2815
- if (store.bot(bot.id)?.activity !== "dead")
2816
- store.setActivity(bot.id, "idle");
2817
2982
  const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
2818
2983
  const routineReportGroup = routineReportThread ? store.groupByThread(routineReportThread) : undefined;
2819
2984
  // Group-origin routines belong to that channel's unread state. Their
@@ -2822,7 +2987,7 @@ bus.subscribe((event) => {
2822
2987
  // pair channel and chipped into both threads, which is the whole of
2823
2988
  // what the person needs to be able to find it.
2824
2989
  if (!routineReportGroup && !internal)
2825
- store.patchBot(bot.id, { unread: true });
2990
+ store.patchTask(bot.id, event.threadId, { unread: true });
2826
2991
  // A failed peer turn stays a chip too. The bot that delegated is woken
2827
2992
  // with the failure and answers the person in its own thread — buzzing
2828
2993
  // here as well would ring twice for one piece of news.
@@ -2834,36 +2999,39 @@ bus.subscribe((event) => {
2834
2999
  : reply;
2835
3000
  notify(buildNotification("done", bot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: bot.avatarUrl }));
2836
3001
  }
2837
- if (screenPollers.has(bot.id)) {
3002
+ if (screenPollers.has(event.threadId)) {
3003
+ if (resourceOwner)
3004
+ settlingResourceOwners.set(resourceOwner.threadId, resourceOwner.generation);
2838
3005
  // the last live frame becomes a settled inline screen message —
2839
3006
  // the screenshot-in-chat moment. One fresh capture first, so the
2840
3007
  // frame shows the turn's END state (the final tool's poke may
2841
3008
  // still be in flight).
2842
3009
  //
2843
- // The capture is slow (a real screenshot round trip) and the bot
2844
- // is already idle, so a fast follow-up send or the steer-queue
2845
- // drain can land BEFORE the frame does. Anchor the frame to the
2846
- // turn's actual last message now, and chain-insert it there when
2847
- // it arrives — otherwise the user's next message ends up stranded
2848
- // 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.
2849
3013
  const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
2850
- void finalScreenFrame(bot.id, event.threadId).then((frame) => {
2851
- // the bot may have been deleted while the capture ran
2852
- if (frame && store.bot(bot.id)) {
2853
- if (group)
2854
- pushMessage({ role: "bot", kind: "screen", png: frame.png, mime: frame.mime });
2855
- else
2856
- 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 });
2857
3021
  }
2858
- }).finally(clearVpsTurn);
3022
+ }).catch(() => { }).finally(() => {
3023
+ clearTimeout(timeout);
3024
+ settleDirectTurn(true);
3025
+ });
2859
3026
  }
2860
- else if (vpsTurn) {
2861
- clearVpsTurn();
3027
+ else {
3028
+ settleDirectTurn();
2862
3029
  }
2863
3030
  }
2864
3031
  const speaker = groupSpeakers.get(event.threadId);
2865
3032
  const group = store.groupByThread(event.threadId);
2866
3033
  if (speaker && group?.busyBotId === speaker.botId) {
3034
+ releaseTurnResources(turnResourceOwners.get(event.threadId));
2867
3035
  groupSpeakers.delete(event.threadId);
2868
3036
  store.patchGroup(group.id, { busyBotId: null, unread: true });
2869
3037
  const speakingBot = store.bot(speaker.botId);
@@ -2897,16 +3065,20 @@ const delegationWatch = new Map();
2897
3065
  const delegationWakeBudget = new DelegationWakeBudget();
2898
3066
  const pendingDelegationWakes = new Map();
2899
3067
  function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
3068
+ if (!store.taskByThread(botId, threadId))
3069
+ return;
2900
3070
  const prompt = failureReason
2901
3071
  ? buildDelegationFailurePrompt(targetName, failureReason)
2902
3072
  : buildDelegationRevivalPrompt(targetName);
2903
3073
  void startTurn(botId, prompt, {
2904
3074
  threadId,
2905
3075
  cardContinuation: true,
2906
- unattended: isUnattended(botId),
3076
+ unattended: isUnattended(botId, threadId),
2907
3077
  })
2908
3078
  .then(() => undefined)
2909
3079
  .catch((error) => {
3080
+ if (!store.taskByThread(botId, threadId))
3081
+ return;
2910
3082
  const message = error instanceof Error ? error.message : String(error);
2911
3083
  // Raced with a user turn claiming the bot — retry once it settles.
2912
3084
  if (/already working/i.test(message)) {
@@ -2924,11 +3096,13 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason) {
2924
3096
  });
2925
3097
  }
2926
3098
  function wakeDelegationSource(source, threadId, targetName, failureReason) {
3099
+ if (!store.taskByThread(source.id, threadId))
3100
+ return;
2927
3101
  // Busy? Hold the wake until the source settles, then drain it — the
2928
3102
  // delegated reply is already in the thread, so nothing is lost, and the
2929
3103
  // source processes it the moment it is free rather than only on a later
2930
3104
  // user nudge.
2931
- if (store.bot(source.id)?.busy) {
3105
+ if (threadBusy(source.id, threadId) || activeGroupTurnForBot(source.id)) {
2932
3106
  pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason });
2933
3107
  return;
2934
3108
  }
@@ -2938,7 +3112,11 @@ function wakeDelegationSource(source, threadId, targetName, failureReason) {
2938
3112
  }
2939
3113
  function drainDelegationWakes() {
2940
3114
  for (const [threadId, entry] of pendingDelegationWakes) {
2941
- 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))
2942
3120
  continue;
2943
3121
  pendingDelegationWakes.delete(threadId);
2944
3122
  if (!delegationWakeBudget.tryAcquire(threadId))
@@ -2961,14 +3139,11 @@ function markTaskContextExternallyUpdated(bot, threadId) {
2961
3139
  const task = store.taskByThread(bot.id, threadId);
2962
3140
  if (!task)
2963
3141
  return;
2964
- task.resumeCursors = {};
2965
- task.lastInstanceId = `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`;
2966
- // patchBot persists the task mutation and broadcasts unread/context state.
2967
- // The legacy cursor mirror follows only the task currently open in chat.
2968
- const patch = { unread: true };
2969
- if (bot.threadId === threadId)
2970
- patch.resumeCursors = {};
2971
- store.patchBot(bot.id, patch);
3142
+ store.patchTask(bot.id, threadId, {
3143
+ resumeCursors: {},
3144
+ lastInstanceId: `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`,
3145
+ unread: true,
3146
+ });
2972
3147
  }
2973
3148
  /** Consume one delegated-turn watch and mirror exactly one terminal state.
2974
3149
  * Some harness paths settle a busy bot without a provider turn.completed
@@ -3049,7 +3224,7 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
3049
3224
  // The delegated result is already in the shared transcript; a group
3050
3225
  // continuation is the responsibility of the room's own turn engine.
3051
3226
  }
3052
- else {
3227
+ else if (store.taskByThread(source.id, watched.sourceThreadId)) {
3053
3228
  // 1:1 source: the source thread is the delegating bot's own task.
3054
3229
  if (ok && reply.trim()) {
3055
3230
  const sourceReply = {
@@ -3179,8 +3354,9 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3179
3354
  });
3180
3355
  };
3181
3356
  return startTurn(toBotId, text, {
3357
+ threadId: targetThreadId,
3182
3358
  commsDepth,
3183
- unattended: isUnattended(sourceBotId),
3359
+ unattended: isUnattended(sourceBotId, sourceThreadId),
3184
3360
  // startTurn schedules provider/integration setup after marking the bot
3185
3361
  // busy. Those asynchronous setup failures do not emit turn.completed,
3186
3362
  // so clear the watch and report them through this callback too.
@@ -3262,7 +3438,10 @@ function drainQueuedSends() {
3262
3438
  ok: false,
3263
3439
  },
3264
3440
  });
3265
- }));
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)));
3266
3445
  }
3267
3446
  const screenPollers = new Map();
3268
3447
  /** The preview shares the box's single command endpoint with the agent's
@@ -3271,38 +3450,56 @@ const screenPollers = new Map();
3271
3450
  * and never two in flight. */
3272
3451
  const SCREEN_POLL_MS = 6000;
3273
3452
  const SCREEN_MIN_GAP_MS = 3000;
3453
+ const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
3274
3454
  /** `screenIsTheWork` starts the turn already counting as screen usage: a
3275
3455
  * boxAgent's whole session runs ON the box, so every tool it calls acts on
3276
3456
  * that screen even though none of them is named like a computer tool. Its
3277
3457
  * shell-only turns are kept honest by the settle-time hash gate instead. */
3278
- function startScreenPoller(botId, captures, { screenIsTheWork = false } = {}) {
3458
+ function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
3279
3459
  if (!captures.computer && !captures.browser)
3280
3460
  return;
3281
- if (screenPollers.has(botId))
3461
+ if (screenPollers.has(threadId))
3282
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;
3283
3476
  // Assign rather than spread: the source's last-frame getter deliberately
3284
3477
  // hides a stale frame as soon as the selected surface changes.
3285
3478
  const entry = Object.assign(createScreenFrameSource({
3286
- captures,
3479
+ captures: {
3480
+ computer: guarded(captures.computer, computer?.resource),
3481
+ browser: guarded(captures.browser, browserSession ? `browser:${browserSession}` : undefined),
3482
+ },
3287
3483
  control: () => ({
3288
3484
  held: computerControl.snapshot(botId).held,
3289
3485
  revision: computerControlRevision.get(botId) ?? 0,
3290
3486
  }),
3291
- onFrame: (frame) => broadcast({ kind: "screen", botId, ...frame }),
3487
+ onFrame: (frame) => broadcast({ kind: "screen", botId, threadId, ...frame }),
3292
3488
  minGapMs: SCREEN_MIN_GAP_MS,
3293
3489
  }), {
3294
3490
  timer: null,
3491
+ botId,
3295
3492
  touched: screenIsTheWork,
3296
3493
  });
3297
3494
  entry.timer = setInterval(() => void entry.capture(), SCREEN_POLL_MS);
3298
- screenPollers.set(botId, entry);
3495
+ screenPollers.set(threadId, entry);
3299
3496
  }
3300
3497
  /** Event-driven refresh: capture NOW (the bot just acted on its screen)
3301
3498
  * instead of waiting for the next interval tick. Rate-limited inside
3302
3499
  * capture() — a tool-heavy turn used to fire one full REST chain per
3303
3500
  * completed tool, competing with the agent for the same endpoint. */
3304
- function pokeScreenPoller(botId, touches, surface) {
3305
- const entry = screenPollers.get(botId);
3501
+ function pokeScreenPoller(threadId, touches, surface) {
3502
+ const entry = screenPollers.get(threadId);
3306
3503
  if (!entry)
3307
3504
  return;
3308
3505
  // the same signal, read twice: a completed computer tool is both the
@@ -3321,13 +3518,14 @@ function pokeScreenPoller(botId, touches, surface) {
3321
3518
  entry.surface = surface;
3322
3519
  void entry.capture();
3323
3520
  }
3324
- function stopScreenPoller(botId) {
3325
- const entry = screenPollers.get(botId);
3326
- if (!entry)
3327
- return;
3328
- if (entry.timer)
3329
- clearInterval(entry.timer);
3330
- 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
+ }
3331
3529
  }
3332
3530
  /** sha256 of the frame each bot last settled into a transcript — the
3333
3531
  * comparison the hash gate needs is "this turn's end state against what
@@ -3336,8 +3534,8 @@ function stopScreenPoller(botId) {
3336
3534
  * newest screen message so a restart does not re-picture the same idle
3337
3535
  * desktop either. */
3338
3536
  const settledScreenHashes = new Map();
3339
- function shownScreenHash(botId, threadId) {
3340
- const known = settledScreenHashes.get(botId);
3537
+ function shownScreenHash(threadId) {
3538
+ const known = settledScreenHashes.get(threadId);
3341
3539
  if (known)
3342
3540
  return known;
3343
3541
  const shown = store.messagesFor(threadId).findLast((m) => m.kind === "screen" && Boolean(m.png));
@@ -3352,33 +3550,43 @@ function shownScreenHash(botId, threadId) {
3352
3550
  * screen work, so without this its shell-only replies would all end in the
3353
3551
  * same idle desktop. Either way the poller is torn down here, so no
3354
3552
  * per-turn state survives the turn. */
3355
- async function finalScreenFrame(botId, threadId) {
3356
- const entry = screenPollers.get(botId);
3553
+ async function finalScreenFrame(_botId, threadId) {
3554
+ const entry = screenPollers.get(threadId);
3555
+ const owner = turnResourceOwners.get(threadId);
3357
3556
  if (!entry)
3358
3557
  return null;
3359
3558
  if (entry.timer)
3360
3559
  clearInterval(entry.timer);
3361
- screenPollers.delete(botId);
3560
+ screenPollers.delete(threadId);
3362
3561
  if (!entry.touched)
3363
3562
  return null;
3364
3563
  await entry.capture(true);
3564
+ if (!owner || turnResourceOwners.get(threadId)?.generation !== owner.generation ||
3565
+ !store.taskByThread(_botId, threadId))
3566
+ return null;
3365
3567
  const frame = entry.last;
3366
- if (!frame || !settledFrameIsNews(shownScreenHash(botId, threadId), frame.png))
3568
+ if (!frame || !settledFrameIsNews(shownScreenHash(threadId), frame.png))
3367
3569
  return null;
3368
- settledScreenHashes.set(botId, screenFrameHash(frame.png));
3570
+ settledScreenHashes.set(threadId, screenFrameHash(frame.png));
3369
3571
  return frame;
3370
3572
  }
3371
3573
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
3372
3574
  async function startTurn(botId, text, opts) {
3373
- const bot = store.bot(botId);
3374
- if (!bot)
3575
+ const profile = store.bot(botId);
3576
+ if (!profile)
3375
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 });
3376
3582
  if (bot.approvalGrant) {
3377
3583
  throw Object.assign(new Error("this bot's approval level is still being confirmed — try again"), { status: 409 });
3378
3584
  }
3379
3585
  const transitionError = providerTransitionForTurn(bot, opts?.runOn);
3380
3586
  if (transitionError)
3381
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 });
3382
3590
  if (checkpointRestoreLeases.has(botId)) {
3383
3591
  throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
3384
3592
  status: 409,
@@ -3387,19 +3595,24 @@ async function startTurn(botId, text, opts) {
3387
3595
  if (boxLifecycleBusyBots.has(botId)) {
3388
3596
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
3389
3597
  }
3390
- if (bot.busy)
3391
- throw Object.assign(new Error("the bot is already working — interrupt it first"), { status: 409 });
3392
- 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
+ }
3393
3606
  // Retire anything a previous turn left behind before minting this turn's
3394
3607
  // integrations. Completion and interrupt paths do the same; this is the
3395
3608
  // final backstop against a retained proxy process.
3396
3609
  revokeInternalCapabilitiesForThread(threadId);
3397
3610
  // a webhook turn, or one inherited from a bot already running unattended
3398
3611
  if (opts?.automationSource === "webhook" || opts?.unattended)
3399
- markUnattended(bot.id);
3612
+ markUnattended(bot.id, threadId);
3400
3613
  // a person typing into this bot ends the unattended window immediately
3401
3614
  else if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
3402
- clearUnattended(bot.id);
3615
+ clearUnattended(threadId);
3403
3616
  delegationWakeBudget.reset(threadId);
3404
3617
  }
3405
3618
  const task = store.taskByThread(bot.id, threadId);
@@ -3434,6 +3647,9 @@ async function startTurn(botId, text, opts) {
3434
3647
  }
3435
3648
  console.error(`[omb-turn] bot=${botId} text=${JSON.stringify(resolvedImages.text.slice(0, 70))} images=${turnImages.length} depth=${commsDepth} card=${Boolean(opts?.cardContinuation)}`);
3436
3649
  const instanceId = instance.instanceId;
3650
+ if (providerInstancesChanging.has(instanceId)) {
3651
+ throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
3652
+ }
3437
3653
  const model = opts?.runOn === "cloud" ? instance.models.default : bot.modelSelection.model;
3438
3654
  // a cloud routine borrows the instance default model, so it borrows no
3439
3655
  // per-bot effort either
@@ -3445,6 +3661,13 @@ async function startTurn(botId, text, opts) {
3445
3661
  }
3446
3662
  // an edit hands us its already-branched user message; a plain send appends
3447
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
+ }
3448
3671
  if (!userMessage) {
3449
3672
  userMessage = opts?.cardContinuation
3450
3673
  ? { id: `card-${randomUUID()}`, at: Date.now(), role: "user", kind: "text", text }
@@ -3488,7 +3711,7 @@ async function startTurn(botId, text, opts) {
3488
3711
  // inline (transcript-replay drivers get it via transcript). The flag is
3489
3712
  // cleared only once the turn is actually dispatched — clearing it here
3490
3713
  // would cost the next attempt its history if this dispatch fails.
3491
- const rewound = threadId === bot.threadId && Boolean(bot.rewound);
3714
+ const rewound = Boolean(task.rewound);
3492
3715
  // A fresh engine — the user switched this bot's model mid-thread — has no
3493
3716
  // current session here either, so it gets the same replay. Distinct from
3494
3717
  // rewound: the OTHER instances' cursors are left alone (a rewind wipes
@@ -3544,16 +3767,19 @@ async function startTurn(botId, text, opts) {
3544
3767
  // in the background — box provisioning can take ~90s and must never
3545
3768
  // hang the HTTP request
3546
3769
  const dispatchClaimId = randomUUID();
3547
- directTurnGenerationByBot.set(bot.id, dispatchClaimId);
3548
- 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);
3549
3775
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
3550
- store.setActivity(bot.id, "working");
3776
+ store.setTaskActivity(bot.id, threadId, "working");
3551
3777
  // The badge is "this bot answered you, and you have not looked yet". A
3552
3778
  // person starting a turn has looked; a teammate's hop has not — the fold
3553
3779
  // never re-marks an internal turn, so clearing here would silently spend
3554
3780
  // a signal the person still owes a glance to.
3555
3781
  if (commsDepth === 0)
3556
- store.patchBot(bot.id, { unread: false });
3782
+ store.patchTask(bot.id, threadId, { unread: false });
3557
3783
  turnUsage.delete(threadId);
3558
3784
  void (async () => {
3559
3785
  try {
@@ -3564,6 +3790,8 @@ async function startTurn(botId, text, opts) {
3564
3790
  ...(skillAuthoring ? ["skillAuthoring"] : []),
3565
3791
  ], availableSkills());
3566
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");
3567
3795
  integrations.phone = phoneIntegration();
3568
3796
  }
3569
3797
  // the user's connected apps, but only to a driver that can mount
@@ -3588,7 +3816,9 @@ async function startTurn(botId, text, opts) {
3588
3816
  // desk, not the whole house — and the workspace is where its
3589
3817
  // MEMORY.md lives. API/box engines have no local filesystem story.
3590
3818
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
3591
- const privateWorkspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
3819
+ if (worksInWorkspace)
3820
+ ensureWorkspace(bot.id);
3821
+ const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
3592
3822
  const skillInstructions = renderSkillInstructions(selectedSkills, {
3593
3823
  includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
3594
3824
  });
@@ -3606,6 +3836,9 @@ async function startTurn(botId, text, opts) {
3606
3836
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace)
3607
3837
  : null;
3608
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
+ }
3609
3842
  // Checkpoint explicit project folders, where a bot can overwrite the
3610
3843
  // user's work. Its private OpenMaus workspace is app-owned and changes
3611
3844
  // on nearly every ordinary chat; snapshotting it would add hidden disk
@@ -3649,6 +3882,7 @@ async function startTurn(botId, text, opts) {
3649
3882
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
3650
3883
  }
3651
3884
  const localVmTarget = localVmTargetForBot(bot.id);
3885
+ bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
3652
3886
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
3653
3887
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
3654
3888
  }
@@ -3696,7 +3930,8 @@ async function startTurn(botId, text, opts) {
3696
3930
  const cua = readCuaConnection();
3697
3931
  if (!cua)
3698
3932
  throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
3699
- integrations.localComputer = cua;
3933
+ bindTurnComputer(resourceOwner, "computer:host");
3934
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
3700
3935
  computerKind = "local";
3701
3936
  }
3702
3937
  // A VPS is a local-agent computer mount, never a remote agent runner.
@@ -3709,6 +3944,9 @@ async function startTurn(botId, text, opts) {
3709
3944
  if (unsupported && wants === undefined)
3710
3945
  autoVpsProblem = unsupported;
3711
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);
3712
3950
  activeVpsThreads.set(bot.id, threadId);
3713
3951
  const remote = wants === "cloud" || bot.autoStartVps
3714
3952
  ? await vps.vpsComputerAction("provision", cfg, bot.id)
@@ -3736,6 +3974,10 @@ async function startTurn(botId, text, opts) {
3736
3974
  // Cloud is also strict when explicitly selected. Auto (unset) reuses an
3737
3975
  // existing cloud box, then falls back to host CUA without provisioning.
3738
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);
3739
3981
  if (!mountsCloudComputer && wants === "cloud") {
3740
3982
  throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
3741
3983
  }
@@ -3769,6 +4011,7 @@ async function startTurn(botId, text, opts) {
3769
4011
  });
3770
4012
  }
3771
4013
  if (b && lifecycle === "attach") {
4014
+ bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
3772
4015
  previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
3773
4016
  if (mountsCloudComputer) {
3774
4017
  integrations.computer = {
@@ -3799,7 +4042,8 @@ async function startTurn(botId, text, opts) {
3799
4042
  })) {
3800
4043
  const cua = readCuaConnection();
3801
4044
  if (cua) {
3802
- integrations.localComputer = cua;
4045
+ bindTurnComputer(resourceOwner, "computer:host");
4046
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
3803
4047
  computerKind = "local";
3804
4048
  }
3805
4049
  }
@@ -3877,7 +4121,7 @@ async function startTurn(botId, text, opts) {
3877
4121
  liveBot.browser !== false &&
3878
4122
  instance.adapter.capabilities.browserMcp === true) {
3879
4123
  const selectedProfile = liveBot.browserProfile;
3880
- browser = browserIntegration(bot.id, selectedProfile);
4124
+ browser = await browserIntegration(bot.id, selectedProfile, { threadId, generation: dispatchClaimId });
3881
4125
  if (browser)
3882
4126
  integrations.browser = browser.integration;
3883
4127
  // The browser lost its frame source when the Electron surface was
@@ -3887,8 +4131,9 @@ async function startTurn(botId, text, opts) {
3887
4131
  // the work was a web page, because agent-browser runs its own headless
3888
4132
  // Chrome on the host rather than inside that desktop.
3889
4133
  if (browser) {
3890
- const frame = { binaryPath: browser.integration.command, env: browser.integration.env };
3891
- browserCapture = () => agentBrowserFrame(frame);
4134
+ const frame = { binaryPath: browser.spec.command, env: browser.spec.env };
4135
+ const session = browser.session;
4136
+ browserCapture = () => browserRuntime.withAgentAction(session, () => agentBrowserFrame(frame));
3892
4137
  }
3893
4138
  }
3894
4139
  // A cancelled adapter can be between accepting sendTurn and revealing
@@ -3927,7 +4172,7 @@ async function startTurn(botId, text, opts) {
3927
4172
  { id: "profile", label: "Profile changes", text: profilePrompt },
3928
4173
  { id: "learn", label: "Skill authoring", text: learnPrompt },
3929
4174
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3930
- { id: "memory", label: "Memory", text: privateWorkspace ? memorySystemPrompt(bot.id) : "" },
4175
+ { id: "memory", label: "Memory", text: privateWorkspace ? memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents) }) : "" },
3931
4176
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
3932
4177
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
3933
4178
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
@@ -3938,7 +4183,7 @@ async function startTurn(botId, text, opts) {
3938
4183
  threadId,
3939
4184
  text: turnText,
3940
4185
  images: turnImages,
3941
- approvalMode: approvalModeForTurn(liveBot ?? bot, commsDepth > 0),
4186
+ approvalMode: approvalModeForTurn(bot, commsDepth > 0),
3942
4187
  model,
3943
4188
  effort,
3944
4189
  // a rewound thread never resumes the abandoned branch's session
@@ -3957,10 +4202,10 @@ async function startTurn(botId, text, opts) {
3957
4202
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
3958
4203
  }
3959
4204
  bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
3960
- clearDirectTurnDispatch(bot.id, dispatchClaimId);
4205
+ clearDirectTurnDispatch(threadId, dispatchClaimId);
3961
4206
  // dispatched: the rewind is spent, and the old cursors are dead
3962
4207
  if (rewound)
3963
- store.patchBot(bot.id, { rewound: false, resumeCursors: {} });
4208
+ store.patchTask(bot.id, threadId, { rewound: false, resumeCursors: {} });
3964
4209
  // and this engine now owns the thread's most recent turn
3965
4210
  // Consume exactly the external-update generation this turn replayed.
3966
4211
  // If a newer delegated result landed during setup, its unique marker
@@ -3972,21 +4217,29 @@ async function startTurn(botId, text, opts) {
3972
4217
  // after its own turn.completed would never be torn down — it would
3973
4218
  // keep polling the box forever, carrying dead per-turn state. busy
3974
4219
  // is flipped false in the fold, so it is the honest "still running".
3975
- if ((previewCapture || browserCapture) && store.bot(bot.id)?.busy) {
3976
- 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" });
3977
4222
  }
3978
4223
  // An adapter may publish completion synchronously just before its
3979
4224
  // dispatch promise resolves. The event could not use the turn-id map
3980
4225
  // above yet, so close this exact generation from durable busy state.
3981
- if (store.bot(bot.id)?.busy !== true) {
4226
+ if (!threadBusy(bot.id, threadId) && directTurnGenerationByThread.get(threadId) === dispatchClaimId) {
3982
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();
3983
4235
  }
3984
4236
  }
3985
4237
  catch (e) {
3986
4238
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
3987
- clearDirectTurnDispatch(bot.id, dispatchClaimId);
4239
+ clearDirectTurnDispatch(threadId, dispatchClaimId);
3988
4240
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
3989
- const ownsLatestGeneration = directTurnGenerationByBot.get(bot.id) === dispatchClaimId;
4241
+ const ownsLatestGeneration = directTurnGenerationByThread.get(threadId) === dispatchClaimId;
4242
+ releaseTurnResources(resourceOwner);
3990
4243
  if (ownsLatestGeneration) {
3991
4244
  releaseLocalVmThread(threadId);
3992
4245
  if (activeVpsThreads.get(bot.id) === threadId)
@@ -3996,8 +4249,9 @@ async function startTurn(botId, text, opts) {
3996
4249
  }
3997
4250
  if (e instanceof DirectTurnSetupCancelled) {
3998
4251
  opts?.onDispatchError?.(e.message);
3999
- if (ownsLatestGeneration && store.bot(bot.id)?.busy) {
4000
- store.setActivity(bot.id, "idle");
4252
+ if (ownsLatestGeneration && threadBusy(bot.id, threadId)) {
4253
+ store.setTaskActivity(bot.id, threadId, "idle");
4254
+ directTurnBots.delete(threadId);
4001
4255
  retryDelegationsWaitingOn(bot.id);
4002
4256
  }
4003
4257
  if (ownsLatestGeneration) {
@@ -4029,7 +4283,8 @@ async function startTurn(botId, text, opts) {
4029
4283
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
4030
4284
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
4031
4285
  }
4032
- store.setActivity(bot.id, "idle");
4286
+ store.setTaskActivity(bot.id, threadId, "idle");
4287
+ directTurnBots.delete(threadId);
4033
4288
  retryDelegationsWaitingOn(bot.id);
4034
4289
  opts?.onDispatchError?.(message);
4035
4290
  // a dispatch failure never emits turn.completed, so the settle-driven
@@ -4140,7 +4395,7 @@ function syncRoutineRunToSource(run) {
4140
4395
  if (source.group)
4141
4396
  store.patchGroup(source.group.id, { unread: true });
4142
4397
  else
4143
- store.patchBot(source.bot.id, { unread: true });
4398
+ store.patchTask(source.bot.id, sourceThreadId, { unread: true });
4144
4399
  }
4145
4400
  return sourceThreadId;
4146
4401
  }
@@ -4162,6 +4417,12 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4162
4417
  const bot = store.bot(botId);
4163
4418
  if (!bot)
4164
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);
4165
4426
  const routineRun = routines?.activeBotRunForBot(bot.id);
4166
4427
  if (routineRun) {
4167
4428
  if (routineRun.threadId)
@@ -4170,6 +4431,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4170
4431
  await routines.cancelRun(routineRun.id);
4171
4432
  if (routineRun.threadId)
4172
4433
  closeOpenApprovals(routineRun.threadId);
4434
+ await directStop;
4173
4435
  return;
4174
4436
  }
4175
4437
  const groupTurn = activeGroupTurnForBot(bot.id);
@@ -4177,6 +4439,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4177
4439
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
4178
4440
  cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
4179
4441
  const results = await Promise.allSettled([
4442
+ directStop,
4180
4443
  registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(groupTurn.threadId),
4181
4444
  ]);
4182
4445
  closeOpenApprovals(groupTurn.threadId);
@@ -4185,16 +4448,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4185
4448
  throw failure.reason;
4186
4449
  return;
4187
4450
  }
4188
- const directClaim = cancelDirectTurnDispatch(bot.id);
4189
- const threadId = directClaim?.threadId ?? bot.threadId;
4190
- revokeInternalCapabilitiesForThread(threadId);
4191
- const results = await Promise.allSettled([
4192
- registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(threadId),
4193
- ]);
4194
- closeOpenApprovals(threadId);
4195
- const failure = results.find((result) => result.status === "rejected");
4196
- if (failure)
4197
- throw failure.reason;
4451
+ await directStop;
4198
4452
  }
4199
4453
  routines = new RoutineManager({
4200
4454
  emit: broadcast,
@@ -4233,7 +4487,7 @@ routines = new RoutineManager({
4233
4487
  });
4234
4488
  },
4235
4489
  interruptTurn: async (botId, threadId, runOn) => {
4236
- const bot = store.bot(botId);
4490
+ const bot = botForThread(botId, threadId);
4237
4491
  cancelDirectTurnDispatch(botId, threadId);
4238
4492
  revokeInternalCapabilitiesForThread(threadId);
4239
4493
  const instance = runOn === "cloud"
@@ -4601,6 +4855,10 @@ operation,
4601
4855
  setupRetry = 0) {
4602
4856
  if (isCancelled?.())
4603
4857
  return false;
4858
+ if (providerFleetReloading) {
4859
+ onDispatchError?.("provider settings are being updated — try again shortly");
4860
+ return false;
4861
+ }
4604
4862
  const group = store.group(groupId);
4605
4863
  const bot = store.bot(botId);
4606
4864
  const ownsThread = group?.dm
@@ -4614,11 +4872,15 @@ setupRetry = 0) {
4614
4872
  }
4615
4873
  revokeInternalCapabilitiesForThread(threadId);
4616
4874
  spoken.add(botId);
4617
- const preparedApprovalMode = approvalModeForTurn(bot);
4875
+ const preparedApprovalMode = approvalModeForTurn(bot, false, threadId);
4618
4876
  const preparedSelection = { ...bot.modelSelection };
4619
4877
  const preparedComposio = bot.composio;
4620
4878
  const instance = registry.get(bot.modelSelection.instanceId);
4621
4879
  const userName = cfg.profile?.name?.trim() || "User";
4880
+ if (providerInstancesChanging.has(bot.modelSelection.instanceId)) {
4881
+ onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
4882
+ return true;
4883
+ }
4622
4884
  if (!instance) {
4623
4885
  const message = `${bot.name}'s model is unavailable`;
4624
4886
  store.appendMessage(threadId, {
@@ -4653,6 +4915,8 @@ setupRetry = 0) {
4653
4915
  return true;
4654
4916
  }
4655
4917
  const internalGeneration = beginInternalCapabilityGeneration(threadId);
4918
+ const resourceOwner = { threadId, generation: internalGeneration };
4919
+ turnResourceOwners.set(threadId, resourceOwner);
4656
4920
  let roomVmTarget = null;
4657
4921
  let retainRoomVmLease = false;
4658
4922
  let roomSpeaker;
@@ -4661,6 +4925,7 @@ setupRetry = 0) {
4661
4925
  if (roomVmTarget && localVmThreadTargets.get(threadId) === roomVmTarget)
4662
4926
  releaseLocalVmThread(threadId);
4663
4927
  roomVmTarget = null;
4928
+ releaseTurnResources(resourceOwner);
4664
4929
  };
4665
4930
  try {
4666
4931
  const integrations = {};
@@ -4684,6 +4949,8 @@ setupRetry = 0) {
4684
4949
  const skills = availableSkills();
4685
4950
  const selectedSkills = mergeSkills(selectBundledSkills(roomContext, instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], skills), selectBundledSkills(latestUser?.text ?? "", skillAuthoring ? ["skillAuthoring"] : [], skills));
4686
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");
4687
4954
  integrations.phone = phoneIntegration();
4688
4955
  }
4689
4956
  try {
@@ -4728,7 +4995,7 @@ setupRetry = 0) {
4728
4995
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
4729
4996
  return false;
4730
4997
  const setupChanged = registry.get(preparedSelection.instanceId) !== instance ||
4731
- approvalModeForTurn(readyBot) !== preparedApprovalMode ||
4998
+ approvalModeForTurn(readyBot, false, threadId) !== preparedApprovalMode ||
4732
4999
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
4733
5000
  readyBot.modelSelection.model !== preparedSelection.model ||
4734
5001
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -4801,6 +5068,11 @@ setupRetry = 0) {
4801
5068
  return true;
4802
5069
  }
4803
5070
  store.setActivity(bot.id, "working");
5071
+ // Claim cleanup ownership before browser preparation can yield or reject.
5072
+ // The finally block must release exactly this setup, never a newer turn.
5073
+ roomSpeaker = { botId: bot.id, name: bot.name, color: bot.color };
5074
+ groupSpeakers.set(threadId, roomSpeaker);
5075
+ store.patchGroup(readyGroup.id, { busyBotId: bot.id });
4804
5076
  orchestration?.onClaimed?.();
4805
5077
  // Connected-app discovery above can yield for a network round trip. A
4806
5078
  // profile may be removed, or the browser feature switched off, during that
@@ -4811,24 +5083,19 @@ setupRetry = 0) {
4811
5083
  readyBot.browser !== false &&
4812
5084
  instance.adapter.capabilities.browserMcp === true) {
4813
5085
  const selectedProfile = readyBot.browserProfile;
4814
- const browser = browserIntegration(readyBot.id, selectedProfile);
5086
+ const browser = await browserIntegration(readyBot.id, selectedProfile, { threadId, generation: internalGeneration });
4815
5087
  if (browser)
4816
5088
  integrations.browser = browser.integration;
4817
5089
  }
4818
- // Stop/delete may land while Electron is registering the capability. The
4819
- // callback above prevents publication; this second check also unwinds the
4820
- // room's setup claim so no provider turn starts after Stop returned.
5090
+ // Stop/delete may land while browser state is being prepared. Capability
5091
+ // publication and this exact claim are both fenced; finally releases only
5092
+ // this setup, so a replacement turn's busy state is never cleared here.
4821
5093
  const browserReadyBot = store.bot(readyBot.id);
4822
- if (isCancelled?.() || !browserReadyBot || !browserReadyBot.busy) {
4823
- if (browserReadyBot?.busy) {
4824
- store.setActivity(browserReadyBot.id, "idle");
4825
- retryDelegationsWaitingOn(browserReadyBot.id);
4826
- }
5094
+ if (isCancelled?.() || !browserReadyBot?.busy ||
5095
+ groupSpeakers.get(threadId) !== roomSpeaker ||
5096
+ activeInternalGenerationByThread.get(threadId) !== internalGeneration) {
4827
5097
  return false;
4828
5098
  }
4829
- store.patchGroup(readyGroup.id, { busyBotId: bot.id }); // the store's change stream carries the frame
4830
- roomSpeaker = { botId: bot.id, name: bot.name, color: bot.color };
4831
- groupSpeakers.set(threadId, roomSpeaker);
4832
5099
  // Room and Goal turns use the speaker's desktop, never the coordinator's.
4833
5100
  // Claim the same lease as direct turns before asynchronous VM setup.
4834
5101
  if (readyBot.computer === "vm") {
@@ -4837,6 +5104,7 @@ setupRetry = 0) {
4837
5104
  }
4838
5105
  // A distinct identity fences cleanup even in shared mode on the same room thread.
4839
5106
  const target = { ...localVmTargetForBot(readyBot.id) };
5107
+ bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
4840
5108
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
4841
5109
  throw new Error("this Local VM is being started, stopped, or replaced");
4842
5110
  }
@@ -5028,7 +5296,7 @@ setupRetry = 0) {
5028
5296
  threadId,
5029
5297
  text,
5030
5298
  images: turnImages,
5031
- approvalMode: approvalModeForTurn(readyBot),
5299
+ approvalMode: approvalModeForTurn(readyBot, false, threadId),
5032
5300
  system: roomSystem,
5033
5301
  cwd,
5034
5302
  integrations,
@@ -5113,6 +5381,8 @@ setupRetry = 0) {
5113
5381
  // same bounded cleanup as the stall watchdog. An unbound goal handshake
5114
5382
  // keeps the room closed until attribution becomes safe.
5115
5383
  const releaseOwnership = () => {
5384
+ if (turnResourceOwners.get(threadId)?.generation !== resourceOwner.generation)
5385
+ return;
5116
5386
  if (hasUnboundDiscardedGroupGoalTurn(threadId)) {
5117
5387
  const retry = setTimeout(releaseOwnership, 1_000);
5118
5388
  retry.unref?.();
@@ -6100,7 +6370,7 @@ function dispatchConnectorResume(entry) {
6100
6370
  return;
6101
6371
  const names = entry.labels.join(", ");
6102
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.`;
6103
- if (owner.bot.busy) {
6373
+ if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
6104
6374
  pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
6105
6375
  return;
6106
6376
  }
@@ -6153,7 +6423,8 @@ function maybeResumeConnectors(botId, threadId, resumeKey) {
6153
6423
  }
6154
6424
  function drainConnectorResumes() {
6155
6425
  for (const [key, entry] of pendingConnectorResumes) {
6156
- 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))
6157
6428
  continue;
6158
6429
  pendingConnectorResumes.delete(key);
6159
6430
  dispatchConnectorResume(entry);
@@ -6226,7 +6497,7 @@ function dispatchSecretResume(entry) {
6226
6497
  const prompt = entry.outcome === "provided"
6227
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.`
6228
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.`;
6229
- if (owner.bot.busy) {
6500
+ if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
6230
6501
  pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
6231
6502
  return;
6232
6503
  }
@@ -6371,7 +6642,8 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
6371
6642
  }
6372
6643
  function drainSecretResumes() {
6373
6644
  for (const [key, entry] of pendingSecretResumes) {
6374
- 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))
6375
6647
  continue;
6376
6648
  pendingSecretResumes.delete(key);
6377
6649
  dispatchSecretResume(entry);
@@ -6569,15 +6841,19 @@ function configStatus() {
6569
6841
  // show the same durable session as an agent, but config PATCH validation
6570
6842
  // keeps it read-only and rejects callers that try to choose it.
6571
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 ?? [] },
6572
6846
  };
6573
6847
  }
6574
6848
  function configForAccess(status, admin) {
6575
6849
  if (admin)
6576
6850
  return status;
6577
- // Configured-or-not is fine; an SSH alias, an email, and a browser
6578
- // 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.
6579
6854
  return {
6580
6855
  ...status,
6856
+ signIn: { admins: [], members: [] },
6581
6857
  vps: { configured: status.vps.configured, sshAlias: "" },
6582
6858
  profile: { name: status.profile.name, email: "" },
6583
6859
  browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
@@ -6594,35 +6870,104 @@ function persistMcpServers(next) {
6594
6870
  // when an empty section is omitted by an older config file.
6595
6871
  cfg.mcpServers = next;
6596
6872
  }
6873
+ async function describeInstances() {
6874
+ const configs = instanceConfigs(cfg);
6875
+ return (await registry.describe()).map((instance) => {
6876
+ const entry = configs[instance.instanceId];
6877
+ if (entry?.driver !== "claudeAgent")
6878
+ return instance;
6879
+ try {
6880
+ const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
6881
+ return { ...instance, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
6882
+ }
6883
+ catch {
6884
+ // A malformed saved config remains a repairable shadow, never takes
6885
+ // the model picker down or offers a login for the wrong directory.
6886
+ return { ...instance, install: { ...instance.install, signInCommand: undefined } };
6887
+ }
6888
+ });
6889
+ }
6890
+ async function persistProviderInstance(instanceId, instances) {
6891
+ saveConfig({ instances }, { replaceInstances: true });
6892
+ cfg.instances = instances;
6893
+ providerAuthSessions.clearInstance(instanceId);
6894
+ bus.detach(instanceId);
6895
+ // No whole-fleet reload: other bots keep their live CLI processes, event
6896
+ // subscriptions and approval capabilities while this one is replaced.
6897
+ if (Object.hasOwn(instances, instanceId)) {
6898
+ await registry.load({ [instanceId]: instanceConfigs(cfg)[instanceId] });
6899
+ const live = registry.get(instanceId);
6900
+ if (live)
6901
+ bus.attach([live]);
6902
+ }
6903
+ else {
6904
+ await registry.dispose(instanceId);
6905
+ }
6906
+ resetPathCache();
6907
+ }
6597
6908
  /** Rebuild the provider fleet after a config change so new keys take
6598
6909
  * effect without a server restart (kills any in-flight turns). */
6599
6910
  async function reloadProviders() {
6911
+ providerFleetReloading = true;
6600
6912
  providerAuthSessions.clear();
6601
6913
  // Every provider process is about to die. Revoke all turn capabilities in
6602
6914
  // one synchronous step before the first teardown await, including room/task
6603
6915
  // threads that are not a bot's default DM.
6604
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
+ }
6605
6928
  bus.detachAll();
6606
- await registry.disposeAll();
6607
- await registry.load(instanceConfigs(cfg));
6608
- bus.attach(registry.instances());
6609
- // A killed turn's terminal events can die with the old fleet (dispose is
6610
- // async under the hood), stranding the bot busy — and its screen poller —
6611
- // forever. Settle anything still marked busy.
6612
- for (const b of store.bots.filter((b) => b.busy)) {
6613
- const vmThread = [...localVmThreadTargets.entries()].find(([, target]) => localVmLeaseFor(target).current(localVmOwnerBusy)?.botId === b.id)?.[0];
6614
- if (vmThread)
6615
- releaseLocalVmThread(vmThread);
6616
- stopScreenPoller(b.id);
6617
- activeVpsThreads.delete(b.id);
6618
- finalizeDelegationWatch(b.threadId, false, "", "Delegated turn did not finish — provider settings changed");
6619
- store.appendMessage(b.threadId, {
6620
- role: "bot",
6621
- kind: "activity",
6622
- tool: { name: "error: turn interrupted — provider settings changed", ok: false },
6623
- });
6624
- store.setActivity(b.id, "idle");
6625
- 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;
6626
6971
  }
6627
6972
  // killed turns settle here without a turn.completed event, so anything
6628
6973
  // queued behind them drains now — onto the freshly loaded fleet
@@ -6634,6 +6979,7 @@ async function reloadProviders() {
6634
6979
  // and reload sequence single-flight so two settings requests cannot drop one
6635
6980
  // another's changes or dispose a fleet while another reload is creating it.
6636
6981
  let providerConfigBusy = false;
6982
+ const providerInstancesChanging = new Set();
6637
6983
  let mcpConfigBusy = false;
6638
6984
  const MAX_CONCURRENT_MCP_PROBES = 2;
6639
6985
  let mcpProbesInFlight = 0;
@@ -6739,7 +7085,7 @@ const handleRequest = async (req, res) => {
6739
7085
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
6740
7086
  return;
6741
7087
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
6742
- 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() }));
6743
7089
  }
6744
7090
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
6745
7091
  if (method === "GET" && domainCheck) {
@@ -6747,6 +7093,46 @@ const handleRequest = async (req, res) => {
6747
7093
  const challenge = customDomainVerifier.challenge(domainCheck[1]);
6748
7094
  return json(res, challenge ? 200 : 404, challenge ?? { error: "No active domain check." });
6749
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
+ }
6750
7136
  if (method === "POST" && path === "/api/auth/pair") {
6751
7137
  // JSON only: a cross-site HTML form cannot send this content type
6752
7138
  // without a preflight, so a stray unused code cannot be planted as a
@@ -6764,7 +7150,7 @@ const handleRequest = async (req, res) => {
6764
7150
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
6765
7151
  return json(res, result.status, { error: result.error });
6766
7152
  }
6767
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED });
7153
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled() });
6768
7154
  if (wantsCookie) {
6769
7155
  const secure = requestOrigin(req)?.startsWith("https://") === true;
6770
7156
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
@@ -6815,6 +7201,8 @@ const handleRequest = async (req, res) => {
6815
7201
  expiresAt: auth.session.expiresAt,
6816
7202
  via: auth.via,
6817
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 } : {}),
6818
7206
  });
6819
7207
  }
6820
7208
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -6944,11 +7332,13 @@ const handleRequest = async (req, res) => {
6944
7332
  if (!internalSender) {
6945
7333
  return json(res, 401, { error: "unauthorized" });
6946
7334
  }
6947
- const requiredCapabilityKind = path.startsWith("/api/internal/connectors/")
6948
- ? "connectors"
6949
- : path === "/api/internal/computer-control"
6950
- ? "computer"
6951
- : "agents";
7335
+ const requiredCapabilityKind = path === "/api/internal/browser/mcp"
7336
+ ? "browser"
7337
+ : path.startsWith("/api/internal/connectors/")
7338
+ ? "connectors"
7339
+ : path === "/api/internal/computer-control"
7340
+ ? "computer"
7341
+ : "agents";
6952
7342
  if (internalCapability.kind !== requiredCapabilityKind) {
6953
7343
  return json(res, 403, { error: "this internal capability cannot access that service" });
6954
7344
  }
@@ -6992,6 +7382,38 @@ const handleRequest = async (req, res) => {
6992
7382
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
6993
7383
  }
6994
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
+ }
7390
+ if (method === "POST" && path === "/api/internal/browser/mcp") {
7391
+ const body = await readInternalBody();
7392
+ const bot = store.bot(internalCapability.botId);
7393
+ if (!bot || bot.browser === false || !builtInBrowserEnabled(cfg)) {
7394
+ return json(res, 403, { error: "browser tools are not enabled for this bot" });
7395
+ }
7396
+ const browser = await browserIntegration(bot.id, bot.browserProfile);
7397
+ if (!browser || browser.session !== internalCapability.browserSession) {
7398
+ return json(res, 409, { error: "this browser profile changed; start a new turn" });
7399
+ }
7400
+ if (body?.method !== "tools/list" && body?.method !== "tools/call") {
7401
+ return json(res, 400, { error: "unsupported browser method" });
7402
+ }
7403
+ const result = await browserRuntime.agentRpc(browser.session, browser.spec, body.method, body.params, () => {
7404
+ requireActiveInternalCapability();
7405
+ const current = store.bot(bot.id);
7406
+ if (!current || current.browser === false || !builtInBrowserEnabled(cfg) ||
7407
+ currentBrowserSession(current.id, current.browserProfile) !== browser.session) {
7408
+ throw Object.assign(new Error("Browser access changed while connecting."), { status: 409 });
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
+ }
7413
+ });
7414
+ requireActiveInternalCapability();
7415
+ return json(res, 200, { result });
7416
+ }
6995
7417
  if (method === "GET" && path === "/api/internal/agents") {
6996
7418
  const sender = internalSender;
6997
7419
  // title/description included so the caller can judge the team (who
@@ -7183,7 +7605,7 @@ const handleRequest = async (req, res) => {
7183
7605
  const from = store.bot(fromBotId);
7184
7606
  if (!from)
7185
7607
  return json(res, 403, { error: "unknown sender" });
7186
- const fromThreadId = String(url.searchParams.get("fromThreadId") ?? from.threadId);
7608
+ const fromThreadId = internalCapability.threadId;
7187
7609
  if (!connectorThread(from.id, fromThreadId)) {
7188
7610
  return json(res, 403, { error: "source conversation does not belong to sender" });
7189
7611
  }
@@ -7215,7 +7637,7 @@ const handleRequest = async (req, res) => {
7215
7637
  const from = store.bot(fromBotId);
7216
7638
  if (!from)
7217
7639
  return json(res, 403, { error: "unknown sender" });
7218
- const fromThreadId = String(url.searchParams.get("fromThreadId") ?? from.threadId);
7640
+ const fromThreadId = internalCapability.threadId;
7219
7641
  if (!connectorThread(from.id, fromThreadId)) {
7220
7642
  return json(res, 403, { error: "source conversation does not belong to sender" });
7221
7643
  }
@@ -7430,11 +7852,12 @@ const handleRequest = async (req, res) => {
7430
7852
  const prefixed = withPeerProvenance(message, {
7431
7853
  botName: currentFrom.name,
7432
7854
  delivery: "ask_bot",
7433
- unattended: isUnattended(currentFrom.id),
7855
+ unattended: isUnattended(currentFrom.id, fromThreadId),
7434
7856
  });
7435
- const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId);
7857
+ const targetThreadId = currentTarget.threadId;
7858
+ const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId, fromThreadId, targetThreadId);
7436
7859
  requireActiveInternalCapability();
7437
- if (outcome.status === "timeout" && !delegationWatch.has(currentTarget.threadId)) {
7860
+ if (outcome.status === "timeout" && !delegationWatch.has(targetThreadId)) {
7438
7861
  // The peer's turn is still running — only the wait ended. Convert
7439
7862
  // the ask into a delegation claim ticket: the watch mirrors the
7440
7863
  // terminal state into the channel AND the asker's thread when the
@@ -7442,7 +7865,7 @@ const handleRequest = async (req, res) => {
7442
7865
  // Losing the reply was the old behavior, and it read as "the bots
7443
7866
  // don't respond to each other".
7444
7867
  const taskId = newId();
7445
- delegationWatch.set(currentTarget.threadId, {
7868
+ delegationWatch.set(targetThreadId, {
7446
7869
  channelId: channel.id,
7447
7870
  toBotId,
7448
7871
  toBotName: currentTarget.name,
@@ -7681,7 +8104,7 @@ const handleRequest = async (req, res) => {
7681
8104
  // Unattended inheritance: the mark rides the sender, and reading it
7682
8105
  // here is also what keeps its window alive through a turn that only
7683
8106
  // posts — an aged-out mark would hand the next hop to auto-approve.
7684
- const unattended = isUnattended(poster.id);
8107
+ const unattended = isUnattended(poster.id, internalCapability.threadId);
7685
8108
  const posted = store.appendMessage(room.threadId, {
7686
8109
  role: "bot",
7687
8110
  kind: "text",
@@ -7839,6 +8262,14 @@ const handleRequest = async (req, res) => {
7839
8262
  return json(res, 404, { error: "no such bot" });
7840
8263
  if (method === "GET") {
7841
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
+ }
7842
8273
  return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null });
7843
8274
  }
7844
8275
  if (method === "POST") {
@@ -7850,7 +8281,7 @@ const handleRequest = async (req, res) => {
7850
8281
  // screen it needs hands on are in the room — so send the person
7851
8282
  // where the work is, and say which room it was.
7852
8283
  const roomTurn = activeGroupTurnForBot(bot.id);
7853
- const target = blockedTarget(bot, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
8284
+ const target = blockedTarget({ ...bot, threadId: internalCapability.threadId }, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
7854
8285
  notify(buildNotification("takeover", bot, target.threadId, snapshot.helpReason ?? "asked you to take over", {
7855
8286
  group: target.group,
7856
8287
  }));
@@ -8084,6 +8515,45 @@ const handleRequest = async (req, res) => {
8084
8515
  : json(res, 404, { error: "no such webhook" });
8085
8516
  }
8086
8517
  // ── events stream ──
8518
+ // Owner-only (default-deny in request-auth). Never mix login frames into
8519
+ // the general events feed, which is also visible to client-only devices.
8520
+ const liveBrowserMatch = /^\/api\/bots\/([\w-]+)\/browser\/(live|action)$/.exec(path);
8521
+ if (liveBrowserMatch) {
8522
+ res.setHeader("cache-control", "no-store");
8523
+ const bot = store.bot(liveBrowserMatch[1]);
8524
+ if (!bot)
8525
+ return json(res, 404, { error: "no such bot" });
8526
+ if (!builtInBrowserEnabled(cfg) || bot.browser === false) {
8527
+ return json(res, 409, { error: "Enable this bot's browser in its profile first." });
8528
+ }
8529
+ const browser = await browserIntegration(bot.id, bot.browserProfile);
8530
+ if (!browser)
8531
+ return json(res, 503, { error: "Install the browser engine first." });
8532
+ const owner = auth.kind === "session" ? auth.session.id : "local-owner";
8533
+ const isCurrent = () => {
8534
+ const current = store.bot(bot.id);
8535
+ return !!current && current.browser !== false && builtInBrowserEnabled(cfg)
8536
+ && currentBrowserSession(current.id, current.browserProfile) === browser.session
8537
+ && (auth.kind !== "session" || sessions.isLive(auth.session.id));
8538
+ };
8539
+ if (method === "GET" && liveBrowserMatch[2] === "live") {
8540
+ req.socket.setTimeout(0);
8541
+ return await browserLive.open({ botId: bot.id, session: browser.session, spec: browser.spec, owner, isCurrent, res });
8542
+ }
8543
+ if (method === "POST" && liveBrowserMatch[2] === "action") {
8544
+ const body = await readBody(req, 32_768);
8545
+ if (!isCurrent())
8546
+ return json(res, 409, { error: "This browser session changed. Reopen the browser panel." });
8547
+ if (typeof body?.viewerId !== "string")
8548
+ return json(res, 400, { error: "A live browser connection is required." });
8549
+ if (body.type === "restart" && store.bots.some((candidate) => candidate.busy &&
8550
+ currentBrowserSession(candidate.id, candidate.browserProfile) === browser.session)) {
8551
+ return json(res, 409, { error: "Stop every bot using this profile before restarting its browser." });
8552
+ }
8553
+ return json(res, 200, await browserLive.action({ viewerId: body.viewerId, botId: bot.id, owner, body }));
8554
+ }
8555
+ return json(res, 405, { error: "method not allowed" });
8556
+ }
8087
8557
  if (method === "GET" && path === "/api/events") {
8088
8558
  const client = {
8089
8559
  res,
@@ -8156,6 +8626,16 @@ const handleRequest = async (req, res) => {
8156
8626
  return;
8157
8627
  }
8158
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
+ };
8159
8639
  if (method === "GET" && path === "/api/bots") {
8160
8640
  const limit = pageSize(url.searchParams.get("messages"));
8161
8641
  if (limit === null)
@@ -8187,14 +8667,14 @@ const handleRequest = async (req, res) => {
8187
8667
  const window = messageWindow(threadId, around, limit ?? DEFAULT_PAGE);
8188
8668
  if (!window)
8189
8669
  return json(res, 404, { error: "no such message" });
8190
- return json(res, 200, window);
8670
+ return json(res, 200, { ...window, activeLeafId: store.activeLeaf(threadId) });
8191
8671
  }
8192
8672
  // An unknown cursor must not silently answer with the newest page —
8193
8673
  // the client would paginate in a circle and never reach the top.
8194
8674
  if (before && !store.messagesFor(threadId).some((msg) => msg.id === before)) {
8195
8675
  return json(res, 404, { error: "no such message" });
8196
8676
  }
8197
- 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) });
8198
8678
  }
8199
8679
  // the pixels of one screen message, fetched only when something shows it
8200
8680
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages\/([\w-]+)\/image$/);
@@ -9472,10 +9952,7 @@ const handleRequest = async (req, res) => {
9472
9952
  if (!bot) {
9473
9953
  // There are no awaits between the refreshed lookup and this patch, but
9474
9954
  // keep the attachment invariant explicit if the store ever changes.
9475
- try {
9476
- unlinkSync(saved.path);
9477
- }
9478
- catch { }
9955
+ deleteAttachment(saved.path);
9479
9956
  return json(res, 404, { error: "no such bot" });
9480
9957
  }
9481
9958
  const visible = wireBot(bot);
@@ -9512,16 +9989,25 @@ const handleRequest = async (req, res) => {
9512
9989
  const existing = store.bot(m[1]);
9513
9990
  if (!existing)
9514
9991
  return json(res, 404, { error: "no such bot" });
9992
+ requirePinnedClientThread(existing.id, undefined);
9993
+ const selected = requestedTaskBot(existing.id, undefined);
9515
9994
  if (existing.approvalGrant) {
9516
9995
  return json(res, 409, { error: "wait for the approval-level change to finish before changing models" });
9517
9996
  }
9518
- 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);
9519
9998
  if (!checked.ok)
9520
9999
  return json(res, checked.status, { error: checked.error });
9521
- const existingApprovalMode = approvalModeFor(existing);
9522
- if ((existingApprovalMode === "full" || existingApprovalMode === "custom") &&
9523
- (!supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, existingApprovalMode) ||
9524
- 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
+ })) {
9525
10011
  return json(res, 400, {
9526
10012
  error: "Changing providers with elevated permissions requires choosing Ask first",
9527
10013
  });
@@ -9531,13 +10017,16 @@ const handleRequest = async (req, res) => {
9531
10017
  const bot = store.patchBot(existing.id, { modelSelection: checked.selection });
9532
10018
  if (!bot)
9533
10019
  return json(res, 404, { error: "no such bot" });
10020
+ store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection });
9534
10021
  return json(res, 200, { bot: wireBot(bot) });
9535
10022
  }
9536
10023
  m = path.match(/^\/api\/bots\/([\w-]+)\/read$/);
9537
10024
  if (m && method === "POST") {
9538
- const bot = store.patchBot(m[1], { unread: false });
9539
- if (!bot)
9540
- 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);
9541
10030
  const visible = wireBot(bot);
9542
10031
  broadcast({ kind: "bot", bot: visible });
9543
10032
  return json(res, 200, { bot: visible });
@@ -9545,10 +10034,9 @@ const handleRequest = async (req, res) => {
9545
10034
  m = path.match(/^\/api\/bots\/([\w-]+)\/always-allow$/);
9546
10035
  if (m && method === "POST") {
9547
10036
  const body = await readBody(req);
10037
+ requirePinnedClientThread(m[1], body?.threadId);
9548
10038
  const allowKey = typeof body.allowKey === "string" ? body.allowKey : "";
9549
- const bot = store.bot(m[1]);
9550
- if (!bot)
9551
- return json(res, 404, { error: "no such bot" });
10039
+ const bot = requestedTaskBot(m[1], body.threadId);
9552
10040
  if (!allowKey)
9553
10041
  return json(res, 400, { error: "allowKey required" });
9554
10042
  const pending = store.messagesFor(bot.threadId).some((message) => message.card?.requestId &&
@@ -9558,9 +10046,13 @@ const handleRequest = async (req, res) => {
9558
10046
  if (!pending) {
9559
10047
  return json(res, 409, { error: "that grant is not on a pending approval for this bot" });
9560
10048
  }
9561
- const updated = store.patchBot(bot.id, {
10049
+ store.patchTask(bot.id, bot.threadId, {
9562
10050
  alwaysAllow: [...new Set([...(bot.alwaysAllow ?? []), allowKey])].slice(0, 200),
9563
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);
9564
10056
  const visible = wireBot(updated);
9565
10057
  broadcast({ kind: "bot", bot: visible });
9566
10058
  return json(res, 200, { bot: visible });
@@ -9577,10 +10069,13 @@ const handleRequest = async (req, res) => {
9577
10069
  return json(res, 403, { error: `forbidden: this session may change how a bot looks, not "${field}" (needs the admin scope)` });
9578
10070
  }
9579
10071
  const existingBot = store.bot(m[1]);
10072
+ const selectedTask = existingBot ? requestedTaskBot(existingBot.id, undefined) : null;
9580
10073
  const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
9581
10074
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
9582
10075
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
9583
10076
  }
10077
+ const beforeBrowserProfile = existingBot?.browserProfile;
10078
+ const beforeBrowserEnabled = existingBot?.browser;
9584
10079
  // Neither Codex (free-form string field) nor Grok (lazy, logs-only)
9585
10080
  // rejects an unknown effort level at their own boundary — this is the
9586
10081
  // only real gate, so it stays. But it fires only when the target
@@ -9593,6 +10088,8 @@ const handleRequest = async (req, res) => {
9593
10088
  // safe — startTurn refuses to run a turn on an unavailable instance
9594
10089
  // anyway, so an unverifiable level never reaches a CLI.
9595
10090
  const rawSelection = body.modelSelection;
10091
+ if (rawSelection !== undefined)
10092
+ requirePinnedClientThread(m[1], undefined);
9596
10093
  if (existingBot?.approvalGrant &&
9597
10094
  (rawSelection !== undefined || body.approvalMode !== undefined || body.autoApprove !== undefined)) {
9598
10095
  return json(res, 409, { error: "wait for the approval-level change to finish before changing this setting" });
@@ -9602,10 +10099,15 @@ const handleRequest = async (req, res) => {
9602
10099
  }
9603
10100
  let normalizedSelection;
9604
10101
  if (rawSelection !== undefined) {
9605
- 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);
9606
10103
  if (!checked.ok)
9607
10104
  return json(res, checked.status, { error: checked.error });
9608
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
+ }
9609
10111
  }
9610
10112
  // Persona/profile fields reach prompts and paired clients. Both this
9611
10113
  // broad desktop endpoint and the paired-safe profile endpoint pass
@@ -9697,6 +10199,10 @@ const handleRequest = async (req, res) => {
9697
10199
  if (existingBot?.busy && requestedProfile !== existingBot.browserProfile) {
9698
10200
  return json(res, 409, { error: "stop this bot's turn before changing its browser profile" });
9699
10201
  }
10202
+ if (existingBot && requestedProfile !== existingBot.browserProfile &&
10203
+ browserRuntime.heldBy(currentBrowserSession(existingBot.id, existingBot.browserProfile))) {
10204
+ return json(res, 409, { error: "Release browser control before changing its profile." });
10205
+ }
9700
10206
  if (requestedProfile === undefined)
9701
10207
  patch.browserProfile = undefined;
9702
10208
  else if (typeof requestedProfile === "string" &&
@@ -9770,6 +10276,14 @@ const handleRequest = async (req, res) => {
9770
10276
  patch.autoApprove = requestedApprovalMode === "auto";
9771
10277
  }
9772
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
+ }
9773
10287
  if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") &&
9774
10288
  (body.approvalMode !== undefined || normalizedSelection !== undefined) &&
9775
10289
  (!targetSelection || !supportsApprovalMode(registry.cliTarget(targetSelection.instanceId)?.driverKind, requestedApprovalMode) ||
@@ -9894,21 +10408,42 @@ const handleRequest = async (req, res) => {
9894
10408
  console.warn(`bot ${m[1]}: ${loosened.join(", ")} loosened by ${requestSource(req)} through the local API with no paired session`);
9895
10409
  }
9896
10410
  if (existingBot?.computer === "local" && computerSpecified && requestedComputer !== "local") {
9897
- const routineThread = routines.activeBotRunForBot(existingBot.id)?.threadId;
9898
- const groupThread = activeGroupTurnForBot(existingBot.id)?.threadId;
9899
- const activeThread = routineThread || groupThread || existingBot.threadId;
9900
- cancelDirectTurnDispatch(existingBot.id, activeThread);
9901
- revokeInternalCapabilitiesForThread(activeThread);
9902
- await registry
9903
- .get(existingBot.modelSelection.instanceId)
9904
- ?.adapter.interruptTurn(activeThread)
9905
- .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
+ }
9906
10422
  }
9907
10423
  const chiefMovedSections = Boolean(existingBot?.chiefOfStaff) &&
9908
10424
  body.chiefOfStaff !== false &&
9909
10425
  section !== undefined &&
9910
10426
  sectionKey(existingBot?.section) !== sectionKey(section);
9911
10427
  let bot;
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
+ }
10442
+ if (freshBrowserBot && body.browserProfile !== undefined &&
10443
+ patch.browserProfile !== freshBrowserBot.browserProfile &&
10444
+ (freshBrowserBot.busy || browserRuntime.heldBy(currentBrowserSession(freshBrowserBot.id, freshBrowserBot.browserProfile)))) {
10445
+ return json(res, 409, { error: "Stop the bot and release browser control before changing its profile." });
10446
+ }
9912
10447
  if (profile.patch.soul !== undefined) {
9913
10448
  // A mixed settings request must not turn a runtime revocation into
9914
10449
  // a persist-first edit. Apply runtime fields with their existing
@@ -9925,6 +10460,14 @@ const handleRequest = async (req, res) => {
9925
10460
  }
9926
10461
  if (!bot)
9927
10462
  return json(res, 404, { error: "no such bot" });
10463
+ if (normalizedSelection && selectedTask)
10464
+ store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection });
10465
+ if (existingBot && (bot.browserProfile !== beforeBrowserProfile || bot.browser !== beforeBrowserEnabled)) {
10466
+ browserLive.closeForBot(bot.id);
10467
+ if (beforeBrowserProfile === "guest" && (bot.browserProfile !== "guest" || bot.browser === false)) {
10468
+ void forgetTemporaryBrowser(bot.id).catch((error) => console.warn("temporary browser cleanup failed", error));
10469
+ }
10470
+ }
9928
10471
  const chiefChanges = body.chiefOfStaff === true || chiefMovedSections
9929
10472
  ? store.setChiefOfStaff(bot.id)
9930
10473
  : [];
@@ -9943,6 +10486,7 @@ const handleRequest = async (req, res) => {
9943
10486
  await Promise.allSettled(store.bots
9944
10487
  .filter((bot) => bot.computer === "local")
9945
10488
  .map(async (bot) => {
10489
+ await interruptAllDirectThreads(bot.id);
9946
10490
  const routineRun = routines.activeBotRunForBot(bot.id);
9947
10491
  if (routineRun) {
9948
10492
  cancelDirectTurnDispatch(bot.id, routineRun.threadId);
@@ -9950,7 +10494,6 @@ const handleRequest = async (req, res) => {
9950
10494
  revokeInternalCapabilitiesForThread(routineRun.threadId);
9951
10495
  }
9952
10496
  await routines.cancelRun(routineRun.id);
9953
- return;
9954
10497
  }
9955
10498
  const instance = registry.get(bot.modelSelection.instanceId);
9956
10499
  const groupTurn = activeGroupTurnForBot(bot.id);
@@ -9959,13 +10502,7 @@ const handleRequest = async (req, res) => {
9959
10502
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
9960
10503
  await instance?.adapter.interruptTurn(groupTurn.threadId).catch(() => { });
9961
10504
  closeOpenApprovals(groupTurn.threadId);
9962
- return;
9963
10505
  }
9964
- const directClaim = cancelDirectTurnDispatch(bot.id);
9965
- const threadId = directClaim?.threadId ?? bot.threadId;
9966
- revokeInternalCapabilitiesForThread(threadId);
9967
- await instance?.adapter.interruptTurn(threadId).catch(() => { });
9968
- closeOpenApprovals(threadId);
9969
10506
  }));
9970
10507
  return json(res, 200, { ok: true });
9971
10508
  }
@@ -9995,7 +10532,7 @@ const handleRequest = async (req, res) => {
9995
10532
  // A direct turn that has already claimed the bot can provision a Box in
9996
10533
  // its background setup. Do not let deletion race that work while a Box
9997
10534
  // account is configured; the person can stop the turn and retry.
9998
- if ((box.boxConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || directTurnDispatchClaims.has(bot.id))) {
10535
+ if ((box.boxConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || hasDirectDispatch(bot.id))) {
9999
10536
  return json(res, 409, { error: "stop this bot's work before checking and deleting its cloud computer" });
10000
10537
  }
10001
10538
  const botBoxRecovery = boxCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
@@ -10093,18 +10630,19 @@ const handleRequest = async (req, res) => {
10093
10630
  const browserCleanupRequest = browserCleanup.prepare("bot", bot.id);
10094
10631
  try {
10095
10632
  // a running turn dies with its bot
10096
- const directClaim = cancelDirectTurnDispatch(bot.id);
10097
- const directThreadId = directClaim?.threadId ?? bot.threadId;
10098
10633
  // Invalidate every bot-callable bearer before the first asynchronous
10099
10634
  // teardown step. A request that already passed its initial header
10100
10635
  // check is revalidated after its body arrives and must fail closed.
10101
- revokeInternalCapabilitiesForThread(directThreadId);
10102
- directTurnGenerationByBot.delete(bot.id);
10103
- await registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(directThreadId).catch(() => { });
10104
- closeOpenApprovals(directThreadId);
10636
+ for (const task of store.tasks(bot.id))
10637
+ revokeInternalCapabilitiesForThread(task.threadId);
10638
+ await interruptAllDirectThreads(bot.id);
10105
10639
  // Deletion removes the thread before a late turn.completed can fold
10106
10640
  // staged provider images into a message, so dispose them here.
10107
- 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
+ }
10108
10646
  stopScreenPoller(bot.id);
10109
10647
  activeVpsThreads.delete(bot.id);
10110
10648
  routines.disableForBot(bot.id);
@@ -10121,6 +10659,8 @@ const handleRequest = async (req, res) => {
10121
10659
  localVmIdles.get(target.key)?.cancel();
10122
10660
  localVmIdles.delete(target.key);
10123
10661
  store.deleteBot(bot.id);
10662
+ browserLive.closeForBot(bot.id);
10663
+ await forgetTemporaryBrowser(bot.id);
10124
10664
  }
10125
10665
  catch (error) {
10126
10666
  if (browserCleanupRequest)
@@ -10479,20 +11019,18 @@ const handleRequest = async (req, res) => {
10479
11019
  // onboarding/ask cards persist their answered/dismissed state
10480
11020
  m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
10481
11021
  if (m && method === "PATCH") {
10482
- const bot = store.bot(m[1]);
10483
- if (!bot)
10484
- 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);
10485
11027
  const existing = store.messagesFor(bot.threadId).find((msg) => msg.id === m[2]);
10486
11028
  if (!existing?.card)
10487
11029
  return json(res, 404, { error: "no such card" });
10488
11030
  if (existing.card.requestId) {
10489
11031
  return json(res, 409, { error: "request cards must be answered through the approval endpoint" });
10490
11032
  }
10491
- const body = await readBody(req);
10492
- if (!body || typeof body !== "object" || Array.isArray(body)) {
10493
- return json(res, 400, { error: "body must be a JSON object" });
10494
- }
10495
- if (Object.keys(body).some((key) => key !== "answered" && key !== "dismissed")) {
11033
+ if (Object.keys(body).some((key) => key !== "answered" && key !== "dismissed" && key !== "threadId")) {
10496
11034
  return json(res, 400, { error: "only answered and dismissed may be changed" });
10497
11035
  }
10498
11036
  if (body.answered !== undefined && typeof body.answered !== "string") {
@@ -10522,6 +11060,7 @@ const handleRequest = async (req, res) => {
10522
11060
  const bot = store.bot(m[1]);
10523
11061
  if (!bot)
10524
11062
  return json(res, 404, { error: "no such bot" });
11063
+ requirePinnedClientThread(bot.id, body.threadId);
10525
11064
  if (body.threadId !== undefined && (typeof body.threadId !== "string" || !/^[\w-]+$/.test(body.threadId))) {
10526
11065
  return json(res, 400, { error: "threadId must be a task id" });
10527
11066
  }
@@ -10558,17 +11097,12 @@ const handleRequest = async (req, res) => {
10558
11097
  return { ok: true, queued: true, queueId: queued.id, threadId };
10559
11098
  }
10560
11099
  }
10561
- const currentAtStart = store.bot(bot.id);
11100
+ const currentAtStart = store.projectBotForTask(bot.id, threadId);
10562
11101
  if (!currentAtStart)
10563
11102
  throw Object.assign(new Error("no such bot"), { status: 404 });
10564
11103
  if (!store.taskByThread(currentAtStart.id, threadId)) {
10565
11104
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
10566
11105
  }
10567
- if (currentAtStart.threadId !== threadId) {
10568
- throw Object.assign(new Error("the bot switched tasks before it could receive the message"), {
10569
- status: 409,
10570
- });
10571
- }
10572
11106
  // Claude can accept the message inside its live turn. If the write
10573
11107
  // loses a race with turn settlement, or the engine cannot steer, the
10574
11108
  // existing server-side queue records it atomically for the next turn.
@@ -10590,17 +11124,12 @@ const handleRequest = async (req, res) => {
10590
11124
  // successful steer; otherwise that late acknowledgement writes a user
10591
11125
  // message into a task the bot no longer owns. A conflict leaves the
10592
11126
  // text in the client's composer/outbox to resend deliberately.
10593
- const current = store.bot(bot.id);
11127
+ const current = store.projectBotForTask(bot.id, threadId);
10594
11128
  if (!current)
10595
11129
  throw Object.assign(new Error("no such bot"), { status: 404 });
10596
11130
  if (!store.taskByThread(bot.id, threadId)) {
10597
11131
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
10598
11132
  }
10599
- if (current.threadId !== threadId) {
10600
- throw Object.assign(new Error("the bot switched tasks before it could receive the message"), {
10601
- status: 409,
10602
- });
10603
- }
10604
11133
  if (steered) {
10605
11134
  if (!current.busy) {
10606
11135
  throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
@@ -10614,7 +11143,7 @@ const handleRequest = async (req, res) => {
10614
11143
  // desktop's owner capability, which every mutation there has
10615
11144
  // already shown) clears the mark.
10616
11145
  if (auth.kind === "session" || DESKTOP_MANAGED)
10617
- clearUnattended(current.id);
11146
+ clearUnattended(threadId);
10618
11147
  const message = store.appendMessage(threadId, {
10619
11148
  role: "user",
10620
11149
  kind: "text",
@@ -10643,11 +11172,11 @@ const handleRequest = async (req, res) => {
10643
11172
  }
10644
11173
  m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)$/);
10645
11174
  if (m && method === "DELETE") {
10646
- const bot = store.bot(m[1]);
10647
- if (!bot)
10648
- 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);
10649
11178
  const queueId = m[2];
10650
- if (!cancelSteeredMessage(bot.id, queueId)) {
11179
+ if (!cancelSteeredMessage(bot.id, queueId, bot.threadId)) {
10651
11180
  return json(res, 404, { error: "no such queued message" });
10652
11181
  }
10653
11182
  return json(res, 200, { ok: true });
@@ -10659,18 +11188,17 @@ const handleRequest = async (req, res) => {
10659
11188
  m = path.match(/^\/api\/bots\/([\w-]+)\/messages\/([\w-]+)\/edit$/);
10660
11189
  if (m && method === "POST") {
10661
11190
  const messageId = m[2];
10662
- const bot = store.bot(m[1]);
10663
- if (!bot)
10664
- return json(res, 404, { error: "no such bot" });
10665
11191
  const body = await readBody(req);
11192
+ requirePinnedClientThread(m[1], body?.threadId);
11193
+ const bot = requestedTaskBot(m[1], body.threadId);
10666
11194
  const text = String(body.text ?? "").trim();
10667
11195
  if (!text)
10668
11196
  return json(res, 400, { error: "text required" });
10669
11197
  // everything from here down is synchronous, so two racing edits can
10670
11198
  // never both get past this check: startTurn flips busy before the
10671
11199
  // next request is handled
10672
- if (bot.busy)
10673
- 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" });
10674
11202
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
10675
11203
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
10676
11204
  }
@@ -10683,23 +11211,20 @@ const handleRequest = async (req, res) => {
10683
11211
  error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
10684
11212
  });
10685
11213
  }
10686
- const message = store.branchMessage(bot.threadId, messageId, text);
10687
- if (!message)
10688
- return json(res, 404, { error: "no such message" });
10689
- store.patchBot(bot.id, { rewound: true });
10690
- const replyTo = message.replyToId ? resolveReplyTarget(bot.threadId, message.replyToId) : undefined;
10691
- await startTurn(bot.id, text, { userMessage: message, replyTo });
10692
- 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 });
10693
11219
  }
10694
11220
  // switch which fork of the conversation is visible (no new turn)
10695
11221
  m = path.match(/^\/api\/bots\/([\w-]+)\/active-branch$/);
10696
11222
  if (m && method === "POST") {
10697
11223
  const body = await readBody(req);
10698
- const bot = store.bot(m[1]);
10699
- if (!bot)
10700
- return json(res, 404, { error: "no such bot" });
10701
- if (bot.busy)
10702
- 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" });
10703
11228
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
10704
11229
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
10705
11230
  }
@@ -10707,15 +11232,14 @@ const handleRequest = async (req, res) => {
10707
11232
  if (!leaf)
10708
11233
  return json(res, 404, { error: "no such message" });
10709
11234
  // provider sessions still hold the other branch — next turn replays
10710
- store.patchBot(bot.id, { rewound: true });
11235
+ store.patchTask(bot.id, bot.threadId, { rewound: true });
10711
11236
  return json(res, 200, { activeLeafId: leaf });
10712
11237
  }
10713
11238
  m = path.match(/^\/api\/bots\/([\w-]+)\/respond$/);
10714
11239
  if (m && method === "POST") {
10715
- const bot = store.bot(m[1]);
10716
- if (!bot)
10717
- return json(res, 404, { error: "no such bot" });
10718
11240
  const body = await readBody(req);
11241
+ const selected = requestedTaskBot(m[1], body.threadId);
11242
+ const bot = botForThread(selected.id, selected.threadId);
10719
11243
  const behavior = requestBehavior(body.behavior);
10720
11244
  const reviewedSha256 = typeof body.reviewedSha256 === "string" ? body.reviewedSha256 : undefined;
10721
11245
  if (!behavior)
@@ -10748,7 +11272,8 @@ const handleRequest = async (req, res) => {
10748
11272
  // peer-approval intercept: harness-native cards carry a requestId
10749
11273
  // that lives in peer-approval's pending map. Resolve them here so
10750
11274
  // the provider adapter never sees a request it didn't raise.
10751
- 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)) {
10752
11277
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
10753
11278
  }
10754
11279
  const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name });
@@ -10818,7 +11343,8 @@ const handleRequest = async (req, res) => {
10818
11343
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
10819
11344
  // belongs to the bus rather than to a speaker, so resolve it before we go
10820
11345
  // looking for one — a room between turns has no speaker to find.
10821
- if (resolvePeerComms(approvalBus, requestId, behavior)) {
11346
+ if (store.messagesFor(threadId).some((message) => message.card?.requestId === requestId) &&
11347
+ resolvePeerComms(approvalBus, requestId, behavior)) {
10822
11348
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
10823
11349
  }
10824
11350
  const group = store.groupByThread(threadId);
@@ -10834,7 +11360,8 @@ const handleRequest = async (req, res) => {
10834
11360
  : store.botByThread(threadId);
10835
11361
  if (!owner && !pending)
10836
11362
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
10837
- 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);
10838
11365
  return json(res, 200, { ok: true, outcome });
10839
11366
  }
10840
11367
  m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
@@ -10847,11 +11374,22 @@ const handleRequest = async (req, res) => {
10847
11374
  return json(res, 400, { error: "body must be a JSON object" });
10848
11375
  }
10849
11376
  const body = rawBody ?? {};
11377
+ requirePinnedClientThread(bot.id, body.threadId);
10850
11378
  const expectedThreadId = body.threadId;
10851
11379
  if (expectedThreadId !== undefined && (typeof expectedThreadId !== "string" || !/^[\w-]+$/.test(expectedThreadId))) {
10852
11380
  return json(res, 400, { error: "threadId must be a task id" });
10853
11381
  }
10854
- 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);
10855
11393
  const routineRun = routines.activeBotRunForBot(bot.id);
10856
11394
  if (routineRun) {
10857
11395
  if (expectedThreadId !== undefined && routineRun.threadId !== expectedThreadId) {
@@ -10864,7 +11402,7 @@ const handleRequest = async (req, res) => {
10864
11402
  await routines.cancelRun(routineRun.id);
10865
11403
  return json(res, 200, { ok: true });
10866
11404
  }
10867
- const instance = registry.get(bot.modelSelection.instanceId);
11405
+ const instance = registry.get((botForThread(bot.id, expectedThreadId ?? bot.threadId) ?? bot).modelSelection.instanceId);
10868
11406
  // a bot busy in a ROOM is running on the room's thread — stopping it
10869
11407
  // from its own chat must reach that turn, not just the 1:1 thread
10870
11408
  const busyGroup = activeGroupTurnForBot(bot.id);
@@ -10884,11 +11422,7 @@ const handleRequest = async (req, res) => {
10884
11422
  directClaim?.threadId !== expectedThreadId) {
10885
11423
  return json(res, 409, { error: "the bot switched tasks before it could be interrupted" });
10886
11424
  }
10887
- const cancelledDirect = cancelDirectTurnDispatch(bot.id, expectedThreadId);
10888
- const directThreadId = cancelledDirect?.threadId ?? bot.threadId;
10889
- revokeInternalCapabilitiesForThread(directThreadId);
10890
- await instance?.adapter.interruptTurn(directThreadId).catch(() => { });
10891
- closeOpenApprovals(directThreadId);
11425
+ await interruptDirectThread(bot.id, expectedThreadId ?? bot.threadId);
10892
11426
  return json(res, 200, { ok: true });
10893
11427
  }
10894
11428
  // ── tasks: a bot's separate contexts ────────────────────────────────
@@ -10901,18 +11435,50 @@ const handleRequest = async (req, res) => {
10901
11435
  activeLeafId: store.activeLeaf(bot.threadId),
10902
11436
  tasks: store.tasks(bot.id).map(wireTask),
10903
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
+ }
10904
11469
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
10905
11470
  if (m && method === "POST") {
10906
11471
  const body = await readBody(req);
10907
11472
  const bot = store.bot(m[1]);
10908
11473
  if (!bot)
10909
11474
  return json(res, 404, { error: "no such bot" });
10910
- if (bot.busy)
10911
- return json(res, 409, { error: "this bot is working — let it finish before starting a task" });
10912
11475
  if (phoneSecretSubmissions.hasBot(bot.id)) {
10913
11476
  return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
10914
11477
  }
10915
- 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);
10916
11482
  if (!task)
10917
11483
  return json(res, 500, { error: "couldn't create that task" });
10918
11484
  const fresh = botWithThread(store.bot(bot.id));
@@ -10927,12 +11493,7 @@ const handleRequest = async (req, res) => {
10927
11493
  if (phoneSecretSubmissions.hasBot(bot.id)) {
10928
11494
  return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
10929
11495
  }
10930
- // Switching the active thread while its provider turn is still running
10931
- // loses ownership of the process and can make a later interrupt target
10932
- // the wrong task. Keep this mutation atomic at the HTTP boundary; an
10933
- // MCP client cannot make a safe check-then-switch across two requests.
10934
- if (bot.busy)
10935
- 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.
10936
11497
  const switched = store.switchTask(bot.id, m[2]);
10937
11498
  if (!switched)
10938
11499
  return json(res, 404, { error: "no such task" });
@@ -10945,12 +11506,81 @@ const handleRequest = async (req, res) => {
10945
11506
  }
10946
11507
  if (m && method === "PATCH") {
10947
11508
  const body = await readBody(req);
10948
- const task = store.renameTask(m[1], m[2], String(body.title ?? ""));
10949
- 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)
10950
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);
10951
11581
  const fresh = botWithThread(store.bot(m[1]));
10952
11582
  broadcast({ kind: "bot", bot: fresh });
10953
- return json(res, 200, { task: wireTask(task) });
11583
+ return json(res, 200, { task: wireTask(task), bot: fresh });
10954
11584
  }
10955
11585
  if (m && method === "DELETE") {
10956
11586
  const bot = store.bot(m[1]);
@@ -10960,7 +11590,7 @@ const handleRequest = async (req, res) => {
10960
11590
  if (phoneSecretSubmissions.hasThread(m[2])) {
10961
11591
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
10962
11592
  }
10963
- if (bot?.busy && (bot.threadId === m[2] || routines.isActiveThread(m[2]))) {
11593
+ if (threadBusy(bot.id, m[2]) || routines.isActiveThread(m[2])) {
10964
11594
  return json(res, 409, { error: "this task is running — stop it first" });
10965
11595
  }
10966
11596
  const stagedSkillCleanups = stagedSkillCleanupsForThread(m[2]);
@@ -11239,7 +11869,26 @@ const handleRequest = async (req, res) => {
11239
11869
  // Windows never pushes PATH changes into a live process, so without
11240
11870
  // this the answer is frozen at boot and "check again" is a no-op.
11241
11871
  resetPathCache();
11242
- return json(res, 200, { instances: await registry.describe() });
11872
+ return json(res, 200, { instances: await describeInstances() });
11873
+ }
11874
+ if (method === "POST" && path === "/api/instances/claude-accounts") {
11875
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
11876
+ return json(res, 415, { error: "content-type must be application/json" });
11877
+ }
11878
+ const parsed = createClaudeAccountSchema.safeParse(await readBody(req, 8192));
11879
+ if (!parsed.success)
11880
+ return json(res, 400, { error: "Enter an account name (up to 80 characters) and an optional configuration directory." });
11881
+ if (providerConfigBusy)
11882
+ return json(res, 409, { error: "provider settings are already being updated" });
11883
+ providerConfigBusy = true;
11884
+ try {
11885
+ const { instanceId, instances } = newClaudeAccount(cfg, parsed.data);
11886
+ await persistProviderInstance(instanceId, instances);
11887
+ return json(res, 201, { instanceId, instances: await describeInstances() });
11888
+ }
11889
+ finally {
11890
+ providerConfigBusy = false;
11891
+ }
11243
11892
  }
11244
11893
  const authStatus = /^\/api\/instances\/([\w.-]+)\/auth\/status$/.exec(path);
11245
11894
  if (method === "GET" && authStatus) {
@@ -11261,12 +11910,12 @@ const handleRequest = async (req, res) => {
11261
11910
  if (action === "refresh-models") {
11262
11911
  if (!(await registry.refreshModels(instanceId)))
11263
11912
  return json(res, 404, { error: "unknown instance" });
11264
- return json(res, 200, { instances: await registry.describe() });
11913
+ return json(res, 200, { instances: await describeInstances() });
11265
11914
  }
11266
11915
  if (action === "install") {
11267
11916
  if (!(await registry.installRuntime(instanceId)))
11268
11917
  return json(res, 404, { error: "managed installation is unavailable" });
11269
- return json(res, 200, { instances: await registry.describe() });
11918
+ return json(res, 200, { instances: await describeInstances() });
11270
11919
  }
11271
11920
  if (action === "auth/start") {
11272
11921
  const instance = registry.get(instanceId);
@@ -11283,9 +11932,10 @@ const handleRequest = async (req, res) => {
11283
11932
  if (action === "auth/complete") {
11284
11933
  const body = await readBody(req);
11285
11934
  const flowId = typeof body?.flowId === "string" ? body.flowId : "";
11286
- 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 : "";
11287
11937
  if (!flowId || !callbackUrl)
11288
- return json(res, 400, { error: "flowId and callbackUrl are required" });
11938
+ return json(res, 400, { error: "flowId and a code or callbackUrl are required" });
11289
11939
  await providerAuthSessions.complete(instanceId, owner, flowId, callbackUrl);
11290
11940
  return json(res, 200, { ok: true });
11291
11941
  }
@@ -11336,6 +11986,14 @@ const handleRequest = async (req, res) => {
11336
11986
  // executable already configured for this Claude instance. The JSON gate
11337
11987
  // keeps a hostile page from triggering a local process with a simple
11338
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
+ });
11339
11997
  const claudeUpdate = /^\/api\/instances\/([\w.-]+)\/claude-update$/.exec(path);
11340
11998
  if (method === "POST" && claudeUpdate) {
11341
11999
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
@@ -11353,7 +12011,7 @@ const handleRequest = async (req, res) => {
11353
12011
  if (claudeUpdatesInFlight.has(target.cli)) {
11354
12012
  return json(res, 409, { error: "this Claude installation is already updating" });
11355
12013
  }
11356
- 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);
11357
12015
  if (active) {
11358
12016
  return json(res, 409, { error: "wait for running Claude tasks to finish before updating" });
11359
12017
  }
@@ -11372,38 +12030,81 @@ const handleRequest = async (req, res) => {
11372
12030
  }
11373
12031
  // ── per-instance CLI path override (custom builds / versioned bins) ──
11374
12032
  // PATCH /api/instances/:id {cli: "/path/to/cli" | ""} — "" reverts to the
11375
- // driver default. Kills in-flight turns like any provider reload.
12033
+ // driver default. Only this idle instance is replaced; siblings keep running.
11376
12034
  const instancePatch = /^\/api\/instances\/([\w.-]+)$/.exec(path);
11377
12035
  if (method === "PATCH" && instancePatch) {
11378
12036
  // same non-simple-request gate as the local-VM lifecycle routes
11379
12037
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
11380
12038
  return json(res, 415, { error: "content-type must be application/json" });
11381
12039
  }
11382
- const body = await readBody(req);
11383
- if (typeof body?.cli !== "string")
11384
- return json(res, 400, { error: "cli must be a string" });
11385
- if (/[\n\r]/.test(body.cli))
11386
- return json(res, 400, { error: "cli must not contain newlines" });
12040
+ const parsed = instanceSettingsSchema.safeParse(await readBody(req, 16384));
12041
+ if (!parsed.success)
12042
+ return json(res, 400, { error: "Supply a valid CLI path, account name or configuration directory." });
12043
+ const body = parsed.data;
12044
+ const instanceId = instancePatch[1];
11387
12045
  if (providerConfigBusy)
11388
12046
  return json(res, 409, { error: "provider settings are already being updated" });
12047
+ if (busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
12048
+ return json(res, 409, { error: "Wait for bots using this account to finish before changing its settings." });
12049
+ }
11389
12050
  providerConfigBusy = true;
12051
+ providerInstancesChanging.add(instanceId);
11390
12052
  try {
11391
- const result = withInstanceCli(cfg, instancePatch[1], body.cli);
11392
- if (!result.ok)
11393
- return json(res, 404, { error: `unknown instance "${instancePatch[1]}"` });
11394
- // persist the whole instances map this rebuild produced — a fresh
11395
- // saveConfig({instances}) merge would re-derive defaults identically,
11396
- // but writing the resolved map keeps disk and runtime in lockstep
11397
- saveConfig({ instances: result.config.instances });
11398
- Object.assign(cfg, loadConfig());
11399
- await reloadProviders();
11400
- // rescan BEFORE describe(): the response's cliCandidates are computed
11401
- // from the memoized PATH, so resetting after would answer this request
11402
- // with the pre-reset cache
11403
- resetPathCache();
11404
- return json(res, 200, { instances: await registry.describe() });
12053
+ const result = body.cli === undefined ? { ok: true, config: cfg } : withInstanceCli(cfg, instanceId, body.cli);
12054
+ const instances = persistableInstanceConfigs(result.config);
12055
+ if (!result.ok || !Object.hasOwn(instances, instanceId))
12056
+ return json(res, 404, { error: `unknown instance "${instanceId}"` });
12057
+ const entry = instances[instanceId];
12058
+ if ((body.displayName !== undefined || body.configDir !== undefined) && entry.driver !== "claudeAgent") {
12059
+ return json(res, 400, { error: "Account settings are currently available for Claude only." });
12060
+ }
12061
+ if (body.displayName !== undefined)
12062
+ entry.displayName = body.displayName;
12063
+ if (body.configDir !== undefined) {
12064
+ let previousDir;
12065
+ try {
12066
+ previousDir = configuredAccountDirectory(entry);
12067
+ }
12068
+ catch { /* Allow repairing an unused malformed account. */ }
12069
+ entry.config = { ...entry.config, configDir: body.configDir };
12070
+ if (previousDir !== configuredAccountDirectory(entry)) {
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));
12072
+ if (used)
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." });
12074
+ assertSeparateClaudeAccount(instances, instanceId, entry);
12075
+ }
12076
+ }
12077
+ await persistProviderInstance(instanceId, instances);
12078
+ return json(res, 200, { instances: await describeInstances() });
11405
12079
  }
11406
12080
  finally {
12081
+ providerInstancesChanging.delete(instanceId);
12082
+ providerConfigBusy = false;
12083
+ }
12084
+ }
12085
+ if (method === "DELETE" && instancePatch) {
12086
+ const instanceId = instancePatch[1];
12087
+ if (providerConfigBusy)
12088
+ return json(res, 409, { error: "provider settings are already being updated" });
12089
+ const instances = persistableInstanceConfigs(cfg);
12090
+ if (!Object.hasOwn(instances, instanceId))
12091
+ return json(res, 404, { error: "unknown instance" });
12092
+ if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
12093
+ return json(res, 400, { error: "Only added Claude accounts can be removed here." });
12094
+ }
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)) {
12097
+ return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
12098
+ }
12099
+ providerConfigBusy = true;
12100
+ providerInstancesChanging.add(instanceId);
12101
+ try {
12102
+ delete instances[instanceId];
12103
+ await persistProviderInstance(instanceId, instances);
12104
+ return json(res, 200, { instances: await describeInstances() });
12105
+ }
12106
+ finally {
12107
+ providerInstancesChanging.delete(instanceId);
11407
12108
  providerConfigBusy = false;
11408
12109
  }
11409
12110
  }
@@ -11520,12 +12221,24 @@ const handleRequest = async (req, res) => {
11520
12221
  return json(res, 400, { error: "nothing to save" });
11521
12222
  if (providerConfigBusy)
11522
12223
  return json(res, 409, { error: "provider settings are already being updated" });
12224
+ if (patch.browserProfiles !== undefined && body.expectedBrowserProfiles !== undefined) {
12225
+ const current = (cfg.browserProfiles ?? []).map(({ id, name }) => ({ id, name }));
12226
+ if (JSON.stringify(body.expectedBrowserProfiles) !== JSON.stringify(current)) {
12227
+ return json(res, 409, { error: "Browser profiles changed in another window. Review the refreshed list and try again." });
12228
+ }
12229
+ }
11523
12230
  const disablingBuiltInBrowser = patch.features?.browser === false && builtInBrowserEnabled(cfg);
11524
12231
  const removedBrowserProfileIds = patch.browserProfiles === undefined
11525
12232
  ? []
11526
12233
  : (cfg.browserProfiles ?? [])
11527
12234
  .map((profile) => profile.id)
11528
12235
  .filter((id) => !patch.browserProfiles.some((profile) => profile.id === id));
12236
+ const profileControlConflict = () => removedBrowserProfileIds.some((id) => {
12237
+ const target = browserProfilePartitionTarget(cfg, id);
12238
+ return target && browserRuntime.heldBy(browserSessionId("", target.partitionId));
12239
+ });
12240
+ if (profileControlConflict())
12241
+ return json(res, 409, { error: "Release browser control before deleting its profile." });
11529
12242
  if (patch.browserProfiles !== undefined) {
11530
12243
  const currentProfiles = new Map((cfg.browserProfiles ?? []).map((profile) => [profile.id, profile]));
11531
12244
  const nextProfiles = patch.browserProfiles.map((profile) => {
@@ -11754,6 +12467,8 @@ const handleRequest = async (req, res) => {
11754
12467
  return json(res, 409, { error: conflict });
11755
12468
  }
11756
12469
  const browserCleanupRequests = [];
12470
+ if (profileControlConflict())
12471
+ return json(res, 409, { error: "Release browser control before deleting its profile." });
11757
12472
  try {
11758
12473
  for (const profileId of removedBrowserProfileIds) {
11759
12474
  const target = browserProfilePartitionTarget(cfg, profileId);
@@ -11818,6 +12533,12 @@ const handleRequest = async (req, res) => {
11818
12533
  throw error;
11819
12534
  }
11820
12535
  let browserReferenceCleanupError = null;
12536
+ if (disablingBuiltInBrowser)
12537
+ browserLive.closeAll();
12538
+ for (const request of browserCleanupRequests) {
12539
+ if (request.kind === "profile")
12540
+ browserLive.closeForSession(browserSessionId("", request.partitionId));
12541
+ }
11821
12542
  if (patch.browserProfiles !== undefined) {
11822
12543
  const retained = new Set(patch.browserProfiles.map((profile) => profile.id));
11823
12544
  try {
@@ -12310,6 +13031,21 @@ calendarCalls.start();
12310
13031
  // Resolve the edition before accepting requests so /api/edition is never a guess.
12311
13032
  console.log(describeEdition(await loadEnterpriseLayer()));
12312
13033
  console.log(describeBrand(loadBrand()));
13034
+ // Reclaim upload partials a previous run crashed out of, and warm the
13035
+ // attachment quota cache off the same scan. This used to happen implicitly on
13036
+ // every reservation, which is exactly what made uploads quadratic in
13037
+ // directory size; do the initial sweep before accepting requests.
13038
+ // ponytail: once per boot, not periodic. A partial orphaned while this
13039
+ // process is up survives until the next restart — add a timer only if that
13040
+ // shows up as real quota pressure.
13041
+ try {
13042
+ const reclaimedPartials = cleanupStaleAttachmentPartials();
13043
+ if (reclaimedPartials > 0)
13044
+ console.log(`reclaimed ${reclaimedPartials} abandoned upload partial(s)`);
13045
+ }
13046
+ catch (error) {
13047
+ console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
13048
+ }
12313
13049
  server.listen(PORT, "127.0.0.1", () => {
12314
13050
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
12315
13051
  });
@@ -12335,6 +13071,7 @@ const gracefulShutdown = createGracefulShutdown({
12335
13071
  // asynchronous shutdown jobs drain. Invalidate their turn bearers before
12336
13072
  // any cleanup function reaches an await.
12337
13073
  revokeAllInternalCapabilities();
13074
+ browserLive.closeAll();
12338
13075
  for (const idle of localVmIdles.values())
12339
13076
  idle.cancel();
12340
13077
  vps.closeAllVpsDesktopTunnels();
@@ -12345,6 +13082,10 @@ const gracefulShutdown = createGracefulShutdown({
12345
13082
  tunnelListener?.close();
12346
13083
  },
12347
13084
  () => registry.disposeAll(),
13085
+ async () => {
13086
+ await Promise.all([...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)));
13087
+ await browserRuntime.closeAll();
13088
+ },
12348
13089
  () => flushAllProfileHistory(),
12349
13090
  ],
12350
13091
  // Cleanup jobs run concurrently. Release only after they settle (or reach