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