openmausbot 0.1.80 → 0.1.82
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/README.md +6 -5
- package/dist/assets/{index-D6RZKWks.js → index-5HVwp5m2.js} +1 -1
- package/dist/assets/index-BLLsDK2F.js +309 -0
- package/dist/assets/index-CKysBq-V.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +41 -10
- package/dist-server/drivers/agents-proxy.js +50 -11
- package/dist-server/index.js +3525 -1296
- package/dist-server/openmausbot.js +412 -84
- package/dist-server/pair-cli.js +412 -84
- package/dist-server/server/box-delete-journal.js +471 -0
- package/dist-server/server/box.js +345 -75
- package/dist-server/server/cli-phone-setup.js +2 -2
- package/dist-server/server/cli.js +84 -12
- package/dist-server/server/config.js +48 -18
- package/dist-server/server/container-computer.js +7 -0
- package/dist-server/server/contracts.js +5 -0
- package/dist-server/server/default-model-selection.js +1 -0
- package/dist-server/server/drivers/acp/core.js +113 -19
- package/dist-server/server/drivers/acp/opencode-go.js +19 -0
- package/dist-server/server/drivers/agents-proxy.js +49 -11
- package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
- package/dist-server/server/drivers/claude.js +33 -2
- package/dist-server/server/drivers/codex.js +93 -8
- package/dist-server/server/drivers/native.js +6 -1
- package/dist-server/server/harness/bus.js +4 -0
- package/dist-server/server/harness/registry.js +1 -0
- package/dist-server/server/index.js +869 -226
- package/dist-server/server/local-vm-lease.js +4 -0
- package/dist-server/server/mcp-http.js +337 -0
- package/dist-server/server/mcp-probe.js +67 -18
- package/dist-server/server/mcp-registry.js +153 -58
- package/dist-server/server/member-turn.js +13 -0
- package/dist-server/server/message-db.js +74 -8
- package/dist-server/server/notify.js +8 -3
- package/dist-server/server/recall-disclosure.js +4 -0
- package/dist-server/server/recent-work.js +142 -0
- package/dist-server/server/request-auth.js +3 -1
- package/dist-server/server/routines.js +36 -1
- package/dist-server/server/screen-frame-gate.js +4 -94
- package/dist-server/server/sessions.js +22 -3
- package/dist-server/server/store.js +16 -1
- package/dist-server/server/surface.js +41 -19
- package/dist-server/server/team-setup-requests.js +1 -1
- package/dist-server/server/thread-log-rotation.js +104 -0
- package/dist-server/server/tts/fish.js +155 -0
- package/dist-server/server/tts/index.js +25 -3
- package/dist-server/server/turn-resources.js +7 -2
- package/dist-server/server/vps-computer.js +26 -4
- package/dist-server/server/vps-ssh.js +88 -0
- package/dist-server/server/workspace-backup.js +11 -0
- package/dist-server/server/workspace.js +2 -1
- package/dist-server/shared/credential-request.js +10 -0
- package/dist-server/shared/tool-surface.js +62 -0
- package/dist-server/vps-container-mcp.js +41 -10
- package/package.json +1 -1
- package/dist/assets/index-Cu8BkIfo.js +0 -305
- package/dist/assets/index-tfpeJAIG.css +0 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// folds one SSE event stream; every provider process runs here.
|
|
4
4
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { rm as removeDirectory } from "node:fs/promises";
|
|
6
7
|
import { createServer } from "node:http";
|
|
7
8
|
import { extname, join } from "node:path";
|
|
8
9
|
import { z } from "zod";
|
|
@@ -31,13 +32,14 @@ import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from
|
|
|
31
32
|
import * as box from "./box.js";
|
|
32
33
|
import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
|
|
33
34
|
import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-idempotency.js";
|
|
35
|
+
import { boxDeletionSnapshot } from "./box-delete-journal.js";
|
|
34
36
|
import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
|
|
35
37
|
import * as composio from "./composio.js";
|
|
36
38
|
import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
|
|
37
39
|
import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
|
|
38
40
|
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
39
41
|
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
|
|
40
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
|
|
42
|
+
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, threadEventLogMaxBytes, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
|
|
41
43
|
import { ComputerControl } from "./computer-control.js";
|
|
42
44
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
43
45
|
import { registerEnginesBinDir } from "./engine-install.js";
|
|
@@ -49,7 +51,7 @@ import { entitled } from "./enterprise.js";
|
|
|
49
51
|
import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
|
|
50
52
|
import { describeSpawnFailure, execCli } from "./procs.js";
|
|
51
53
|
import { blockedTarget, buildNotification } from "./notify.js";
|
|
52
|
-
import { isEffortLevel, newId, } from "./contracts.js";
|
|
54
|
+
import { isEffortLevel, isModelVariant, newId, } from "./contracts.js";
|
|
53
55
|
import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
|
|
54
56
|
import { decodeGeneratedImage } from "./generated-image.js";
|
|
55
57
|
import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
@@ -57,8 +59,9 @@ import { probeMcpServer } from "./mcp-probe.js";
|
|
|
57
59
|
import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
|
|
58
60
|
import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
|
|
59
61
|
import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
|
|
60
|
-
import { readMessageText, recallMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
|
|
61
|
-
import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
62
|
+
import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
|
|
63
|
+
import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
64
|
+
import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
|
|
62
65
|
/** A session_read answer competes with the transcript for the context
|
|
63
66
|
* window; a computer-use turn's output can run to hundreds of KB. */
|
|
64
67
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
@@ -120,8 +123,9 @@ import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageAgentAsMember, parseBotPac
|
|
|
120
123
|
import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.js";
|
|
121
124
|
import { takeImportName } from "../shared/import-name.js";
|
|
122
125
|
import { readThreadEvents } from "./thread-events.js";
|
|
126
|
+
import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
|
|
123
127
|
import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
|
|
124
|
-
import { memberTurnSelection } from "./member-turn.js";
|
|
128
|
+
import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
|
|
125
129
|
import { WebhookManager } from "./webhooks.js";
|
|
126
130
|
import { SPAWNED_PROXIES } from "./proxy-paths.js";
|
|
127
131
|
import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.js";
|
|
@@ -130,8 +134,9 @@ import { createBotPackageExport } from "./package-export.js";
|
|
|
130
134
|
import { createTeamBackup, importTeamBackup } from "./team-backup.js";
|
|
131
135
|
import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
|
|
132
136
|
import { shouldMountLocalComputer } from "./local-routing.js";
|
|
137
|
+
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
133
138
|
import { modelContextWindow } from "./model-context-window.js";
|
|
134
|
-
import { resolveSurface } from "./surface.js";
|
|
139
|
+
import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
135
140
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
136
141
|
import { createGracefulShutdown } from "./graceful-shutdown.js";
|
|
137
142
|
import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
|
|
@@ -222,6 +227,10 @@ let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
|
|
|
222
227
|
// Where remote clients reach this server (a proxy's public address); pairing URLs use it.
|
|
223
228
|
const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
|
|
224
229
|
const cfg = loadConfig();
|
|
230
|
+
// The per-thread event log cap is checked after every NDJSON append.
|
|
231
|
+
// config.json is read once per process (a change restarts the server, like
|
|
232
|
+
// every other hand-edited knob), so a binding made here never goes stale.
|
|
233
|
+
bindThreadLogCapProvider(() => threadEventLogMaxBytes(cfg));
|
|
225
234
|
const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
|
|
226
235
|
// "Sign in with your email" on /pair: the allow-list is read per call so a
|
|
227
236
|
// Settings change or an env bootstrap applies without a restart.
|
|
@@ -388,6 +397,7 @@ const INTERNAL_CAPABILITY_ORPHAN_MS = 30 * 24 * 60 * 60_000;
|
|
|
388
397
|
const internalCapabilities = new Map();
|
|
389
398
|
const activeInternalGenerationByThread = new Map();
|
|
390
399
|
const internalGenerationByProviderTurn = new ProviderTurnGenerationRegistry();
|
|
400
|
+
const computerSelectionTurns = new Map();
|
|
391
401
|
function beginInternalCapabilityGeneration(threadId, generation = randomUUID()) {
|
|
392
402
|
const previous = activeInternalGenerationByThread.get(threadId);
|
|
393
403
|
if (previous)
|
|
@@ -418,6 +428,7 @@ function revokeInternalCapabilityGeneration(threadId, generation) {
|
|
|
418
428
|
internalGenerationByProviderTurn.deleteGeneration(threadId, generation);
|
|
419
429
|
}
|
|
420
430
|
function revokeInternalCapabilitiesForThread(threadId) {
|
|
431
|
+
computerSelectionTurns.delete(threadId);
|
|
421
432
|
const generation = activeInternalGenerationByThread.get(threadId);
|
|
422
433
|
if (generation)
|
|
423
434
|
revokeInternalCapabilityGeneration(threadId, generation);
|
|
@@ -430,6 +441,7 @@ function revokeInternalCapabilitiesForThread(threadId) {
|
|
|
430
441
|
}
|
|
431
442
|
}
|
|
432
443
|
function revokeAllInternalCapabilities() {
|
|
444
|
+
computerSelectionTurns.clear();
|
|
433
445
|
internalCapabilities.clear();
|
|
434
446
|
activeInternalGenerationByThread.clear();
|
|
435
447
|
internalGenerationByProviderTurn.clear();
|
|
@@ -464,6 +476,10 @@ function authorizedInternalCapability(header) {
|
|
|
464
476
|
return null;
|
|
465
477
|
}
|
|
466
478
|
function internalCapabilityIsActive(capability) {
|
|
479
|
+
const switching = computerSelectionTurns.get(capability.threadId);
|
|
480
|
+
if ((capability.kind === "computer" || capability.kind === "browser") &&
|
|
481
|
+
switching?.generation === capability.generation && switching.selected)
|
|
482
|
+
return false;
|
|
467
483
|
if (capability.teamComputerId) {
|
|
468
484
|
const pinned = teamComputerTurns.get(capability.threadId);
|
|
469
485
|
if (pinned?.computerId !== capability.teamComputerId || pinned.owner.generation !== capability.generation ||
|
|
@@ -600,9 +616,39 @@ function releaseTurnResources(owner) {
|
|
|
600
616
|
teamComputerTurns.delete(owner.threadId);
|
|
601
617
|
}
|
|
602
618
|
}
|
|
603
|
-
function bindTurnComputer(owner, resource, exclusive = false) {
|
|
604
|
-
|
|
605
|
-
|
|
619
|
+
async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
620
|
+
const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
|
|
621
|
+
turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
|
|
622
|
+
let waitingMessage;
|
|
623
|
+
const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
|
|
624
|
+
try {
|
|
625
|
+
while (true) {
|
|
626
|
+
if (!active())
|
|
627
|
+
throw new DirectTurnSetupCancelled("Computer wait cancelled");
|
|
628
|
+
if (!exclusive || claimTurnResource(owner, resource))
|
|
629
|
+
break;
|
|
630
|
+
if (!waitingMessage) {
|
|
631
|
+
const blocker = turnResources.blocker(resource, owner);
|
|
632
|
+
const holderBot = blocker && store.botByThread(blocker.threadId);
|
|
633
|
+
const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
|
|
634
|
+
const holder = holderBot ? `${holderBot.name}${holderTask?.title ? ` / ${holderTask.title}` : ""}`
|
|
635
|
+
: blocker && store.groupByThread(blocker.threadId)?.name;
|
|
636
|
+
waitingMessage = store.appendMessage(owner.threadId, {
|
|
637
|
+
role: "bot", kind: "activity",
|
|
638
|
+
tool: { name: `Waiting for computer${holder ? ` — ${holder} is using it` : ""}; will continue automatically` },
|
|
639
|
+
...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (Date.now() >= deadline)
|
|
643
|
+
throw new Error("Computer is still busy. Stop the turn using it, then retry.");
|
|
644
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
if (waitingMessage)
|
|
649
|
+
store.patchMessage(owner.threadId, waitingMessage.id, {
|
|
650
|
+
tool: { name: active() && turnResources.owns(resource, owner) ? "Computer available — continuing" : "Computer wait ended", ok: true },
|
|
651
|
+
});
|
|
606
652
|
}
|
|
607
653
|
turnResourceOwners.set(owner.threadId, owner);
|
|
608
654
|
turnComputerResources.set(owner.threadId, { owner, resource });
|
|
@@ -1019,9 +1065,19 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
|
|
|
1019
1065
|
}
|
|
1020
1066
|
selection.effort = value.effort;
|
|
1021
1067
|
}
|
|
1068
|
+
if (value.variant !== undefined) {
|
|
1069
|
+
if (!isModelVariant(value.variant)) {
|
|
1070
|
+
return { ok: false, status: 400, error: "variant must be a non-empty model variant ID" };
|
|
1071
|
+
}
|
|
1072
|
+
if (value.effort !== undefined) {
|
|
1073
|
+
return { ok: false, status: 400, error: "choose either a model variant or an effort level" };
|
|
1074
|
+
}
|
|
1075
|
+
selection.variant = value.variant;
|
|
1076
|
+
}
|
|
1022
1077
|
const changed = current && (selection.instanceId !== current.selection.instanceId ||
|
|
1023
1078
|
selection.model !== current.selection.model ||
|
|
1024
|
-
selection.effort !== current.selection.effort
|
|
1079
|
+
selection.effort !== current.selection.effort ||
|
|
1080
|
+
selection.variant !== current.selection.variant);
|
|
1025
1081
|
if (current?.busy && changed) {
|
|
1026
1082
|
return { ok: false, status: 409, error: "the bot is working — stop it before changing models" };
|
|
1027
1083
|
}
|
|
@@ -1051,6 +1107,9 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
|
|
|
1051
1107
|
if (target && selection.effort !== undefined && !allowed.includes(selection.effort)) {
|
|
1052
1108
|
return { ok: false, status: 400, error: `effort "${selection.effort}" is not offered by this bot's engine` };
|
|
1053
1109
|
}
|
|
1110
|
+
if (target && selection.variant !== undefined && !target.adapter.capabilities.modelVariants) {
|
|
1111
|
+
return { ok: false, status: 400, error: "model variants are not offered by this bot's engine" };
|
|
1112
|
+
}
|
|
1054
1113
|
return { ok: true, selection };
|
|
1055
1114
|
}
|
|
1056
1115
|
function checkedTaskModelSwitch(current, raw, updateBotDefault, resetApprovalToAsk, requireAvailableModel = false, trusted = false) {
|
|
@@ -1237,7 +1296,7 @@ function previewSystemPrompt(bot) {
|
|
|
1237
1296
|
]
|
|
1238
1297
|
.filter(Boolean)
|
|
1239
1298
|
.join(" ");
|
|
1240
|
-
const instance =
|
|
1299
|
+
const instance = turnInstance(bot);
|
|
1241
1300
|
const caps = instance?.adapter.capabilities;
|
|
1242
1301
|
const teamComputer = inheritedTeamComputer(bot);
|
|
1243
1302
|
const previewComputer = teamComputer ? "cloud" : bot.computer;
|
|
@@ -1278,7 +1337,12 @@ function previewSystemPrompt(bot) {
|
|
|
1278
1337
|
},
|
|
1279
1338
|
{ id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
|
|
1280
1339
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
|
|
1281
|
-
|
|
1340
|
+
// Auto cannot know its place until dispatch, so the preview stays silent
|
|
1341
|
+
// there and only carries the note; explicit settings preview the paragraph.
|
|
1342
|
+
{ id: "plan", label: "Surface", text: previewPlan.computer === undefined ? "" : surfacePrompt({
|
|
1343
|
+
computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
|
|
1344
|
+
browser: previewPlan.computer === undefined ? false : previewPlan.browser,
|
|
1345
|
+
}, { note: previewPlan.note }) },
|
|
1282
1346
|
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
|
|
1283
1347
|
{ id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(customMcpServers(cfg, bot.mcpServers))) : "" },
|
|
1284
1348
|
{ id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
@@ -2051,7 +2115,16 @@ function outstandingAssignmentsPrompt(threadId) {
|
|
|
2051
2115
|
const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
2052
2116
|
validate: (node, parent) => roomHandoffProblem(node, parent) ??
|
|
2053
2117
|
(parent && store.bot(parent.botId)?.approvePeerComms && !fullAccessForSource(parent.botId, parent.threadId) && !node.approvalGranted ? "Sender now requires peer approval; submit a new approved request" : undefined),
|
|
2054
|
-
|
|
2118
|
+
// A direct follow-up is owed to one conversation, so it waits for that
|
|
2119
|
+
// conversation, not for the whole bot. Bot-level busy aggregates every
|
|
2120
|
+
// thread — including cards still waiting on the person — so one busy
|
|
2121
|
+
// sibling thread would otherwise starve the owed resume forever while the
|
|
2122
|
+
// UI keeps showing this thread working. Fresh work still queues behind a
|
|
2123
|
+
// busy teammate's whole bot (#1238); an owed resume only needs its own
|
|
2124
|
+
// thread free and a thread slot to admit it.
|
|
2125
|
+
busy: n => !n.groupId && n.status === "resume"
|
|
2126
|
+
? threadBusy(n.botId, n.threadId) || botAtThreadCapacity(n.botId)
|
|
2127
|
+
: Boolean(store.bot(n.botId)?.busy || (n.groupId && store.group(n.groupId) && groupIsWorking(store.group(n.groupId)))),
|
|
2055
2128
|
changed: (groupIds, directThreadIds) => {
|
|
2056
2129
|
for (const id of groupIds) {
|
|
2057
2130
|
const group = store.group(id);
|
|
@@ -2900,10 +2973,12 @@ const watchdog = new TurnWatchdog({
|
|
|
2900
2973
|
groupSpeakers.delete(turn.threadId);
|
|
2901
2974
|
store.patchGroup(group.id, { busyBotId: null, unread: true });
|
|
2902
2975
|
}
|
|
2976
|
+
// A cleared UI busy flag must not strand this finished generation's
|
|
2977
|
+
// computer claim. The generation checks above protect replacements.
|
|
2978
|
+
releaseTurnResources(stalledResourceOwner);
|
|
2903
2979
|
const currentBot = store.bot(turn.botId);
|
|
2904
2980
|
if (currentBot?.busy) {
|
|
2905
2981
|
stopScreenPoller(currentBot.id, turn.threadId);
|
|
2906
|
-
releaseTurnResources(stalledResourceOwner);
|
|
2907
2982
|
if (activeVpsThreads.get(currentBot.id) === turn.threadId)
|
|
2908
2983
|
activeVpsThreads.delete(currentBot.id);
|
|
2909
2984
|
if (store.taskByThread(currentBot.id, turn.threadId))
|
|
@@ -2963,6 +3038,41 @@ bus.subscribe((event) => {
|
|
|
2963
3038
|
endMemoryTurn(event.threadId);
|
|
2964
3039
|
}
|
|
2965
3040
|
});
|
|
3041
|
+
// One line per finished turn in the bot's daily log (server/recent-work.ts):
|
|
3042
|
+
// what it said last, the tools it used, whether the turn failed. What a bot
|
|
3043
|
+
// did in one conversation was invisible from every other; the log is where
|
|
3044
|
+
// session_search finds it later, and it is never loaded whole into a
|
|
3045
|
+
// prompt. In a room the reply names its speaker; a room turn that never
|
|
3046
|
+
// replied has no one to credit and leaves no line.
|
|
3047
|
+
bus.subscribe((event) => {
|
|
3048
|
+
if (shouldIgnoreProviderEvent(event))
|
|
3049
|
+
return;
|
|
3050
|
+
if (event.type !== "turn.completed" || !event.turnId)
|
|
3051
|
+
return;
|
|
3052
|
+
try {
|
|
3053
|
+
const messages = store.messagesFor(event.threadId);
|
|
3054
|
+
const reply = messages.findLast((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
|
|
3055
|
+
const bot = store.botByThread(event.threadId) ?? (reply?.from ? store.bot(reply.from.botId) : undefined);
|
|
3056
|
+
if (!bot)
|
|
3057
|
+
return;
|
|
3058
|
+
const tools = messages
|
|
3059
|
+
.filter((message) => message.kind === "activity" && message.turnId === event.turnId && message.tool?.name)
|
|
3060
|
+
.map((message) => message.tool.name);
|
|
3061
|
+
const line = turnOutcomeLine({ ok: event.ok, reply: reply?.text, stopReason: event.stopReason, tools });
|
|
3062
|
+
if (!line)
|
|
3063
|
+
return;
|
|
3064
|
+
appendMemoryLog(bot.id, line, {
|
|
3065
|
+
source: memorySourceLabel({
|
|
3066
|
+
room: store.groupByThread(event.threadId),
|
|
3067
|
+
task: store.taskByThread(bot.id, event.threadId),
|
|
3068
|
+
threadId: event.threadId,
|
|
3069
|
+
}),
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
catch {
|
|
3073
|
+
// a missing note never fails a turn
|
|
3074
|
+
}
|
|
3075
|
+
});
|
|
2966
3076
|
// Bots currently working with nobody at the keyboard — a webhook turn, or a
|
|
2967
3077
|
// turn a webhook-driven bot handed to a teammate. Auto mode is a decision
|
|
2968
3078
|
// someone made for turns they were present for, so these don't inherit it:
|
|
@@ -3042,6 +3152,15 @@ const localVmLeases = new LocalVmLeasePool(30 * 60_000);
|
|
|
3042
3152
|
const localVmLifecycleBusy = new Set();
|
|
3043
3153
|
const localVmThreadTargets = new Map();
|
|
3044
3154
|
const localVmActiveThreads = new Map();
|
|
3155
|
+
/** Local VM targets this process has seen ready or recreatable — at boot, in
|
|
3156
|
+
* the inventory, or in a turn. Auto probes the container runtime for a VM
|
|
3157
|
+
* only when one of these exists, so an ordinary Auto turn on a machine with
|
|
3158
|
+
* no VM never pays for a docker or podman call. */
|
|
3159
|
+
const localVmSeen = new Set();
|
|
3160
|
+
function noteLocalVmSeen(target, status) {
|
|
3161
|
+
if (status && autoLocalVmAttachable(status))
|
|
3162
|
+
localVmSeen.add(target.key);
|
|
3163
|
+
}
|
|
3045
3164
|
let localVmImageBusy = false;
|
|
3046
3165
|
let localVmProvisionBusy = false;
|
|
3047
3166
|
let localVmModeChangeBusy = false;
|
|
@@ -3136,12 +3255,12 @@ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
|
|
|
3136
3255
|
throw new Error("The team computer is being changed; wait for it to finish");
|
|
3137
3256
|
if (computerControl.snapshot(ownerId).held)
|
|
3138
3257
|
throw new Error("Release human control of the team computer before starting another turn");
|
|
3139
|
-
bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
|
|
3258
|
+
await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
|
|
3140
3259
|
teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
|
|
3141
3260
|
let machine = await box.findBox(cfg, ownerId);
|
|
3142
3261
|
if (!machine)
|
|
3143
3262
|
throw new Error("The team's Box computer is missing; explicitly create or retry it from the Team map");
|
|
3144
|
-
bindTurnComputer(owner, `computer:box:${machine.id}`, true);
|
|
3263
|
+
await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
|
|
3145
3264
|
const action = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
|
|
3146
3265
|
if (action === "wake")
|
|
3147
3266
|
machine = await box.readyBox(cfg, ownerId);
|
|
@@ -3201,7 +3320,8 @@ function providerOperationConflict(provider) {
|
|
|
3201
3320
|
if (boxInventoryRequestsBusyIds.size > 0 || orphanBoxLifecycleBusyIds.size > 0) {
|
|
3202
3321
|
return "wait for cloud computer actions to finish before changing the Box account";
|
|
3203
3322
|
}
|
|
3204
|
-
|
|
3323
|
+
const deletingBoxIds = new Set(boxDeletionSnapshot().map((entry) => entry.boxId));
|
|
3324
|
+
if (boxCreateRecoverySnapshot().some((entry) => !entry.resolved && (!entry.boxId || !deletingBoxIds.has(entry.boxId)))) {
|
|
3205
3325
|
return "finish reconciling pending cloud computer creation before changing the Box account";
|
|
3206
3326
|
}
|
|
3207
3327
|
}
|
|
@@ -3210,34 +3330,168 @@ function providerOperationConflict(provider) {
|
|
|
3210
3330
|
}
|
|
3211
3331
|
return null;
|
|
3212
3332
|
}
|
|
3213
|
-
function
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3333
|
+
function turnSurfacePlan(bot, runOn, threadId) {
|
|
3334
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
3335
|
+
const forcedBox = runOn === "cloud" || Boolean(inheritedTeamComputer(bot));
|
|
3336
|
+
return resolveSurface({
|
|
3337
|
+
destination: forcedBox ? "cloud" : bot.computer,
|
|
3338
|
+
pinnedSurface: forcedBox || !threadId ? null : store.taskByThread(bot.id, threadId)?.surface,
|
|
3339
|
+
browserOn: builtInBrowserEnabled(cfg) && bot.browser !== false && instance?.adapter.capabilities.browserMcp === true,
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
function turnProvider(bot, runOn, threadId) {
|
|
3343
|
+
if (runOn === "cloud" || inheritedTeamComputer(bot))
|
|
3217
3344
|
return "box";
|
|
3218
|
-
|
|
3345
|
+
const wants = turnSurfacePlan(bot, runOn, threadId).computer;
|
|
3346
|
+
if (wants !== undefined && wants !== "cloud")
|
|
3219
3347
|
return null;
|
|
3220
|
-
|
|
3348
|
+
if (registry.get(bot.modelSelection.instanceId)?.driverKind === "boxAgent")
|
|
3349
|
+
return "box";
|
|
3350
|
+
return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
|
|
3221
3351
|
}
|
|
3222
3352
|
/** A turn on the cloud computer runs ON the cloud computer: the Box runs the
|
|
3223
3353
|
* bot's own harness there with the computer tools built in, so nothing on this
|
|
3224
3354
|
* machine relays clicks and screenshots. Every start/interrupt of a turn asks
|
|
3225
3355
|
* here which engine owns it. */
|
|
3226
|
-
function turnInstance(bot, runOn) {
|
|
3227
|
-
const onBox = runOn === "
|
|
3228
|
-
|| turnProvider(bot, runOn) === "box" && (bot.computer === "cloud" || inheritedTeamComputer(bot) !== undefined);
|
|
3356
|
+
function turnInstance(bot, runOn, threadId) {
|
|
3357
|
+
const onBox = turnProvider(bot, runOn, threadId) === "box";
|
|
3229
3358
|
return onBox
|
|
3230
3359
|
? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
|
|
3231
3360
|
: registry.get(bot.modelSelection.instanceId);
|
|
3232
3361
|
}
|
|
3362
|
+
/** Preview requests carry the selected conversation, not whichever thread
|
|
3363
|
+
* happens to be the bot's default. A query never grants lifecycle authority. */
|
|
3364
|
+
function computerPreviewBot(botId, url) {
|
|
3365
|
+
const threadId = url.searchParams.get("threadId");
|
|
3366
|
+
if (!threadId)
|
|
3367
|
+
return store.bot(botId);
|
|
3368
|
+
const bot = store.projectBotForTask(botId, threadId);
|
|
3369
|
+
if (!bot)
|
|
3370
|
+
throw Object.assign(new Error("no such task"), { status: 404 });
|
|
3371
|
+
return directTurnBots.get(threadId) ?? bot;
|
|
3372
|
+
}
|
|
3373
|
+
async function computerPreviewSurface(bot, threadId) {
|
|
3374
|
+
const plan = turnSurfacePlan(bot, undefined, threadId);
|
|
3375
|
+
if (plan.computer !== undefined)
|
|
3376
|
+
return plan.computer === "off" && plan.browser ? "browser" : plan.computer;
|
|
3377
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
3378
|
+
if (instance?.driverKind === "boxAgent")
|
|
3379
|
+
return "cloud";
|
|
3380
|
+
if (bot.cloudBackend === "vps") {
|
|
3381
|
+
const remote = await vps.vpsComputerStatus(cfg, bot.id);
|
|
3382
|
+
if (remote.ready)
|
|
3383
|
+
return "cloud";
|
|
3384
|
+
}
|
|
3385
|
+
const target = localVmTargetForBot(bot.id);
|
|
3386
|
+
if (instance?.adapter.capabilities.computerMcp && localVmSeen.has(target.key)) {
|
|
3387
|
+
const vm = await containerComputerStatus(undefined, undefined, target).catch(() => null);
|
|
3388
|
+
if (vm && autoLocalVmAttachable(vm))
|
|
3389
|
+
return "vm";
|
|
3390
|
+
}
|
|
3391
|
+
if (shouldMountLocalComputer({ requested: undefined, hostPlatform: process.platform,
|
|
3392
|
+
providerSupportsLocal: instance?.adapter.capabilities.localComputerMcp === true }) && readCuaConnection())
|
|
3393
|
+
return "local";
|
|
3394
|
+
if (bot.cloudBackend === "vps")
|
|
3395
|
+
return "cloud"; // show its unavailable reason
|
|
3396
|
+
return plan.browser ? "browser" : "off";
|
|
3397
|
+
}
|
|
3398
|
+
/** Discovery is read-only. Starting or creating a configured computer is
|
|
3399
|
+
* deferred until a chat tool selects it and the old turn releases its tools. */
|
|
3400
|
+
async function selectableComputers(bot) {
|
|
3401
|
+
const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
|
|
3402
|
+
const off = bot.computer === "off";
|
|
3403
|
+
const localEngine = registry.get(bot.modelSelection.instanceId)?.driverKind !== "boxAgent";
|
|
3404
|
+
return Promise.all(["cloud", "vm", "local", "browser"].map(async (surface) => {
|
|
3405
|
+
let ready = false;
|
|
3406
|
+
let canStart = false;
|
|
3407
|
+
let canCreate = false;
|
|
3408
|
+
let reason = "This computer is not configured or running. Open the Computer panel to set it up.";
|
|
3409
|
+
try {
|
|
3410
|
+
if (off)
|
|
3411
|
+
reason = "Computer access is Off in this bot's settings.";
|
|
3412
|
+
else if (surface === "cloud") {
|
|
3413
|
+
if (bot.cloudBackend === "vps") {
|
|
3414
|
+
const status = localEngine && caps?.computerMcp ? await vps.vpsComputerStatus(cfg, bot.id) : null;
|
|
3415
|
+
ready = status?.ready === true;
|
|
3416
|
+
canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
|
|
3417
|
+
status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
|
|
3418
|
+
canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
|
|
3419
|
+
reason = status?.problem ?? reason;
|
|
3420
|
+
}
|
|
3421
|
+
else if (box.boxConfigured(cfg) && registry.instances().some(instance => instance.driverKind === "boxAgent")) {
|
|
3422
|
+
const status = await box.boxStatus(cfg, bot.id);
|
|
3423
|
+
const lifecycle = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
|
|
3424
|
+
ready = lifecycle === "attach";
|
|
3425
|
+
canStart = lifecycle === "wake";
|
|
3426
|
+
canCreate = lifecycle === "provision";
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
else if (surface === "vm" && localEngine && caps?.computerMcp) {
|
|
3430
|
+
const target = localVmTargetForBot(bot.id);
|
|
3431
|
+
const status = await containerComputerStatus(undefined, undefined, target);
|
|
3432
|
+
ready = status.ready;
|
|
3433
|
+
canCreate = !ready && autoLocalVmAttachable(status);
|
|
3434
|
+
if (ready || canCreate)
|
|
3435
|
+
noteLocalVmSeen(target, status);
|
|
3436
|
+
reason = status.problem ?? reason;
|
|
3437
|
+
}
|
|
3438
|
+
else if (surface === "local") {
|
|
3439
|
+
ready = shouldMountLocalComputer({ requested: "local", hostPlatform: process.platform,
|
|
3440
|
+
providerSupportsLocal: caps?.localComputerMcp === true }) && Boolean(readCuaConnection());
|
|
3441
|
+
}
|
|
3442
|
+
else if (surface === "browser") {
|
|
3443
|
+
ready = caps?.browserMcp === true && builtInBrowserEnabled(cfg) && bot.browser !== false && browserEngineStatus().kind === "ready";
|
|
3444
|
+
reason = "The built-in browser is disabled, not installed, or unsupported by this model engine.";
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
catch (error) {
|
|
3448
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
3449
|
+
}
|
|
3450
|
+
const available = ready || canStart || canCreate;
|
|
3451
|
+
return { surface, label: surfaceLabel(surface), available, ready, canStart, canCreate, ...(!available ? { reason } : {}) };
|
|
3452
|
+
}));
|
|
3453
|
+
}
|
|
3454
|
+
function continueComputerSelection(threadId, generation, succeeded) {
|
|
3455
|
+
const selection = computerSelectionTurns.get(threadId);
|
|
3456
|
+
if (!selection || selection.generation !== generation)
|
|
3457
|
+
return false;
|
|
3458
|
+
if (!succeeded || !selection.selected) {
|
|
3459
|
+
computerSelectionTurns.delete(threadId);
|
|
3460
|
+
return false;
|
|
3461
|
+
}
|
|
3462
|
+
const surface = selection.selected;
|
|
3463
|
+
setImmediate(() => {
|
|
3464
|
+
// Stop, deletion, a new user send, or any replacement generation wins.
|
|
3465
|
+
if (computerSelectionTurns.get(threadId) !== selection || directTurnGenerationByThread.get(threadId) !== generation)
|
|
3466
|
+
return;
|
|
3467
|
+
computerSelectionTurns.delete(threadId);
|
|
3468
|
+
const bot = store.projectBotForTask(selection.botId, threadId);
|
|
3469
|
+
if (!bot || bot.computer === "off" || threadBusy(bot.id, threadId))
|
|
3470
|
+
return;
|
|
3471
|
+
if (store.taskByThread(bot.id, threadId)?.surface !== selection.previousSurface)
|
|
3472
|
+
return;
|
|
3473
|
+
if (queuedThreadPosition(bot.id, threadId) !== null)
|
|
3474
|
+
return;
|
|
3475
|
+
if (store.activePath(threadId).findLast(message => message.role === "user" && message.kind === "text")?.id !== selection.source.id)
|
|
3476
|
+
return;
|
|
3477
|
+
store.patchTask(bot.id, threadId, { surface });
|
|
3478
|
+
const text = `The computer selection is now ${surfaceLabel(surface)}. Continue the user's original request using the tools mounted for this turn; verify the result before claiming success.\n\n${selection.text}`;
|
|
3479
|
+
void startTurn(bot.id, text, { threadId, userMessage: selection.source, computerSelectionContinuation: true }).catch(error => {
|
|
3480
|
+
if (store.taskByThread(bot.id, threadId))
|
|
3481
|
+
store.appendMessage(threadId, { role: "bot", kind: "activity",
|
|
3482
|
+
tool: { name: `Could not continue on ${surfaceLabel(surface)}: ${error instanceof Error ? error.message : String(error)}`, ok: false } });
|
|
3483
|
+
});
|
|
3484
|
+
});
|
|
3485
|
+
return true;
|
|
3486
|
+
}
|
|
3233
3487
|
/** The engine that dispatched each live turn. A bot's settings may change
|
|
3234
3488
|
* mid-turn; an interrupt or steer must reach the engine actually running. */
|
|
3235
3489
|
const runningTurnEngines = new Map();
|
|
3236
3490
|
function runningTurnInstance(bot, threadId, runOn) {
|
|
3237
|
-
return runningTurnEngines.get(threadId) ?? turnInstance(bot, runOn);
|
|
3491
|
+
return runningTurnEngines.get(threadId) ?? turnInstance(bot, runOn, threadId);
|
|
3238
3492
|
}
|
|
3239
|
-
function providerTransitionForTurn(bot, runOn) {
|
|
3240
|
-
const provider = turnProvider(bot, runOn);
|
|
3493
|
+
function providerTransitionForTurn(bot, runOn, threadId) {
|
|
3494
|
+
const provider = turnProvider(bot, runOn, threadId);
|
|
3241
3495
|
return provider && computerProviderConfigTransitions.has(provider)
|
|
3242
3496
|
? providerTransitionMessage(provider)
|
|
3243
3497
|
: null;
|
|
@@ -3338,6 +3592,7 @@ function releaseLocalVmThread(threadId) {
|
|
|
3338
3592
|
void (async () => {
|
|
3339
3593
|
if (localVmMode(cfg) !== "per-bot") {
|
|
3340
3594
|
const status = await containerComputerStatus(undefined, undefined, SHARED_LOCAL_VM_TARGET).catch(() => null);
|
|
3595
|
+
noteLocalVmSeen(SHARED_LOCAL_VM_TARGET, status);
|
|
3341
3596
|
if (shouldArmLocalVmIdle(status))
|
|
3342
3597
|
localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
|
|
3343
3598
|
return;
|
|
@@ -3348,6 +3603,7 @@ void (async () => {
|
|
|
3348
3603
|
const existing = await discoverExistingPerBotLocalVms(store.bots, runtime.runtime).catch(() => []);
|
|
3349
3604
|
const statuses = await Promise.all(existing.map(({ target }) => containerComputerStatus(undefined, undefined, target).catch(() => null)));
|
|
3350
3605
|
existing.forEach(({ target }, index) => {
|
|
3606
|
+
noteLocalVmSeen(target, statuses[index]);
|
|
3351
3607
|
if (shouldArmLocalVmIdle(statuses[index]))
|
|
3352
3608
|
localVmIdleFor(target).touch();
|
|
3353
3609
|
});
|
|
@@ -3371,6 +3627,7 @@ async function localVmInventoryPayload() {
|
|
|
3371
3627
|
const status = statuses[index];
|
|
3372
3628
|
if (!status)
|
|
3373
3629
|
return [];
|
|
3630
|
+
noteLocalVmSeen(target, status);
|
|
3374
3631
|
const inUse = localVmActiveThreads.has(target.key) ||
|
|
3375
3632
|
localVmLeaseFor(target).current(localVmOwnerBusy) !== null;
|
|
3376
3633
|
const entry = localVmInventoryEntry(bot, status, inUse);
|
|
@@ -3807,6 +4064,8 @@ bus.subscribe((event) => {
|
|
|
3807
4064
|
store.setTaskActivity(bot.id, event.threadId, "idle");
|
|
3808
4065
|
}
|
|
3809
4066
|
directTurnBots.delete(event.threadId);
|
|
4067
|
+
if (continueComputerSelection(event.threadId, generation, event.ok))
|
|
4068
|
+
return;
|
|
3810
4069
|
// Ordinary completion still drains from the existing bus subscribers.
|
|
3811
4070
|
// An asynchronous screenshot finishes after those subscribers ran.
|
|
3812
4071
|
if (resumeQueued) {
|
|
@@ -3868,7 +4127,7 @@ bus.subscribe((event) => {
|
|
|
3868
4127
|
// A failed peer turn stays a chip too. The bot that delegated is woken
|
|
3869
4128
|
// with the failure and answers the person in its own thread — buzzing
|
|
3870
4129
|
// here as well would ring twice for one piece of news.
|
|
3871
|
-
if ((!routineRun || routineRun.status === "completed") && !internal) {
|
|
4130
|
+
if ((!routineRun || routineRun.status === "completed") && !internal && !computerSelectionTurns.get(event.threadId)?.selected) {
|
|
3872
4131
|
// the frame carries the bot's avatar so every desktop client can
|
|
3873
4132
|
// show the notification under that bot's own face
|
|
3874
4133
|
const completionDetail = routineRun
|
|
@@ -4622,7 +4881,7 @@ async function startTurn(botId, text, opts) {
|
|
|
4622
4881
|
if (bot.approvalGrant) {
|
|
4623
4882
|
throw Object.assign(new Error("this bot's approval level is still being confirmed — try again"), { status: 409 });
|
|
4624
4883
|
}
|
|
4625
|
-
const transitionError = providerTransitionForTurn(bot, opts?.runOn);
|
|
4884
|
+
const transitionError = providerTransitionForTurn(bot, opts?.runOn, threadId);
|
|
4626
4885
|
if (transitionError)
|
|
4627
4886
|
throw Object.assign(new Error(transitionError), { status: 409 });
|
|
4628
4887
|
if (providerFleetReloading)
|
|
@@ -4666,9 +4925,10 @@ async function startTurn(botId, text, opts) {
|
|
|
4666
4925
|
const task = store.taskByThread(bot.id, threadId);
|
|
4667
4926
|
if (!task)
|
|
4668
4927
|
throw Object.assign(new Error("no such task"), { status: 404 });
|
|
4669
|
-
const
|
|
4928
|
+
const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
|
|
4929
|
+
const instance = turnInstance(bot, opts?.runOn, threadId);
|
|
4670
4930
|
if (!instance) {
|
|
4671
|
-
throw Object.assign(new Error(opts?.runOn
|
|
4931
|
+
throw Object.assign(new Error(turnProvider(bot, opts?.runOn, threadId) === "box"
|
|
4672
4932
|
? "the Cloud VM runner is unavailable — configure Box in App Settings"
|
|
4673
4933
|
: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
4674
4934
|
}
|
|
@@ -4696,10 +4956,13 @@ async function startTurn(botId, text, opts) {
|
|
|
4696
4956
|
if (providerInstancesChanging.has(instanceId)) {
|
|
4697
4957
|
throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
|
|
4698
4958
|
}
|
|
4699
|
-
const
|
|
4959
|
+
const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
|
|
4960
|
+
const model = opts?.runOn === "cloud" || switchedEngine ? instance.models.default : bot.modelSelection.model;
|
|
4700
4961
|
// a cloud routine borrows the instance default model, so it borrows no
|
|
4701
4962
|
// per-bot effort either
|
|
4702
|
-
const effort = opts?.runOn === "cloud" ? undefined : bot.modelSelection.effort;
|
|
4963
|
+
const effort = opts?.runOn === "cloud" || switchedEngine ? undefined : bot.modelSelection.effort;
|
|
4964
|
+
const variant = opts?.runOn === "cloud" || switchedEngine ? undefined : bot.modelSelection.variant;
|
|
4965
|
+
assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
|
|
4703
4966
|
// A selection can be persisted while its engine is offline. Re-check when
|
|
4704
4967
|
// the engine returns so an old or unsupported value never reaches a CLI.
|
|
4705
4968
|
if (effort && !instance.adapter.capabilities.effortLevels?.includes(effort)) {
|
|
@@ -4833,6 +5096,12 @@ async function startTurn(botId, text, opts) {
|
|
|
4833
5096
|
directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
|
|
4834
5097
|
directTurnBots.set(threadId, bot);
|
|
4835
5098
|
beginInternalCapabilityGeneration(threadId, dispatchClaimId);
|
|
5099
|
+
if (!opts?.computerSelectionContinuation && !opts?.cardContinuation && !opts?.automationSource && !opts?.unattended &&
|
|
5100
|
+
!opts?.commsDepth && !opts?.coordination && !inheritedTeamComputer(bot) && bot.computer !== "off" && agentsMounted) {
|
|
5101
|
+
const source = store.activePath(threadId).findLast(message => message.id === userMessage?.id && message.role === "user" && !message.peerAsk);
|
|
5102
|
+
if (source)
|
|
5103
|
+
computerSelectionTurns.set(threadId, { generation: dispatchClaimId, botId: bot.id, source, text });
|
|
5104
|
+
}
|
|
4836
5105
|
store.setTaskActivity(bot.id, threadId, "working");
|
|
4837
5106
|
// A closed thread that gets a new turn is open again: the person (or the
|
|
4838
5107
|
// opener) picked it back up, so its row returns to the sidebar and
|
|
@@ -4922,21 +5191,23 @@ async function startTurn(botId, text, opts) {
|
|
|
4922
5191
|
const teamComputer = inheritedTeamComputer(bot);
|
|
4923
5192
|
const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
|
|
4924
5193
|
const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
|
|
4925
|
-
|
|
5194
|
+
// Box's native runner owns its computer tools. Local drivers mount
|
|
5195
|
+
// Local VM/VPS tools, but have no Box relay to execute this descriptor.
|
|
5196
|
+
const mountsCloudComputer = instance.driverKind === "boxAgent";
|
|
4926
5197
|
const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true;
|
|
4927
5198
|
// Where this turn's hands may land. The bot's "Works on" choice is
|
|
4928
5199
|
// strict; a browser-only bot gets no computer at all, and a bot whose
|
|
4929
5200
|
// browser is withheld (workspace flag, its own switch, or an engine
|
|
4930
5201
|
// without browser tools) gets told so instead of silently falling back
|
|
4931
5202
|
// to a desktop it was never meant to touch.
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
if (
|
|
5203
|
+
// The conversation's own place wins over the bot default: the person
|
|
5204
|
+
// pinned it from the composer, or its first Auto turn recorded where it
|
|
5205
|
+
// landed. A team computer or a cloud routine is not this conversation's
|
|
5206
|
+
// choice, so those ignore the pin.
|
|
5207
|
+
const dispatchTask = store.taskByThread(bot.id, threadId);
|
|
5208
|
+
if (plan.clearPin && dispatchTask)
|
|
5209
|
+
store.patchTask(bot.id, threadId, { surface: undefined });
|
|
5210
|
+
if (plan.computer !== undefined && plan.computer !== "cloud" && instance.driverKind === "boxAgent") {
|
|
4940
5211
|
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
4941
5212
|
}
|
|
4942
5213
|
const wants = plan.computer;
|
|
@@ -4946,47 +5217,77 @@ async function startTurn(botId, text, opts) {
|
|
|
4946
5217
|
let autoVpsProblem = null;
|
|
4947
5218
|
// Explicit destinations are strict. In particular, Local VM must never
|
|
4948
5219
|
// fall through to host CUA and accidentally click on the user's Mac.
|
|
4949
|
-
|
|
5220
|
+
// The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
|
|
5221
|
+
// is strict and throws with the reason. Auto only reaches a VM this bot
|
|
5222
|
+
// already has — ready now, or one whose desktop image is prepared and can
|
|
5223
|
+
// be recreated on demand after idling away — and never creates a first
|
|
5224
|
+
// VM on its own; every failure there is a quiet "not this place".
|
|
5225
|
+
const attachLocalVm = async (strict) => {
|
|
4950
5226
|
if (!mountsComputerMcp || instance.driverKind === "boxAgent") {
|
|
5227
|
+
if (!strict)
|
|
5228
|
+
return false;
|
|
4951
5229
|
throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
|
|
4952
5230
|
}
|
|
4953
5231
|
const localVmTarget = localVmTargetForBot(bot.id);
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
|
|
4970
|
-
}
|
|
4971
|
-
integrations.localComputer = containerComputerMcp(localVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
|
|
4972
|
-
computerKind = "vm";
|
|
4973
|
-
// Same contract as the Box and VPS branches below: without this the
|
|
4974
|
-
// poller never starts, so the Local VM publishes no `screen` events
|
|
4975
|
-
// and every client that only has the stream (the phone) waits
|
|
4976
|
-
// forever. The web panel hid the gap by polling the screenshot
|
|
4977
|
-
// route itself.
|
|
4978
|
-
previewCapture = () => {
|
|
4979
|
-
// The shared desktop outlives the turn. Once another thread owns
|
|
4980
|
-
// it, a capture still in flight would picture ITS work under this
|
|
4981
|
-
// bot's name — live and in the settled transcript frame, which is
|
|
4982
|
-
// taken after the lease is already released. No owner means the
|
|
4983
|
-
// desktop is simply idle: that final frame is ours to keep.
|
|
4984
|
-
const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
|
|
4985
|
-
if (owner && owner.threadId !== threadId) {
|
|
4986
|
-
throw new Error("the Local VM moved on to another turn");
|
|
5232
|
+
if (!strict) {
|
|
5233
|
+
// Nothing this process has ever seen for this target, and nobody is
|
|
5234
|
+
// relying on an unattended run: do not pay for a runtime probe.
|
|
5235
|
+
if (!localVmSeen.has(localVmTarget.key) && !opts?.automationSource)
|
|
5236
|
+
return false;
|
|
5237
|
+
const seen = await containerComputerStatus(undefined, undefined, localVmTarget).catch(() => null);
|
|
5238
|
+
if (!seen || !autoLocalVmAttachable(seen))
|
|
5239
|
+
return false;
|
|
5240
|
+
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key))
|
|
5241
|
+
return false;
|
|
5242
|
+
}
|
|
5243
|
+
try {
|
|
5244
|
+
await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
|
|
5245
|
+
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
|
|
5246
|
+
throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
|
|
4987
5247
|
}
|
|
4988
|
-
|
|
4989
|
-
|
|
5248
|
+
// Claim before the first await. The lifecycle route performs its
|
|
5249
|
+
// matching check synchronously, so neither side can enter while the
|
|
5250
|
+
// other is between inspection and mutation.
|
|
5251
|
+
if (!localVmLeaseFor(localVmTarget).claim(threadId, bot.id, localVmOwnerBusy)) {
|
|
5252
|
+
throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
|
|
5253
|
+
}
|
|
5254
|
+
localVmThreadTargets.set(threadId, localVmTarget);
|
|
5255
|
+
localVmActiveThreads.set(localVmTarget.key, threadId);
|
|
5256
|
+
localVmIdleFor(localVmTarget).touch();
|
|
5257
|
+
const localVm = await readyLocalVmForTurn(bot.id, localVmTarget);
|
|
5258
|
+
if (!localVm.ready || !localVm.runtime) {
|
|
5259
|
+
throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
|
|
5260
|
+
}
|
|
5261
|
+
integrations.localComputer = containerComputerMcp(localVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
|
|
5262
|
+
// Same contract as the Box and VPS branches below: without this the
|
|
5263
|
+
// poller never starts, so the Local VM publishes no `screen` events
|
|
5264
|
+
// and every client that only has the stream (the phone) waits
|
|
5265
|
+
// forever. The web panel hid the gap by polling the screenshot
|
|
5266
|
+
// route itself.
|
|
5267
|
+
previewCapture = () => {
|
|
5268
|
+
// The shared desktop outlives the turn. Once another thread owns
|
|
5269
|
+
// it, a capture still in flight would picture ITS work under this
|
|
5270
|
+
// bot's name — live and in the settled transcript frame, which is
|
|
5271
|
+
// taken after the lease is already released. No owner means the
|
|
5272
|
+
// desktop is simply idle: that final frame is ours to keep.
|
|
5273
|
+
const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
|
|
5274
|
+
if (owner && owner.threadId !== threadId) {
|
|
5275
|
+
throw new Error("the Local VM moved on to another turn");
|
|
5276
|
+
}
|
|
5277
|
+
return containerComputerFrame(undefined, undefined, localVmTarget);
|
|
5278
|
+
};
|
|
5279
|
+
return true;
|
|
5280
|
+
}
|
|
5281
|
+
catch (error) {
|
|
5282
|
+
if (strict)
|
|
5283
|
+
throw error;
|
|
5284
|
+
releaseLocalVmThread(threadId);
|
|
5285
|
+
return false;
|
|
5286
|
+
}
|
|
5287
|
+
};
|
|
5288
|
+
if (wants === "vm") {
|
|
5289
|
+
if (await attachLocalVm(true))
|
|
5290
|
+
computerKind = "vm";
|
|
4990
5291
|
}
|
|
4991
5292
|
else if (wants === "local") {
|
|
4992
5293
|
if (!shouldMountLocalComputer({
|
|
@@ -4999,7 +5300,7 @@ async function startTurn(botId, text, opts) {
|
|
|
4999
5300
|
const cua = readCuaConnection();
|
|
5000
5301
|
if (!cua)
|
|
5001
5302
|
throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
|
|
5002
|
-
bindTurnComputer(resourceOwner, "computer:host");
|
|
5303
|
+
await bindTurnComputer(resourceOwner, "computer:host");
|
|
5003
5304
|
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
5004
5305
|
computerKind = "local";
|
|
5005
5306
|
}
|
|
@@ -5015,9 +5316,10 @@ async function startTurn(botId, text, opts) {
|
|
|
5015
5316
|
if (!unsupported) {
|
|
5016
5317
|
// The remote lifecycle and container are shared by this bot. Keep
|
|
5017
5318
|
// its explicit computer turns serialized; ordinary threads still run.
|
|
5018
|
-
bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
|
|
5319
|
+
await bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
|
|
5019
5320
|
activeVpsThreads.set(bot.id, threadId);
|
|
5020
|
-
|
|
5321
|
+
let remote;
|
|
5322
|
+
remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
|
|
5021
5323
|
? await vps.vpsComputerAction("provision", cfg, bot.id)
|
|
5022
5324
|
: await vps.inspectVpsForAuto(cfg, bot.id);
|
|
5023
5325
|
if (remote?.ready && remote.sshAlias) {
|
|
@@ -5040,23 +5342,33 @@ async function startTurn(botId, text, opts) {
|
|
|
5040
5342
|
}
|
|
5041
5343
|
}
|
|
5042
5344
|
}
|
|
5043
|
-
// Cloud is
|
|
5044
|
-
//
|
|
5345
|
+
// Cloud is strict when selected. Only the native Box engine can reuse
|
|
5346
|
+
// a Box on Auto; local engines have no relay for its desktop tools.
|
|
5045
5347
|
if (teamComputer) {
|
|
5046
5348
|
const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, mountsCloudComputer, instance.driverKind === "boxAgent");
|
|
5047
5349
|
integrations.computer = attached.integration;
|
|
5048
5350
|
previewCapture = attached.capture;
|
|
5049
5351
|
computerKind = "box";
|
|
5050
5352
|
}
|
|
5051
|
-
if (!teamComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
|
|
5353
|
+
if (!teamComputer && mountsCloudComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
|
|
5052
5354
|
// Explicit cloud turns can provision/wake the same bot's Box. Claim
|
|
5053
5355
|
// before any network await so setup itself cannot race another turn.
|
|
5054
5356
|
if (wants === "cloud")
|
|
5055
|
-
bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
|
|
5357
|
+
await bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
|
|
5056
5358
|
if (!mountsCloudComputer && wants === "cloud") {
|
|
5057
5359
|
throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
|
|
5058
5360
|
}
|
|
5059
|
-
let b
|
|
5361
|
+
let b;
|
|
5362
|
+
try {
|
|
5363
|
+
b = await box.findBox(cfg, bot.id);
|
|
5364
|
+
}
|
|
5365
|
+
catch (error) {
|
|
5366
|
+
// Auto may fall through when an optional provider is offline, but a
|
|
5367
|
+
// durable deletion fence must never be mistaken for "no computer".
|
|
5368
|
+
if (wants === "cloud" || error?.status === 409)
|
|
5369
|
+
throw error;
|
|
5370
|
+
b = null;
|
|
5371
|
+
}
|
|
5060
5372
|
let lifecycle = box.boxTurnLifecycleAction({
|
|
5061
5373
|
explicitCloud: wants === "cloud",
|
|
5062
5374
|
canMount: mountsCloudComputer,
|
|
@@ -5065,7 +5377,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5065
5377
|
if (lifecycle === "provision") {
|
|
5066
5378
|
broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
|
|
5067
5379
|
await box.provisionBox(cfg, bot.id, bot.name);
|
|
5068
|
-
b = await box.findBox(cfg, bot.id)
|
|
5380
|
+
b = await box.findBox(cfg, bot.id);
|
|
5069
5381
|
lifecycle = box.boxTurnLifecycleAction({
|
|
5070
5382
|
explicitCloud: true,
|
|
5071
5383
|
canMount: mountsCloudComputer,
|
|
@@ -5078,7 +5390,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5078
5390
|
// consent boundary for the resume (~8s, and it un-pauses billing).
|
|
5079
5391
|
if (lifecycle === "wake") {
|
|
5080
5392
|
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
5081
|
-
b = (await box.readyBox(cfg, bot.id)
|
|
5393
|
+
b = (await box.readyBox(cfg, bot.id)) ?? b;
|
|
5082
5394
|
lifecycle = box.boxTurnLifecycleAction({
|
|
5083
5395
|
explicitCloud: true,
|
|
5084
5396
|
canMount: mountsCloudComputer,
|
|
@@ -5086,7 +5398,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5086
5398
|
});
|
|
5087
5399
|
}
|
|
5088
5400
|
if (b && lifecycle === "attach") {
|
|
5089
|
-
bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
|
|
5401
|
+
await bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
|
|
5090
5402
|
previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
|
|
5091
5403
|
if (mountsCloudComputer) {
|
|
5092
5404
|
integrations.computer = {
|
|
@@ -5107,9 +5419,19 @@ async function startTurn(botId, text, opts) {
|
|
|
5107
5419
|
}
|
|
5108
5420
|
// Auto-only host fallback. Electron owns cua-driver/TCC attribution;
|
|
5109
5421
|
// the harness only reads its already-running connection descriptor.
|
|
5422
|
+
// Auto reaches a Local VM this bot already has before it ever touches the
|
|
5423
|
+
// host's own desktop: on a headless server that VM is the only desktop
|
|
5424
|
+
// there is, and a person who prepared one meant it to be used.
|
|
5425
|
+
if (wants === undefined && !integrations.computer && !integrations.localComputer && await attachLocalVm(false))
|
|
5426
|
+
computerKind = "vm";
|
|
5427
|
+
// An unattended run on a bot with a VPS configured never lands on the
|
|
5428
|
+
// host's own desktop instead: a scheduled job clicking on someone's
|
|
5429
|
+
// laptop is worse than a scheduled job that fails and says why.
|
|
5430
|
+
const unattendedVps = cloudBackend === "vps" && Boolean(opts?.automationSource);
|
|
5110
5431
|
if (!integrations.computer &&
|
|
5111
5432
|
!integrations.localComputer &&
|
|
5112
5433
|
wants === undefined &&
|
|
5434
|
+
!unattendedVps &&
|
|
5113
5435
|
shouldMountLocalComputer({
|
|
5114
5436
|
requested: undefined,
|
|
5115
5437
|
hostPlatform: process.platform,
|
|
@@ -5117,7 +5439,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5117
5439
|
})) {
|
|
5118
5440
|
const cua = readCuaConnection();
|
|
5119
5441
|
if (cua) {
|
|
5120
|
-
bindTurnComputer(resourceOwner, "computer:host");
|
|
5442
|
+
await bindTurnComputer(resourceOwner, "computer:host");
|
|
5121
5443
|
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
5122
5444
|
computerKind = "local";
|
|
5123
5445
|
}
|
|
@@ -5126,10 +5448,13 @@ async function startTurn(botId, text, opts) {
|
|
|
5126
5448
|
cloudBackend === "vps" &&
|
|
5127
5449
|
!integrations.computer &&
|
|
5128
5450
|
!integrations.localComputer &&
|
|
5129
|
-
autoVpsProblem
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5451
|
+
autoVpsProblem &&
|
|
5452
|
+
!computerSelectionTurns.has(threadId)) {
|
|
5453
|
+
const hint = opts?.automationSource
|
|
5454
|
+
? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in App Settings → Connections."
|
|
5455
|
+
: bot.autoStartVps
|
|
5456
|
+
? "Check the VPS connection in App Settings → Connections."
|
|
5457
|
+
: "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
|
|
5133
5458
|
throw new Error(`${autoVpsProblem}. ${hint}`);
|
|
5134
5459
|
}
|
|
5135
5460
|
// Agent control tools include peer comms and the secure credential
|
|
@@ -5197,8 +5522,13 @@ async function startTurn(botId, text, opts) {
|
|
|
5197
5522
|
description: liveBot?.description ?? bot.description,
|
|
5198
5523
|
text: providerText,
|
|
5199
5524
|
});
|
|
5525
|
+
// One place per turn. On Auto the branches above may have reached a
|
|
5526
|
+
// computer; then the built-in browser stays unmounted and web work
|
|
5527
|
+
// happens in that computer's own browser, where the person can see it.
|
|
5528
|
+
const mountedComputer = surfaceOfComputerKind(computerKind);
|
|
5200
5529
|
if (liveBot &&
|
|
5201
5530
|
plan.browser &&
|
|
5531
|
+
!(plan.computer === undefined && mountedComputer) &&
|
|
5202
5532
|
builtInBrowserEnabled(cfg) &&
|
|
5203
5533
|
liveBot.browser !== false &&
|
|
5204
5534
|
instance.adapter.capabilities.browserMcp === true) {
|
|
@@ -5218,6 +5548,17 @@ async function startTurn(botId, text, opts) {
|
|
|
5218
5548
|
browserCapture = () => browserRuntime.withAgentAction(session, () => agentBrowserFrame(frame));
|
|
5219
5549
|
}
|
|
5220
5550
|
}
|
|
5551
|
+
// An Auto conversation remembers where its first turn landed, so later
|
|
5552
|
+
// turns stay there and the composer can show it. Explicit settings are
|
|
5553
|
+
// not recorded: changing the bot's Works on should move its threads.
|
|
5554
|
+
if (bot.computer === undefined && !teamComputer && opts?.runOn !== "cloud" && !plan.pinned) {
|
|
5555
|
+
const used = mountedComputer ?? (integrations.browser ? "browser" : null);
|
|
5556
|
+
if (used)
|
|
5557
|
+
store.patchTask(bot.id, threadId, { surface: used });
|
|
5558
|
+
}
|
|
5559
|
+
const computerSelection = computerSelectionTurns.get(threadId);
|
|
5560
|
+
if (computerSelection)
|
|
5561
|
+
computerSelection.mounted = mountedComputer ?? (integrations.browser ? "browser" : undefined);
|
|
5221
5562
|
// A cancelled adapter can be between accepting sendTurn and revealing
|
|
5222
5563
|
// its provider turn id. Never overlap a replacement with that ambiguous
|
|
5223
5564
|
// pre-id window: wait for the old handshake to settle or for its bounded
|
|
@@ -5248,7 +5589,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5248
5589
|
{ id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
|
|
5249
5590
|
{ id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
|
|
5250
5591
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
|
|
5251
|
-
{ id: "plan", label: "Surface", text: plan.note },
|
|
5592
|
+
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
|
|
5252
5593
|
// gated on the integration, not the key: the hint only goes to a
|
|
5253
5594
|
// bot whose driver actually mounted the tools
|
|
5254
5595
|
{ id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
|
|
@@ -5264,6 +5605,9 @@ async function startTurn(botId, text, opts) {
|
|
|
5264
5605
|
{ id: "profile", label: "Profile changes", text: profilePrompt },
|
|
5265
5606
|
{ id: "learn", label: "Skill authoring", text: learnPrompt },
|
|
5266
5607
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
5608
|
+
// what the bot said lately in its other conversations, so a task
|
|
5609
|
+
// never redoes — or forgets — what another one already did
|
|
5610
|
+
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(store, bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
|
|
5267
5611
|
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
|
|
5268
5612
|
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
|
|
5269
5613
|
{ id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
|
|
@@ -5276,11 +5620,12 @@ async function startTurn(botId, text, opts) {
|
|
|
5276
5620
|
threadId,
|
|
5277
5621
|
botId: bot.id,
|
|
5278
5622
|
text: turnText,
|
|
5279
|
-
refreshSystemPrompt:
|
|
5623
|
+
refreshSystemPrompt: true,
|
|
5280
5624
|
images: turnImages,
|
|
5281
5625
|
approvalMode: approvalModeForTurn(bot, commsDepth > 0),
|
|
5282
5626
|
model,
|
|
5283
5627
|
effort,
|
|
5628
|
+
variant,
|
|
5284
5629
|
// a rewound thread never resumes the abandoned branch's session
|
|
5285
5630
|
// the active task's own session — another task's cursor would
|
|
5286
5631
|
// resume the wrong conversation and defeat the context bubble
|
|
@@ -5291,6 +5636,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5291
5636
|
systemStable: prompt.stable,
|
|
5292
5637
|
systemVolatile: prompt.volatile,
|
|
5293
5638
|
integrations,
|
|
5639
|
+
mcpFromUserConfig: claudeUserMcpEnabled(cfg),
|
|
5294
5640
|
cwd,
|
|
5295
5641
|
}), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
|
|
5296
5642
|
await instance.adapter.interruptTurn(threadId).catch(() => { });
|
|
@@ -5342,6 +5688,8 @@ async function startTurn(botId, text, opts) {
|
|
|
5342
5688
|
}
|
|
5343
5689
|
}
|
|
5344
5690
|
catch (e) {
|
|
5691
|
+
if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
|
|
5692
|
+
computerSelectionTurns.delete(threadId);
|
|
5345
5693
|
settleDirectFollowup(dispatchClaimId);
|
|
5346
5694
|
clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
|
|
5347
5695
|
clearDirectTurnDispatch(threadId, dispatchClaimId);
|
|
@@ -5455,6 +5803,8 @@ function routineRunCard(run) {
|
|
|
5455
5803
|
};
|
|
5456
5804
|
if (run.goalStatus)
|
|
5457
5805
|
card.goalStatus = run.goalStatus;
|
|
5806
|
+
if (run.deferredAt != null && run.status === "queued")
|
|
5807
|
+
card.deferredAt = run.deferredAt;
|
|
5458
5808
|
if (run.threadId)
|
|
5459
5809
|
card.executionThreadId = run.threadId;
|
|
5460
5810
|
if (summary)
|
|
@@ -5485,7 +5835,9 @@ function routineRunFallbackText(card) {
|
|
|
5485
5835
|
? "was cancelled"
|
|
5486
5836
|
: card.status === "missed"
|
|
5487
5837
|
? "was missed"
|
|
5488
|
-
: card.status
|
|
5838
|
+
: card.status === "queued" && card.deferredAt != null
|
|
5839
|
+
? "deferred: target busy"
|
|
5840
|
+
: card.status);
|
|
5489
5841
|
return `Routine “${card.routineName}” ${state}`;
|
|
5490
5842
|
}
|
|
5491
5843
|
/** Upsert one durable lifecycle card per run. Replaying the same transition,
|
|
@@ -5689,6 +6041,17 @@ routines = new RoutineManager({
|
|
|
5689
6041
|
const notificationBot = routineSourceOwner(run)?.bot ?? bot;
|
|
5690
6042
|
notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
|
|
5691
6043
|
},
|
|
6044
|
+
onRunDeferred: (run) => {
|
|
6045
|
+
const bot = store.bot(run.botId);
|
|
6046
|
+
if (!bot)
|
|
6047
|
+
return;
|
|
6048
|
+
const minutes = run.deferredAt != null && run.deferredNoticeAt != null
|
|
6049
|
+
? Math.max(1, Math.round((run.deferredNoticeAt - run.deferredAt) / 60_000))
|
|
6050
|
+
: null;
|
|
6051
|
+
const detail = `${redactSecretsInText(run.routineName)}: target busy${minutes != null ? ` for ${minutes} minutes` : ""}`;
|
|
6052
|
+
const notificationBot = routineSourceOwner(run)?.bot ?? bot;
|
|
6053
|
+
notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
|
|
6054
|
+
},
|
|
5692
6055
|
});
|
|
5693
6056
|
// The scheduler receipt and room transcript live in separate durable stores.
|
|
5694
6057
|
// If the process exited between those two writes, prefer the correlated
|
|
@@ -5784,6 +6147,9 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
5784
6147
|
if (computerProviderConfigTransitions.size > 0) {
|
|
5785
6148
|
return deletionResponse(409, { error: "computer provider settings are being updated — wait before deleting this bot" });
|
|
5786
6149
|
}
|
|
6150
|
+
if (localVmModeChangeBusy) {
|
|
6151
|
+
return deletionResponse(409, { error: "Local VM settings are being updated — wait before deleting this bot" });
|
|
6152
|
+
}
|
|
5787
6153
|
if (boxLifecycleBusyBots.has(bot.id)) {
|
|
5788
6154
|
return deletionResponse(409, { error: "wait for this bot's cloud computer action to finish before deleting the bot" });
|
|
5789
6155
|
}
|
|
@@ -5806,6 +6172,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
5806
6172
|
return deletionResponse(409, { error: "stop this bot's work before checking and deleting its cloud computer" });
|
|
5807
6173
|
}
|
|
5808
6174
|
const botBoxRecovery = boxCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
|
|
6175
|
+
const botBoxDeletions = boxDeletionSnapshot().filter((entry) => entry.ownerBotId === bot.id);
|
|
5809
6176
|
if (botBoxRecovery.some((entry) => !entry.resolved)) {
|
|
5810
6177
|
return deletionResponse(409, {
|
|
5811
6178
|
error: "finish reconciling this bot's pending cloud computer creation before deleting it — check ascii.dev, then retry Box setup",
|
|
@@ -5822,84 +6189,94 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
5822
6189
|
releaseComputerLifecycle();
|
|
5823
6190
|
return deletionResponse(409, { error: "this bot or one of its channels is securely saving a credential" });
|
|
5824
6191
|
}
|
|
6192
|
+
let claimedLocalVmTarget = null;
|
|
5825
6193
|
try {
|
|
6194
|
+
let localVmCleanup = null;
|
|
5826
6195
|
if (localVmMode(cfg) === "per-bot") {
|
|
5827
6196
|
const target = perBotLocalVmTarget(bot.id);
|
|
5828
6197
|
if (localVmActiveThreads.has(target.key) || localVmLifecycleBusy.has(target.key)) {
|
|
5829
6198
|
return deletionResponse(409, { error: "stop this bot's Local VM turn or setup action before deleting the bot" });
|
|
5830
6199
|
}
|
|
6200
|
+
if (localVmLeaseFor(target).current(localVmOwnerBusy)) {
|
|
6201
|
+
return deletionResponse(409, { error: "stop this bot's Local VM turn before deleting the bot" });
|
|
6202
|
+
}
|
|
6203
|
+
// Hold the target from preflight through deletion. A simultaneous
|
|
6204
|
+
// mode change or lifecycle route must not recreate the container
|
|
6205
|
+
// after we checked it and before its bot owner disappears.
|
|
6206
|
+
localVmLifecycleBusy.add(target.key);
|
|
6207
|
+
claimedLocalVmTarget = target;
|
|
5831
6208
|
const vm = await containerComputerStatus(undefined, undefined, target);
|
|
5832
6209
|
if (!vm.daemonUp && existsSync(target.workspaceDir)) {
|
|
5833
6210
|
return deletionResponse(409, {
|
|
5834
|
-
error: "start the container runtime
|
|
6211
|
+
error: "start the container runtime so OpenMausBot can remove this bot's Local VM while deleting it",
|
|
5835
6212
|
});
|
|
5836
6213
|
}
|
|
5837
|
-
if (vm.container !== "missing") {
|
|
5838
|
-
return deletionResponse(409, {
|
|
6214
|
+
if (vm.container !== "missing" && !vm.managed) {
|
|
6215
|
+
return deletionResponse(409, {
|
|
6216
|
+
error: `The container named ${vm.container_name} was not created by OpenMausBot. Remove it manually before deleting this bot`,
|
|
6217
|
+
});
|
|
5839
6218
|
}
|
|
6219
|
+
localVmCleanup = {
|
|
6220
|
+
target,
|
|
6221
|
+
removeContainer: vm.container !== "missing",
|
|
6222
|
+
};
|
|
5840
6223
|
}
|
|
5841
|
-
//
|
|
5842
|
-
//
|
|
5843
|
-
//
|
|
6224
|
+
// Preflight every provider before deleting any resource. Cleanup can
|
|
6225
|
+
// still fail mid-flight across independent providers, but a missing
|
|
6226
|
+
// credential or offline daemon should not cause avoidable partial work.
|
|
5844
6227
|
const vpsInventory = await vps.listManagedVpsComputers(cfg, managedBoxOwners());
|
|
5845
6228
|
if (vpsInventory.configured && !vpsInventory.available) {
|
|
5846
6229
|
return deletionResponse(503, {
|
|
5847
|
-
error: `${vpsInventory.problem ?? "VPS computer inventory is unavailable"}.
|
|
6230
|
+
error: `${vpsInventory.problem ?? "VPS computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
|
|
5848
6231
|
});
|
|
5849
6232
|
}
|
|
5850
|
-
|
|
6233
|
+
const ownedVpsComputers = vpsInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
|
|
6234
|
+
if ((botBoxRecovery.length > 0 || botBoxDeletions.length > 0) && !box.boxConfigured(cfg)) {
|
|
5851
6235
|
return deletionResponse(409, {
|
|
5852
|
-
error: "
|
|
6236
|
+
error: "Reconnect the Box account that owns this bot's remembered cloud computer, then retry deletion",
|
|
5853
6237
|
});
|
|
5854
6238
|
}
|
|
5855
|
-
// LIST is eventually consistent, and a remembered Box may also have
|
|
5856
|
-
// been renamed outside OpenMausBot. The create journal is stronger
|
|
5857
|
-
// ownership evidence: inspect every durable id directly before the bot
|
|
5858
|
-
// record that makes it discoverable can be removed. Missing credentials
|
|
5859
|
-
// or an unavailable provider must fail closed.
|
|
5860
|
-
for (const recovery of botBoxRecovery) {
|
|
5861
|
-
if (!recovery.boxId) {
|
|
5862
|
-
return deletionResponse(409, {
|
|
5863
|
-
error: "finish reconciling this bot's pending cloud computer creation before deleting it",
|
|
5864
|
-
});
|
|
5865
|
-
}
|
|
5866
|
-
const inspected = await box.inspectBoxIdentity(cfg, recovery.boxId);
|
|
5867
|
-
if (!inspected.available) {
|
|
5868
|
-
return deletionResponse(503, {
|
|
5869
|
-
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Restore its Box account before deleting this bot`,
|
|
5870
|
-
});
|
|
5871
|
-
}
|
|
5872
|
-
if (inspected.identity) {
|
|
5873
|
-
return deletionResponse(409, {
|
|
5874
|
-
error: "delete this bot's remembered cloud computer from Settings → Computers before deleting the bot",
|
|
5875
|
-
});
|
|
5876
|
-
}
|
|
5877
|
-
// A direct 404/410 is authoritative even while account LIST catches
|
|
5878
|
-
// up. Retire only this exact provider identity, then continue looking
|
|
5879
|
-
// for any older name-based resource the journal never recorded.
|
|
5880
|
-
retireDeletedBoxCreate(recovery.boxId);
|
|
5881
|
-
}
|
|
5882
|
-
// A Box survives destination/backend changes and contains browser
|
|
5883
|
-
// sessions and files. Resolve ownership from a fresh provider listing;
|
|
5884
|
-
// deleting the bot first would make that durable machine look orphaned.
|
|
5885
6239
|
const cloudInventory = await box.listManagedBoxes(cfg, managedBoxOwners());
|
|
5886
6240
|
if (cloudInventory.configured && !cloudInventory.available) {
|
|
5887
6241
|
return deletionResponse(503, {
|
|
5888
|
-
error: `${cloudInventory.problem ?? "cloud computer inventory is unavailable"}.
|
|
5889
|
-
});
|
|
5890
|
-
}
|
|
5891
|
-
if (cloudInventory.instances.some((instance) => instance.ownerBotId === bot.id)) {
|
|
5892
|
-
return deletionResponse(409, {
|
|
5893
|
-
error: "delete this bot's cloud computer from Settings → Computers before deleting the bot",
|
|
6242
|
+
error: `${cloudInventory.problem ?? "cloud computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
|
|
5894
6243
|
});
|
|
5895
6244
|
}
|
|
5896
|
-
|
|
5897
|
-
//
|
|
5898
|
-
//
|
|
5899
|
-
//
|
|
6245
|
+
const ownedBoxComputers = cloudInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
|
|
6246
|
+
// Revalidate a reviewed Chief-of-Staff request and establish the
|
|
6247
|
+
// browser cleanup intent before the first irreversible provider
|
|
6248
|
+
// mutation. A stale review or damaged journal therefore leaves every
|
|
6249
|
+
// computer intact. Cross-provider rollback is impossible, so every
|
|
6250
|
+
// subsequent operation is exact and retry-safe.
|
|
5900
6251
|
revalidate();
|
|
5901
6252
|
const browserCleanupRequest = browserCleanup.prepare("bot", bot.id);
|
|
5902
6253
|
try {
|
|
6254
|
+
// Provider-owned computers are durable, billable resources. Remove
|
|
6255
|
+
// each exact, freshly revalidated identity before making its bot
|
|
6256
|
+
// owner disappear. Shared team computers use a different owner id
|
|
6257
|
+
// and are intentionally absent from these lists.
|
|
6258
|
+
for (const instance of ownedBoxComputers) {
|
|
6259
|
+
const removed = await box.deleteManagedBox(cfg, managedBoxOwners(), instance.boxId, instance.name);
|
|
6260
|
+
if (removed.pending) {
|
|
6261
|
+
throw Object.assign(new Error("The cloud computer deletion has started but is still finishing. The bot was kept; retry in a moment"), { status: 409 });
|
|
6262
|
+
}
|
|
6263
|
+
}
|
|
6264
|
+
for (const instance of ownedVpsComputers) {
|
|
6265
|
+
await vps.removeManagedVpsComputer(cfg, managedBoxOwners(), instance.name, instance.name);
|
|
6266
|
+
}
|
|
6267
|
+
if (localVmCleanup) {
|
|
6268
|
+
if (localVmCleanup.removeContainer) {
|
|
6269
|
+
await containerComputerAction("remove", undefined, undefined, localVmCleanup.target);
|
|
6270
|
+
}
|
|
6271
|
+
// Unlike the standalone "Delete VM" action, deleting the bot is
|
|
6272
|
+
// a complete erasure: its now-ownerless desktop files and browser
|
|
6273
|
+
// session must not remain hidden on disk or block the event loop.
|
|
6274
|
+
await removeDirectory(localVmCleanup.target.workspaceDir, { recursive: true, force: true });
|
|
6275
|
+
localVmSeen.delete(localVmCleanup.target.key);
|
|
6276
|
+
localVmIdles.get(localVmCleanup.target.key)?.cancel();
|
|
6277
|
+
localVmIdles.delete(localVmCleanup.target.key);
|
|
6278
|
+
localVmLeases.forget(localVmCleanup.target.key);
|
|
6279
|
+
}
|
|
5903
6280
|
// a running turn dies with its bot
|
|
5904
6281
|
// Invalidate every bot-callable bearer before the first asynchronous
|
|
5905
6282
|
// teardown step. A request that already passed its initial header
|
|
@@ -5913,7 +6290,6 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
5913
6290
|
revokeInternalCapabilitiesForThread(task.threadId);
|
|
5914
6291
|
}
|
|
5915
6292
|
await interruptAllDirectThreads(bot.id);
|
|
5916
|
-
revalidate();
|
|
5917
6293
|
// Deletion removes the thread before a late turn.completed can fold
|
|
5918
6294
|
// staged provider images into a message, so dispose them here.
|
|
5919
6295
|
for (const task of store.tasks(bot.id)) {
|
|
@@ -5934,6 +6310,12 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
5934
6310
|
const target = perBotLocalVmTarget(bot.id);
|
|
5935
6311
|
localVmIdles.get(target.key)?.cancel();
|
|
5936
6312
|
localVmIdles.delete(target.key);
|
|
6313
|
+
// Provider and local-computer teardown above can await for an
|
|
6314
|
+
// arbitrary amount of time. A reviewed Chief deletion is bound to
|
|
6315
|
+
// the exact target profile it presented; re-check that receipt at
|
|
6316
|
+
// the final durable mutation boundary so a concurrent profile edit
|
|
6317
|
+
// cannot be erased under a stale approval.
|
|
6318
|
+
revalidate();
|
|
5937
6319
|
store.deleteBot(bot.id, setupRequest);
|
|
5938
6320
|
// Removing schedules is not a security revocation. Keep them intact
|
|
5939
6321
|
// if the bot/receipt write fails, so a failed deletion is retryable.
|
|
@@ -5969,6 +6351,8 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
5969
6351
|
return deletionResponse(200, { ok: true });
|
|
5970
6352
|
}
|
|
5971
6353
|
finally {
|
|
6354
|
+
if (claimedLocalVmTarget)
|
|
6355
|
+
localVmLifecycleBusy.delete(claimedLocalVmTarget.key);
|
|
5972
6356
|
releaseComputerLifecycle();
|
|
5973
6357
|
releasePhoneSecretMutation();
|
|
5974
6358
|
}
|
|
@@ -6470,6 +6854,7 @@ setupRetry = 0) {
|
|
|
6470
6854
|
// A workspace at its monthly spend limit rechecks the cap at execution time
|
|
6471
6855
|
// for every room, goal, queued, calendar, and chained-mention turn.
|
|
6472
6856
|
assertWithinBudget(cfg, DATA_DIR);
|
|
6857
|
+
assertModelVariantSupported(preparedSelection, instance.adapter.capabilities);
|
|
6473
6858
|
const integrations = {};
|
|
6474
6859
|
const skillAuthoring = skillAuthoringEnabled(cfg) &&
|
|
6475
6860
|
hop === 0 &&
|
|
@@ -6541,6 +6926,7 @@ setupRetry = 0) {
|
|
|
6541
6926
|
readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
|
|
6542
6927
|
readyBot.modelSelection.model !== preparedSelection.model ||
|
|
6543
6928
|
readyBot.modelSelection.effort !== preparedSelection.effort ||
|
|
6929
|
+
readyBot.modelSelection.variant !== preparedSelection.variant ||
|
|
6544
6930
|
readyBot.composio !== preparedComposio;
|
|
6545
6931
|
if (setupChanged) {
|
|
6546
6932
|
if (setupRetry === 0) {
|
|
@@ -6631,7 +7017,15 @@ setupRetry = 0) {
|
|
|
6631
7017
|
readyBot.browser !== false &&
|
|
6632
7018
|
instance.adapter.capabilities.browserMcp === true,
|
|
6633
7019
|
});
|
|
6634
|
-
|
|
7020
|
+
// Channels currently mount a team Box or a Local VM. Do not let an
|
|
7021
|
+
// explicitly selected, unsupported destination become a tool-free turn
|
|
7022
|
+
// that can claim to have acted on that screen.
|
|
7023
|
+
if (!roomTeamComputer && (roomPlan.computer === "cloud" || roomPlan.computer === "local")) {
|
|
7024
|
+
throw new Error("This computer destination is not available in channels yet — open a bot thread to work on it, or use a Local VM, team computer, or Browser here");
|
|
7025
|
+
}
|
|
7026
|
+
// One place per room turn as well: a team computer reached on Auto means
|
|
7027
|
+
// no separate built-in browser.
|
|
7028
|
+
if (roomPlan.browser && !(roomPlan.computer === undefined && roomTeamComputer)) {
|
|
6635
7029
|
const selectedProfile = readyBot.browserProfile;
|
|
6636
7030
|
const browser = await browserIntegration(readyBot.id, selectedProfile, { threadId, generation: internalGeneration });
|
|
6637
7031
|
if (browser)
|
|
@@ -6647,7 +7041,7 @@ setupRetry = 0) {
|
|
|
6647
7041
|
return false;
|
|
6648
7042
|
}
|
|
6649
7043
|
if (roomTeamComputer) {
|
|
6650
|
-
const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.
|
|
7044
|
+
const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.driverKind === "boxAgent", instance.driverKind === "boxAgent");
|
|
6651
7045
|
if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
|
|
6652
7046
|
activeInternalGenerationByThread.get(threadId) !== internalGeneration)
|
|
6653
7047
|
return false;
|
|
@@ -6662,7 +7056,7 @@ setupRetry = 0) {
|
|
|
6662
7056
|
}
|
|
6663
7057
|
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
6664
7058
|
const target = { ...localVmTargetForBot(readyBot.id) };
|
|
6665
|
-
bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
7059
|
+
await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
6666
7060
|
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
|
|
6667
7061
|
throw new Error("this Local VM is being started, stopped, or replaced");
|
|
6668
7062
|
}
|
|
@@ -6760,14 +7154,32 @@ setupRetry = 0) {
|
|
|
6760
7154
|
store.patchBot(bot.id, { soulDrift: drift.drift });
|
|
6761
7155
|
}
|
|
6762
7156
|
const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace });
|
|
7157
|
+
// The same brief a 1:1 turn gets: what this member said lately in its
|
|
7158
|
+
// other conversations, so a standup is answered from what happened. A
|
|
7159
|
+
// brief can carry a private chat into the room; as with session_search
|
|
7160
|
+
// (#754) the room is told, once per source thread, rather than the
|
|
7161
|
+
// crossing being blocked.
|
|
7162
|
+
const recentLines = recentWork(store, bot, { userName, currentThreadId: threadId });
|
|
7163
|
+
{
|
|
7164
|
+
const crossing = claimRecallCrossings(threadId, recentLines.filter((line) => line.private).map((line) => line.threadId));
|
|
7165
|
+
if (crossing.count) {
|
|
7166
|
+
store.appendMessage(threadId, {
|
|
7167
|
+
role: "bot",
|
|
7168
|
+
kind: "activity",
|
|
7169
|
+
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
7170
|
+
tool: { name: briefCrossingLabel(bot.name, crossing.count), ok: true },
|
|
7171
|
+
});
|
|
7172
|
+
}
|
|
7173
|
+
}
|
|
6763
7174
|
const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
|
|
6764
7175
|
{ id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
|
|
6765
7176
|
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
6766
7177
|
{ id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer ? instance.driverKind === "boxAgent" ? "box-agent" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
|
|
6767
7178
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
|
|
6768
|
-
{ id: "plan", label: "Surface", text: roomPlan.note },
|
|
7179
|
+
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : null, browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
|
|
6769
7180
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
6770
7181
|
{ id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
7182
|
+
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
6771
7183
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
6772
7184
|
// the room path has always put a newline before memory and trimmed
|
|
6773
7185
|
// the block's leading space; keep that so existing prompts are
|
|
@@ -6881,7 +7293,7 @@ setupRetry = 0) {
|
|
|
6881
7293
|
threadId,
|
|
6882
7294
|
botId: readyBot.id,
|
|
6883
7295
|
text,
|
|
6884
|
-
refreshSystemPrompt:
|
|
7296
|
+
refreshSystemPrompt: true,
|
|
6885
7297
|
images: turnImages,
|
|
6886
7298
|
approvalMode: approvalModeForTurn(readyBot, Boolean(orchestration?.roomHandoffId)),
|
|
6887
7299
|
system: roomSystem.text,
|
|
@@ -6889,7 +7301,10 @@ setupRetry = 0) {
|
|
|
6889
7301
|
systemVolatile: roomSystem.volatile,
|
|
6890
7302
|
cwd,
|
|
6891
7303
|
integrations,
|
|
6892
|
-
|
|
7304
|
+
mcpFromUserConfig: claudeUserMcpEnabled(cfg),
|
|
7305
|
+
...(instance.instanceId === readyBot.modelSelection.instanceId
|
|
7306
|
+
? memberTurnSelection(readyBot.modelSelection)
|
|
7307
|
+
: { model: instance.models.default }),
|
|
6893
7308
|
}), () => abandoned || Boolean(isCancelled?.()), async () => {
|
|
6894
7309
|
// Stop may have landed while the adapter was authenticating, before
|
|
6895
7310
|
// it had an active process for the first interrupt to reach. Now that
|
|
@@ -7081,8 +7496,11 @@ setupRetry = 0) {
|
|
|
7081
7496
|
catch (error) {
|
|
7082
7497
|
if (providerDispatched)
|
|
7083
7498
|
throw error;
|
|
7499
|
+
if (error instanceof DirectTurnSetupCancelled)
|
|
7500
|
+
return false;
|
|
7084
7501
|
const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
|
|
7085
|
-
|
|
7502
|
+
const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
|
|
7503
|
+
if (!roomSpeaker && !isSpendCap && !isVariantError)
|
|
7086
7504
|
throw error;
|
|
7087
7505
|
const message = error instanceof Error ? error.message : "Local VM setup failed";
|
|
7088
7506
|
store.appendMessage(threadId, {
|
|
@@ -8334,6 +8752,7 @@ function cliProbeEnvironment() {
|
|
|
8334
8752
|
"COMPOSIO_API_KEY",
|
|
8335
8753
|
"OMB_COMPOSIO_BROKER_TOKEN",
|
|
8336
8754
|
"OMB_TTS_KEY",
|
|
8755
|
+
"OMB_FISH_AUDIO_API_KEY",
|
|
8337
8756
|
"OMB_OPENAI_IMAGE_KEY",
|
|
8338
8757
|
"OMB_CUSTOM_IMAGE_KEY",
|
|
8339
8758
|
"ANTHROPIC_API_KEY",
|
|
@@ -8382,6 +8801,7 @@ async function localVmPayload(target) {
|
|
|
8382
8801
|
*/
|
|
8383
8802
|
async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
|
|
8384
8803
|
let status = await containerComputerStatus(undefined, undefined, target);
|
|
8804
|
+
noteLocalVmSeen(target, status);
|
|
8385
8805
|
if (!isCurrent())
|
|
8386
8806
|
return status;
|
|
8387
8807
|
if (status.ready || !localVmRecreatableOnDemand(status))
|
|
@@ -8480,6 +8900,9 @@ function configStatus() {
|
|
|
8480
8900
|
// shell and the Settings UI read it so they offer nothing this server
|
|
8481
8901
|
// would refuse.
|
|
8482
8902
|
sharedComputers: sharedComputersEnabled(cfg),
|
|
8903
|
+
// Plugins → MCP servers switch: Claude bots also see this machine's
|
|
8904
|
+
// own Claude Code MCP servers
|
|
8905
|
+
claudeUserMcp: claudeUserMcpEnabled(cfg),
|
|
8483
8906
|
},
|
|
8484
8907
|
// first-run progress — not a secret; the app decides whether to show
|
|
8485
8908
|
// the welcome tour from this, never from browser storage
|
|
@@ -8517,6 +8940,20 @@ function configForAccess(status, admin) {
|
|
|
8517
8940
|
function mcpServerResponse() {
|
|
8518
8941
|
return { servers: listMcpServers(cfg.mcpServers) };
|
|
8519
8942
|
}
|
|
8943
|
+
/** The fields a new server may set, in whichever shape the form sent — a
|
|
8944
|
+
* command to run or a URL to reach. Absent keys stay absent, so the strict
|
|
8945
|
+
* schema of one shape never sees the other shape's `undefined`s. */
|
|
8946
|
+
function mcpServerBody(body) {
|
|
8947
|
+
const out = {};
|
|
8948
|
+
if (!body || typeof body !== "object")
|
|
8949
|
+
return out;
|
|
8950
|
+
const record = body;
|
|
8951
|
+
for (const key of ["command", "args", "env", "type", "url", "headers", "enabled"]) {
|
|
8952
|
+
if (record[key] !== undefined)
|
|
8953
|
+
out[key] = record[key];
|
|
8954
|
+
}
|
|
8955
|
+
return out;
|
|
8956
|
+
}
|
|
8520
8957
|
function persistMcpServers(next) {
|
|
8521
8958
|
saveConfig({ mcpServers: next });
|
|
8522
8959
|
// Do not reload the provider fleet: integrations are assembled from cfg at
|
|
@@ -8892,7 +9329,7 @@ const handleRequest = async (req, res) => {
|
|
|
8892
9329
|
// Hosted workspaces have one sign-in authority. A missing optional layer
|
|
8893
9330
|
// must not accidentally reactivate legacy email/QR credential minting.
|
|
8894
9331
|
if (HOSTED_WORKSPACE) {
|
|
8895
|
-
if (method === "POST" && ["/api/auth/pair", "/api/auth/pairing", "/api/auth/email/start", "/api/auth/email/verify"].includes(path)) {
|
|
9332
|
+
if (method === "POST" && ["/api/auth/pair", "/api/pair", "/api/auth/pairing", "/api/auth/email/start", "/api/auth/email/verify"].includes(path)) {
|
|
8896
9333
|
return json(res, 403, { error: "Sign in through the workspace portal." });
|
|
8897
9334
|
}
|
|
8898
9335
|
if (workspaceAccess) {
|
|
@@ -8983,6 +9420,43 @@ const handleRequest = async (req, res) => {
|
|
|
8983
9420
|
}
|
|
8984
9421
|
return json(res, 200, { token: result.token, session: result.session, environment });
|
|
8985
9422
|
}
|
|
9423
|
+
// The route the iOS and Android companion apps already POST to. Until now
|
|
9424
|
+
// only the desktop's companion sidecar answered it, so a self-hosted
|
|
9425
|
+
// server had nothing for a native phone to pair against: the app could
|
|
9426
|
+
// reach the server and pass its health probe, then ask for a credential
|
|
9427
|
+
// the server could not issue. Same window, same lockout and the same
|
|
9428
|
+
// single-use exchange as /api/auth/pair above; only the request and
|
|
9429
|
+
// response shapes differ, because the apps were written against the
|
|
9430
|
+
// sidecar. Public and unauthenticated for the same reason
|
|
9431
|
+
// /api/auth/pair is: redeeming a one-time credential IS the sign-in.
|
|
9432
|
+
if (method === "POST" && path === "/api/pair") {
|
|
9433
|
+
if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
|
|
9434
|
+
return json(res, 415, { error: "send the pairing credential as JSON (content-type: application/json)" });
|
|
9435
|
+
}
|
|
9436
|
+
const body = await readBody(req);
|
|
9437
|
+
const credential = typeof body?.credential === "string" ? body.credential : typeof body?.code === "string" ? body.code : "";
|
|
9438
|
+
const label = typeof body?.deviceName === "string" ? body.deviceName : "";
|
|
9439
|
+
const attemptId = typeof body?.pairRequestId === "string" ? body.pairRequestId : undefined;
|
|
9440
|
+
const paired = sessions.exchange({ code: credential, label, attemptId, source: requestSource(req), fallbackLabel: labelFromUserAgent(req.headers["user-agent"]) });
|
|
9441
|
+
if (!paired.ok) {
|
|
9442
|
+
console.warn(`pairing refused from ${requestSource(req)}: ${paired.error}`);
|
|
9443
|
+
return json(res, paired.status, { error: paired.error });
|
|
9444
|
+
}
|
|
9445
|
+
// The shape the companion apps decode (android/core Models.kt,
|
|
9446
|
+
// PairResponseSerializer): token, device and serverName are required;
|
|
9447
|
+
// hosts and endpoints are advisory and deliberately omitted, because a
|
|
9448
|
+
// harness has no sidecar endpoints to advertise.
|
|
9449
|
+
return json(res, 200, {
|
|
9450
|
+
token: paired.token,
|
|
9451
|
+
device: {
|
|
9452
|
+
id: paired.session.id,
|
|
9453
|
+
name: paired.session.label,
|
|
9454
|
+
createdAt: paired.session.createdAt,
|
|
9455
|
+
lastSeenAt: paired.session.lastSeenAt,
|
|
9456
|
+
},
|
|
9457
|
+
serverName: environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label,
|
|
9458
|
+
});
|
|
9459
|
+
}
|
|
8986
9460
|
const gate = resolveRequestAuth(req, {
|
|
8987
9461
|
sessions,
|
|
8988
9462
|
cookieName: SESSION_COOKIE,
|
|
@@ -9078,11 +9552,22 @@ const handleRequest = async (req, res) => {
|
|
|
9078
9552
|
const origin = requestOrigin(req);
|
|
9079
9553
|
const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
|
|
9080
9554
|
const code = formatPairingCode(opened.code);
|
|
9555
|
+
// Two links for one window. `url` opens the web app and is what a
|
|
9556
|
+
// browser and the iOS app already read. `inviteUrl` is the custom
|
|
9557
|
+
// scheme the native companion scanners accept; it carries the
|
|
9558
|
+
// credential encoding because those scanners cannot take a typed code.
|
|
9559
|
+
const serverName = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label;
|
|
9560
|
+
const invite = base
|
|
9561
|
+
? `openmausbot://pair?address=${encodeURIComponent(base)}&token=${encodeURIComponent(opened.credential)}&name=${encodeURIComponent(serverName)}`
|
|
9562
|
+
: null;
|
|
9081
9563
|
return json(res, 200, {
|
|
9082
9564
|
id: opened.id,
|
|
9083
9565
|
code,
|
|
9566
|
+
credential: opened.credential,
|
|
9084
9567
|
expiresAt: opened.expiresAt,
|
|
9085
9568
|
url: base ? `${base}/pair#code=${code}` : null,
|
|
9569
|
+
inviteUrl: invite,
|
|
9570
|
+
serverName,
|
|
9086
9571
|
hint: base
|
|
9087
9572
|
? null
|
|
9088
9573
|
: "this server has no public address to put in a link: set OMB_PUBLIC_URL, or open /pair on the address you use and type the code",
|
|
@@ -9302,6 +9787,43 @@ const handleRequest = async (req, res) => {
|
|
|
9302
9787
|
task: store.taskByThread(internalSender.id, internalCapability.threadId),
|
|
9303
9788
|
threadId: internalCapability.threadId,
|
|
9304
9789
|
});
|
|
9790
|
+
if (path === "/api/internal/computer/select" && (method === "GET" || method === "POST")) {
|
|
9791
|
+
const source = computerSelectionTurns.get(internalCapability.threadId);
|
|
9792
|
+
const bot = store.projectBotForTask(internalSender.id, internalCapability.threadId);
|
|
9793
|
+
const canSelect = Boolean(source && source.generation === internalCapability.generation && bot && bot.computer !== "off");
|
|
9794
|
+
if (!bot)
|
|
9795
|
+
return json(res, 403, { error: "Computer selection belongs to a direct bot conversation." });
|
|
9796
|
+
const requested = method === "POST" ? (await readInternalBody()).surface : undefined;
|
|
9797
|
+
if (method === "POST" && !canSelect)
|
|
9798
|
+
return json(res, 403, { error: "Computer selection is only available once per direct user request, with computer access enabled." });
|
|
9799
|
+
if (method === "POST" && requested !== "auto" && !parseSurface(requested)) {
|
|
9800
|
+
return json(res, 400, { error: "surface must be auto, cloud, vm, local, or browser" });
|
|
9801
|
+
}
|
|
9802
|
+
const options = await selectableComputers(bot);
|
|
9803
|
+
const current = source ? source.mounted ?? "off" : await computerPreviewSurface(bot, bot.threadId);
|
|
9804
|
+
requireActiveInternalCapability();
|
|
9805
|
+
if (method === "GET")
|
|
9806
|
+
return json(res, 200, { current, canSelect, options });
|
|
9807
|
+
if (computerSelectionTurns.get(internalCapability.threadId) !== source)
|
|
9808
|
+
return json(res, 409, { error: "The user request ended before its computer was selected." });
|
|
9809
|
+
const option = requested === "auto"
|
|
9810
|
+
? options.find(option => option.ready && option.surface === current) ?? options.find(option => option.ready && option.surface === "vm") ?? options.find(option => option.ready) ?? options.find(option => option.canStart) ?? options.find(option => option.canCreate && option.surface === "vm") ?? options.find(option => option.canCreate)
|
|
9811
|
+
: options.find(option => option.surface === requested);
|
|
9812
|
+
if (!option?.available)
|
|
9813
|
+
return json(res, 409, { error: option?.reason ?? "No configured computer or browser is available. Open the Computer panel to set one up.", options });
|
|
9814
|
+
if (source.selected) {
|
|
9815
|
+
if (source.selected !== option.surface)
|
|
9816
|
+
return json(res, 409, { error: "A computer switch is already pending. End this turn to continue there." });
|
|
9817
|
+
}
|
|
9818
|
+
else {
|
|
9819
|
+
if (option.surface === current && option.ready)
|
|
9820
|
+
return json(res, 200, { status: "ready", surface: current, message: "This computer is already selected. Use its mounted tools." });
|
|
9821
|
+
source.selected = option.surface;
|
|
9822
|
+
source.previousSurface = store.taskByThread(bot.id, bot.threadId)?.surface;
|
|
9823
|
+
}
|
|
9824
|
+
return json(res, 200, { status: "pending", surface: option.surface,
|
|
9825
|
+
message: `End this turn now without using the previous computer tools. OpenMausBot will continue the original request on ${option.label} with a fresh tool connection.` });
|
|
9826
|
+
}
|
|
9305
9827
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
9306
9828
|
const body = await readInternalBody();
|
|
9307
9829
|
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
|
|
@@ -9694,30 +10216,64 @@ const handleRequest = async (req, res) => {
|
|
|
9694
10216
|
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
9695
10217
|
}
|
|
9696
10218
|
const q = String(url.searchParams.get("q") ?? "").trim();
|
|
9697
|
-
|
|
9698
|
-
|
|
10219
|
+
// A recall by time needs no words: "what happened since yesterday"
|
|
10220
|
+
// is the standup question, and it has no keyword.
|
|
10221
|
+
const now = Date.now();
|
|
10222
|
+
const sinceRaw = url.searchParams.get("since");
|
|
10223
|
+
const untilRaw = url.searchParams.get("until");
|
|
10224
|
+
const since = sinceRaw ? parseSince(sinceRaw, now) : null;
|
|
10225
|
+
const until = untilRaw ? parseSince(untilRaw, now) : null;
|
|
10226
|
+
if (sinceRaw && since === null)
|
|
10227
|
+
return json(res, 400, { error: "since must be a date, or a span like 24h, 3d, today, yesterday" });
|
|
10228
|
+
if (untilRaw && until === null)
|
|
10229
|
+
return json(res, 400, { error: "until must be a date, or a span like 24h, 3d, today, yesterday" });
|
|
10230
|
+
if (!q && since === null)
|
|
10231
|
+
return json(res, 400, { error: "q or since is required" });
|
|
10232
|
+
const range = since !== null || until !== null
|
|
10233
|
+
? { ...(since !== null ? { since } : {}), ...(until !== null ? { until } : {}) }
|
|
10234
|
+
: undefined;
|
|
9699
10235
|
const rawLimit = Number(url.searchParams.get("limit"));
|
|
9700
10236
|
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.trunc(rawLimit), 25) : 12;
|
|
9701
10237
|
// Memory files ride along by default: the bot's own notes are as
|
|
9702
10238
|
// much its notebook as its transcripts, and scoped the same way —
|
|
9703
|
-
// the caller's own bot, never another's.
|
|
10239
|
+
// the caller's own bot, never another's. A recall by time alone
|
|
10240
|
+
// has no words to match a file with.
|
|
9704
10241
|
const scope = url.searchParams.get("scope") ?? "all";
|
|
9705
10242
|
if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
|
|
9706
10243
|
return json(res, 400, { error: "scope must be all, conversations, or memory" });
|
|
9707
10244
|
}
|
|
9708
|
-
const memoryHits = scope === "conversations" ? [] : searchMemoryFiles(from.id, q, limit);
|
|
10245
|
+
const memoryHits = scope === "conversations" || !q ? [] : searchMemoryFiles(from.id, q, limit);
|
|
9709
10246
|
if (scope === "memory")
|
|
9710
10247
|
return json(res, 200, { hits: [], memoryHits });
|
|
9711
|
-
|
|
9712
|
-
//
|
|
9713
|
-
//
|
|
10248
|
+
// Own threads: the bot's main chat and tasks, and the rooms it is a
|
|
10249
|
+
// member of with their tasks — conversations it already saw in full.
|
|
10250
|
+
// Still own-bot: another bot's threads never enter this list.
|
|
10251
|
+
const roomByThread = new Map();
|
|
10252
|
+
for (const group of store.groups) {
|
|
10253
|
+
if (!group.memberIds.includes(from.id))
|
|
10254
|
+
continue;
|
|
10255
|
+
roomByThread.set(group.threadId, group);
|
|
10256
|
+
for (const task of group.tasks ?? [])
|
|
10257
|
+
roomByThread.set(task.threadId, group);
|
|
10258
|
+
}
|
|
10259
|
+
const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])];
|
|
10260
|
+
// A room is the only place a recall can be a disclosure, and only a
|
|
10261
|
+
// private chat is one: in a 1:1 the user already owns every thread
|
|
10262
|
+
// the bot can reach, and a room's lines were said in the open.
|
|
9714
10263
|
const inRoom = Boolean(store.groupByThread(fromThreadId));
|
|
9715
|
-
const
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
9720
|
-
|
|
10264
|
+
const found = q ? recallMessages(q, ownThreads, limit, range) : recentMessages(ownThreads, range ?? {}, limit);
|
|
10265
|
+
const hits = found.map((hit) => {
|
|
10266
|
+
const room = roomByThread.get(hit.threadId);
|
|
10267
|
+
return {
|
|
10268
|
+
...hit,
|
|
10269
|
+
task: room
|
|
10270
|
+
? (room.tasks ?? []).find((task) => task.threadId === hit.threadId)?.title
|
|
10271
|
+
: store.taskByThread(from.id, hit.threadId)?.title,
|
|
10272
|
+
...(room ? { room: room.name } : {}),
|
|
10273
|
+
current: hit.threadId === fromThreadId,
|
|
10274
|
+
crossed: inRoom && !room && hit.threadId !== fromThreadId,
|
|
10275
|
+
};
|
|
10276
|
+
});
|
|
9721
10277
|
if (inRoom) {
|
|
9722
10278
|
discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
|
|
9723
10279
|
}
|
|
@@ -10723,7 +11279,9 @@ const handleRequest = async (req, res) => {
|
|
|
10723
11279
|
// where the work is, and say which room it was.
|
|
10724
11280
|
const roomTurn = activeGroupTurnForBot(bot.id);
|
|
10725
11281
|
const target = blockedTarget({ ...bot, threadId: internalCapability.threadId }, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
|
|
10726
|
-
|
|
11282
|
+
const helpPlace = store.taskByThread(bot.id, internalCapability.threadId)?.surface ?? bot.computer;
|
|
11283
|
+
const helpWhere = helpPlace && helpPlace !== "off" ? ` on ${surfaceLabel(helpPlace)}` : "";
|
|
11284
|
+
notify(buildNotification("takeover", bot, target.threadId, `${snapshot.helpReason ?? "asked you to take over"}${helpWhere}`, {
|
|
10727
11285
|
group: target.group,
|
|
10728
11286
|
}));
|
|
10729
11287
|
return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null, requestId });
|
|
@@ -10852,6 +11410,11 @@ const handleRequest = async (req, res) => {
|
|
|
10852
11410
|
if (path === "/api/routines" && method === "POST") {
|
|
10853
11411
|
return json(res, 201, { routine: routines.create(await readBody(req)) });
|
|
10854
11412
|
}
|
|
11413
|
+
// The desktop shell polls this to decide whether to hold the computer
|
|
11414
|
+
// awake: a run in flight, or a routine due within the hour.
|
|
11415
|
+
if (path === "/api/routines/wake" && method === "GET") {
|
|
11416
|
+
return json(res, 200, routines.wakeHold());
|
|
11417
|
+
}
|
|
10855
11418
|
let routineMatch = path.match(/^\/api\/routines\/([\w-]+)\/run$/);
|
|
10856
11419
|
if (routineMatch && method === "POST") {
|
|
10857
11420
|
const run = routines.runNow(routineMatch[1]);
|
|
@@ -11084,7 +11647,7 @@ const handleRequest = async (req, res) => {
|
|
|
11084
11647
|
if (threadId === undefined &&
|
|
11085
11648
|
(auth.kind === "session" || req.headers["x-openmausbot-companion"] === "1") &&
|
|
11086
11649
|
store.tasks(botId).length > 1) {
|
|
11087
|
-
throw Object.assign(new Error("This bot has
|
|
11650
|
+
throw Object.assign(new Error("This bot has more than one thread. Update the OpenMausBot app on this device, then choose a thread and try again."), { status: 409 });
|
|
11088
11651
|
}
|
|
11089
11652
|
};
|
|
11090
11653
|
if (method === "GET" && path === "/api/bots") {
|
|
@@ -12352,7 +12915,7 @@ const handleRequest = async (req, res) => {
|
|
|
12352
12915
|
if (m && method === "PATCH") {
|
|
12353
12916
|
const body = await readBody(req);
|
|
12354
12917
|
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
12355
|
-
const unsupported = Object.keys(body).find((key) => key !== "instanceId" && key !== "model" && key !== "effort");
|
|
12918
|
+
const unsupported = Object.keys(body).find((key) => key !== "instanceId" && key !== "model" && key !== "effort" && key !== "variant");
|
|
12356
12919
|
if (unsupported)
|
|
12357
12920
|
return json(res, 400, { error: `unsupported model field: ${unsupported}` });
|
|
12358
12921
|
}
|
|
@@ -13497,7 +14060,7 @@ const handleRequest = async (req, res) => {
|
|
|
13497
14060
|
// message intact for the next ordinary turn, where central image
|
|
13498
14061
|
// admission can hand it to the provider natively.
|
|
13499
14062
|
const carriesImages = extractTurnImages(text).images.length > 0;
|
|
13500
|
-
if (!carriesImages && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14063
|
+
if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
13501
14064
|
steered = await instance.adapter
|
|
13502
14065
|
.steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
|
|
13503
14066
|
.catch(() => false);
|
|
@@ -13926,7 +14489,7 @@ const handleRequest = async (req, res) => {
|
|
|
13926
14489
|
const current = store.projectBotForTask(m[1], m[2]);
|
|
13927
14490
|
if (!current)
|
|
13928
14491
|
return json(res, 404, { error: "no such task" });
|
|
13929
|
-
const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt"]);
|
|
14492
|
+
const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "surface"]);
|
|
13930
14493
|
if (Object.keys(body).some((key) => !allowed.has(key)))
|
|
13931
14494
|
return json(res, 400, { error: "unsupported thread setting" });
|
|
13932
14495
|
for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
|
|
@@ -13963,6 +14526,18 @@ const handleRequest = async (req, res) => {
|
|
|
13963
14526
|
else
|
|
13964
14527
|
return json(res, 400, { error: "archivedAt must be a timestamp, or null to unarchive" });
|
|
13965
14528
|
}
|
|
14529
|
+
if (body.surface !== undefined) {
|
|
14530
|
+
if (threadBusy(current.id, current.threadId))
|
|
14531
|
+
return json(res, 409, { error: "Stop this thread before changing its computer destination." });
|
|
14532
|
+
// Where this conversation works, chosen from the composer. Null follows
|
|
14533
|
+
// the bot's Works on again. Reachability is the turn's to judge.
|
|
14534
|
+
if (body.surface === null)
|
|
14535
|
+
patch.surface = undefined;
|
|
14536
|
+
else if (parseSurface(body.surface))
|
|
14537
|
+
patch.surface = parseSurface(body.surface);
|
|
14538
|
+
else
|
|
14539
|
+
return json(res, 400, { error: "surface must be cloud, vm, local, browser, or null to follow the bot" });
|
|
14540
|
+
}
|
|
13966
14541
|
if (body.pinnedMessageId !== undefined) {
|
|
13967
14542
|
if (body.pinnedMessageId === null || body.pinnedMessageId === "")
|
|
13968
14543
|
patch.pinnedMessageId = undefined;
|
|
@@ -14288,7 +14863,7 @@ const handleRequest = async (req, res) => {
|
|
|
14288
14863
|
}
|
|
14289
14864
|
m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer$/);
|
|
14290
14865
|
if (m && method === "GET") {
|
|
14291
|
-
const bot =
|
|
14866
|
+
const bot = computerPreviewBot(m[1], url);
|
|
14292
14867
|
if (!bot)
|
|
14293
14868
|
return json(res, 404, { error: "no such bot" });
|
|
14294
14869
|
return json(res, 200, await localVmPayload(localVmTargetForBot(bot.id)));
|
|
@@ -14358,9 +14933,12 @@ const handleRequest = async (req, res) => {
|
|
|
14358
14933
|
}
|
|
14359
14934
|
m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/screenshot$/);
|
|
14360
14935
|
if (m && method === "POST") {
|
|
14361
|
-
const bot =
|
|
14936
|
+
const bot = computerPreviewBot(m[1], url);
|
|
14362
14937
|
if (!bot)
|
|
14363
14938
|
return json(res, 404, { error: "no such bot" });
|
|
14939
|
+
if (url.searchParams.has("threadId") && await computerPreviewSurface(bot, bot.threadId) !== "vm") {
|
|
14940
|
+
return json(res, 409, { error: "This conversation is not using the Local VM" });
|
|
14941
|
+
}
|
|
14364
14942
|
const target = localVmTargetForBot(bot.id);
|
|
14365
14943
|
localVmIdleFor(target).touch();
|
|
14366
14944
|
return json(res, 200, {
|
|
@@ -14784,7 +15362,7 @@ const handleRequest = async (req, res) => {
|
|
|
14784
15362
|
providerConfigBusy = false;
|
|
14785
15363
|
}
|
|
14786
15364
|
}
|
|
14787
|
-
// ── custom MCP servers (
|
|
15365
|
+
// ── custom MCP servers (a local command or a URL; secrets write-only) ──
|
|
14788
15366
|
if (method === "GET" && path === "/api/mcp/servers") {
|
|
14789
15367
|
return json(res, 200, mcpServerResponse());
|
|
14790
15368
|
}
|
|
@@ -14830,12 +15408,7 @@ const handleRequest = async (req, res) => {
|
|
|
14830
15408
|
if (Object.keys(current).length >= MAX_MCP_SERVERS) {
|
|
14831
15409
|
return json(res, 400, { error: `You can add at most ${MAX_MCP_SERVERS} MCP servers.` });
|
|
14832
15410
|
}
|
|
14833
|
-
const parsed = parseMcpServerMutation(name,
|
|
14834
|
-
command: body?.command,
|
|
14835
|
-
args: body?.args,
|
|
14836
|
-
env: body?.env,
|
|
14837
|
-
enabled: body?.enabled,
|
|
14838
|
-
});
|
|
15411
|
+
const parsed = parseMcpServerMutation(name, mcpServerBody(body));
|
|
14839
15412
|
if (!parsed.ok)
|
|
14840
15413
|
return json(res, 400, { error: parsed.error });
|
|
14841
15414
|
persistMcpServers({ ...current, [name]: parsed.server });
|
|
@@ -14931,6 +15504,14 @@ const handleRequest = async (req, res) => {
|
|
|
14931
15504
|
const patch = parseConfigPatch(body);
|
|
14932
15505
|
if (!Object.keys(patch).length)
|
|
14933
15506
|
return json(res, 400, { error: "nothing to save" });
|
|
15507
|
+
const changingVoiceProvider = patch.tts?.provider !== undefined
|
|
15508
|
+
&& patch.tts.provider !== tts.voiceProvider(cfg);
|
|
15509
|
+
if (changingVoiceProvider && patch.tts?.voice === undefined) {
|
|
15510
|
+
// Voice ids are provider-owned opaque values. Never carry a default
|
|
15511
|
+
// from one catalog into another. A caller may explicitly supply a
|
|
15512
|
+
// voice for the newly selected provider in this same atomic patch.
|
|
15513
|
+
patch.tts = { ...patch.tts, voice: "" };
|
|
15514
|
+
}
|
|
14934
15515
|
if (providerConfigBusy)
|
|
14935
15516
|
return json(res, 409, { error: "provider settings are already being updated" });
|
|
14936
15517
|
if (patch.browserProfiles !== undefined && body.expectedBrowserProfiles !== undefined) {
|
|
@@ -15014,42 +15595,83 @@ const handleRequest = async (req, res) => {
|
|
|
15014
15595
|
if (resourceError)
|
|
15015
15596
|
return json(res, 409, { error: resourceError });
|
|
15016
15597
|
}
|
|
15017
|
-
|
|
15598
|
+
let boxRecovery = changingBoxToken ? boxCreateRecoverySnapshot() : [];
|
|
15599
|
+
let boxDeletions = changingBoxToken ? boxDeletionSnapshot() : [];
|
|
15600
|
+
if (changingBoxToken && boxDeletions.length > 0 && !nextBoxToken) {
|
|
15601
|
+
return json(res, 409, {
|
|
15602
|
+
error: "finish or retry pending cloud computer deletion before removing the Box account",
|
|
15603
|
+
});
|
|
15604
|
+
}
|
|
15605
|
+
let replacementProvedByDeletion = false;
|
|
15606
|
+
if (changingBoxToken && boxDeletions.length > 0 && nextBoxToken) {
|
|
15607
|
+
try {
|
|
15608
|
+
await box.verifyBoxDeletionCredential({ box: { token: nextBoxToken } });
|
|
15609
|
+
replacementProvedByDeletion = true;
|
|
15610
|
+
// Verification can observe a completed operation and retire both
|
|
15611
|
+
// its deletion fence and matching create receipt. Never continue
|
|
15612
|
+
// with the pre-verification snapshots: they would demand access
|
|
15613
|
+
// to a Box whose exact operation just proved it was deleted.
|
|
15614
|
+
boxRecovery = boxCreateRecoverySnapshot();
|
|
15615
|
+
boxDeletions = boxDeletionSnapshot();
|
|
15616
|
+
}
|
|
15617
|
+
catch (error) {
|
|
15618
|
+
return json(res, error?.status ?? 503, {
|
|
15619
|
+
error: error instanceof Error ? error.message : String(error),
|
|
15620
|
+
});
|
|
15621
|
+
}
|
|
15622
|
+
}
|
|
15018
15623
|
let currentBoxInventory = null;
|
|
15019
15624
|
let currentBoxResources = null;
|
|
15020
15625
|
const journalBoxResources = [];
|
|
15626
|
+
const deletingBoxIds = new Set(boxDeletions.map((entry) => entry.boxId));
|
|
15021
15627
|
if (changingBoxToken && currentBoxToken) {
|
|
15022
15628
|
currentBoxInventory = await box.listManagedBoxes({ box: { token: currentBoxToken } }, managedBoxOwners());
|
|
15023
15629
|
if (!currentBoxInventory.available) {
|
|
15024
|
-
|
|
15025
|
-
error: `${currentBoxInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
|
|
15026
|
-
});
|
|
15027
|
-
}
|
|
15028
|
-
const currentById = new Map(currentBoxInventory.instances.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
|
|
15029
|
-
for (const recovery of boxRecovery) {
|
|
15030
|
-
if (!recovery.boxId)
|
|
15031
|
-
continue;
|
|
15032
|
-
const inspected = await box.inspectBoxIdentity({ box: { token: currentBoxToken } }, recovery.boxId);
|
|
15033
|
-
if (!inspected.available) {
|
|
15630
|
+
if (!replacementProvedByDeletion) {
|
|
15034
15631
|
return json(res, 503, {
|
|
15035
|
-
error: `${
|
|
15632
|
+
error: `${currentBoxInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
|
|
15036
15633
|
});
|
|
15037
15634
|
}
|
|
15038
|
-
|
|
15039
|
-
|
|
15040
|
-
|
|
15041
|
-
|
|
15042
|
-
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
const
|
|
15046
|
-
|
|
15047
|
-
|
|
15635
|
+
// The old token may be the reason this deletion is stuck. A
|
|
15636
|
+
// target-bound operation/identity proved the replacement belongs
|
|
15637
|
+
// to the same account, so do not deadlock credential recovery on
|
|
15638
|
+
// an inventory request made with the expired token.
|
|
15639
|
+
currentBoxInventory = null;
|
|
15640
|
+
}
|
|
15641
|
+
if (currentBoxInventory) {
|
|
15642
|
+
const currentById = new Map(currentBoxInventory.instances.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
|
|
15643
|
+
for (const recovery of boxRecovery) {
|
|
15644
|
+
if (!recovery.boxId)
|
|
15645
|
+
continue;
|
|
15646
|
+
// A failed provisioning attempt may never have reached the
|
|
15647
|
+
// deterministic rename. The replacement credential already
|
|
15648
|
+
// proved the exact deletion target, so its in-flight resource
|
|
15649
|
+
// is governed by that stronger target-bound receipt rather
|
|
15650
|
+
// than an OpenMausBot name check.
|
|
15651
|
+
if (replacementProvedByDeletion && deletingBoxIds.has(recovery.boxId))
|
|
15652
|
+
continue;
|
|
15653
|
+
const inspected = await box.inspectBoxIdentity({ box: { token: currentBoxToken } }, recovery.boxId);
|
|
15654
|
+
if (!inspected.available) {
|
|
15655
|
+
return json(res, 503, {
|
|
15656
|
+
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Box account and retry`,
|
|
15657
|
+
});
|
|
15658
|
+
}
|
|
15659
|
+
if (!inspected.identity) {
|
|
15660
|
+
// Reconcile exact stale receipts while the current credential is
|
|
15661
|
+
// still available. Leaving one behind would make a later token
|
|
15662
|
+
// addition demand access to a Box the provider proved is gone.
|
|
15663
|
+
retireDeletedBoxCreate(recovery.boxId);
|
|
15664
|
+
continue;
|
|
15665
|
+
}
|
|
15666
|
+
const listed = currentById.get(inspected.identity.boxId);
|
|
15667
|
+
if (listed && listed.name !== inspected.identity.name) {
|
|
15668
|
+
return json(res, 503, { error: "ascii.dev returned conflicting cloud computer identities; keep the current Box account and retry" });
|
|
15669
|
+
}
|
|
15670
|
+
currentById.set(inspected.identity.boxId, inspected.identity);
|
|
15671
|
+
journalBoxResources.push(inspected.identity);
|
|
15048
15672
|
}
|
|
15049
|
-
currentById.
|
|
15050
|
-
journalBoxResources.push(inspected.identity);
|
|
15673
|
+
currentBoxResources = [...currentById.values()];
|
|
15051
15674
|
}
|
|
15052
|
-
currentBoxResources = [...currentById.values()];
|
|
15053
15675
|
}
|
|
15054
15676
|
if (changingLocalVmMode) {
|
|
15055
15677
|
if (localVmActiveThreads.size > 0 || localVmLifecycleBusy.size > 0 || localVmImageBusy) {
|
|
@@ -15096,7 +15718,7 @@ const handleRequest = async (req, res) => {
|
|
|
15096
15718
|
if (!check.ok)
|
|
15097
15719
|
return json(res, 400, { error: check.message });
|
|
15098
15720
|
}
|
|
15099
|
-
if (changingBoxToken && !currentBoxToken && boxRecovery.length > 0) {
|
|
15721
|
+
if (changingBoxToken && (!currentBoxToken || replacementProvedByDeletion) && boxRecovery.length > 0) {
|
|
15100
15722
|
if (!nextBoxToken) {
|
|
15101
15723
|
return json(res, 409, { error: "restore the Box account that owns the remembered cloud computers before clearing it" });
|
|
15102
15724
|
}
|
|
@@ -15104,6 +15726,8 @@ const handleRequest = async (req, res) => {
|
|
|
15104
15726
|
if (!recovery.boxId) {
|
|
15105
15727
|
return json(res, 409, { error: "finish reconciling pending cloud computer creation before changing the Box account" });
|
|
15106
15728
|
}
|
|
15729
|
+
if (replacementProvedByDeletion && deletingBoxIds.has(recovery.boxId))
|
|
15730
|
+
continue;
|
|
15107
15731
|
const inspected = await box.inspectBoxIdentity({ box: { token: nextBoxToken } }, recovery.boxId);
|
|
15108
15732
|
if (!inspected.available) {
|
|
15109
15733
|
return json(res, 503, {
|
|
@@ -15147,15 +15771,17 @@ const handleRequest = async (req, res) => {
|
|
|
15147
15771
|
if (resourceError)
|
|
15148
15772
|
return json(res, 409, { error: resourceError });
|
|
15149
15773
|
}
|
|
15150
|
-
//
|
|
15151
|
-
//
|
|
15152
|
-
//
|
|
15774
|
+
// Each cloud voice provider owns its own credential. Validate the field
|
|
15775
|
+
// against that provider rather than whichever provider happens to be
|
|
15776
|
+
// selected, so switching engines never sends one service another's key.
|
|
15153
15777
|
const newTts = patch.tts;
|
|
15154
|
-
|
|
15155
|
-
|
|
15156
|
-
|
|
15157
|
-
|
|
15158
|
-
|
|
15778
|
+
if (newTts?.key?.trim()) {
|
|
15779
|
+
const check = await tts.verifyKey("elevenlabs", newTts.key.trim());
|
|
15780
|
+
if (!check.ok)
|
|
15781
|
+
return json(res, 400, { error: check.message });
|
|
15782
|
+
}
|
|
15783
|
+
if (newTts?.fishKey?.trim()) {
|
|
15784
|
+
const check = await tts.verifyKey("fish", newTts.fishKey.trim());
|
|
15159
15785
|
if (!check.ok)
|
|
15160
15786
|
return json(res, 400, { error: check.message });
|
|
15161
15787
|
}
|
|
@@ -15200,6 +15826,13 @@ const handleRequest = async (req, res) => {
|
|
|
15200
15826
|
let configWriteCommitted = false;
|
|
15201
15827
|
const externalSecretStorage = url.searchParams.get("secretStorage") === "external";
|
|
15202
15828
|
try {
|
|
15829
|
+
// Provider-owned voice ids must be invalidated before the provider
|
|
15830
|
+
// commit. If bots.json cannot be written, leave the old provider in
|
|
15831
|
+
// place rather than committing a new provider with stale bot voices.
|
|
15832
|
+
// A later config-write failure may leave voices cleared, which is the
|
|
15833
|
+
// safe side of this cross-file mutation: no foreign id can be spoken.
|
|
15834
|
+
if (changingVoiceProvider)
|
|
15835
|
+
store.clearVoiceSelections();
|
|
15203
15836
|
if (externalSecretStorage) {
|
|
15204
15837
|
// The packaged Electron caller commits supplied credentials to the
|
|
15205
15838
|
// OS-encrypted store before entering this route. Persist every
|
|
@@ -15217,6 +15850,8 @@ const handleRequest = async (req, res) => {
|
|
|
15217
15850
|
persisted.opencodeGo.apiKey = "";
|
|
15218
15851
|
if (persisted.tts?.key !== undefined)
|
|
15219
15852
|
persisted.tts.key = "";
|
|
15853
|
+
if (persisted.tts?.fishKey !== undefined)
|
|
15854
|
+
persisted.tts.fishKey = "";
|
|
15220
15855
|
if (persisted.imageGen?.key !== undefined)
|
|
15221
15856
|
persisted.imageGen.key = "";
|
|
15222
15857
|
if (persisted.imageGen?.customApiKey !== undefined)
|
|
@@ -15582,15 +16217,18 @@ const handleRequest = async (req, res) => {
|
|
|
15582
16217
|
// ── the bot's cloud computer (Box) ──
|
|
15583
16218
|
m = path.match(/^\/api\/bots\/([\w-]+)\/computer$/);
|
|
15584
16219
|
if (m && method === "GET") {
|
|
15585
|
-
const bot =
|
|
16220
|
+
const bot = computerPreviewBot(m[1], url);
|
|
15586
16221
|
if (!bot)
|
|
15587
16222
|
return json(res, 404, { error: "no such bot" });
|
|
16223
|
+
const surface = url.searchParams.has("threadId") ? await computerPreviewSurface(bot, bot.threadId) : "cloud";
|
|
16224
|
+
if (surface !== "cloud")
|
|
16225
|
+
return json(res, 200, { surface, configured: false, backend: bot.cloudBackend === "vps" ? "vps" : "box" });
|
|
15588
16226
|
const teamComputer = inheritedTeamComputer(bot);
|
|
15589
16227
|
if (teamComputer)
|
|
15590
|
-
return json(res, 200, { backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await box.boxStatus(cfg, teamComputerOwner(teamComputer.id))) });
|
|
16228
|
+
return json(res, 200, { surface, backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await box.boxStatus(cfg, teamComputerOwner(teamComputer.id))) });
|
|
15591
16229
|
return bot.cloudBackend === "vps"
|
|
15592
|
-
? json(res, 200, { backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) })
|
|
15593
|
-
: json(res, 200, { backend: "box", ...(await box.boxStatus(cfg, bot.id)) });
|
|
16230
|
+
? json(res, 200, { surface, backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) })
|
|
16231
|
+
: json(res, 200, { surface, backend: "box", ...(await box.boxStatus(cfg, bot.id)) });
|
|
15594
16232
|
}
|
|
15595
16233
|
// Who is driving this bot's computer. GET is the panel's initial read;
|
|
15596
16234
|
// POST take/release/dismiss-help are the person's three moves. The bot
|
|
@@ -15651,7 +16289,7 @@ const handleRequest = async (req, res) => {
|
|
|
15651
16289
|
}
|
|
15652
16290
|
m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/viewer-close$/);
|
|
15653
16291
|
if (m && method === "POST") {
|
|
15654
|
-
const bot =
|
|
16292
|
+
const bot = computerPreviewBot(m[1], url);
|
|
15655
16293
|
if (!bot)
|
|
15656
16294
|
return json(res, 404, { error: "no such bot" });
|
|
15657
16295
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
@@ -15662,9 +16300,14 @@ const handleRequest = async (req, res) => {
|
|
|
15662
16300
|
m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot|remove)$/);
|
|
15663
16301
|
if (m && method === "POST") {
|
|
15664
16302
|
const botId = m[1];
|
|
15665
|
-
const
|
|
16303
|
+
const previewOnly = m[2] === "screenshot" || m[2] === "join";
|
|
16304
|
+
const bot = previewOnly ? computerPreviewBot(botId, url) : store.bot(botId);
|
|
15666
16305
|
if (!bot)
|
|
15667
16306
|
return json(res, 404, { error: "no such bot" });
|
|
16307
|
+
const threadPreview = previewOnly && url.searchParams.has("threadId");
|
|
16308
|
+
if (threadPreview && await computerPreviewSurface(bot, bot.threadId) !== "cloud") {
|
|
16309
|
+
return json(res, 409, { error: "This conversation is not using the cloud computer" });
|
|
16310
|
+
}
|
|
15668
16311
|
// Requiring JSON makes every computer mutation a non-simple browser
|
|
15669
16312
|
// request (same reasoning as the Local VM lifecycle routes above): a
|
|
15670
16313
|
// hostile page cannot submit it with a form, and its cross-origin JSON
|
|
@@ -15756,7 +16399,7 @@ const handleRequest = async (req, res) => {
|
|
|
15756
16399
|
});
|
|
15757
16400
|
}
|
|
15758
16401
|
}
|
|
15759
|
-
if (bot.computer !== "cloud") {
|
|
16402
|
+
if (bot.computer !== "cloud" && !threadPreview) {
|
|
15760
16403
|
return json(res, 409, {
|
|
15761
16404
|
error: "Choose Cloud before changing or opening this Box. Auto only checks existing computer state.",
|
|
15762
16405
|
});
|
|
@@ -15771,7 +16414,7 @@ const handleRequest = async (req, res) => {
|
|
|
15771
16414
|
case "provision":
|
|
15772
16415
|
return json(res, 200, await box.provisionBox(cfg, botId, bot.name));
|
|
15773
16416
|
case "join":
|
|
15774
|
-
return json(res, 200, await (activeBoxTurn ? box.joinReadyBox(cfg, botId) : box.joinBox(cfg, botId)));
|
|
16417
|
+
return json(res, 200, await (activeBoxTurn || threadPreview ? box.joinReadyBox(cfg, botId) : box.joinBox(cfg, botId)));
|
|
15775
16418
|
case "sleep":
|
|
15776
16419
|
return json(res, 200, await box.sleepBox(cfg, botId));
|
|
15777
16420
|
case "exec":
|