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