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.
- package/dist/assets/index-A3CBTxwl.css +1 -0
- package/dist/assets/{index-BZeH50da.js → index-BwpNqgCm.js} +1 -1
- package/dist/assets/index-CDuyoVPJ.js +278 -0
- package/dist/index.html +2 -2
- package/dist-server/browser-proxy.js +121 -0
- package/dist-server/computer-proxy.js +14 -3
- package/dist-server/container-mcp.js +43 -19
- package/dist-server/drivers/agents-proxy.js +31 -0
- package/dist-server/index.js +18985 -16059
- package/dist-server/local-computer-proxy.js +433 -0
- package/dist-server/local-computer.js +48 -5
- package/dist-server/mcp-server.js +38 -17
- package/dist-server/openmausbot.js +1666 -775
- package/dist-server/pair-cli.js +1666 -775
- package/dist-server/proxy-paths.js +2 -0
- package/dist-server/server/account-signin.js +103 -0
- package/dist-server/server/attachments.js +125 -13
- package/dist-server/server/browser-engine-release.js +10 -8
- package/dist-server/server/browser-engine.js +226 -26
- package/dist-server/server/browser-live.js +567 -0
- package/dist-server/server/browser-proxy.js +129 -0
- package/dist-server/server/browser-runtime.js +370 -0
- package/dist-server/server/caddy.js +203 -0
- package/dist-server/server/claude-accounts.js +103 -0
- package/dist-server/server/cli.js +163 -22
- package/dist-server/server/computer-proxy.js +11 -2
- package/dist-server/server/config.js +41 -19
- package/dist-server/server/control-client.js +7 -1
- package/dist-server/server/custom-domain.js +14 -0
- package/dist-server/server/drivers/agents-proxy.js +34 -0
- package/dist-server/server/drivers/claude-login-auth.js +334 -0
- package/dist-server/server/drivers/claude.js +85 -19
- package/dist-server/server/environment.js +1 -0
- package/dist-server/server/harness/bus.js +9 -4
- package/dist-server/server/harness/registry.js +15 -1
- package/dist-server/server/index.js +1120 -379
- package/dist-server/server/local-computer-proxy.js +28 -0
- package/dist-server/server/local-computer.js +17 -0
- package/dist-server/server/mcp-bridge.js +27 -11
- package/dist-server/server/peer-approval.js +19 -6
- package/dist-server/server/provider-auth-sessions.js +7 -0
- package/dist-server/server/proxy-paths.js +2 -0
- package/dist-server/server/sessions.js +42 -1
- package/dist-server/server/steer-queue.js +10 -8
- package/dist-server/server/store.js +234 -34
- package/dist-server/server/team-backup.js +1 -0
- package/dist-server/server/tunnel.js +27 -1
- package/dist-server/server/turn-resources.js +50 -0
- package/dist-server/server/workspace.js +53 -3
- package/dist-server/src/lib/composer-attachments.js +3 -3
- package/dist-server/vps-container-mcp.js +43 -19
- package/package.json +1 -1
- package/dist/assets/index-BOrNJcfG.js +0 -273
- 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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
490
|
-
|
|
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(
|
|
495
|
-
return claim?.id === claimId && claim.
|
|
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(
|
|
579
|
+
directTurnDispatchClaims.set(threadId, { id: claimId, botId, threadId, phase: "dispatching" });
|
|
501
580
|
return true;
|
|
502
581
|
}
|
|
503
|
-
function clearDirectTurnDispatch(
|
|
504
|
-
if (directTurnDispatchClaims.get(
|
|
505
|
-
directTurnDispatchClaims.delete(
|
|
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
|
|
509
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
2047
|
-
//
|
|
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
|
|
2185
|
+
const unattendedThreads = new Map();
|
|
2054
2186
|
const UNATTENDED_TTL_MS = 30 * 60_000;
|
|
2055
|
-
function markUnattended(botId) {
|
|
2056
|
-
|
|
2187
|
+
function markUnattended(botId, threadId) {
|
|
2188
|
+
unattendedThreads.set(threadId, { botId, at: Date.now() });
|
|
2057
2189
|
}
|
|
2058
|
-
function clearUnattended(
|
|
2059
|
-
|
|
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
|
-
|
|
2065
|
-
|
|
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 && !
|
|
2071
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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 (
|
|
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.
|
|
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
|
|
2799
|
-
const
|
|
2800
|
-
|
|
2801
|
-
|
|
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.
|
|
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(
|
|
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
|
-
//
|
|
2844
|
-
//
|
|
2845
|
-
//
|
|
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
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
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(
|
|
3022
|
+
}).catch(() => { }).finally(() => {
|
|
3023
|
+
clearTimeout(timeout);
|
|
3024
|
+
settleDirectTurn(true);
|
|
3025
|
+
});
|
|
2859
3026
|
}
|
|
2860
|
-
else
|
|
2861
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
3305
|
-
const entry = screenPollers.get(
|
|
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
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
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(
|
|
3340
|
-
const known = settledScreenHashes.get(
|
|
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(
|
|
3356
|
-
const entry = screenPollers.get(
|
|
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(
|
|
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(
|
|
3568
|
+
if (!frame || !settledFrameIsNews(shownScreenHash(threadId), frame.png))
|
|
3367
3569
|
return null;
|
|
3368
|
-
settledScreenHashes.set(
|
|
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
|
|
3374
|
-
if (!
|
|
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 (
|
|
3391
|
-
throw Object.assign(new Error("
|
|
3392
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
3548
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3891
|
-
|
|
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(
|
|
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(
|
|
4205
|
+
clearDirectTurnDispatch(threadId, dispatchClaimId);
|
|
3961
4206
|
// dispatched: the rewind is spent, and the old cursors are dead
|
|
3962
4207
|
if (rewound)
|
|
3963
|
-
store.
|
|
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) &&
|
|
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 (
|
|
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(
|
|
4239
|
+
clearDirectTurnDispatch(threadId, dispatchClaimId);
|
|
3988
4240
|
revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
|
|
3989
|
-
const ownsLatestGeneration =
|
|
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 &&
|
|
4000
|
-
store.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
4819
|
-
//
|
|
4820
|
-
//
|
|
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 ||
|
|
4823
|
-
|
|
4824
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
6578
|
-
//
|
|
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
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
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
|
|
6948
|
-
? "
|
|
6949
|
-
: path
|
|
6950
|
-
? "
|
|
6951
|
-
: "
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
9522
|
-
|
|
9523
|
-
(!
|
|
9524
|
-
|
|
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
|
|
9539
|
-
|
|
9540
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
9898
|
-
const
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
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 ||
|
|
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
|
-
|
|
10102
|
-
|
|
10103
|
-
await
|
|
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
|
-
|
|
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
|
|
10483
|
-
if (!
|
|
10484
|
-
return json(res,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
10647
|
-
|
|
10648
|
-
|
|
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.
|
|
10673
|
-
return json(res, 409, { error: "the
|
|
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
|
-
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
|
|
10690
|
-
|
|
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
|
-
|
|
10699
|
-
|
|
10700
|
-
|
|
10701
|
-
|
|
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.
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
10949
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
11383
|
-
if (
|
|
11384
|
-
return json(res, 400, { error: "
|
|
11385
|
-
|
|
11386
|
-
|
|
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,
|
|
11392
|
-
|
|
11393
|
-
|
|
11394
|
-
|
|
11395
|
-
|
|
11396
|
-
|
|
11397
|
-
|
|
11398
|
-
|
|
11399
|
-
|
|
11400
|
-
|
|
11401
|
-
|
|
11402
|
-
|
|
11403
|
-
|
|
11404
|
-
|
|
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
|