openmausbot 0.1.94 → 0.1.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{DesktopViewer-bxM7NV3N.js → DesktopViewer-DsnA23KM.js} +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-BhMdJhRJ.js → abnfDiagram-O67JEVCF-Dkbd2hcj.js} +1 -1
- package/dist/assets/{arc-B5itKDcx.js → arc-Duj5Coy2.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-C5d7UY6U.js → architectureDiagram-NJMV4G6O-uYUsDawP.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-BrZIhMmu.js → blockDiagram-BEXU5L5S-CWDlTpXe.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-7I0ArfbD.js → c4Diagram-YGBWAQC7-B0t15PDc.js} +1 -1
- package/dist/assets/channel-BZxwC-Gw.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BnEsyN36.js → chunk-2Q5K7J3B-BJSCL-HV.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-BXATQ52s.js → chunk-3FUC2YCW-D3_ILccd.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-B0BpTkEx.js → chunk-5VM5RSS4-D3dmwT_S.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-BtLTz6FR.js → chunk-GTNCS2PH-Br73HFPD.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-D6gcZOYt.js → chunk-GWA4HPMP-uSsjxubC.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DCLrosvf.js → chunk-JWPE2WC7-C40VWFyr.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-B9JsknV9.js → chunk-NETBCI7D-D6WNNYwU.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CsoTxw7z.js → chunk-WEXAMYUT-DOEhq64P.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-0F_frB0c.js → chunk-XXDRQBXY-BqJEi6Pi.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-DkrpnflX.js → classDiagram-v2-NBCMYWYE-ByU_GnxT.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-BNDwYBt9.js → cose-bilkent-JH36ORCC-D-Uglxqq.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-JUkNr1Mk.js → cynefin-OW5HDTMX-CZWO7aYj.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-DKeh9N4M.js → cynefinDiagram-VND7K2PF-C70-w0la.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-ve-6T_wX.js → dagre-6A5THRUB-CnhjhExB.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-BuJ2d8rT.js → diagram-22UHCM2B-bnjWCNb4.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-BgOs_Ni-.js → diagram-3UASUU5V-A4uTWoFV.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-DMK6VmcP.js → diagram-ATOU4E4O-Zi6m3NUl.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-DksASlcY.js → diagram-CDSNMT55-DtCcmd5k.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-Bgn8I5k6.js → diagram-MLGK6HIB-DofuETS3.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-zK0jqO8N.js → diagram-MPIPVDR6-Dw2T9PuU.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-vKrbzTwr.js → ebnfDiagram-ZINNZB2B-DbrwPiem.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-Bzv2mPNf.js → elk-276RUBZZ-BvCt1dCb.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-CvOyYuxu.js → erDiagram-OPXOYQCR-Y-QKqPnU.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-CtbnDbc0.js → flowDiagram-KWPJA3E3-CDkL1B5w.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-c4dMRYyG.js → ganttDiagram-FUAMR5RP-CKV5D2F6.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-B34uaQho.js → gitGraphDiagram-X574FWY7-nL9iFTCv.js} +1 -1
- package/dist/assets/{index-CPiqc_DN.js → index-DreDnjOu.js} +1 -1
- package/dist/assets/index-Du7o6Dsf.js +610 -0
- package/dist/assets/index-KxG4bWSr.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-o64ma4CI.js → infoDiagram-VRGFBTTK-C7aHjzsf.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-DV4C9BAm.js → ishikawaDiagram-OU5B5YK6-KUZsNBTh.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-Bf0AHk1H.js → journeyDiagram-ZHPQQLJL-CGUuodFl.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-B1UMSKmE.js → kanban-definition-PNTS6WVX-Cy6r868Z.js} +1 -1
- package/dist/assets/{linear-BpKhuyLm.js → linear-Bl669148.js} +1 -1
- package/dist/assets/{mermaid.core-B_BKNCw5.js → mermaid.core-86Xqz5Mr.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DeARruT1.js → mindmap-definition-NLK3R4M7-G4DzHLrd.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BOQEk_1D.js → pegDiagram-GJSIUBJH-K4eruzY0.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-EIfoiTZo.js → pieDiagram-5QR66LMP-DYdR9PPq.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-GgZwT-fi.js → quadrantDiagram-O4NWA36T-BCAh0nEy.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-D9rDQomj.js → railroadDiagram-XR7U4H2S-p0dGRc4g.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DAh1MIeo.js → requirementDiagram-PLB6GJNP-tLeBG0aL.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CPr6Uz64.js → sankeyDiagram-IPEJSGJF-QRuN16rV.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-BU6VIdcm.js → sequenceDiagram-PO4LG4MO-ueT1nVoa.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-qF2HO41r.js → sizeCapture-INFHLROL-wRS2zWg2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DlJ8G4MD.js → stateDiagram-v2-GCMORJYK-CYzA_hE1.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DzYE8I54.js → swimlanes-2SLR337P-kDnv6LMc.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-B7wzEQ0A.js → timeline-definition-EJHVYXUP-D1y2UbbH.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-BvGgrxIP.js → usecaseDiagram-POWQR4AR-IqB4v9HX.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-CEECdgQg.js → vennDiagram-UO4OBE2U-BEi7KYVF.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-XQHA0P_P.js → wardleyDiagram-VNRHLVJA-Dkybo-Yf.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-B5mLcZI2.js → xychartDiagram-PMCCYNJV-BAPFJLpg.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +27 -13
- package/dist-server/companion/src/listener.js +39 -18
- package/dist-server/companion/src/routes.js +39 -0
- package/dist-server/container-mcp.js +16 -29
- package/dist-server/drivers/agents-proxy.js +161 -82
- package/dist-server/enterprise/server/index.js +2 -1
- package/dist-server/{browser-proxy.js → harness-mcp-proxy.js} +34 -19
- package/dist-server/index.js +4991 -4908
- package/dist-server/local-computer.js +2 -1
- package/dist-server/openmausbot.js +906 -1785
- package/dist-server/pair-cli.js +906 -1785
- package/dist-server/proxy-paths.js +2 -1
- package/dist-server/server/account-signin.js +0 -2
- package/dist-server/server/activity.js +201 -0
- package/dist-server/server/admin-activity.js +1 -4
- package/dist-server/server/auto-approve.js +25 -4
- package/dist-server/server/automatic-recovery.js +1 -1
- package/dist-server/server/boat-create-idempotency.js +0 -7
- package/dist-server/server/boat-delete-journal.js +0 -4
- package/dist-server/server/boat.js +4 -4
- package/dist-server/server/bot-package.js +1 -19
- package/dist-server/server/browser-runtime.js +1 -2
- package/dist-server/server/checkpoints.js +90 -148
- package/dist-server/server/chief-of-staff.js +9 -9
- package/dist-server/server/cli.js +33 -26
- package/dist-server/server/cloud-backend.js +0 -4
- package/dist-server/server/cloud-computer-tools.js +110 -0
- package/dist-server/server/cloud-home-start.js +12 -15
- package/dist-server/server/cloud-home.js +0 -5
- package/dist-server/server/cloud-lending.js +0 -3
- package/dist-server/server/cloud-move-http.js +44 -30
- package/dist-server/server/cloud-move.js +20 -18
- package/dist-server/server/composio.js +41 -5
- package/dist-server/server/computer-control.js +0 -15
- package/dist-server/server/computer-observation.js +0 -74
- package/dist-server/server/config.js +121 -65
- package/dist-server/server/container-computer.js +23 -24
- package/dist-server/server/control-client.js +4 -10
- package/dist-server/server/decider/jev.js +1 -4
- package/dist-server/server/decider/log.js +1 -3
- package/dist-server/server/decision-log.js +1 -4
- package/dist-server/server/delegations.js +3 -2
- package/dist-server/server/drivers/acp/core.js +11 -8
- package/dist-server/server/drivers/acp/opencode-go.js +17 -16
- package/dist-server/server/drivers/agents-call.js +88 -92
- package/dist-server/server/drivers/agents-catalog.js +90 -11
- package/dist-server/server/drivers/agents-proxy.js +10 -31
- package/dist-server/server/drivers/antigravity.js +1 -4
- package/dist-server/server/drivers/boatagent.js +23 -4
- package/dist-server/server/drivers/cerebras.js +3 -3
- package/dist-server/server/drivers/chat-mcp-tools.js +4 -7
- package/dist-server/server/drivers/claude-login-auth.js +2 -11
- package/dist-server/server/drivers/claude.js +16 -14
- package/dist-server/server/drivers/codex-device-auth.js +3 -1
- package/dist-server/server/drivers/codex.js +5 -4
- package/dist-server/server/drivers/grok.js +1 -1
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
- package/dist-server/server/drivers/openai-chat.js +18 -11
- package/dist-server/server/drivers/prompt-split.js +4 -4
- package/dist-server/server/drivers/retry.js +8 -4
- package/dist-server/server/engine-install.js +1 -5
- package/dist-server/server/harness/bus.js +49 -0
- package/dist-server/server/harness/registry.js +0 -1
- package/dist-server/server/{browser-proxy.js → harness-mcp-proxy.js} +40 -20
- package/dist-server/server/index.js +1190 -971
- package/dist-server/server/lending-memory.js +12 -8
- package/dist-server/server/mcp-registry.js +35 -0
- package/dist-server/server/memory-entries.js +38 -8
- package/dist-server/server/memory-organize.js +1 -8
- package/dist-server/server/memory-topics.js +4 -4
- package/dist-server/server/memory-upkeep.js +13 -15
- package/dist-server/server/message-file.js +9 -13
- package/dist-server/server/outbound-counts.js +79 -0
- package/dist-server/server/outbound-requests.js +60 -0
- package/dist-server/server/package-import.js +1 -1
- package/dist-server/server/peer-delivery.js +5 -19
- package/dist-server/server/peer-roster.js +30 -4
- package/dist-server/server/profile-versions.js +0 -31
- package/dist-server/server/provider-key-check.js +2 -1
- package/dist-server/server/proxy-paths.js +2 -1
- package/dist-server/server/restart.js +40 -0
- package/dist-server/server/room-handoffs.js +17 -13
- package/dist-server/server/routes/bot-memory.js +0 -24
- package/dist-server/server/routine-requests.js +2 -5
- package/dist-server/server/routines.js +10 -5
- package/dist-server/server/server-launcher.js +53 -0
- package/dist-server/server/setup-mode.js +0 -2
- package/dist-server/server/skill-learn.js +0 -1
- package/dist-server/server/skill-library.js +0 -15
- package/dist-server/server/store.js +10 -2
- package/dist-server/server/surface.js +69 -28
- package/dist-server/server/system-prompt.js +17 -7
- package/dist-server/server/team-manifest.js +1 -6
- package/dist-server/server/team-memory.js +209 -0
- package/dist-server/server/thread-events.js +2 -2
- package/dist-server/server/tunnel.js +4 -1
- package/dist-server/server/turn-images.js +1 -21
- package/dist-server/server/turn-resources.js +19 -150
- package/dist-server/server/usage-ledger.js +3 -1
- package/dist-server/server/vps-computer.js +4 -14
- package/dist-server/server/workspace.js +87 -50
- package/dist-server/server-launcher.js +72 -0
- package/dist-server/shared/activity.js +3 -0
- package/dist-server/shared/ask-question.js +1 -1
- package/dist-server/shared/bot-avatar.js +8 -0
- package/dist-server/shared/cloud-computer.js +12 -0
- package/dist-server/shared/connector-scopes.js +78 -0
- package/dist-server/shared/failed-turn.js +25 -0
- package/dist-server/shared/live-approval.js +2 -2
- package/dist-server/shared/outbound.js +128 -0
- package/dist-server/shared/package-format.js +3 -3
- package/dist-server/shared/pairing-link.js +144 -0
- package/dist-server/shared/tool-surface.js +3 -2
- package/dist-server/vps-container-mcp.js +16 -29
- package/dist-server/workspace-backup.worker.js +14 -27
- package/enterprise/server/index.js +2 -1
- package/package.json +1 -1
- package/dist/assets/channel-CdJX6i8k.js +0 -1
- package/dist/assets/index-CPYrGsv1.js +0 -610
- package/dist/assets/index-CzT42GR8.css +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +0 -8
- package/dist-server/server/claim-idle.js +0 -33
- package/dist-server/server/cloud-overflow.js +0 -167
- package/dist-server/server/drivers/chat-boat-tools.js +0 -90
- package/dist-server/server/drivers/grokagent.js +0 -4
- package/dist-server/server/openmaus-status-capsule.js +0 -329
- package/dist-server/server/tightening-requests.js +0 -435
- package/dist-server/shared/tightening-request.js +0 -87
|
@@ -27,12 +27,15 @@ import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsI
|
|
|
27
27
|
import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
|
|
28
28
|
import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
|
|
29
29
|
import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
|
|
30
|
+
import { failedTurnTool } from "../shared/failed-turn.js";
|
|
31
|
+
import { phonePairingLink } from "../shared/pairing-link.js";
|
|
32
|
+
import { canWorkOnCloud } from "../shared/cloud-computer.js";
|
|
30
33
|
import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
|
|
31
34
|
import { createApprovalModeSupport } from "./harness-capabilities.js";
|
|
32
35
|
import { escapeAttribute } from "../src/lib/composer-attachments.js";
|
|
33
36
|
import { threadRefUrl } from "../src/lib/thread-refs.js";
|
|
34
37
|
import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
|
|
35
|
-
import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
|
|
38
|
+
import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
|
|
36
39
|
import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
|
|
37
40
|
import { updateClaudeCli } from "./claude-update.js";
|
|
38
41
|
import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
|
|
@@ -51,13 +54,14 @@ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
|
|
|
51
54
|
import { groupTurnCwd } from "./room-cwd.js";
|
|
52
55
|
import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
|
|
53
56
|
import * as boat from "./boat.js";
|
|
57
|
+
import { cloudComputerRpc } from "./cloud-computer-tools.js";
|
|
54
58
|
import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
|
|
55
59
|
import { isEffortLevel } from "../shared/wire.js";
|
|
56
60
|
import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequestCard } from "../shared/ask-question.js";
|
|
57
61
|
import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
|
|
58
62
|
import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
|
|
59
63
|
import { boatDeletionSnapshot } from "./boat-delete-journal.js";
|
|
60
|
-
import { liveDecisionRefusal } from "../shared/live-approval.js";
|
|
64
|
+
import { liveCardKind, liveDecisionRefusal } from "../shared/live-approval.js";
|
|
61
65
|
import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
|
|
62
66
|
import * as composio from "./composio.js";
|
|
63
67
|
import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
|
|
@@ -66,10 +70,9 @@ import { buildRecall } from "./recall.js";
|
|
|
66
70
|
import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
|
|
67
71
|
import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
|
|
68
72
|
import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
|
|
69
|
-
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
70
73
|
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmWakeAction, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
|
|
71
74
|
import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
|
|
72
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled,
|
|
75
|
+
import { cacheUntilConfigChanges, ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, mergeOpenCodeProviderKeys, openCodeProviderKeys, } from "./config.js";
|
|
73
76
|
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
74
77
|
import { ComputerControl } from "./computer-control.js";
|
|
75
78
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
@@ -89,12 +92,12 @@ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
|
|
|
89
92
|
import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
|
|
90
93
|
import { recoveryCapabilityError } from "./automatic-recovery.js";
|
|
91
94
|
import { decodeGeneratedImage } from "./generated-image.js";
|
|
92
|
-
import { MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
95
|
+
import { addDisabledMcpServer, MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
93
96
|
import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, withMcpSignIn, withoutPendingSignIn } from "./mcp-oauth.js";
|
|
94
97
|
import { probeMcpServer } from "./mcp-probe.js";
|
|
95
98
|
import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
|
|
96
99
|
import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
|
|
97
|
-
import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
|
|
100
|
+
import { openCodeProviderKeysAllowed, setOpenCodeOwnProviderKeys, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
|
|
98
101
|
import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
|
|
99
102
|
import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
|
|
100
103
|
import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
@@ -115,9 +118,10 @@ import { ProviderRegistry } from "./harness/registry.js";
|
|
|
115
118
|
import { ManagedDesktopProviders } from "./managed-desktop.js";
|
|
116
119
|
import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
|
|
117
120
|
import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
|
|
118
|
-
import { boatNotConfiguredMessage,
|
|
121
|
+
import { boatNotConfiguredMessage, CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
|
|
119
122
|
import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
|
|
120
|
-
import { createCloudMoveRoutes } from "./cloud-move-http.js";
|
|
123
|
+
import { createCloudMoveRoutes, workspaceShared } from "./cloud-move-http.js";
|
|
124
|
+
import { RESTART_EXIT_CODE } from "./restart.js";
|
|
121
125
|
import { holdIncludedServices } from "./included-services.js";
|
|
122
126
|
import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
|
|
123
127
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
@@ -137,8 +141,7 @@ import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart,
|
|
|
137
141
|
import { extractTurnImages } from "./turn-images.js";
|
|
138
142
|
import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
|
|
139
143
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
140
|
-
import { TurnResources
|
|
141
|
-
import { IdleReleasePolicy } from "./claim-idle.js";
|
|
144
|
+
import { TurnResources } from "./turn-resources.js";
|
|
142
145
|
import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, TASK_WORKSPACES_DIR, readMemoryOnlyFromRegularFiles, workspaceDir, } from "./workspace.js";
|
|
143
146
|
import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
|
|
144
147
|
import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
|
|
@@ -150,7 +153,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
|
|
|
150
153
|
import { expandLearnTurnText, learnSource } from "./skill-learn.js";
|
|
151
154
|
import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
|
|
152
155
|
import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
|
|
153
|
-
import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
|
|
156
|
+
import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, TEAM_MEMORY_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, teamAvailabilityPart, } from "./system-prompt.js";
|
|
154
157
|
import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
|
|
155
158
|
import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
|
|
156
159
|
import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
|
|
@@ -173,7 +176,6 @@ import { createOptionsCard } from "./options-card.js";
|
|
|
173
176
|
import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
|
|
174
177
|
import { ProfileRequestService } from "./profile-requests.js";
|
|
175
178
|
import { ModelRequestService } from "./model-requests.js";
|
|
176
|
-
import { TighteningRequestService } from "./tightening-requests.js";
|
|
177
179
|
import { TeamSetupError, TeamSetupRequestService } from "./team-setup-requests.js";
|
|
178
180
|
import { profileRevision, profileSnapshot } from "./profile-revision.js";
|
|
179
181
|
import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfileChange } from "./profile-versions.js";
|
|
@@ -183,6 +185,12 @@ import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-libr
|
|
|
183
185
|
import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageSummary, parsePackageDocument, renderBotPackageMarkdown } from "./bot-package.js";
|
|
184
186
|
import { createTeamManifest, parseTeamManifest } from "./team-manifest.js";
|
|
185
187
|
import { readThreadEvents } from "./thread-events.js";
|
|
188
|
+
import { TeamMemory, TEAM_MEMORY_KINDS } from "./team-memory.js";
|
|
189
|
+
import { describeTool, readBotActivity } from "./activity.js";
|
|
190
|
+
import { OutboundCounts } from "./outbound-counts.js";
|
|
191
|
+
import { OutboundRequestService } from "./outbound-requests.js";
|
|
192
|
+
import { DEFAULT_OUTBOUND_POLICY, connectorCallsIn, normalizeOutboundPolicy, outboundCallsIn } from "../shared/outbound.js";
|
|
193
|
+
import { connectorAccessDecision, describeConnectorScopes, normalizeConnectorScopes } from "../shared/connector-scopes.js";
|
|
186
194
|
import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
|
|
187
195
|
import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
|
|
188
196
|
import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
|
|
@@ -202,9 +210,8 @@ import { shouldMountLocalComputer } from "./local-routing.js";
|
|
|
202
210
|
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
203
211
|
import { startAutoVmClaim } from "./auto-vm-claims.js";
|
|
204
212
|
import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
|
|
205
|
-
import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
|
|
206
213
|
import { modelContextWindow } from "./model-context-window.js";
|
|
207
|
-
import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
214
|
+
import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
208
215
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
209
216
|
import { createGracefulShutdown } from "./graceful-shutdown.js";
|
|
210
217
|
import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
|
|
@@ -292,10 +299,12 @@ const workspaceMaintenance = new WorkspaceBackupMaintenance();
|
|
|
292
299
|
// Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
|
|
293
300
|
// for this server, the paired sessions, and the cookie the served UI uses.
|
|
294
301
|
const ENVIRONMENT_ID = loadEnvironmentId(DATA_DIR);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
302
|
+
// Every stream frame and request rechecks email sessions against this list,
|
|
303
|
+
// so config.json is parsed again only when the file changes.
|
|
304
|
+
const signInAllowList = cacheUntilConfigChanges((config) => ({
|
|
305
|
+
admins: parseAllowList(config.signIn?.admins?.join(",")),
|
|
306
|
+
members: parseAllowList(config.signIn?.members?.join(",")),
|
|
307
|
+
}));
|
|
299
308
|
const sessions = new SessionRegistry({
|
|
300
309
|
file: join(DATA_DIR, "sessions.json"),
|
|
301
310
|
emailScopesSnapshot: () => {
|
|
@@ -767,11 +776,16 @@ function approvalShape(shape) {
|
|
|
767
776
|
/** Who opens a routine's results conversation on a Cloud home: the writer
|
|
768
777
|
* of this request, else the owner for a routine that is theirs (they wrote
|
|
769
778
|
* it as it stands, or they are its writer: cloud-owner.ts), else its last
|
|
770
|
-
* writer, else nobody.
|
|
779
|
+
* writer, else nobody. A webhook's run is the owner's: only their own
|
|
780
|
+
* devices can create, edit or rotate one (admin scope), so it runs at the
|
|
781
|
+
* bot's own level, in its folder, as on the desktop. Its payload still never
|
|
782
|
+
* reaches the lent Mac: cloud-lending.ts refuses every webhook run. */
|
|
771
783
|
function routineOpener(routineId) {
|
|
772
784
|
if (routineWriterInFlight)
|
|
773
785
|
return routineWriterInFlight;
|
|
774
786
|
const routine = routines?.listRoutines().find((candidate) => candidate.id === routineId);
|
|
787
|
+
if (!routine && CLOUD_OWNER_KEY && webhooks.list().some((hook) => hook.id === routineId))
|
|
788
|
+
return CLOUD_OWNER_KEY;
|
|
775
789
|
if (routine && CLOUD_OWNER_KEY && cloudRoutineAuthors?.authored(routineId, routine))
|
|
776
790
|
return CLOUD_OWNER_KEY;
|
|
777
791
|
const writer = cloudRoutineAuthors?.writer(routineId);
|
|
@@ -940,6 +954,9 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
|
|
|
940
954
|
? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
|
|
941
955
|
: null;
|
|
942
956
|
}
|
|
957
|
+
if (!auth.scopes.includes("admin") && store.messagesFor(threadId).some(message => message.card?.requestId === requestId && message.card.teamMemoryRequest)) {
|
|
958
|
+
return "Only a workspace admin can review shared team memory.";
|
|
959
|
+
}
|
|
943
960
|
// A Cloud home is one person's: a device paired with chat-only access (a
|
|
944
961
|
// guest) may read along but never answer, or its words would reach the
|
|
945
962
|
// owner's turn, and through it a lent Mac (docs/cloud-pro.md).
|
|
@@ -1236,11 +1253,11 @@ function memberFrameContext(visible) {
|
|
|
1236
1253
|
webhookBot: (webhookId) => webhooks.list().find((webhook) => webhook.id === webhookId)?.botId,
|
|
1237
1254
|
freshBot: (botId) => {
|
|
1238
1255
|
const bot = store.bot(botId);
|
|
1239
|
-
return bot ?
|
|
1256
|
+
return bot ? publicBot(bot) : undefined;
|
|
1240
1257
|
},
|
|
1241
1258
|
freshGroup: (groupId) => {
|
|
1242
1259
|
const group = store.group(groupId);
|
|
1243
|
-
return group ?
|
|
1260
|
+
return group ? groupWithThread(group) : undefined;
|
|
1244
1261
|
},
|
|
1245
1262
|
};
|
|
1246
1263
|
}
|
|
@@ -1511,6 +1528,8 @@ const openCodeKeysAllowed = () => openCodeProviderKeysAllowed({
|
|
|
1511
1528
|
sharedSignIn: sharedSignIn(signInAllowList()),
|
|
1512
1529
|
});
|
|
1513
1530
|
setOpenCodeProviderKeyPolicy(openCodeKeysAllowed);
|
|
1531
|
+
// Keys the owner saved for OpenCode in Settings reach it on every server.
|
|
1532
|
+
setOpenCodeOwnProviderKeys(() => openCodeProviderKeys(cfg));
|
|
1514
1533
|
let openCodeKeysLastAllowed = openCodeKeysAllowed();
|
|
1515
1534
|
/** OpenCode lists a provider's models only while it may read that
|
|
1516
1535
|
* provider's key. When enrollment or the sign-in list changes the answer,
|
|
@@ -1743,6 +1762,8 @@ utilityParentPort?.on("message", event => {
|
|
|
1743
1762
|
});
|
|
1744
1763
|
});
|
|
1745
1764
|
const MAX_ATTACHED_FILES_PER_TURN = 10;
|
|
1765
|
+
const MAX_ROOM_POSTS_PER_TURN = 3;
|
|
1766
|
+
const MAX_CREATED_BOTS_PER_TURN = 4;
|
|
1746
1767
|
// A capability lives for the exact provider-turn generation, including while
|
|
1747
1768
|
// that turn is parked on a human approval. The long ceiling is only an orphan
|
|
1748
1769
|
// backstop for an impossible-to-settle adapter; normal terminal paths revoke
|
|
@@ -1799,15 +1820,52 @@ function beginInternalCapabilityGeneration(threadId, generation = randomUUID())
|
|
|
1799
1820
|
activeInternalGenerationByThread.set(threadId, generation);
|
|
1800
1821
|
return generation;
|
|
1801
1822
|
}
|
|
1802
|
-
|
|
1823
|
+
// The bearer an integration's proxy holds is part of the engine's launch
|
|
1824
|
+
// contract (Claude's process key, ACP's session inputs): a new one every turn
|
|
1825
|
+
// meant a relaunch, a resumed transcript and new MCP servers every turn. So
|
|
1826
|
+
// the bearer stays the same while the same bot works the same thread with the
|
|
1827
|
+
// same grants and approval level, and only the record behind it is per turn:
|
|
1828
|
+
// between turns, and after any revoke, nothing honours it. A changed grant,
|
|
1829
|
+
// Stop, a stall, a deleted bot or thread or a provider reload gives the next
|
|
1830
|
+
// turn a new bearer, so whatever still holds the old one is never honoured
|
|
1831
|
+
// again.
|
|
1832
|
+
/** threadId → "botId kind" → the bearer and the grants it was minted for. */
|
|
1833
|
+
const sessionCredentials = new Map();
|
|
1834
|
+
/** What a bearer stands for: everything its record grants (a turn's counters
|
|
1835
|
+
* all start at zero), plus the thread's approval level; not the turn. */
|
|
1836
|
+
function sessionGrants({ generation: _turn, ...grants }) {
|
|
1837
|
+
const thread = botForThread(grants.botId, grants.threadId);
|
|
1838
|
+
return JSON.stringify({ ...grants, approval: thread ? approvalModeFor(thread) : null });
|
|
1839
|
+
}
|
|
1840
|
+
/** The thread's bearer for this integration, new only when its grants changed. */
|
|
1841
|
+
function sessionBearer(capability) {
|
|
1842
|
+
const credentials = sessionCredentials.get(capability.threadId) ?? new Map();
|
|
1843
|
+
sessionCredentials.set(capability.threadId, credentials);
|
|
1844
|
+
const slot = `${capability.botId} ${capability.kind}`;
|
|
1845
|
+
const grants = sessionGrants(capability);
|
|
1846
|
+
const held = credentials.get(slot);
|
|
1847
|
+
if (held?.grants === grants)
|
|
1848
|
+
return held.token;
|
|
1849
|
+
const token = randomBytes(24).toString("hex");
|
|
1850
|
+
credentials.set(slot, { grants, token });
|
|
1851
|
+
return token;
|
|
1852
|
+
}
|
|
1853
|
+
function mintInternalCapability(capability, { perTurn = false } = {}) {
|
|
1803
1854
|
if (activeInternalGenerationByThread.get(capability.threadId) !== capability.generation) {
|
|
1804
1855
|
throw new Error("cannot mint an integration capability for an inactive turn");
|
|
1805
1856
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1857
|
+
// perTurn: a bridge into a machine the harness stops and starts between
|
|
1858
|
+
// turns (a Local VM, the VPS, a Boat). A warm engine never restarts a
|
|
1859
|
+
// server whose machine went away, so such a turn gets a new bearer and
|
|
1860
|
+
// starts its engine fresh.
|
|
1861
|
+
const token = perTurn ? randomBytes(24).toString("hex") : sessionBearer(capability);
|
|
1862
|
+
// Minted twice in one turn: keep that turn's record and its counters.
|
|
1863
|
+
if (internalCapabilities.get(token)?.generation !== capability.generation) {
|
|
1864
|
+
internalCapabilities.set(token, {
|
|
1865
|
+
...capability,
|
|
1866
|
+
orphanExpiresAt: Date.now() + INTERNAL_CAPABILITY_ORPHAN_MS,
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1811
1869
|
return token;
|
|
1812
1870
|
}
|
|
1813
1871
|
/** A turn that was not provably the owner's has ended: judge its bot's
|
|
@@ -1841,7 +1899,9 @@ function revokeInternalCapabilityGeneration(threadId, generation) {
|
|
|
1841
1899
|
}
|
|
1842
1900
|
internalGenerationByProviderTurn.deleteGeneration(threadId, generation);
|
|
1843
1901
|
}
|
|
1844
|
-
|
|
1902
|
+
/** Before a new turn on this thread mints: nothing an earlier turn here was
|
|
1903
|
+
* given stays honoured. The thread's bearers stay, so its engine stays warm. */
|
|
1904
|
+
function revokeEarlierTurnCapabilities(threadId) {
|
|
1845
1905
|
computerSelectionTurns.delete(threadId);
|
|
1846
1906
|
const generation = activeInternalGenerationByThread.get(threadId);
|
|
1847
1907
|
if (generation)
|
|
@@ -1854,9 +1914,16 @@ function revokeInternalCapabilitiesForThread(threadId) {
|
|
|
1854
1914
|
internalCapabilities.delete(token);
|
|
1855
1915
|
}
|
|
1856
1916
|
}
|
|
1917
|
+
/** Stop, a stall, a deleted bot or thread, a provider change: the thread's
|
|
1918
|
+
* bearers are retired too, so nothing that still holds one is honoured again. */
|
|
1919
|
+
function revokeInternalCapabilitiesForThread(threadId) {
|
|
1920
|
+
revokeEarlierTurnCapabilities(threadId);
|
|
1921
|
+
sessionCredentials.delete(threadId);
|
|
1922
|
+
}
|
|
1857
1923
|
function revokeAllInternalCapabilities() {
|
|
1858
1924
|
computerSelectionTurns.clear();
|
|
1859
1925
|
internalCapabilities.clear();
|
|
1926
|
+
sessionCredentials.clear();
|
|
1860
1927
|
// foreignTurns stays: a turn that is not provably the owner's can still
|
|
1861
1928
|
// write while its engine is torn down, and its own end (which revokes its
|
|
1862
1929
|
// generation by id) is what judges it (server/lending-memory.ts).
|
|
@@ -2020,6 +2087,15 @@ class ComputerWaitParked extends Error {
|
|
|
2020
2087
|
this.generation = generation;
|
|
2021
2088
|
}
|
|
2022
2089
|
}
|
|
2090
|
+
/** A place the turn was told to use failed to attach: its cause plus the one
|
|
2091
|
+
* control that changes it (placeUnavailable). A cancelled setup, a parked
|
|
2092
|
+
* computer wait and an already-worded place failure pass through as they
|
|
2093
|
+
* are. The one wrapper for a bot thread and a room turn. */
|
|
2094
|
+
function asPlaceFailure(error, place, source, limit) {
|
|
2095
|
+
if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
|
|
2096
|
+
return error;
|
|
2097
|
+
return placeUnavailable(place, source, error instanceof Error ? error.message : String(error), limit);
|
|
2098
|
+
}
|
|
2023
2099
|
const directTurnDispatchClaims = new Map();
|
|
2024
2100
|
const directTurnGenerationByThread = new Map();
|
|
2025
2101
|
// Only the latest direct request per thread is retained. A fresh user turn
|
|
@@ -2062,173 +2138,14 @@ const turnResources = new TurnResources();
|
|
|
2062
2138
|
const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
|
|
2063
2139
|
const turnResourceOwners = new Map();
|
|
2064
2140
|
const turnComputerResources = new Map();
|
|
2065
|
-
/** Screen-touching computer calls that started before their turn's computer
|
|
2066
|
-
* claim was bound (#1653 over the lazy attach of #1361): the start event
|
|
2067
|
-
* finds no claim to fence, so it waits here and bindTurnComputer carries it
|
|
2068
|
-
* into the fresh claim, keeping the quiet window from releasing a seat
|
|
2069
|
-
* under a call the desktop is already executing. */
|
|
2070
|
-
const pendingComputerCallStarts = new Map();
|
|
2071
2141
|
const teamComputerTurns = new Map();
|
|
2072
2142
|
const settlingResourceOwners = new Map();
|
|
2073
|
-
/** Idle release for computer claims (#1653), disabled until the feature
|
|
2074
|
-
* flag is on: a seat that stays screen-quiet for the policy's window is
|
|
2075
|
-
* released to waiting turns while its holder's turn still lives, and the
|
|
2076
|
-
* previous holder re-claims directly inside the reclaim window — it
|
|
2077
|
-
* yields once another turn holds the seat. The durable VM profile keeps
|
|
2078
|
-
* its login state, so that re-claim is cheap. Read per claim: a flag
|
|
2079
|
-
* change applies to new claims, never to a seat held under the old
|
|
2080
|
-
* setting. computer:phone is excluded: it is the #1663 exclusivity lock,
|
|
2081
|
-
* claimed once per turn with no activity signal to renew it. */
|
|
2082
|
-
const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
|
|
2083
|
-
function computerClaimIdlePolicy(resource) {
|
|
2084
|
-
return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
|
|
2085
|
-
}
|
|
2086
|
-
/** #1655: the consent ledger for local-wait cloud overflow, and the live
|
|
2087
|
-
* cloud seats it has started. One seat per bot — it is the bot's own Box,
|
|
2088
|
-
* shared by that bot's threads the way every other computer is. */
|
|
2089
|
-
const cloudOverflowConsent = new CloudOverflowConsent();
|
|
2090
|
-
const cloudSeatLeases = new Map();
|
|
2091
|
-
let cloudSeatSweepTimer;
|
|
2092
|
-
/** One stop attempt per seat at a time (#1655): the sweep ticks faster
|
|
2093
|
-
* than a Box sleeps, and a second stop call would race the first. */
|
|
2094
|
-
const cloudSeatStopsInFlight = new Set();
|
|
2095
|
-
/** The waits that may overflow: a turn queued for a local desktop, never
|
|
2096
|
-
* one already bound for a cloud or VPS seat. */
|
|
2097
|
-
function localComputerWait(resource) {
|
|
2098
|
-
return resource === "computer:host" || resource.startsWith("computer:vm:");
|
|
2099
|
-
}
|
|
2100
|
-
function cloudOverflowSituation(owner, resource, started) {
|
|
2101
|
-
if (!localComputerWait(resource))
|
|
2102
|
-
return null;
|
|
2103
|
-
if (!store.botByThread(owner.threadId))
|
|
2104
|
-
return null;
|
|
2105
|
-
const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
|
|
2106
|
-
return {
|
|
2107
|
-
featureEnabled: cloudOverflowEnabled(cfg),
|
|
2108
|
-
// An organisation that refuses the Box kind has nothing to offer, and
|
|
2109
|
-
// the decision must fail closed before any card is written.
|
|
2110
|
-
cloudConfigured: boat.boatConfigured(cfg) && managedPolicy.computerRefusal("box") === undefined,
|
|
2111
|
-
perSecondCostUsd,
|
|
2112
|
-
// Consent answers the priced card (#1655): a grant counts only at the
|
|
2113
|
-
// rate its card showed, so a config change re-offers instead of
|
|
2114
|
-
// starting at a price nobody approved.
|
|
2115
|
-
consented: cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd ?? undefined),
|
|
2116
|
-
offered: cloudOverflowConsent.offered(owner.threadId, perSecondCostUsd ?? undefined),
|
|
2117
|
-
started,
|
|
2118
|
-
};
|
|
2119
|
-
}
|
|
2120
2143
|
function claimTurnResource(owner, resource) {
|
|
2121
|
-
|
|
2122
|
-
if (!turnResources.claim(resource, owner, idle ? { idle } : {}))
|
|
2144
|
+
if (!turnResources.claim(resource, owner))
|
|
2123
2145
|
return false;
|
|
2124
2146
|
turnResourceOwners.set(owner.threadId, owner);
|
|
2125
2147
|
return true;
|
|
2126
2148
|
}
|
|
2127
|
-
/** Start the waiting bot's cloud seat (#1655). True once handled — started
|
|
2128
|
-
* or terminally failed — so the wait never retries every poll; false when
|
|
2129
|
-
* another turn of this bot owns the Box lifecycle and a later poll may
|
|
2130
|
-
* still land. */
|
|
2131
|
-
async function startCloudSeat(owner) {
|
|
2132
|
-
const bot = store.botByThread(owner.threadId);
|
|
2133
|
-
if (!bot)
|
|
2134
|
-
return true;
|
|
2135
|
-
const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
|
|
2136
|
-
if (!perSecondCostUsd)
|
|
2137
|
-
return true;
|
|
2138
|
-
const boxBotResource = "computer:box-bot:" + bot.id;
|
|
2139
|
-
if (!claimTurnResource(owner, boxBotResource))
|
|
2140
|
-
return false;
|
|
2141
|
-
try {
|
|
2142
|
-
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
2143
|
-
const machine = await boat.readyBoat(cfg, bot.id);
|
|
2144
|
-
if (!machine)
|
|
2145
|
-
throw new Error("the cloud computer did not wake");
|
|
2146
|
-
// Consent can be revoked while the Box wakes (#1655): a seat nobody
|
|
2147
|
-
// consented to must not bill. It is released and put straight back to
|
|
2148
|
-
// sleep — and if even that fails, leased as already idle so the sweep
|
|
2149
|
-
// keeps retrying with backoff.
|
|
2150
|
-
if (!cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd)) {
|
|
2151
|
-
turnResources.releaseOne(boxBotResource, owner);
|
|
2152
|
-
const idleStopMs = cloudOverflowIdleStopMs(cfg);
|
|
2153
|
-
void boat.sleepBoat(cfg, bot.id).catch(() => {
|
|
2154
|
-
cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, now: Date.now() - idleStopMs - 1, idleStopMs }));
|
|
2155
|
-
ensureCloudSeatSweep();
|
|
2156
|
-
});
|
|
2157
|
-
store.appendMessage(owner.threadId, {
|
|
2158
|
-
role: "bot", kind: "activity",
|
|
2159
|
-
tool: { name: "Cloud overflow consent was revoked while the cloud computer woke; it stops unbilled and the local wait continues.", ok: true },
|
|
2160
|
-
});
|
|
2161
|
-
return true;
|
|
2162
|
-
}
|
|
2163
|
-
cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, idleStopMs: cloudOverflowIdleStopMs(cfg) }));
|
|
2164
|
-
ensureCloudSeatSweep();
|
|
2165
|
-
store.appendMessage(owner.threadId, {
|
|
2166
|
-
role: "bot", kind: "activity",
|
|
2167
|
-
tool: { name: cloudSeatStartedText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
|
|
2168
|
-
});
|
|
2169
|
-
return true;
|
|
2170
|
-
}
|
|
2171
|
-
catch (error) {
|
|
2172
|
-
turnResources.releaseOne(boxBotResource, owner);
|
|
2173
|
-
store.appendMessage(owner.threadId, {
|
|
2174
|
-
role: "bot", kind: "activity",
|
|
2175
|
-
tool: { name: "Cloud computer could not start (" + (error instanceof Error ? error.message : String(error)) + "); the local wait continues.", ok: false },
|
|
2176
|
-
});
|
|
2177
|
-
return true;
|
|
2178
|
-
}
|
|
2179
|
-
}
|
|
2180
|
-
/** A per-second-billed seat must stop on idleness even when nothing else
|
|
2181
|
-
* touches the server, so this slice carries the one timer — unref'd, it
|
|
2182
|
-
* can never hold the process open, and it dies with the last lease. */
|
|
2183
|
-
function ensureCloudSeatSweep() {
|
|
2184
|
-
if (cloudSeatSweepTimer)
|
|
2185
|
-
return;
|
|
2186
|
-
cloudSeatSweepTimer = setInterval(() => {
|
|
2187
|
-
sweepIdleCloudSeats();
|
|
2188
|
-
if (!cloudSeatLeases.size && cloudSeatSweepTimer) {
|
|
2189
|
-
clearInterval(cloudSeatSweepTimer);
|
|
2190
|
-
cloudSeatSweepTimer = undefined;
|
|
2191
|
-
}
|
|
2192
|
-
}, 15_000);
|
|
2193
|
-
cloudSeatSweepTimer.unref?.();
|
|
2194
|
-
}
|
|
2195
|
-
/** Stop every cloud seat idle past its window (#1655): the machine is
|
|
2196
|
-
* archived, billing pauses, and the transcript says which wait's seat it
|
|
2197
|
-
* was. Only real computer tool completions refresh a lease — the poller's
|
|
2198
|
-
* preview frames never reach the touch call. */
|
|
2199
|
-
/** Stop one running cloud seat (#1655): the Box sleeps, and only a
|
|
2200
|
-
* successful stop clears the lease and its Box claim — a failed stop
|
|
2201
|
-
* stays leased and retryable with backoff, because the seat still bills
|
|
2202
|
-
* until it sleeps. The claim releases for the lease's own generation,
|
|
2203
|
-
* never a newer turn's. */
|
|
2204
|
-
function stopCloudSeat(botId, lease, stoppedName, now = Date.now()) {
|
|
2205
|
-
if (cloudSeatStopsInFlight.has(botId))
|
|
2206
|
-
return;
|
|
2207
|
-
if (cloudSeatLeases.get(botId) !== lease)
|
|
2208
|
-
return;
|
|
2209
|
-
cloudSeatStopsInFlight.add(botId);
|
|
2210
|
-
void boat.sleepBoat(cfg, botId)
|
|
2211
|
-
.then(() => {
|
|
2212
|
-
if (cloudSeatLeases.get(botId) === lease)
|
|
2213
|
-
cloudSeatLeases.delete(botId);
|
|
2214
|
-
// The stopped seat's Box claim goes with it, so a still-waiting
|
|
2215
|
-
// turn may start a fresh seat for the same bot.
|
|
2216
|
-
turnResources.releaseOne(`computer:box-bot:${botId}`, { threadId: lease.threadId, generation: lease.generation });
|
|
2217
|
-
store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: stoppedName, ok: true } });
|
|
2218
|
-
})
|
|
2219
|
-
.catch(() => {
|
|
2220
|
-
lease.deferSleep(now);
|
|
2221
|
-
store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: "Cloud computer could not stop — the sweep retries with backoff; stop it from Settings if it stays awake. Billing continues until it sleeps.", ok: false } });
|
|
2222
|
-
})
|
|
2223
|
-
.finally(() => cloudSeatStopsInFlight.delete(botId));
|
|
2224
|
-
}
|
|
2225
|
-
function sweepIdleCloudSeats(now = Date.now()) {
|
|
2226
|
-
for (const [botId, lease] of cloudSeatLeases) {
|
|
2227
|
-
if (!lease.idleElapsed(now) || !lease.sleepDue(now))
|
|
2228
|
-
continue;
|
|
2229
|
-
stopCloudSeat(botId, lease, cloudSeatStoppedText(lease.idleFor(now)), now);
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
2149
|
function releaseTurnResources(owner) {
|
|
2233
2150
|
if (!owner)
|
|
2234
2151
|
return;
|
|
@@ -2241,8 +2158,6 @@ function releaseTurnResources(owner) {
|
|
|
2241
2158
|
turnResourceOwners.delete(owner.threadId);
|
|
2242
2159
|
if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
|
|
2243
2160
|
turnComputerResources.delete(owner.threadId);
|
|
2244
|
-
if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
|
|
2245
|
-
pendingComputerCallStarts.delete(owner.threadId);
|
|
2246
2161
|
const teamTurn = teamComputerTurns.get(owner.threadId);
|
|
2247
2162
|
if (teamTurn?.owner.generation === owner.generation) {
|
|
2248
2163
|
stopScreenPoller(teamTurn.botId, owner.threadId);
|
|
@@ -2272,10 +2187,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2272
2187
|
// turn.wait_* events carry the same facts for the inspector log.
|
|
2273
2188
|
let waitedSince;
|
|
2274
2189
|
let waitEnded = false;
|
|
2275
|
-
// #1655: whether this wait already started (or terminally failed to
|
|
2276
|
-
// start) a cloud seat; the decision is re-evaluated every poll because
|
|
2277
|
-
// consent can arrive mid-wait.
|
|
2278
|
-
let overflowHandled = false;
|
|
2279
2190
|
const waitEventBase = () => {
|
|
2280
2191
|
const selection = store.botByThread(owner.threadId)?.modelSelection;
|
|
2281
2192
|
const instance = selection ? registry.get(selection.instanceId) : null;
|
|
@@ -2349,32 +2260,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2349
2260
|
position,
|
|
2350
2261
|
...(holder ? { holder } : {}),
|
|
2351
2262
|
});
|
|
2352
|
-
// #1655: the wait is where an overflow choice gets made. The card
|
|
2353
|
-
// names the per-second cost before anything can start, and only a
|
|
2354
|
-
// turn genuinely waiting on a local desktop gets one. Fail closed:
|
|
2355
|
-
// feature off, cloud unusable, or no operator-set rate means no
|
|
2356
|
-
// card at all — Auto never offers, never starts.
|
|
2357
|
-
const overflow = cloudOverflowSituation(owner, resource, overflowHandled);
|
|
2358
|
-
const perSecondCostUsd = overflow?.perSecondCostUsd ?? null;
|
|
2359
|
-
if (overflow && perSecondCostUsd !== null && cloudOverflowAction(overflow).kind === "offer") {
|
|
2360
|
-
store.appendMessage(owner.threadId, {
|
|
2361
|
-
role: "bot", kind: "activity",
|
|
2362
|
-
tool: { name: cloudOverflowOfferText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
|
|
2363
|
-
});
|
|
2364
|
-
cloudOverflowConsent.markOffered(owner.threadId, perSecondCostUsd);
|
|
2365
|
-
}
|
|
2366
2263
|
}
|
|
2367
|
-
// #1655: consent can arrive mid-wait — the card's answer, or a
|
|
2368
|
-
// thread the operator allowlisted — and only then may the cloud
|
|
2369
|
-
// seat start, its cost chip landing beside the waiting one. The
|
|
2370
|
-
// local wait itself is untouched: it still ends by acquisition,
|
|
2371
|
-
// stop, or the ceiling (parking, once that stack lands).
|
|
2372
|
-
if (!overflowHandled) {
|
|
2373
|
-
const situation = cloudOverflowSituation(owner, resource, overflowHandled);
|
|
2374
|
-
if (situation && cloudOverflowAction(situation).kind === "start")
|
|
2375
|
-
overflowHandled = await startCloudSeat(owner);
|
|
2376
|
-
}
|
|
2377
|
-
sweepIdleCloudSeats();
|
|
2378
2264
|
if (Date.now() >= deadline) {
|
|
2379
2265
|
// ADR-2 (#1651): the ceiling parks the turn — settled, with a
|
|
2380
2266
|
// registered resume — instead of failing it. The owner mark lets the
|
|
@@ -2396,15 +2282,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2396
2282
|
}
|
|
2397
2283
|
turnResourceOwners.set(owner.threadId, owner);
|
|
2398
2284
|
turnComputerResources.set(owner.threadId, { owner, resource });
|
|
2399
|
-
// A screen call that started before this bind (#1653) fenced nothing
|
|
2400
|
-
// because no claim existed yet: carry its pending start into the fresh
|
|
2401
|
-
// claim so the idle window still defers to the running call.
|
|
2402
|
-
const pendingStarts = pendingComputerCallStarts.get(owner.threadId);
|
|
2403
|
-
if (pendingStarts?.generation === owner.generation && pendingStarts.count > 0) {
|
|
2404
|
-
pendingComputerCallStarts.delete(owner.threadId);
|
|
2405
|
-
for (let i = 0; i < pendingStarts.count; i += 1)
|
|
2406
|
-
turnResources.beginComputerCall(resource, owner);
|
|
2407
|
-
}
|
|
2408
2285
|
}
|
|
2409
2286
|
function botForThread(botId, threadId) {
|
|
2410
2287
|
return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
|
|
@@ -2450,15 +2327,9 @@ function canAdmitDirectTurn(botId, threadId) {
|
|
|
2450
2327
|
/** Routine and webhook dispatch shares startTurn's admission preconditions
|
|
2451
2328
|
* instead of waiting for whole-bot idleness: a free thread slot and no
|
|
2452
2329
|
* active group turn. A group turn blocks scheduled starts the same way it
|
|
2453
|
-
* blocks every other turn kind; it does not consume a capacity slot.
|
|
2454
|
-
*
|
|
2455
|
-
*
|
|
2456
|
-
* startTurn's cwd resolution: a free thread slot alone can miss a second
|
|
2457
|
-
* task of this bot landing in that one folder (#F-collide). Predicting that
|
|
2458
|
-
* collision here lets it defer through the same busy-target machinery as
|
|
2459
|
-
* capacity and group-turn contention, instead of reaching startTurn's own
|
|
2460
|
-
* workspace claim — which throws deep inside a detached dispatch that no
|
|
2461
|
-
* caller here awaits, so the run would otherwise fail permanently. */
|
|
2330
|
+
* blocks every other turn kind; it does not consume a capacity slot. The
|
|
2331
|
+
* bot's project folder is not a slot: a fresh run works there beside the
|
|
2332
|
+
* bot's other threads (see startTurn's cwd resolution). */
|
|
2462
2333
|
function unattendedDispatchState(botId) {
|
|
2463
2334
|
const bot = store.bot(botId);
|
|
2464
2335
|
const decision = admit("unattended", {}, {
|
|
@@ -2468,16 +2339,6 @@ function unattendedDispatchState(botId) {
|
|
|
2468
2339
|
});
|
|
2469
2340
|
if (decision.action === "refuse")
|
|
2470
2341
|
return decision.code === "missing" ? "missing" : "busy";
|
|
2471
|
-
if (bot?.cwd) {
|
|
2472
|
-
try {
|
|
2473
|
-
if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
|
|
2474
|
-
return "busy";
|
|
2475
|
-
}
|
|
2476
|
-
catch {
|
|
2477
|
-
// An unresolvable folder is startTurn's own admission check to
|
|
2478
|
-
// report; this prediction only ever adds a defer, never a refusal.
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
2342
|
return "ready";
|
|
2482
2343
|
}
|
|
2483
2344
|
function requestedTaskBot(botId, rawThreadId) {
|
|
@@ -2724,8 +2585,8 @@ async function browserIntegration(botId, profile, turn) {
|
|
|
2724
2585
|
const token = mintInternalCapability({ botId, ...turn, browserSession: session,
|
|
2725
2586
|
kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
|
|
2726
2587
|
return { profile: partitionId, session, spec, integration: {
|
|
2727
|
-
command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
|
|
2728
|
-
...AGENTS_NODE_FLAG,
|
|
2588
|
+
command: process.execPath, args: [SPAWNED_PROXIES.harnessMcp, "browser"], env: {
|
|
2589
|
+
...AGENTS_NODE_FLAG, OMB_MCP_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
|
|
2729
2590
|
},
|
|
2730
2591
|
} };
|
|
2731
2592
|
}
|
|
@@ -2826,8 +2687,14 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
|
|
|
2826
2687
|
}).strict(),
|
|
2827
2688
|
...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown(), forBotId: z.unknown().optional() }).strict()),
|
|
2828
2689
|
]);
|
|
2829
|
-
/** The loopback endpoint a bot's computer proxy polls before acting.
|
|
2830
|
-
|
|
2690
|
+
/** The loopback endpoint a bot's computer proxy polls before acting. With a
|
|
2691
|
+
* Boat id, the same turn-scoped token also serves that Boat's tools at
|
|
2692
|
+
* /api/internal/computer/mcp, and is revoked when the turn ends. This
|
|
2693
|
+
* computer runs as long as the app does, so its bridge keeps the thread's
|
|
2694
|
+
* bearer like every other integration; any other machine (a Local VM, the
|
|
2695
|
+
* VPS, a Boat) gets a new bearer each turn. */
|
|
2696
|
+
function controlIntegration(botId, threadId, generation, machine = {}) {
|
|
2697
|
+
const { localVmTarget, boxId } = machine === "this computer" ? {} : machine;
|
|
2831
2698
|
return {
|
|
2832
2699
|
url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
|
|
2833
2700
|
token: mintInternalCapability({
|
|
@@ -2837,11 +2704,12 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
|
|
|
2837
2704
|
depth: 0,
|
|
2838
2705
|
kind: "computer",
|
|
2839
2706
|
...(localVmTarget ? { localVmTarget } : {}),
|
|
2707
|
+
...(boxId ? { boxId } : {}),
|
|
2840
2708
|
...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
|
|
2841
2709
|
skillAuthoring: false,
|
|
2842
2710
|
createdBots: 0,
|
|
2843
2711
|
openedThreads: 0,
|
|
2844
|
-
}),
|
|
2712
|
+
}, { perTurn: machine !== "this computer" }),
|
|
2845
2713
|
};
|
|
2846
2714
|
}
|
|
2847
2715
|
function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
|
|
@@ -3139,9 +3007,6 @@ function packageImportDeps(selection) {
|
|
|
3139
3007
|
return avatarUrl;
|
|
3140
3008
|
},
|
|
3141
3009
|
},
|
|
3142
|
-
broadcast: (event) => event.kind === "bot"
|
|
3143
|
-
? broadcast({ kind: "bot", bot: publicBot(event.bot) })
|
|
3144
|
-
: broadcast({ kind: "group", group: publicGroupState(event.group) }),
|
|
3145
3010
|
defaultSelection: () => selection,
|
|
3146
3011
|
presets: presetStore,
|
|
3147
3012
|
};
|
|
@@ -3280,7 +3145,7 @@ const wireTask = (task) => {
|
|
|
3280
3145
|
return asleep ? { ...coordinated, snoozedUntil } : coordinated;
|
|
3281
3146
|
};
|
|
3282
3147
|
const wireBot = (bot) => {
|
|
3283
|
-
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId,
|
|
3148
|
+
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
|
|
3284
3149
|
// An elevated selection is inert until the desktop confirms its exact
|
|
3285
3150
|
// private reply. Every ordinary client sees the effective Ask state during
|
|
3286
3151
|
// that two-phase window, never a grant that may still roll back.
|
|
@@ -3302,7 +3167,7 @@ function toolScopeForTurn(botId) {
|
|
|
3302
3167
|
/** The correlated private response carries the requested value so Electron
|
|
3303
3168
|
* can validate it before sending the confirmation that makes it effective. */
|
|
3304
3169
|
const wireTrustedApprovalBot = (bot) => {
|
|
3305
|
-
const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId,
|
|
3170
|
+
const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
|
|
3306
3171
|
return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
|
|
3307
3172
|
};
|
|
3308
3173
|
/** A settings-based preview, not a receipt of a dispatched turn. No
|
|
@@ -3318,7 +3183,7 @@ function previewSystemPrompt(bot) {
|
|
|
3318
3183
|
]
|
|
3319
3184
|
.filter(Boolean)
|
|
3320
3185
|
.join(" ");
|
|
3321
|
-
const instance =
|
|
3186
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
3322
3187
|
const caps = instance?.adapter.capabilities;
|
|
3323
3188
|
const teamComputer = inheritedTeamComputer(bot);
|
|
3324
3189
|
const previewComputer = teamComputer ? "cloud" : bot.computer;
|
|
@@ -3326,12 +3191,11 @@ function previewSystemPrompt(bot) {
|
|
|
3326
3191
|
kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
|
|
3327
3192
|
? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
|
|
3328
3193
|
driverKind: instance?.driverKind,
|
|
3329
|
-
cloudComputerMcp: caps?.cloudComputerMcp,
|
|
3330
3194
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
3331
3195
|
});
|
|
3332
3196
|
const peers = reachablePeers(store.bots, bot);
|
|
3333
3197
|
const coordination = bot.chiefOfStaff
|
|
3334
|
-
? chiefOfStaffSystemPrompt(bot.id, store.bots, true
|
|
3198
|
+
? chiefOfStaffSystemPrompt(bot.id, store.bots, true)
|
|
3335
3199
|
: peers.length > 0
|
|
3336
3200
|
? peerRosterSystemPrompt(peers)
|
|
3337
3201
|
: "";
|
|
@@ -3367,7 +3231,7 @@ function previewSystemPrompt(bot) {
|
|
|
3367
3231
|
browser: previewPlan.computer === undefined ? false : previewPlan.browser,
|
|
3368
3232
|
}, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
|
|
3369
3233
|
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
|
|
3370
|
-
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
|
|
3234
|
+
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
|
|
3371
3235
|
{ id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
|
|
3372
3236
|
{ id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
3373
3237
|
{ id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
|
|
@@ -3375,6 +3239,8 @@ function previewSystemPrompt(bot) {
|
|
|
3375
3239
|
{ id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
|
|
3376
3240
|
{ id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
|
|
3377
3241
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
3242
|
+
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (agentsMounted ? TEAM_MEMORY_PROMPT : "") },
|
|
3243
|
+
teamAvailabilityPart(agentsMounted && coordination ? peers : []),
|
|
3378
3244
|
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
|
|
3379
3245
|
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
|
|
3380
3246
|
]);
|
|
@@ -3952,10 +3818,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
3952
3818
|
* fetched immediately and a deleted/guessed attachment id cannot become a
|
|
3953
3819
|
* dangling profile reference. */
|
|
3954
3820
|
const storedAvatarExists = (avatarUrl) => attachmentExists(avatarUrl.slice("/api/attachments/".length));
|
|
3821
|
+
/** A bot with its open thread's newest page, for HTTP replies and a
|
|
3822
|
+
* member stream's first frame of it. */
|
|
3955
3823
|
const publicBot = (bot) => ({
|
|
3956
3824
|
...wireBot(bot),
|
|
3957
|
-
|
|
3958
|
-
activeLeafId: store.activeLeaf(bot.threadId),
|
|
3825
|
+
...messagePage(bot.threadId, DEFAULT_PAGE),
|
|
3959
3826
|
tasks: store.tasks(bot.id).map(wireTask),
|
|
3960
3827
|
});
|
|
3961
3828
|
const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
|
|
@@ -4277,9 +4144,11 @@ function outstandingAssignmentsPrompt(threadId) {
|
|
|
4277
4144
|
requestId: node.id,
|
|
4278
4145
|
bot: store.bot(node.botId)?.name ?? "Teammate",
|
|
4279
4146
|
assignment: node.text.slice(0, 1_000),
|
|
4280
|
-
status: node.status === "queued" ? "waiting for that teammate to be free"
|
|
4147
|
+
status: node.status === "queued" ? "waiting for that teammate to be free"
|
|
4148
|
+
: openPersonCard(node.threadId) ? "stopped on a card waiting for the person's approval or answer in that teammate's thread; tell the person to open it"
|
|
4149
|
+
: "working on it now",
|
|
4281
4150
|
}));
|
|
4282
|
-
return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not
|
|
4151
|
+
return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not resend them, do not poll or wait for them, and do not tell the user they were lost. Each result returns to this conversation on its own and resumes you then. To change or add to one, send the change to the same teammate with coordinate_bots; it runs after the current work. Answer the message above with that work still in flight.`;
|
|
4283
4152
|
}
|
|
4284
4153
|
const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
4285
4154
|
validate: (node, parent) => roomHandoffProblem(node, parent) ??
|
|
@@ -4353,9 +4222,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
4353
4222
|
}
|
|
4354
4223
|
// A successful direct work thread closes after its result is reported:
|
|
4355
4224
|
// folded out of the sidebar, never deleted, and reopened if addressed.
|
|
4356
|
-
// Failed or withheld work stays visible
|
|
4225
|
+
// Failed or withheld work stays visible, and so does a thread whose
|
|
4226
|
+
// next request is already waiting there.
|
|
4357
4227
|
const childTask = store.taskByThread(child.botId, child.threadId);
|
|
4358
|
-
if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
|
|
4228
|
+
if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy && !roomHandoffs.activeDirect(child.threadId)
|
|
4359
4229
|
&& childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
|
|
4360
4230
|
store.setTaskClosedBy(child.botId, child.threadId, { botId: parent.botId, name: store.bot(parent.botId)?.name ?? childTask.openedBy.name, at: Date.now() });
|
|
4361
4231
|
}
|
|
@@ -4789,18 +4659,33 @@ function activeGroupTurnForBot(botId) {
|
|
|
4789
4659
|
}
|
|
4790
4660
|
return null;
|
|
4791
4661
|
}
|
|
4662
|
+
/** A room with its open thread's newest page, for HTTP replies and a
|
|
4663
|
+
* member stream's first frame of it. */
|
|
4792
4664
|
const groupWithThread = (group) => ({
|
|
4793
4665
|
...publicGroupState(group),
|
|
4794
|
-
|
|
4795
|
-
activeLeafId: store.activeLeaf(group.threadId),
|
|
4666
|
+
...messagePage(group.threadId, DEFAULT_PAGE),
|
|
4796
4667
|
...(group.dm ? {} : { tasks: store.groupTasks(group.id) }),
|
|
4797
4668
|
});
|
|
4798
4669
|
// The store tells us what it wrote; this is the ONE place that turns those
|
|
4799
4670
|
// into SSE frames. No mutation path can persist without emitting — the
|
|
4800
4671
|
// property holds by construction, not by every call site remembering to
|
|
4801
|
-
// broadcast.
|
|
4802
|
-
//
|
|
4803
|
-
//
|
|
4672
|
+
// broadcast.
|
|
4673
|
+
//
|
|
4674
|
+
// A bot or room frame carries its open thread's newest page only when that
|
|
4675
|
+
// thread is not the one it last went out with: a switch, a new open thread,
|
|
4676
|
+
// deleting the open one, a new bot or room. Other windows swap transcripts
|
|
4677
|
+
// on exactly those frames (one whose open thread was deleted keeps its
|
|
4678
|
+
// composer locked until a page arrives). Every other frame stays slim, and
|
|
4679
|
+
// clients keep the transcript they hold when a frame carries none.
|
|
4680
|
+
const sentThreads = new Map();
|
|
4681
|
+
for (const record of [...store.bots, ...store.groups])
|
|
4682
|
+
sentThreads.set(record.id, record.threadId);
|
|
4683
|
+
function pageIfThreadChanged(id, threadId) {
|
|
4684
|
+
if (sentThreads.get(id) === threadId)
|
|
4685
|
+
return {};
|
|
4686
|
+
sentThreads.set(id, threadId);
|
|
4687
|
+
return messagePage(threadId, DEFAULT_PAGE);
|
|
4688
|
+
}
|
|
4804
4689
|
store.onChange((change) => {
|
|
4805
4690
|
// Who owns which thread, and what each member may see, follow the fleet.
|
|
4806
4691
|
if (change.type !== "message" && change.type !== "message.patch" && change.type !== "thread" && change.type !== "sections")
|
|
@@ -4809,11 +4694,12 @@ store.onChange((change) => {
|
|
|
4809
4694
|
case "sections":
|
|
4810
4695
|
broadcast({ kind: "sections", sections: store.sections });
|
|
4811
4696
|
break;
|
|
4697
|
+
// Live frames never carry screenshot pixels; clients load them by URL.
|
|
4812
4698
|
case "message":
|
|
4813
|
-
broadcast({ kind: "message", threadId: change.threadId, message: change.message });
|
|
4699
|
+
broadcast({ kind: "message", threadId: change.threadId, message: slimMessage(change.message) });
|
|
4814
4700
|
break;
|
|
4815
4701
|
case "message.patch":
|
|
4816
|
-
broadcast({ kind: "message.patch", threadId: change.threadId, message: change.message });
|
|
4702
|
+
broadcast({ kind: "message.patch", threadId: change.threadId, message: slimMessage(change.message) });
|
|
4817
4703
|
break;
|
|
4818
4704
|
case "thread":
|
|
4819
4705
|
broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId });
|
|
@@ -4833,7 +4719,7 @@ store.onChange((change) => {
|
|
|
4833
4719
|
case "bot": {
|
|
4834
4720
|
const bot = store.bot(change.botId);
|
|
4835
4721
|
if (bot)
|
|
4836
|
-
broadcast({ kind: "bot", bot: wireBot(bot) });
|
|
4722
|
+
broadcast({ kind: "bot", bot: { ...wireBot(bot), ...pageIfThreadChanged(bot.id, bot.threadId) } });
|
|
4837
4723
|
// A new audience narrows the rooms this bot is in, for good.
|
|
4838
4724
|
if (bot)
|
|
4839
4725
|
for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id)))
|
|
@@ -4841,6 +4727,7 @@ store.onChange((change) => {
|
|
|
4841
4727
|
break;
|
|
4842
4728
|
}
|
|
4843
4729
|
case "bot.deleted":
|
|
4730
|
+
sentThreads.delete(change.botId);
|
|
4844
4731
|
try {
|
|
4845
4732
|
commandAllowlist.clear(change.botId);
|
|
4846
4733
|
}
|
|
@@ -4852,12 +4739,13 @@ store.onChange((change) => {
|
|
|
4852
4739
|
case "group": {
|
|
4853
4740
|
const group = store.group(change.groupId);
|
|
4854
4741
|
if (group)
|
|
4855
|
-
broadcast({ kind: "group", group: publicGroupState(group) });
|
|
4742
|
+
broadcast({ kind: "group", group: { ...publicGroupState(group), ...pageIfThreadChanged(group.id, group.threadId) } });
|
|
4856
4743
|
if (group)
|
|
4857
4744
|
settleRoomFloor(group);
|
|
4858
4745
|
break;
|
|
4859
4746
|
}
|
|
4860
4747
|
case "group.deleted":
|
|
4748
|
+
sentThreads.delete(change.groupId);
|
|
4861
4749
|
broadcast({ kind: "group.deleted", groupId: change.groupId });
|
|
4862
4750
|
break;
|
|
4863
4751
|
}
|
|
@@ -4882,12 +4770,13 @@ function pageSize(raw) {
|
|
|
4882
4770
|
return null;
|
|
4883
4771
|
return Math.min(size, MESSAGE_PAGE_MAX);
|
|
4884
4772
|
}
|
|
4885
|
-
/** A screen message without its pixels
|
|
4886
|
-
* `/api/threads/:threadId/messages/:id/image`
|
|
4773
|
+
/** A screen message without its pixels, for live frames and pages alike.
|
|
4774
|
+
* The client fetches those from `/api/threads/:threadId/messages/:id/image`
|
|
4775
|
+
* when it actually shows one; `mime` stays so it can name a download. */
|
|
4887
4776
|
function slimMessage(message) {
|
|
4888
4777
|
if (message.kind !== "screen" || !message.png)
|
|
4889
4778
|
return message;
|
|
4890
|
-
const { png: _png,
|
|
4779
|
+
const { png: _png, ...rest } = message;
|
|
4891
4780
|
return { ...rest, hasImage: true };
|
|
4892
4781
|
}
|
|
4893
4782
|
/** `limit === undefined` is the original, unpaginated shape. A bounded,
|
|
@@ -5280,7 +5169,10 @@ always, rememberCommand) {
|
|
|
5280
5169
|
}
|
|
5281
5170
|
if (messageId)
|
|
5282
5171
|
askMessageByRequest.delete(`${threadId}:${requestId}`);
|
|
5283
|
-
|
|
5172
|
+
// The card's own answered state gates the message too: a replayed respond
|
|
5173
|
+
// for a card that already settled must not read as a failed delivery —
|
|
5174
|
+
// nothing was lost, the earlier answer already ran.
|
|
5175
|
+
if (!persistentQuestion && !existing?.card?.answered)
|
|
5284
5176
|
store.appendMessage(threadId, {
|
|
5285
5177
|
role: "bot",
|
|
5286
5178
|
kind: "activity",
|
|
@@ -5291,7 +5183,7 @@ always, rememberCommand) {
|
|
|
5291
5183
|
}
|
|
5292
5184
|
/** A decision or answer on a provider/peer card, made on behalf of `auth`
|
|
5293
5185
|
* (a Live call answers as the person who started the call). Harness-native
|
|
5294
|
-
* proposals (skill, routine, profile, default model,
|
|
5186
|
+
* proposals (skill, routine, profile, default model, team setup)
|
|
5295
5187
|
* are not handled here; they are reviewed on screen (liveDecisionRefusal).
|
|
5296
5188
|
* Mirrors POST /api/threads/:id/respond. */
|
|
5297
5189
|
async function respondToCard(input) {
|
|
@@ -5450,10 +5342,7 @@ const watchdog = new TurnWatchdog({
|
|
|
5450
5342
|
repeats.settle(turn.threadId);
|
|
5451
5343
|
const bot = botForThread(turn.botId, turn.threadId);
|
|
5452
5344
|
const routineRun = activeRoutineRunForThread(turn.threadId);
|
|
5453
|
-
|
|
5454
|
-
? runningTurnInstance(bot, turn.threadId, routineRun?.runOn)
|
|
5455
|
-
: routineRun?.runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") : null;
|
|
5456
|
-
void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
|
|
5345
|
+
void runningTurnInstance(bot, turn.threadId)?.adapter.interruptTurn(turn.threadId).catch(() => { });
|
|
5457
5346
|
const minutes = Math.round(TURN_STALL_MS / 60_000);
|
|
5458
5347
|
if (routineRun?.target === "bot") {
|
|
5459
5348
|
routines?.failThread(turn.threadId, `No activity for ${minutes} minutes — the routine was stopped`);
|
|
@@ -5463,7 +5352,7 @@ const watchdog = new TurnWatchdog({
|
|
|
5463
5352
|
store.appendMessage(turn.threadId, {
|
|
5464
5353
|
role: "bot",
|
|
5465
5354
|
kind: "activity",
|
|
5466
|
-
tool:
|
|
5355
|
+
tool: failedTurnTool(`no activity for ${minutes} minutes — the turn was stopped`),
|
|
5467
5356
|
});
|
|
5468
5357
|
// a routine's stall reports through its own failure path
|
|
5469
5358
|
if (bot && routineRun?.target !== "bot") {
|
|
@@ -5743,6 +5632,8 @@ bus.subscribe((event) => {
|
|
|
5743
5632
|
});
|
|
5744
5633
|
// Per-thread state the digest needs from before the turn: when it started,
|
|
5745
5634
|
// and the checkpoint taken at dispatch (hash + folder) so settle can diff.
|
|
5635
|
+
// `pin` is the dispatch that pinned that commit in the shadow repo, so a
|
|
5636
|
+
// sibling thread's snapshot in the same folder cannot reclaim it mid-turn.
|
|
5746
5637
|
const turnStartedAt = new Map();
|
|
5747
5638
|
const turnCheckpoints = new Map();
|
|
5748
5639
|
// The turn a thread is in, from the last event that named one. Not every
|
|
@@ -5750,9 +5641,18 @@ const turnCheckpoints = new Map();
|
|
|
5750
5641
|
// a digest can only count activity it can attribute — so an unstamped tool
|
|
5751
5642
|
// row is attributed to the thread's live turn instead of to nothing.
|
|
5752
5643
|
const liveTurnByThread = new Map();
|
|
5644
|
+
/** Forget a thread's pre-turn checkpoint and drop its pin, so the shadow
|
|
5645
|
+
* repo's next cleanup may reclaim that commit. */
|
|
5646
|
+
function forgetTurnCheckpoint(threadId) {
|
|
5647
|
+
const checkpoint = turnCheckpoints.get(threadId);
|
|
5648
|
+
if (!checkpoint)
|
|
5649
|
+
return;
|
|
5650
|
+
turnCheckpoints.delete(threadId);
|
|
5651
|
+
void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
|
|
5652
|
+
}
|
|
5753
5653
|
function clearTurnDigestState(threadId) {
|
|
5754
5654
|
turnStartedAt.delete(threadId);
|
|
5755
|
-
|
|
5655
|
+
forgetTurnCheckpoint(threadId);
|
|
5756
5656
|
liveTurnByThread.delete(threadId);
|
|
5757
5657
|
memoryRowsByThread.delete(threadId);
|
|
5758
5658
|
}
|
|
@@ -5821,9 +5721,8 @@ function ingestEngineHook(capability, body) {
|
|
|
5821
5721
|
}
|
|
5822
5722
|
return { ok: true };
|
|
5823
5723
|
}
|
|
5824
|
-
/** Persist observed tools and memory immediately
|
|
5825
|
-
*
|
|
5826
|
-
* that same row only if its task and active branch still exist. */
|
|
5724
|
+
/** Persist observed tools and memory immediately, then enrich that same
|
|
5725
|
+
* row only if its task and active branch still exist. */
|
|
5827
5726
|
async function scheduleTurnDigest(input) {
|
|
5828
5727
|
const at = Date.now();
|
|
5829
5728
|
const startedAt = turnStartedAt.get(input.threadId);
|
|
@@ -5868,6 +5767,8 @@ async function scheduleTurnDigest(input) {
|
|
|
5868
5767
|
}
|
|
5869
5768
|
if (!checkpoint)
|
|
5870
5769
|
return;
|
|
5770
|
+
// A folder-level diff: when a sibling thread of this bot works in the
|
|
5771
|
+
// same folder, its edits since this turn's snapshot are listed here too.
|
|
5871
5772
|
const controller = new AbortController();
|
|
5872
5773
|
let timer;
|
|
5873
5774
|
const capture = async () => {
|
|
@@ -5891,6 +5792,12 @@ async function scheduleTurnDigest(input) {
|
|
|
5891
5792
|
catch (error) {
|
|
5892
5793
|
console.error(`digest: could not record turn ${input.turnId} on ${input.threadId}:`, error instanceof Error ? error.message : error);
|
|
5893
5794
|
}
|
|
5795
|
+
finally {
|
|
5796
|
+
// The pre-turn commit has had its last reader (the diff above is queued
|
|
5797
|
+
// ahead of this in the shadow repo); the next snapshot may reclaim it.
|
|
5798
|
+
if (checkpoint)
|
|
5799
|
+
void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
|
|
5800
|
+
}
|
|
5894
5801
|
}
|
|
5895
5802
|
// Bots currently working with nobody at the keyboard — a webhook turn, or a
|
|
5896
5803
|
// turn a webhook-driven bot handed to a teammate. Auto mode is a decision
|
|
@@ -6026,9 +5933,6 @@ const vpsJoinRequests = new Map();
|
|
|
6026
5933
|
const orphanBoatLifecycleBusyIds = new Set();
|
|
6027
5934
|
const boatInventoryRequestsBusyIds = new Set();
|
|
6028
5935
|
const computerProviderConfigTransitions = new Set();
|
|
6029
|
-
// A restore mutates and cleans a project work tree. Claim the bot across the
|
|
6030
|
-
// entire async Git operation so a turn cannot start in that folder midway.
|
|
6031
|
-
const checkpointRestoreLeases = new Set();
|
|
6032
5936
|
/** The effective idle window, read from config so a Settings change applies
|
|
6033
5937
|
* to both new and already-armed timers (see the config route). */
|
|
6034
5938
|
const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
|
|
@@ -6093,10 +5997,6 @@ async function computerCallGate(internalCapability) {
|
|
|
6093
5997
|
};
|
|
6094
5998
|
}
|
|
6095
5999
|
const computer = turnComputerResources.get(internalCapability.threadId);
|
|
6096
|
-
// The screen call arriving here re-claims an idle-released seat directly
|
|
6097
|
-
// (#1653): inside the reclaim window the previous holder picks its seat
|
|
6098
|
-
// back up without re-entering the wait, and the successful claim
|
|
6099
|
-
// restarts the quiet window. A seat another turn holds still refuses.
|
|
6100
6000
|
if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
|
|
6101
6001
|
!claimTurnResource(computer.owner, computer.resource)) {
|
|
6102
6002
|
return {
|
|
@@ -6176,20 +6076,43 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
|
|
|
6176
6076
|
if (!machine)
|
|
6177
6077
|
throw new Error("The team's Boat computer is missing; explicitly create or retry it from the Team map");
|
|
6178
6078
|
await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
|
|
6179
|
-
const action = boat.boatTurnLifecycleAction({ explicitCloud: true,
|
|
6079
|
+
const action = boat.boatTurnLifecycleAction({ explicitCloud: true, state: typeof machine.state === "string" ? machine.state : null });
|
|
6180
6080
|
if (action === "wake")
|
|
6181
6081
|
machine = await boat.readyBoat(cfg, ownerId);
|
|
6182
|
-
if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true,
|
|
6082
|
+
if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
|
|
6183
6083
|
throw new Error("The team computer is not ready; check it in the Team map");
|
|
6184
6084
|
}
|
|
6185
6085
|
if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
|
|
6186
6086
|
!turnResources.owns(`computer:box:${machine.id}`, owner))
|
|
6187
6087
|
throw new Error("This computer turn ended while its machine was starting");
|
|
6188
6088
|
return {
|
|
6189
|
-
|
|
6089
|
+
mount: cloudComputerMount(botId, owner, machine.id, remoteAgent),
|
|
6190
6090
|
capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
|
|
6191
6091
|
};
|
|
6192
6092
|
}
|
|
6093
|
+
/** The two engine facts the cloud-computer rule reads (shared/cloud-computer.ts). */
|
|
6094
|
+
function cloudEngine(instance) {
|
|
6095
|
+
return { driverKind: instance?.driverKind, computerMcp: instance?.adapter.capabilities.computerMcp };
|
|
6096
|
+
}
|
|
6097
|
+
/** How a turn's engine reaches a cloud computer. The Computer engine runs its
|
|
6098
|
+
* whole turn on the Boat. Every other engine keeps its own model and sign-in
|
|
6099
|
+
* and gets the Boat as one more stdio computer server (harness-mcp-proxy
|
|
6100
|
+
* computer), in the same slot a Local VM or VPS uses: one mechanism on a
|
|
6101
|
+
* desktop, a headless server and an OMB Cloud. The agent process holds only a
|
|
6102
|
+
* turn-scoped capability naming this Boat, never a Boat credential. */
|
|
6103
|
+
function cloudComputerMount(botId, owner, boxId, remoteAgent) {
|
|
6104
|
+
if (remoteAgent)
|
|
6105
|
+
return { computer: { kind: "box", boxId } };
|
|
6106
|
+
const control = controlIntegration(botId, owner.threadId, owner.generation, { boxId });
|
|
6107
|
+
return {
|
|
6108
|
+
localComputer: {
|
|
6109
|
+
command: process.execPath,
|
|
6110
|
+
args: [SPAWNED_PROXIES.harnessMcp, "computer"],
|
|
6111
|
+
env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
|
|
6112
|
+
platform: "linux",
|
|
6113
|
+
},
|
|
6114
|
+
};
|
|
6115
|
+
}
|
|
6193
6116
|
/** "Works on: This computer", strict. One mount for a bot thread and a group
|
|
6194
6117
|
* chat, so the two cannot drift on what they check or how they fail. */
|
|
6195
6118
|
async function mountHostComputer(owner, botId, providerSupportsLocal) {
|
|
@@ -6208,7 +6131,7 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
|
|
|
6208
6131
|
: "CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
|
|
6209
6132
|
}
|
|
6210
6133
|
await bindTurnComputer(owner, "computer:host");
|
|
6211
|
-
return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
|
|
6134
|
+
return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation, "this computer"));
|
|
6212
6135
|
}
|
|
6213
6136
|
/** The bot's own VPS desktop, mounted into the local agent. The desktop lease
|
|
6214
6137
|
* is claimed on the first computer call, through the computer-control gate
|
|
@@ -6248,7 +6171,9 @@ async function mountBotVps(bot, owner, opts) {
|
|
|
6248
6171
|
}
|
|
6249
6172
|
/** The bot's own Boat. Explicit Cloud is the consent boundary: it may create a
|
|
6250
6173
|
* missing machine and wake an archived one (~8s, and it un-pauses billing);
|
|
6251
|
-
* Auto only
|
|
6174
|
+
* Auto (only ever the Computer engine, see startTurn) only attaches a machine
|
|
6175
|
+
* that is already running. Callers have already checked that the engine can
|
|
6176
|
+
* work on it (cloudPlaceDriverError). */
|
|
6252
6177
|
async function attachBotBoat(bot, owner, opts) {
|
|
6253
6178
|
// Explicit cloud turns can provision/wake the same bot's Boat. Claim before
|
|
6254
6179
|
// any network await so setup itself cannot race another turn.
|
|
@@ -6267,7 +6192,6 @@ async function attachBotBoat(bot, owner, opts) {
|
|
|
6267
6192
|
}
|
|
6268
6193
|
const lifecycleOf = (explicitCloud) => boat.boatTurnLifecycleAction({
|
|
6269
6194
|
explicitCloud,
|
|
6270
|
-
canMount: opts.canMount,
|
|
6271
6195
|
state: typeof b?.state === "string" ? b.state : null,
|
|
6272
6196
|
});
|
|
6273
6197
|
let lifecycle = lifecycleOf(opts.explicitCloud);
|
|
@@ -6291,9 +6215,7 @@ async function attachBotBoat(bot, owner, opts) {
|
|
|
6291
6215
|
await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
|
|
6292
6216
|
return {
|
|
6293
6217
|
capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
|
|
6294
|
-
|
|
6295
|
-
? { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(bot.id, owner.threadId, owner.generation) }
|
|
6296
|
-
: null,
|
|
6218
|
+
mount: cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent),
|
|
6297
6219
|
};
|
|
6298
6220
|
}
|
|
6299
6221
|
function managedBoatOwners() {
|
|
@@ -6370,17 +6292,6 @@ function turnProvider(bot, runOn, threadId) {
|
|
|
6370
6292
|
return "box";
|
|
6371
6293
|
return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
|
|
6372
6294
|
}
|
|
6373
|
-
/** A driver with a Boat bridge keeps the selected model. Other engines use
|
|
6374
|
-
* Boat's native runner. Start and interrupt must resolve the same owner. */
|
|
6375
|
-
function turnInstance(bot, runOn, threadId) {
|
|
6376
|
-
const selected = registry.get(bot.modelSelection.instanceId);
|
|
6377
|
-
if (selected?.adapter.capabilities.cloudComputerMcp)
|
|
6378
|
-
return selected;
|
|
6379
|
-
const onBoat = turnProvider(bot, runOn, threadId) === "box";
|
|
6380
|
-
return onBoat
|
|
6381
|
-
? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
|
|
6382
|
-
: registry.get(bot.modelSelection.instanceId);
|
|
6383
|
-
}
|
|
6384
6295
|
/** Preview requests carry the selected conversation, not whichever thread
|
|
6385
6296
|
* happens to be the bot's default. A query never grants lifecycle authority. */
|
|
6386
6297
|
function computerPreviewBot(botId, url) {
|
|
@@ -6423,7 +6334,8 @@ async function computerPreviewSurface(bot, threadId) {
|
|
|
6423
6334
|
* A Cloud home lists only the places it has (cloud-home.ts), so a bot never
|
|
6424
6335
|
* tells the person to set up this computer or a Local VM there. */
|
|
6425
6336
|
async function selectableComputers(bot) {
|
|
6426
|
-
const
|
|
6337
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
6338
|
+
const caps = instance?.adapter.capabilities;
|
|
6427
6339
|
const off = bot.computer === "off";
|
|
6428
6340
|
const localEngine = caps?.remoteAgent !== true;
|
|
6429
6341
|
const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
|
|
@@ -6441,16 +6353,16 @@ async function selectableComputers(bot) {
|
|
|
6441
6353
|
reason = "Computer access is Off in this bot's settings.";
|
|
6442
6354
|
else if (surface === "cloud") {
|
|
6443
6355
|
if (bot.cloudBackend === "vps") {
|
|
6444
|
-
const status =
|
|
6356
|
+
const status = canWorkOnCloud(cloudEngine(instance), "vps") ? await vps.vpsComputerStatus(cfg, bot.id) : null;
|
|
6445
6357
|
ready = status?.ready === true;
|
|
6446
6358
|
canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
|
|
6447
6359
|
status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
|
|
6448
6360
|
canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
|
|
6449
6361
|
reason = status?.problem ?? reason;
|
|
6450
6362
|
}
|
|
6451
|
-
else if (boat.boatConfigured(cfg) && (
|
|
6363
|
+
else if (boat.boatConfigured(cfg) && canWorkOnCloud(cloudEngine(instance), "box")) {
|
|
6452
6364
|
const status = await boat.boatStatus(cfg, bot.id);
|
|
6453
|
-
const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true,
|
|
6365
|
+
const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, state: status.box?.state ?? null });
|
|
6454
6366
|
ready = lifecycle === "attach";
|
|
6455
6367
|
canStart = lifecycle === "wake";
|
|
6456
6368
|
canCreate = lifecycle === "provision";
|
|
@@ -6527,8 +6439,9 @@ function continueComputerSelection(threadId, generation, succeeded) {
|
|
|
6527
6439
|
/** The engine that dispatched each live turn. A bot's settings may change
|
|
6528
6440
|
* mid-turn; an interrupt or steer must reach the engine actually running. */
|
|
6529
6441
|
const runningTurnEngines = new Map();
|
|
6530
|
-
function runningTurnInstance(bot, threadId
|
|
6531
|
-
|
|
6442
|
+
function runningTurnInstance(bot, threadId) {
|
|
6443
|
+
// A turn always runs on the bot's own engine, wherever it works.
|
|
6444
|
+
return runningTurnEngines.get(threadId) ?? (bot ? registry.get(bot.modelSelection.instanceId) : null);
|
|
6532
6445
|
}
|
|
6533
6446
|
function providerTransitionForTurn(bot, runOn, threadId) {
|
|
6534
6447
|
const provider = turnProvider(bot, runOn, threadId);
|
|
@@ -6909,38 +6822,6 @@ bus.subscribe((event) => {
|
|
|
6909
6822
|
if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
|
|
6910
6823
|
pokeScreenPoller(event.threadId, touches, surface);
|
|
6911
6824
|
}
|
|
6912
|
-
// A completed screen-touching computer tool is real screen
|
|
6913
|
-
// activity (#1653): it restarts the idle clock on that turn's
|
|
6914
|
-
// computer claim. The poller's own frames never arrive here, so
|
|
6915
|
-
// they cannot keep a quiet seat held.
|
|
6916
|
-
if (touches && surface === "computer") {
|
|
6917
|
-
const computer = turnComputerResources.get(event.threadId);
|
|
6918
|
-
if (computer) {
|
|
6919
|
-
// A quiet window cannot expire while this call executes.
|
|
6920
|
-
turnResources.endComputerCall(computer.resource, computer.owner);
|
|
6921
|
-
turnResources.activity(computer.resource, computer.owner);
|
|
6922
|
-
}
|
|
6923
|
-
else {
|
|
6924
|
-
// The call never forwarded onto a bound desktop, or the lazy
|
|
6925
|
-
// bind never landed: drop its pending start so it cannot
|
|
6926
|
-
// fence a claim taken later (#1653).
|
|
6927
|
-
const pending = pendingComputerCallStarts.get(event.threadId);
|
|
6928
|
-
if (pending) {
|
|
6929
|
-
if (pending.count > 1)
|
|
6930
|
-
pendingComputerCallStarts.set(event.threadId, { ...pending, count: pending.count - 1 });
|
|
6931
|
-
else
|
|
6932
|
-
pendingComputerCallStarts.delete(event.threadId);
|
|
6933
|
-
}
|
|
6934
|
-
}
|
|
6935
|
-
// Real cloud-screen work refreshes the #1655 idle stop for
|
|
6936
|
-
// that seat; the poller's own frames never arrive here, so
|
|
6937
|
-
// preview traffic cannot keep a paid machine awake.
|
|
6938
|
-
if (computer?.resource.startsWith("computer:box:")) {
|
|
6939
|
-
const seatBot = store.botByThread(event.threadId);
|
|
6940
|
-
if (seatBot)
|
|
6941
|
-
cloudSeatLeases.get(seatBot.id)?.touch();
|
|
6942
|
-
}
|
|
6943
|
-
}
|
|
6944
6825
|
}
|
|
6945
6826
|
}
|
|
6946
6827
|
break;
|
|
@@ -6963,26 +6844,6 @@ bus.subscribe((event) => {
|
|
|
6963
6844
|
});
|
|
6964
6845
|
if (event.itemId)
|
|
6965
6846
|
toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
|
|
6966
|
-
// A screen-touching computer tool opening is an in-flight call on
|
|
6967
|
-
// that turn's claim (#1653): the seat cannot be idle-released out
|
|
6968
|
-
// from under I/O the desktop is still executing. Its completion —
|
|
6969
|
-
// or the turn settling — drops the fence.
|
|
6970
|
-
if (screenTouchingTool(name) && screenSurfaceForTool(name) === "computer") {
|
|
6971
|
-
const computer = turnComputerResources.get(event.threadId);
|
|
6972
|
-
if (computer) {
|
|
6973
|
-
turnResources.beginComputerCall(computer.resource, computer.owner);
|
|
6974
|
-
}
|
|
6975
|
-
else {
|
|
6976
|
-
// A lazy attach (#1361) may not have seated the claim yet: the
|
|
6977
|
-
// gate finishes the bind before the call forwards, so hold the
|
|
6978
|
-
// start here and let bindTurnComputer fence the fresh claim.
|
|
6979
|
-
const generation = activeInternalGenerationByThread.get(event.threadId);
|
|
6980
|
-
if (generation) {
|
|
6981
|
-
const pending = pendingComputerCallStarts.get(event.threadId);
|
|
6982
|
-
pendingComputerCallStarts.set(event.threadId, pending?.generation === generation ? { generation, count: pending.count + 1 } : { generation, count: 1 });
|
|
6983
|
-
}
|
|
6984
|
-
}
|
|
6985
|
-
}
|
|
6986
6847
|
}
|
|
6987
6848
|
break;
|
|
6988
6849
|
case "request.opened": {
|
|
@@ -6994,8 +6855,9 @@ bus.subscribe((event) => {
|
|
|
6994
6855
|
// A permission request here is one the provider left for a person: its
|
|
6995
6856
|
// own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
|
|
6996
6857
|
// Only the person's explicit Full access or exact saved command answers.
|
|
6997
|
-
// The app does not guess whether
|
|
6998
|
-
// always reaches the human — even Full
|
|
6858
|
+
// Approve for me allows a web search. The app does not guess whether
|
|
6859
|
+
// the action is safe. A QUESTION always reaches the human — even Full
|
|
6860
|
+
// access never invents an answer.
|
|
6999
6861
|
const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
7000
6862
|
const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
|
|
7001
6863
|
// A turn a guest drives on a Cloud home is Ask, room turns included,
|
|
@@ -7057,9 +6919,9 @@ bus.subscribe((event) => {
|
|
|
7057
6919
|
if (outcome !== "unavailable")
|
|
7058
6920
|
pushMessage({
|
|
7059
6921
|
role: "bot", kind: "activity",
|
|
7060
|
-
tool:
|
|
7061
|
-
|
|
7062
|
-
|
|
6922
|
+
tool: outcome === "rejected"
|
|
6923
|
+
? { name: `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`, ok: false }
|
|
6924
|
+
: failedTurnTool("could not deliver approval to the provider; retry the task after reconnecting."),
|
|
7063
6925
|
});
|
|
7064
6926
|
return;
|
|
7065
6927
|
}
|
|
@@ -7151,6 +7013,8 @@ bus.subscribe((event) => {
|
|
|
7151
7013
|
store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
|
|
7152
7014
|
else if (asker.busy)
|
|
7153
7015
|
store.setActivity(asker.id, "waiting-on-you");
|
|
7016
|
+
if (event.requestId)
|
|
7017
|
+
showWaitingOnPersonChip(event.threadId, event.requestId, asker, permission ? "approval" : "answer");
|
|
7154
7018
|
const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
|
|
7155
7019
|
notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
|
|
7156
7020
|
}
|
|
@@ -7160,6 +7024,8 @@ bus.subscribe((event) => {
|
|
|
7160
7024
|
case "request.resolved": {
|
|
7161
7025
|
if (event.requestId)
|
|
7162
7026
|
pendingCommandRules.delete(`${event.threadId}:${event.requestId}`);
|
|
7027
|
+
if (event.requestId)
|
|
7028
|
+
settleWaitingOnPersonChips(event.threadId, event.requestId);
|
|
7163
7029
|
// answered (by whoever): the turn is working again, unless it settled
|
|
7164
7030
|
const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
7165
7031
|
if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
|
|
@@ -7205,14 +7071,7 @@ bus.subscribe((event) => {
|
|
|
7205
7071
|
pushMessage({
|
|
7206
7072
|
role: "bot",
|
|
7207
7073
|
kind: "activity",
|
|
7208
|
-
tool:
|
|
7209
|
-
// a setup error carries its fix, which must not be cut mid-command
|
|
7210
|
-
name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
|
|
7211
|
-
ok: false,
|
|
7212
|
-
setup: event.setup,
|
|
7213
|
-
...(event.terminal ? { terminal: true } : {}),
|
|
7214
|
-
...(event.claudeUpdate ? { claudeUpdate: true } : {}),
|
|
7215
|
-
},
|
|
7074
|
+
tool: failedTurnTool(event.message, event),
|
|
7216
7075
|
});
|
|
7217
7076
|
// a setup error means the engine could not even start: the bot is
|
|
7218
7077
|
// dead until something changes, not merely idle. The next successful
|
|
@@ -7230,6 +7089,7 @@ bus.subscribe((event) => {
|
|
|
7230
7089
|
}
|
|
7231
7090
|
break;
|
|
7232
7091
|
case "turn.completed": {
|
|
7092
|
+
settleWaitingOnPersonChips(event.threadId);
|
|
7233
7093
|
// A peer-started turn settles as coordination, not as news. What keeps
|
|
7234
7094
|
// that classification from outliving its turn is the rewrite at
|
|
7235
7095
|
// dispatch, not this line — releasing it here too is hygiene, so a
|
|
@@ -7447,8 +7307,8 @@ bus.subscribe((event) => {
|
|
|
7447
7307
|
// still be in flight).
|
|
7448
7308
|
//
|
|
7449
7309
|
// Keep the thread busy until its bounded final capture releases
|
|
7450
|
-
// the screen
|
|
7451
|
-
//
|
|
7310
|
+
// the screen: an accepted follow-up must not take the desktop
|
|
7311
|
+
// before the END-state frame is captured.
|
|
7452
7312
|
const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
|
|
7453
7313
|
let timeout;
|
|
7454
7314
|
const screenSettled = Promise.race([
|
|
@@ -7540,6 +7400,57 @@ bus.subscribe((event) => {
|
|
|
7540
7400
|
* channel that only ever shows requests is half a record. Mirror the
|
|
7541
7401
|
* reply on success; mirror a failed/stopped terminal chip otherwise. */
|
|
7542
7402
|
const delegationWatch = new Map();
|
|
7403
|
+
/** The card in this thread that is waiting on the person: an approval, a
|
|
7404
|
+
* question or a proposal to review, newest first. */
|
|
7405
|
+
function openPersonCard(threadId) {
|
|
7406
|
+
const messages = store.messagesFor(threadId);
|
|
7407
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
7408
|
+
const card = messages[i].kind === "options" ? messages[i].card : undefined;
|
|
7409
|
+
const kind = liveCardKind(card);
|
|
7410
|
+
if (kind)
|
|
7411
|
+
return { kind, tool: card?.tool };
|
|
7412
|
+
}
|
|
7413
|
+
return undefined;
|
|
7414
|
+
}
|
|
7415
|
+
/** The conversation waiting on work that runs in this thread: a Chief's
|
|
7416
|
+
* delegate_bot task or coordinate_bots assignment. */
|
|
7417
|
+
function delegatorOf(threadId) {
|
|
7418
|
+
const watch = delegationWatch.get(threadId);
|
|
7419
|
+
if (watch?.sourceThreadId)
|
|
7420
|
+
return { threadId: watch.sourceThreadId, botId: watch.sourceBotId };
|
|
7421
|
+
const parent = roomHandoffs.assignerOf(threadId);
|
|
7422
|
+
return parent ? { threadId: parent.threadId, botId: parent.botId } : undefined;
|
|
7423
|
+
}
|
|
7424
|
+
// MOCA-274: a delegated teammate's card used to live only in its own thread,
|
|
7425
|
+
// so the person talking to the Chief never saw that work was waiting on them.
|
|
7426
|
+
// A chip in the delegating conversation points at the card until it settles.
|
|
7427
|
+
const waitingOnPersonChips = new Map();
|
|
7428
|
+
function showWaitingOnPersonChip(threadId, requestId, asker, kind) {
|
|
7429
|
+
const delegator = delegatorOf(threadId);
|
|
7430
|
+
if (!delegator || delegator.threadId === threadId)
|
|
7431
|
+
return;
|
|
7432
|
+
const chip = store.appendMessage(delegator.threadId, { role: "bot", kind: "activity",
|
|
7433
|
+
from: { botId: asker.id, name: asker.name, color: asker.color },
|
|
7434
|
+
tool: { name: `Waiting on your ${kind} in @${asker.name}` },
|
|
7435
|
+
threadRef: { botId: asker.id, threadId, title: store.taskByThread(asker.id, threadId)?.title ?? asker.name },
|
|
7436
|
+
});
|
|
7437
|
+
waitingOnPersonChips.set(`${threadId}:${requestId}`, { threadId: delegator.threadId, messageId: chip.id, name: asker.name, kind });
|
|
7438
|
+
}
|
|
7439
|
+
/** Settle the chip for one answered card, or every chip for this thread once
|
|
7440
|
+
* its turn ends (a card nobody answered can no longer be answered). */
|
|
7441
|
+
function settleWaitingOnPersonChips(threadId, requestId) {
|
|
7442
|
+
for (const [key, chip] of waitingOnPersonChips) {
|
|
7443
|
+
if (requestId ? key !== `${threadId}:${requestId}` : !key.startsWith(`${threadId}:`))
|
|
7444
|
+
continue;
|
|
7445
|
+
waitingOnPersonChips.delete(key);
|
|
7446
|
+
const existing = store.messagesFor(chip.threadId).find((message) => message.id === chip.messageId);
|
|
7447
|
+
if (!existing?.tool)
|
|
7448
|
+
continue;
|
|
7449
|
+
store.patchMessage(chip.threadId, chip.messageId, {
|
|
7450
|
+
tool: { ...existing.tool, name: requestId ? `@${chip.name} got your ${chip.kind}` : `@${chip.name} is no longer waiting on your ${chip.kind}`, ok: true },
|
|
7451
|
+
});
|
|
7452
|
+
}
|
|
7453
|
+
}
|
|
7543
7454
|
// Peer wake: when a delegated reply lands, resume the source bot so it can
|
|
7544
7455
|
// fold the result in and answer the user instead of sitting idle. Mirrors
|
|
7545
7456
|
// the cardContinuation resume pattern used for connector/credential cards.
|
|
@@ -7583,10 +7494,7 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason, rout
|
|
|
7583
7494
|
store.appendMessage(threadId, {
|
|
7584
7495
|
role: "bot",
|
|
7585
7496
|
kind: "activity",
|
|
7586
|
-
tool: {
|
|
7587
|
-
name: `error: could not resume after delegation — ${message.slice(0, 120)}`,
|
|
7588
|
-
ok: false,
|
|
7589
|
-
},
|
|
7497
|
+
tool: failedTurnTool(`could not resume after delegation — ${message}`),
|
|
7590
7498
|
});
|
|
7591
7499
|
routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
|
|
7592
7500
|
});
|
|
@@ -7688,7 +7596,7 @@ function reportIncident(input) {
|
|
|
7688
7596
|
store.appendMessage(incidents.threadId, {
|
|
7689
7597
|
role: "bot",
|
|
7690
7598
|
kind: "activity",
|
|
7691
|
-
tool:
|
|
7599
|
+
tool: failedTurnTool(`the incident could not reach ${chief.name} — ${why}`),
|
|
7692
7600
|
});
|
|
7693
7601
|
tellThePerson();
|
|
7694
7602
|
});
|
|
@@ -7738,7 +7646,7 @@ function markTaskContextExternallyUpdated(bot, threadId) {
|
|
|
7738
7646
|
const owner = task.lastInstanceId;
|
|
7739
7647
|
const record = owner ? task.handedMessages?.[owner] : undefined;
|
|
7740
7648
|
if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
|
|
7741
|
-
|
|
7649
|
+
(botForThread(bot.id, threadId) ?? bot).modelSelection.instanceId === owner &&
|
|
7742
7650
|
registry.get(owner)?.adapter.capabilities.strictResume) {
|
|
7743
7651
|
store.patchTask(bot.id, threadId, { unread: true });
|
|
7744
7652
|
return;
|
|
@@ -8087,7 +7995,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
|
|
|
8087
7995
|
store.appendMessage(sourceThreadId, {
|
|
8088
7996
|
role: "bot",
|
|
8089
7997
|
kind: "activity",
|
|
8090
|
-
tool:
|
|
7998
|
+
tool: failedTurnTool(`delegation to @${bot?.name ?? toBotId} could not start — ${why}`),
|
|
8091
7999
|
});
|
|
8092
8000
|
};
|
|
8093
8001
|
return startTurn(toBotId, text, {
|
|
@@ -8199,10 +8107,7 @@ function drainQueuedSends() {
|
|
|
8199
8107
|
}).catch((err) => {
|
|
8200
8108
|
store.appendMessage(threadId, {
|
|
8201
8109
|
role: "bot", kind: "activity",
|
|
8202
|
-
tool: {
|
|
8203
|
-
name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
|
|
8204
|
-
ok: false,
|
|
8205
|
-
},
|
|
8110
|
+
tool: failedTurnTool(`queued message could not start — ${err instanceof Error ? err.message : String(err)}`),
|
|
8206
8111
|
});
|
|
8207
8112
|
resolve();
|
|
8208
8113
|
// M2: only this group left the queue, and a turn that never
|
|
@@ -8259,10 +8164,7 @@ function drainAsideLane() {
|
|
|
8259
8164
|
}).catch((err) => {
|
|
8260
8165
|
store.appendMessage(threadId, {
|
|
8261
8166
|
role: "bot", kind: "activity",
|
|
8262
|
-
tool: {
|
|
8263
|
-
name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
|
|
8264
|
-
ok: false,
|
|
8265
|
-
},
|
|
8167
|
+
tool: failedTurnTool(`queued aside could not start — ${err instanceof Error ? err.message : String(err)}`),
|
|
8266
8168
|
});
|
|
8267
8169
|
resolve();
|
|
8268
8170
|
}).catch(reject);
|
|
@@ -8554,7 +8456,7 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
|
|
|
8554
8456
|
store.appendMessage(threadId, {
|
|
8555
8457
|
role: "bot",
|
|
8556
8458
|
kind: "activity",
|
|
8557
|
-
tool:
|
|
8459
|
+
tool: failedTurnTool(`this thread could not start — ${why}`),
|
|
8558
8460
|
});
|
|
8559
8461
|
return { state: "failed", error: why };
|
|
8560
8462
|
}
|
|
@@ -8757,11 +8659,6 @@ async function startTurn(botId, text, opts) {
|
|
|
8757
8659
|
// A workspace at its monthly spend limit starts no turn of any kind: a
|
|
8758
8660
|
// person's message, a routine, a peer hop or a webhook all stop here.
|
|
8759
8661
|
assertWithinBudget(cfg, DATA_DIR);
|
|
8760
|
-
if (checkpointRestoreLeases.has(botId)) {
|
|
8761
|
-
throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
|
|
8762
|
-
status: 409,
|
|
8763
|
-
});
|
|
8764
|
-
}
|
|
8765
8662
|
if (boatLifecycleBusyBots.has(botId)) {
|
|
8766
8663
|
throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
|
|
8767
8664
|
}
|
|
@@ -8778,10 +8675,10 @@ async function startTurn(botId, text, opts) {
|
|
|
8778
8675
|
// their results still return here (outstandingAssignmentsPrompt tells this
|
|
8779
8676
|
// turn which are still out). Stop, in this conversation, is the gesture
|
|
8780
8677
|
// that ends coordination — see interruptDirectThread.
|
|
8781
|
-
//
|
|
8782
|
-
// integrations. Completion and interrupt paths do the same;
|
|
8783
|
-
//
|
|
8784
|
-
|
|
8678
|
+
// Revoke anything a previous turn left behind before minting this turn's
|
|
8679
|
+
// integrations. Completion and interrupt paths do the same; a proxy the
|
|
8680
|
+
// warm engine kept is honoured again only through this turn's records.
|
|
8681
|
+
revokeEarlierTurnCapabilities(threadId);
|
|
8785
8682
|
// a webhook turn, or one inherited from a bot already running unattended
|
|
8786
8683
|
if (opts?.automationSource === "webhook" || opts?.unattended)
|
|
8787
8684
|
markUnattended(bot.id, threadId);
|
|
@@ -8796,11 +8693,9 @@ async function startTurn(botId, text, opts) {
|
|
|
8796
8693
|
if (hostedModels && !hostedModels.allows(bot.modelSelection))
|
|
8797
8694
|
throw Object.assign(new Error(hostedModels.error()), { status: 409 });
|
|
8798
8695
|
const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
|
|
8799
|
-
const instance =
|
|
8696
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
8800
8697
|
if (!instance) {
|
|
8801
|
-
throw Object.assign(new Error(
|
|
8802
|
-
? "the Cloud VM runner is unavailable — configure Boat in App Settings"
|
|
8803
|
-
: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
8698
|
+
throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
8804
8699
|
}
|
|
8805
8700
|
const policyRefusal = policyModelRefusal(instance);
|
|
8806
8701
|
if (policyRefusal)
|
|
@@ -8860,13 +8755,12 @@ async function startTurn(botId, text, opts) {
|
|
|
8860
8755
|
if (providerInstancesChanging.has(instanceId)) {
|
|
8861
8756
|
throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
|
|
8862
8757
|
}
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
const
|
|
8869
|
-
const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
|
|
8758
|
+
// A turn always runs on the bot's own engine, model, effort and variant,
|
|
8759
|
+
// wherever it works: a cloud computer is a tool it mounts, never a reason
|
|
8760
|
+
// to swap the engine (the provider_not_configured incident).
|
|
8761
|
+
const model = bot.modelSelection.model;
|
|
8762
|
+
const effort = bot.modelSelection.effort;
|
|
8763
|
+
const variant = bot.modelSelection.variant;
|
|
8870
8764
|
assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
|
|
8871
8765
|
// A selection can be persisted while its engine is offline. Re-check when
|
|
8872
8766
|
// the engine returns so an old or unsupported value never reaches a CLI.
|
|
@@ -9190,42 +9084,43 @@ async function startTurn(botId, text, opts) {
|
|
|
9190
9084
|
}
|
|
9191
9085
|
const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
|
|
9192
9086
|
const skillInstructions = renderSkillInstructions(selectedSkills, {
|
|
9193
|
-
includeRoot: worksInWorkspace
|
|
9087
|
+
includeRoot: worksInWorkspace,
|
|
9194
9088
|
});
|
|
9195
9089
|
const packagePlaybooks = installedPlaybookInstructions(providerText, bot.playbooks);
|
|
9196
9090
|
// An explicit working folder wins for new tasks; otherwise they use
|
|
9197
9091
|
// the private bot workspace. A legacy task with an existing provider
|
|
9198
9092
|
// session deliberately pins to null (the old home-folder behavior),
|
|
9199
9093
|
// because moving a live session would break resume.
|
|
9200
|
-
// A cloud run
|
|
9201
|
-
//
|
|
9202
|
-
//
|
|
9203
|
-
|
|
9204
|
-
store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
|
|
9094
|
+
// A cloud run keeps the bot's own engine on this host, with the cloud
|
|
9095
|
+
// computer as one more tool, so it works in the same folder as any
|
|
9096
|
+
// other turn (only the Computer engine runs on the Boat, and it has no
|
|
9097
|
+
// workspace files).
|
|
9205
9098
|
// On a Cloud home a conversation a guest opened is pinned to its own
|
|
9206
9099
|
// folder when it first runs, not the bot's project folder the owner's
|
|
9207
9100
|
// conversations share (what it leaves there reaches none of them). One
|
|
9208
9101
|
// that ran before keeps its pin, unless the session that opened it was
|
|
9209
9102
|
// revoked: then it was unpinned at boot (server/cloud-owner.ts).
|
|
9210
|
-
const pinnedCwd = privateWorkspace
|
|
9103
|
+
const pinnedCwd = privateWorkspace
|
|
9211
9104
|
? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
|
|
9212
9105
|
: null;
|
|
9106
|
+
// A bot's threads run side by side in one project folder, like several
|
|
9107
|
+
// Claude Code or Codex sessions open in one repo: the folder is never a
|
|
9108
|
+
// turn-long claim. What truly needs one writer queues on its own — the
|
|
9109
|
+
// per-turn snapshot per folder (server/checkpoints.ts), memory through
|
|
9110
|
+
// memory_update — and the engines key their sessions by id, not folder.
|
|
9213
9111
|
const cwd = pinnedCwd ?? undefined;
|
|
9214
|
-
if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
|
|
9215
|
-
throw Object.assign(new Error("another thread is working in this project folder — wait for it to finish or choose a separate folder"), { status: 409, code: "workspace_busy" });
|
|
9216
|
-
}
|
|
9217
9112
|
// Checkpoint explicit project folders, where a bot can overwrite the
|
|
9218
9113
|
// user's work. Its private OpenMaus workspace is app-owned and changes
|
|
9219
9114
|
// on nearly every ordinary chat; snapshotting it would add hidden disk
|
|
9220
|
-
// and process overhead without a user project to
|
|
9115
|
+
// and process overhead without a user project to list changes for.
|
|
9221
9116
|
const checkpointCwd = cwd && cwd !== privateWorkspace ? cwd : undefined;
|
|
9222
9117
|
// dweb is opt-in: without an explicit daemon URL, do not advertise
|
|
9223
9118
|
// tools that would fail on every call or spawn an unnecessary proxy.
|
|
9224
9119
|
const dwebUrl = process.env.DWEB_URL?.trim();
|
|
9225
9120
|
if (dwebUrl)
|
|
9226
9121
|
integrations.dweb = { url: dwebUrl };
|
|
9227
|
-
// Cloud routines always use Boat
|
|
9228
|
-
// only to ordinary turns
|
|
9122
|
+
// Cloud routines always use the bot's Boat. The per-bot backend applies
|
|
9123
|
+
// only to ordinary turns.
|
|
9229
9124
|
const teamComputer = inheritedTeamComputer(bot);
|
|
9230
9125
|
const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
|
|
9231
9126
|
const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
|
|
@@ -9240,8 +9135,6 @@ async function startTurn(botId, text, opts) {
|
|
|
9240
9135
|
// landed. A team computer or a cloud routine is not this conversation's
|
|
9241
9136
|
// choice, so those ignore the pin.
|
|
9242
9137
|
const dispatchTask = store.taskByThread(bot.id, threadId);
|
|
9243
|
-
if (plan.clearPin && dispatchTask)
|
|
9244
|
-
store.patchTask(bot.id, threadId, { surface: undefined });
|
|
9245
9138
|
if (plan.computer !== undefined && plan.computer !== "cloud" && instance.adapter.capabilities.remoteAgent === true) {
|
|
9246
9139
|
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
9247
9140
|
}
|
|
@@ -9253,6 +9146,21 @@ async function startTurn(botId, text, opts) {
|
|
|
9253
9146
|
const placeRefusal = wantedKind && computerPlaceRefusal(wantedKind);
|
|
9254
9147
|
if (placeRefusal)
|
|
9255
9148
|
throw Object.assign(new Error(placeRefusal.message), { status: 409, code: placeRefusal.code });
|
|
9149
|
+
// Where an explicit place came from, so its failure names the one
|
|
9150
|
+
// control that changes it (and a failed Auto-recorded pin is cleared).
|
|
9151
|
+
// Auto and a team's shared computer are nobody's per-turn choice.
|
|
9152
|
+
const namedPlace = teamComputer || wants === undefined || wants === "off" ? undefined : wants;
|
|
9153
|
+
const placeSource = !namedPlace ? undefined
|
|
9154
|
+
: opts?.runOn === "cloud" ? "routine"
|
|
9155
|
+
: plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
|
|
9156
|
+
: "works-on";
|
|
9157
|
+
// An engine that cannot use the cloud computer is refused here, before
|
|
9158
|
+
// anything is created or woken — never handed to another engine.
|
|
9159
|
+
if (wants === "cloud" && placeSource) {
|
|
9160
|
+
const unsupported = cloudPlaceDriverError(cloudEngine(instance), cloudBackend, placeSource);
|
|
9161
|
+
if (unsupported)
|
|
9162
|
+
throw new PlaceUnavailableError("cloud", unsupported);
|
|
9163
|
+
}
|
|
9256
9164
|
let previewCapture = null;
|
|
9257
9165
|
let browserCapture = null;
|
|
9258
9166
|
let computerKind = null;
|
|
@@ -9400,7 +9308,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9400
9308
|
store.appendMessage(threadId, {
|
|
9401
9309
|
role: "bot",
|
|
9402
9310
|
kind: "activity",
|
|
9403
|
-
tool:
|
|
9311
|
+
tool: failedTurnTool(message),
|
|
9404
9312
|
});
|
|
9405
9313
|
if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
|
|
9406
9314
|
notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
|
|
@@ -9506,24 +9414,23 @@ async function startTurn(botId, text, opts) {
|
|
|
9506
9414
|
return false;
|
|
9507
9415
|
}
|
|
9508
9416
|
};
|
|
9509
|
-
|
|
9510
|
-
|
|
9511
|
-
|
|
9512
|
-
|
|
9513
|
-
|
|
9514
|
-
|
|
9515
|
-
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
|
|
9523
|
-
|
|
9524
|
-
if (
|
|
9525
|
-
|
|
9526
|
-
if (!unsupported) {
|
|
9417
|
+
// An explicit place that cannot be attached fails the turn with its
|
|
9418
|
+
// cause and the next action; it never quietly moves the work elsewhere.
|
|
9419
|
+
try {
|
|
9420
|
+
if (wants === "vm") {
|
|
9421
|
+
if (await attachLocalVm(true))
|
|
9422
|
+
computerKind = "vm";
|
|
9423
|
+
}
|
|
9424
|
+
else if (wants === "local") {
|
|
9425
|
+
integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
|
|
9426
|
+
computerKind = "local";
|
|
9427
|
+
}
|
|
9428
|
+
// A VPS is a local-agent computer mount, never a remote agent runner.
|
|
9429
|
+
// Explicit Cloud may prepare/start it. Auto remains read-only unless
|
|
9430
|
+
// the person explicitly opted this bot into remote lifecycle actions.
|
|
9431
|
+
// On Auto an engine that cannot use a computer simply skips it.
|
|
9432
|
+
if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
|
|
9433
|
+
canWorkOnCloud(cloudEngine(instance), "vps")) {
|
|
9527
9434
|
// The VPS "computer" is the desktop inside this bot's managed
|
|
9528
9435
|
// container, and only screen work needs that desktop to itself.
|
|
9529
9436
|
// So the lease is claimed on the first computer call, through the
|
|
@@ -9561,34 +9468,42 @@ async function startTurn(botId, text, opts) {
|
|
|
9561
9468
|
autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
|
|
9562
9469
|
}
|
|
9563
9470
|
}
|
|
9564
|
-
|
|
9565
|
-
|
|
9566
|
-
|
|
9567
|
-
if (teamComputer) {
|
|
9568
|
-
const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
|
|
9569
|
-
integrations.computer = attached.integration;
|
|
9570
|
-
previewCapture = attached.capture;
|
|
9571
|
-
computerKind = "box";
|
|
9572
|
-
}
|
|
9573
|
-
if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
|
|
9574
|
-
const attached = await attachBotBoat(bot, resourceOwner, {
|
|
9575
|
-
explicitCloud: wants === "cloud",
|
|
9576
|
-
canMount: instance.adapter.capabilities.usesCloudComputer === true,
|
|
9577
|
-
remoteAgent: instance.adapter.capabilities.remoteAgent === true,
|
|
9578
|
-
});
|
|
9579
|
-
if (attached) {
|
|
9471
|
+
if (teamComputer) {
|
|
9472
|
+
const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
|
|
9473
|
+
Object.assign(integrations, attached.mount);
|
|
9580
9474
|
previewCapture = attached.capture;
|
|
9581
|
-
|
|
9582
|
-
|
|
9475
|
+
computerKind = "box";
|
|
9476
|
+
}
|
|
9477
|
+
// Cloud is strict when selected. On Auto only the Computer engine,
|
|
9478
|
+
// whose whole turn runs on its Boat, attaches one, and only one that
|
|
9479
|
+
// is already running. Every other engine reaches the Boat only when
|
|
9480
|
+
// this conversation is on it (Works on, a pin, a cloud routine, or
|
|
9481
|
+
// select_computer): an Auto turn never reads the Boat account, so it
|
|
9482
|
+
// adds no Boat call, cannot be stopped by a Boat being deleted, and
|
|
9483
|
+
// never records a place nobody chose.
|
|
9484
|
+
const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
|
|
9485
|
+
if (!teamComputer && cloudBackend === "box" && boat.boatConfigured(cfg) &&
|
|
9486
|
+
(wants === "cloud" || (wants === undefined && remoteAgent && !computerPlaceRefusal("box")))) {
|
|
9487
|
+
const attached = await attachBotBoat(bot, resourceOwner, { explicitCloud: wants === "cloud", remoteAgent });
|
|
9488
|
+
if (attached) {
|
|
9489
|
+
previewCapture = attached.capture;
|
|
9490
|
+
Object.assign(integrations, attached.mount);
|
|
9583
9491
|
computerKind = "box";
|
|
9584
9492
|
}
|
|
9585
9493
|
}
|
|
9494
|
+
if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
|
|
9495
|
+
throw new Error(BOAT_NOT_CONFIGURED);
|
|
9496
|
+
}
|
|
9497
|
+
if (wants === "cloud" && cloudBackend === "box" && computerKind !== "box") {
|
|
9498
|
+
throw new Error("the cloud computer could not be created or reached");
|
|
9499
|
+
}
|
|
9586
9500
|
}
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9501
|
+
catch (error) {
|
|
9502
|
+
// Auto, Off and a team's shared computer name no place; they fail as
|
|
9503
|
+
// they are.
|
|
9504
|
+
if (!namedPlace || !placeSource)
|
|
9505
|
+
throw error;
|
|
9506
|
+
throw asPlaceFailure(error, namedPlace, placeSource);
|
|
9592
9507
|
}
|
|
9593
9508
|
// Auto-only host fallback. Electron owns cua-driver/TCC attribution;
|
|
9594
9509
|
// the harness only reads its already-running connection descriptor.
|
|
@@ -9631,7 +9546,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9631
9546
|
pinAutoSurface("local");
|
|
9632
9547
|
},
|
|
9633
9548
|
});
|
|
9634
|
-
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
9549
|
+
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId, "this computer"));
|
|
9635
9550
|
computerKind = "local";
|
|
9636
9551
|
}
|
|
9637
9552
|
}
|
|
@@ -9679,7 +9594,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9679
9594
|
? mentionedBots(providerText, sectionPeers)
|
|
9680
9595
|
: [];
|
|
9681
9596
|
const coordinationPrompt = bot.chiefOfStaff
|
|
9682
|
-
? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents),
|
|
9597
|
+
? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), boundedCoordination)
|
|
9683
9598
|
: integrations.agents && sectionPeers.length > 0
|
|
9684
9599
|
// Ordinary bots could always CALL the peer tools; until now the
|
|
9685
9600
|
// one generic sentence they got never named a teammate, so the
|
|
@@ -9699,14 +9614,17 @@ async function startTurn(botId, text, opts) {
|
|
|
9699
9614
|
// snapshot() absorbs failures, so checkpointing may delay but never fail
|
|
9700
9615
|
// a turn.
|
|
9701
9616
|
if (checkpointCwd) {
|
|
9702
|
-
|
|
9617
|
+
// Pinned by this dispatch: a sibling thread's snapshot in the same
|
|
9618
|
+
// folder must not reclaim this turn's "before" state while it runs.
|
|
9619
|
+
const before = await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`, undefined, { pin: dispatchClaimId });
|
|
9703
9620
|
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
|
|
9621
|
+
if (before)
|
|
9622
|
+
void checkpoints.release(bot.id, checkpointCwd, dispatchClaimId);
|
|
9704
9623
|
throw new DirectTurnSetupCancelled("turn stopped during checkpoint");
|
|
9705
9624
|
}
|
|
9625
|
+
forgetTurnCheckpoint(threadId);
|
|
9706
9626
|
if (before)
|
|
9707
|
-
turnCheckpoints.set(threadId, { cwd: checkpointCwd, hash: before });
|
|
9708
|
-
else
|
|
9709
|
-
turnCheckpoints.delete(threadId);
|
|
9627
|
+
turnCheckpoints.set(threadId, { botId: bot.id, cwd: checkpointCwd, hash: before, pin: dispatchClaimId });
|
|
9710
9628
|
}
|
|
9711
9629
|
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
|
|
9712
9630
|
throw new DirectTurnSetupCancelled("turn stopped before dispatch");
|
|
@@ -9765,8 +9683,9 @@ async function startTurn(botId, text, opts) {
|
|
|
9765
9683
|
// Pin on use (issue #1650): every computer that registers a claim
|
|
9766
9684
|
// slot — a ready Local VM, the VPS, the host fallback, a VM created
|
|
9767
9685
|
// for this turn — records its pin inside the claim itself, so a turn
|
|
9768
|
-
// that only mounted tools records nothing.
|
|
9769
|
-
//
|
|
9686
|
+
// that only mounted tools records nothing. The Computer engine's Boat
|
|
9687
|
+
// (its whole turn runs there) and the built-in browser (no seat to
|
|
9688
|
+
// claim) pin here.
|
|
9770
9689
|
if (autoPinAllowed()) {
|
|
9771
9690
|
const claimSlot = autoVmClaims.get(threadId);
|
|
9772
9691
|
const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
|
|
@@ -9788,7 +9707,6 @@ async function startTurn(botId, text, opts) {
|
|
|
9788
9707
|
const computerPromptKind = resolveComputerPromptKind({
|
|
9789
9708
|
kind: computerKind,
|
|
9790
9709
|
driverKind: instance.driverKind,
|
|
9791
|
-
cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
|
|
9792
9710
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
9793
9711
|
});
|
|
9794
9712
|
const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
|
|
@@ -9801,14 +9719,14 @@ async function startTurn(botId, text, opts) {
|
|
|
9801
9719
|
// to a turn whose engine actually mounted them (setupMode is already
|
|
9802
9720
|
// false when they are not — see agentsMounted above)
|
|
9803
9721
|
{ id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
|
|
9804
|
-
{ id: "files", label: "File locations", text: worksInWorkspace
|
|
9722
|
+
{ id: "files", label: "File locations", text: worksInWorkspace ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
|
|
9805
9723
|
{ id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
|
|
9806
9724
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
|
|
9807
9725
|
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId), cloudHome: Boolean(CLOUD_HOME) }) },
|
|
9808
9726
|
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
9809
9727
|
// gated on the integration, not the key: the hint only goes to a
|
|
9810
9728
|
// bot whose driver actually mounted the tools
|
|
9811
|
-
{ id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
|
|
9729
|
+
{ id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) + describeConnectorScopes((liveBot ?? bot).connectorScopes) : "" },
|
|
9812
9730
|
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
9813
9731
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
9814
9732
|
{ id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
|
|
@@ -9821,6 +9739,10 @@ async function startTurn(botId, text, opts) {
|
|
|
9821
9739
|
{ id: "profile", label: "Profile changes", text: profilePrompt },
|
|
9822
9740
|
{ id: "learn", label: "Skill authoring", text: learnPrompt },
|
|
9823
9741
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
9742
|
+
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
|
|
9743
|
+
// who on the team roster is busy right now; volatile, so a teammate
|
|
9744
|
+
// starting or finishing work never changes the stable half
|
|
9745
|
+
teamAvailabilityPart(coordinationPrompt ? sectionPeers : []),
|
|
9824
9746
|
// what the bot said lately in its other conversations, so a task
|
|
9825
9747
|
// never redoes — or forgets — what another one already did
|
|
9826
9748
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
|
|
@@ -9866,9 +9788,9 @@ async function startTurn(botId, text, opts) {
|
|
|
9866
9788
|
const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
|
|
9867
9789
|
threadId,
|
|
9868
9790
|
botId: bot.id,
|
|
9869
|
-
startupRecovery: cfg.automaticRecovery?.enabled === true &&
|
|
9791
|
+
startupRecovery: cfg.automaticRecovery?.enabled === true &&
|
|
9792
|
+
(opts?.automaticRecoveryIndex ?? 0) < ((liveBot ?? bot).fallback?.length || (cfg.automaticRecovery.backup ? 1 : 0)),
|
|
9870
9793
|
text: withRecalled(recalled, dispatchContext.turnText),
|
|
9871
|
-
refreshSystemPrompt: true,
|
|
9872
9794
|
images: turnImages,
|
|
9873
9795
|
approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
|
|
9874
9796
|
toolScope,
|
|
@@ -10028,10 +9950,26 @@ async function startTurn(botId, text, opts) {
|
|
|
10028
9950
|
drainDelegationWakes();
|
|
10029
9951
|
return;
|
|
10030
9952
|
}
|
|
9953
|
+
// A failed place never sticks. An Auto-recorded pin to it (a
|
|
9954
|
+
// select_computer switch, or where an earlier Auto turn landed) is the
|
|
9955
|
+
// machine's memory, not a choice: clear it so the next message runs on
|
|
9956
|
+
// Auto. A person's pin stays, and the message names the composer.
|
|
9957
|
+
if (e instanceof PlaceUnavailableError) {
|
|
9958
|
+
const pinned = store.taskByThread(bot.id, threadId);
|
|
9959
|
+
if (pinned?.surface === e.place && pinned.surfaceSource === "auto") {
|
|
9960
|
+
store.patchTask(bot.id, threadId, { surface: undefined, surfaceSource: undefined });
|
|
9961
|
+
}
|
|
9962
|
+
}
|
|
10031
9963
|
let message = e instanceof Error ? e.message : String(e);
|
|
10032
|
-
const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
|
|
10033
9964
|
const current = store.projectBotForTask(bot.id, threadId);
|
|
10034
|
-
|
|
9965
|
+
const backups = cfg.automaticRecovery?.enabled
|
|
9966
|
+
? current?.fallback?.length ? current.fallback : cfg.automaticRecovery.backup ? [cfg.automaticRecovery.backup] : []
|
|
9967
|
+
: [];
|
|
9968
|
+
const nextIndex = backups.findIndex((candidate, index) => index >= (opts?.automaticRecoveryIndex ?? 0) &&
|
|
9969
|
+
registry.get(candidate.instanceId)?.enabled &&
|
|
9970
|
+
(candidate.instanceId !== instanceId || candidate.model !== model));
|
|
9971
|
+
const backup = backups[nextIndex];
|
|
9972
|
+
if (mayRecover && backup && current && (!opts?.cardContinuation || opts.coordination) &&
|
|
10035
9973
|
!directRequestOwners.get(threadId)?.stopped && !hasQueuedSteeredMessages(bot.id, threadId) &&
|
|
10036
9974
|
JSON.stringify(current.modelSelection) === JSON.stringify(bot.modelSelection) &&
|
|
10037
9975
|
(backup.instanceId !== instanceId || backup.model !== model) &&
|
|
@@ -10055,7 +9993,7 @@ async function startTurn(botId, text, opts) {
|
|
|
10055
9993
|
name: `recovery: Automatic recovery: ${instance.displayName || instance.driverKind} could not start. Trying ${target.displayName || target.driverKind} · ${checked.selection.model} once in this thread.`, ok: true,
|
|
10056
9994
|
} });
|
|
10057
9995
|
await startTurn(bot.id, text, { ...opts, threadId, userMessage, editedMessageId: undefined,
|
|
10058
|
-
automaticRecoveryAttempted: true });
|
|
9996
|
+
automaticRecoveryAttempted: true, automaticRecoveryIndex: nextIndex + 1 });
|
|
10059
9997
|
// The backup now owns these callbacks; don't publish a failed
|
|
10060
9998
|
// routine/peer result or release a queued receipt in between.
|
|
10061
9999
|
directFollowupSettlers.delete(dispatchClaimId);
|
|
@@ -10076,7 +10014,7 @@ async function startTurn(botId, text, opts) {
|
|
|
10076
10014
|
role: "bot",
|
|
10077
10015
|
kind: "activity",
|
|
10078
10016
|
turnSucceeded: false,
|
|
10079
|
-
tool:
|
|
10017
|
+
tool: failedTurnTool(message),
|
|
10080
10018
|
});
|
|
10081
10019
|
// Worth a buzz for the same reason a routine failure is, and the rule
|
|
10082
10020
|
// notify.ts encodes: the bot is not working, and the cause is usually
|
|
@@ -10344,9 +10282,10 @@ routines = new RoutineManager({
|
|
|
10344
10282
|
// nobody's is confined to a folder of its own, never the bot's project.
|
|
10345
10283
|
if (task && CLOUD_HOME && routineId)
|
|
10346
10284
|
threadStarters.set(task.threadId, routineOpener(routineId));
|
|
10347
|
-
|
|
10348
|
-
|
|
10349
|
-
|
|
10285
|
+
// The store's frame announces it. A run's task that stays in the
|
|
10286
|
+
// background leaves the open thread alone, so that frame carries no
|
|
10287
|
+
// transcript: a whole thread on every scheduled run once passed the phone
|
|
10288
|
+
// sidecar's 4 MiB event ceiling and ended the stream (MOCA-179).
|
|
10350
10289
|
return task;
|
|
10351
10290
|
},
|
|
10352
10291
|
joinConversation: (run) => {
|
|
@@ -10395,17 +10334,14 @@ routines = new RoutineManager({
|
|
|
10395
10334
|
goalRunId: runId,
|
|
10396
10335
|
});
|
|
10397
10336
|
},
|
|
10398
|
-
interruptTurn: async (botId, threadId
|
|
10337
|
+
interruptTurn: async (botId, threadId) => {
|
|
10399
10338
|
const bot = botForThread(botId, threadId);
|
|
10400
10339
|
pendingDelegationWakes.delete(threadId);
|
|
10401
10340
|
discardDelegations(commsBus, threadId);
|
|
10402
10341
|
cancelDirectTurnDispatch(botId, threadId);
|
|
10403
10342
|
revokeInternalCapabilitiesForThread(threadId);
|
|
10404
|
-
const instance = bot
|
|
10405
|
-
? runningTurnInstance(bot, threadId, runOn)
|
|
10406
|
-
: runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null : null;
|
|
10407
10343
|
try {
|
|
10408
|
-
await
|
|
10344
|
+
await runningTurnInstance(bot, threadId)?.adapter.interruptTurn(threadId);
|
|
10409
10345
|
}
|
|
10410
10346
|
finally {
|
|
10411
10347
|
closeOpenApprovals(threadId);
|
|
@@ -10564,9 +10500,13 @@ async function cloudRoutineReadiness(botId, threadId) {
|
|
|
10564
10500
|
reason: 'The Boat cloud computer needs a working Boat API key. For the bot’s configured computer, including a self-hosted VPS, set run_on="maus" instead.',
|
|
10565
10501
|
};
|
|
10566
10502
|
}
|
|
10567
|
-
const instance =
|
|
10503
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
10568
10504
|
if (!instance)
|
|
10569
|
-
return { ready: false, reason: "The
|
|
10505
|
+
return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
|
|
10506
|
+
// The same rule a cloud turn applies, before anything is provisioned.
|
|
10507
|
+
const unsupported = cloudPlaceDriverError(cloudEngine(instance), "box", "routine");
|
|
10508
|
+
if (unsupported)
|
|
10509
|
+
return { ready: false, reason: unsupported };
|
|
10570
10510
|
try {
|
|
10571
10511
|
if ((await instance.snapshot()).state !== "available") {
|
|
10572
10512
|
return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
|
|
@@ -10859,6 +10799,69 @@ const validateModelProposal = (selection, current) => {
|
|
|
10859
10799
|
}
|
|
10860
10800
|
return null;
|
|
10861
10801
|
};
|
|
10802
|
+
const teamMemory = new TeamMemory(join(DATA_DIR, "team-memory.json"));
|
|
10803
|
+
const outboundRequests = new OutboundRequestService();
|
|
10804
|
+
const outboundCounts = new OutboundCounts(join(DATA_DIR, "outbound-counts.json"));
|
|
10805
|
+
const OUTBOUND_HOLD_MS = 9 * 60_000;
|
|
10806
|
+
function resolveAndSendTrust(res, threadId, requestId, behavior) {
|
|
10807
|
+
const teamMemoryCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamMemoryRequest);
|
|
10808
|
+
if (teamMemoryCard?.card?.teamMemoryRequest) {
|
|
10809
|
+
const request = teamMemoryCard.card.teamMemoryRequest;
|
|
10810
|
+
const proposer = teamMemoryCard.from?.botId ? store.bot(teamMemoryCard.from.botId) : store.botByThread(threadId);
|
|
10811
|
+
const result = teamMemory.resolve(request.section, request.entryId, behavior === "allow" ? "accept" : "reject");
|
|
10812
|
+
if (!result.claimed) {
|
|
10813
|
+
if (!teamMemoryCard.card.answered) {
|
|
10814
|
+
store.patchMessage(threadId, teamMemoryCard.id, { card: { ...teamMemoryCard.card, answered: "unavailable", dismissed: true } });
|
|
10815
|
+
}
|
|
10816
|
+
json(res, 200, { ok: true, outcome: "unavailable" });
|
|
10817
|
+
return true;
|
|
10818
|
+
}
|
|
10819
|
+
if (result.state === "already_settled") {
|
|
10820
|
+
json(res, 200, { ok: true, outcome: "allowed-once", alreadySettled: true });
|
|
10821
|
+
return true;
|
|
10822
|
+
}
|
|
10823
|
+
store.patchMessage(threadId, teamMemoryCard.id, {
|
|
10824
|
+
card: { ...teamMemoryCard.card, answered: result.state === "accepted" ? "allow" : "deny" },
|
|
10825
|
+
});
|
|
10826
|
+
appendDecision(DATA_DIR, {
|
|
10827
|
+
threadId, requestId, botId: proposer?.id, botName: proposer?.name,
|
|
10828
|
+
tool: "propose_team_memory", summary: teamMemoryCard.card.subtitle,
|
|
10829
|
+
decision: result.state === "accepted" ? "user-approved" : "user-denied", source: "user",
|
|
10830
|
+
});
|
|
10831
|
+
json(res, 200, { ok: true, outcome: result.state === "accepted" ? "allowed-once" : "rejected" });
|
|
10832
|
+
return true;
|
|
10833
|
+
}
|
|
10834
|
+
const outboundCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.outboundRequest);
|
|
10835
|
+
if (outboundCard?.card?.outboundRequest) {
|
|
10836
|
+
const outboundBot = outboundCard.from?.botId ? store.bot(outboundCard.from.botId) : store.botByThread(threadId);
|
|
10837
|
+
const result = outboundRequests.resolve({ threadId, requestId, behavior });
|
|
10838
|
+
if (!result.claimed) {
|
|
10839
|
+
// The hold is memory-only: after a restart the card outlives the
|
|
10840
|
+
// relay that was waiting on it, so close it rather than leave an
|
|
10841
|
+
// approval nobody can deliver owning the composer.
|
|
10842
|
+
if (!outboundCard.card.answered) {
|
|
10843
|
+
store.patchMessage(threadId, outboundCard.id, { card: { ...outboundCard.card, answered: "unavailable", dismissed: true } });
|
|
10844
|
+
}
|
|
10845
|
+
json(res, 200, { ok: true, outcome: "unavailable" });
|
|
10846
|
+
return true;
|
|
10847
|
+
}
|
|
10848
|
+
if (result.state === "already_settled") {
|
|
10849
|
+
json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
|
|
10850
|
+
return true;
|
|
10851
|
+
}
|
|
10852
|
+
store.patchMessage(threadId, outboundCard.id, {
|
|
10853
|
+
card: { ...outboundCard.card, answered: result.state === "allowed" ? "allow" : "deny" },
|
|
10854
|
+
});
|
|
10855
|
+
appendDecision(DATA_DIR, {
|
|
10856
|
+
threadId, requestId, botId: outboundBot?.id, botName: outboundBot?.name,
|
|
10857
|
+
tool: outboundCard.card.tool, summary: outboundCard.card.subtitle,
|
|
10858
|
+
decision: result.state === "allowed" ? "user-approved" : "user-denied", source: "user",
|
|
10859
|
+
});
|
|
10860
|
+
json(res, 200, { ok: true, outcome: result.state === "allowed" ? "allowed-once" : "rejected" });
|
|
10861
|
+
return true;
|
|
10862
|
+
}
|
|
10863
|
+
return false;
|
|
10864
|
+
}
|
|
10862
10865
|
const profileRequests = new ProfileRequestService({
|
|
10863
10866
|
store,
|
|
10864
10867
|
autoApply: fullAccessForSource,
|
|
@@ -10875,52 +10878,6 @@ const modelRequests = new ModelRequestService({
|
|
|
10875
10878
|
validateModel: validateModelProposal,
|
|
10876
10879
|
driverCapabilities: driverCapabilitiesFor,
|
|
10877
10880
|
});
|
|
10878
|
-
const tighteningRequests = new TighteningRequestService({
|
|
10879
|
-
store,
|
|
10880
|
-
autoApply: fullAccessForSource,
|
|
10881
|
-
canPersist: proposalPersistence,
|
|
10882
|
-
// Same reach as a profile change: a Chief may tighten a section peer;
|
|
10883
|
-
// anyone else only itself. Re-checked at confirm.
|
|
10884
|
-
validateTarget: (proposerBotId, targetBotId) => {
|
|
10885
|
-
const proposer = store.bot(proposerBotId);
|
|
10886
|
-
const target = store.bot(targetBotId);
|
|
10887
|
-
if (!target)
|
|
10888
|
-
return "that bot no longer exists";
|
|
10889
|
-
if (!proposer?.chiefOfStaff)
|
|
10890
|
-
return "only a section's Chief of Staff can change another bot's permissions";
|
|
10891
|
-
if (!canReachPeer(proposer, target))
|
|
10892
|
-
return "that bot is not in a team this Chief is allowed to manage";
|
|
10893
|
-
return null;
|
|
10894
|
-
},
|
|
10895
|
-
// The card judges the effective mounts, so the snapshot resolves the same
|
|
10896
|
-
// way the engine does: through the config-aware custom-server filter.
|
|
10897
|
-
mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
|
|
10898
|
-
enabledSkills: (botId) => {
|
|
10899
|
-
const record = store.bot(botId);
|
|
10900
|
-
return (record ? listBotSkills(record) : listSkills(botId)).filter((skill) => skill.enabled).map((skill) => skill.name);
|
|
10901
|
-
},
|
|
10902
|
-
disableSkill: (botId, name) => {
|
|
10903
|
-
const record = store.bot(botId);
|
|
10904
|
-
// Private skills win collisions, including disabled/private copies.
|
|
10905
|
-
// Disabling one must not reveal a same-named shared skill instead.
|
|
10906
|
-
if (skillsLibraryEnabled(cfg) && !listSkills(botId).some((skill) => skill.name === name)
|
|
10907
|
-
&& record?.assignedSkills?.includes(name)) {
|
|
10908
|
-
try {
|
|
10909
|
-
const saved = store.patchBot(botId, { assignedSkills: record.assignedSkills.filter((assigned) => assigned !== name) });
|
|
10910
|
-
return saved ? { ok: true } : { ok: false, error: "that bot no longer exists" };
|
|
10911
|
-
}
|
|
10912
|
-
catch (error) {
|
|
10913
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
10914
|
-
}
|
|
10915
|
-
}
|
|
10916
|
-
const disabled = setSkillEnabled(botId, name, false);
|
|
10917
|
-
return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
|
|
10918
|
-
},
|
|
10919
|
-
// The owner's own routes stop a busy bot before touching its approval
|
|
10920
|
-
// level or MCP mounts; the card asks for the same, except an emergency
|
|
10921
|
-
// full/custom → ask downgrade, which applies first and stops after.
|
|
10922
|
-
targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
|
|
10923
|
-
});
|
|
10924
10881
|
const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
|
|
10925
10882
|
const teamSetupRequests = new TeamSetupRequestService({
|
|
10926
10883
|
store, teams: teamSetupTeams, canAccessTeam, canPersist: proposalPersistence, maxBots: MAX_WORKSPACE_BOTS,
|
|
@@ -11232,46 +11189,6 @@ function resolveAndSendModel(res, args) {
|
|
|
11232
11189
|
json(res, 200, { ok: true, outcome: "rejected" });
|
|
11233
11190
|
return true;
|
|
11234
11191
|
}
|
|
11235
|
-
function resolveAndSendTightening(res, args) {
|
|
11236
|
-
const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
|
|
11237
|
-
if (!card)
|
|
11238
|
-
return false;
|
|
11239
|
-
const result = tighteningRequests.resolve(args);
|
|
11240
|
-
if (!result.claimed)
|
|
11241
|
-
return false;
|
|
11242
|
-
if (result.state === "applied" || result.state === "denied") {
|
|
11243
|
-
appendDecision(DATA_DIR, {
|
|
11244
|
-
threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
|
|
11245
|
-
tool: "tighten_permissions", summary: card.subtitle,
|
|
11246
|
-
decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
|
|
11247
|
-
});
|
|
11248
|
-
}
|
|
11249
|
-
if (result.state === "applied") {
|
|
11250
|
-
const target = store.bot(result.targetBotId);
|
|
11251
|
-
if (target)
|
|
11252
|
-
broadcast({ kind: "bot", bot: wireBot(target) });
|
|
11253
|
-
if (result.emergencyStop) {
|
|
11254
|
-
// Mirror the owner's own route: the fail-closed Ask is already
|
|
11255
|
-
// persisted, then the exact turn still holding the elevated
|
|
11256
|
-
// per-turn snapshot is stopped. The response never waits on it.
|
|
11257
|
-
void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
|
|
11258
|
-
console.warn(`[tightening] emergency stop failed for bot ${result.targetBotId}: ${error instanceof Error ? error.message : String(error)}. The fail-closed Ask is persisted, but the in-flight turn keeps its elevated access until it ends.`);
|
|
11259
|
-
});
|
|
11260
|
-
}
|
|
11261
|
-
json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
|
|
11262
|
-
return true;
|
|
11263
|
-
}
|
|
11264
|
-
if (result.state === "invalid") {
|
|
11265
|
-
json(res, result.status, { error: result.error });
|
|
11266
|
-
return true;
|
|
11267
|
-
}
|
|
11268
|
-
if (result.state === "already_settled") {
|
|
11269
|
-
json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
|
|
11270
|
-
return true;
|
|
11271
|
-
}
|
|
11272
|
-
json(res, 200, { ok: true, outcome: "rejected" });
|
|
11273
|
-
return true;
|
|
11274
|
-
}
|
|
11275
11192
|
// Webhook definitions are independent from calendar schedules, but every
|
|
11276
11193
|
// delivery joins the same RoutineManager queue. That keeps unattended work
|
|
11277
11194
|
// ordered behind a busy MAUS and gives webhook runs the same durable receipts.
|
|
@@ -11466,7 +11383,7 @@ setupRetry = 0) {
|
|
|
11466
11383
|
onDispatchError?.(`${bot.name}'s approval level is still being confirmed — skipped this round`);
|
|
11467
11384
|
return true;
|
|
11468
11385
|
}
|
|
11469
|
-
|
|
11386
|
+
revokeEarlierTurnCapabilities(threadId);
|
|
11470
11387
|
spoken.add(botId);
|
|
11471
11388
|
// Must be the SAME resolver the readiness re-check uses below, or a Chief's
|
|
11472
11389
|
// delegated Full elevation makes the two disagree by construction: every
|
|
@@ -11475,7 +11392,7 @@ setupRetry = 0) {
|
|
|
11475
11392
|
const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
|
|
11476
11393
|
const preparedSelection = { ...bot.modelSelection };
|
|
11477
11394
|
const preparedComposio = bot.composio;
|
|
11478
|
-
const instance =
|
|
11395
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
11479
11396
|
const userName = cfg.profile?.name?.trim() || "User";
|
|
11480
11397
|
if (providerInstancesChanging.has(bot.modelSelection.instanceId)) {
|
|
11481
11398
|
onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
|
|
@@ -11494,7 +11411,7 @@ setupRetry = 0) {
|
|
|
11494
11411
|
role: "bot",
|
|
11495
11412
|
kind: "activity",
|
|
11496
11413
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
11497
|
-
tool:
|
|
11414
|
+
tool: failedTurnTool(message),
|
|
11498
11415
|
});
|
|
11499
11416
|
onDispatchError?.(message);
|
|
11500
11417
|
return true;
|
|
@@ -11626,7 +11543,7 @@ setupRetry = 0) {
|
|
|
11626
11543
|
role: "bot",
|
|
11627
11544
|
kind: "activity",
|
|
11628
11545
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
11629
|
-
tool:
|
|
11546
|
+
tool: failedTurnTool(message),
|
|
11630
11547
|
});
|
|
11631
11548
|
onDispatchError?.(message);
|
|
11632
11549
|
return true;
|
|
@@ -11655,7 +11572,7 @@ setupRetry = 0) {
|
|
|
11655
11572
|
: Boolean(readyGroup && store.groupTaskByThread(readyGroup.id, threadId));
|
|
11656
11573
|
if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
|
|
11657
11574
|
return false;
|
|
11658
|
-
const setupChanged =
|
|
11575
|
+
const setupChanged = registry.get(readyBot.modelSelection.instanceId) !== instance ||
|
|
11659
11576
|
roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
|
|
11660
11577
|
readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
|
|
11661
11578
|
readyBot.modelSelection.model !== preparedSelection.model ||
|
|
@@ -11780,6 +11697,13 @@ setupRetry = 0) {
|
|
|
11780
11697
|
if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
|
|
11781
11698
|
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
11782
11699
|
}
|
|
11700
|
+
// The same one rule as a bot thread, before anything is provisioned: an
|
|
11701
|
+
// engine that cannot use the cloud computer is refused, never swapped.
|
|
11702
|
+
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
11703
|
+
const unsupported = cloudPlaceDriverError(cloudEngine(instance), turnProvider(readyBot) === "vps" ? "vps" : "box");
|
|
11704
|
+
if (unsupported)
|
|
11705
|
+
throw new PlaceUnavailableError("cloud", unsupported);
|
|
11706
|
+
}
|
|
11783
11707
|
// One place per room turn as well: a team computer reached on Auto means
|
|
11784
11708
|
// no separate built-in browser.
|
|
11785
11709
|
if (roomPlan.browser && !(roomPlan.computer === undefined && roomTeamComputer)) {
|
|
@@ -11798,11 +11722,11 @@ setupRetry = 0) {
|
|
|
11798
11722
|
return false;
|
|
11799
11723
|
}
|
|
11800
11724
|
if (roomTeamComputer) {
|
|
11801
|
-
const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, instance
|
|
11725
|
+
const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
|
|
11802
11726
|
if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
|
|
11803
11727
|
activeInternalGenerationByThread.get(threadId) !== internalGeneration)
|
|
11804
11728
|
return false;
|
|
11805
|
-
integrations
|
|
11729
|
+
Object.assign(integrations, attached.mount);
|
|
11806
11730
|
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
|
|
11807
11731
|
}
|
|
11808
11732
|
// The speaker's own explicit place, mounted exactly as its bot thread
|
|
@@ -11811,89 +11735,96 @@ setupRetry = 0) {
|
|
|
11811
11735
|
const roomSetupIsCurrent = () => !isCancelled?.() &&
|
|
11812
11736
|
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
11813
11737
|
activeInternalGenerationByThread.get(threadId) === internalGeneration;
|
|
11814
|
-
|
|
11815
|
-
|
|
11816
|
-
|
|
11817
|
-
|
|
11818
|
-
|
|
11819
|
-
}
|
|
11820
|
-
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
11821
|
-
if (turnProvider(readyBot) === "vps") {
|
|
11822
|
-
const unsupported = vps.vpsDriverError(instance.driverKind, instance.adapter.capabilities.computerMcp === true);
|
|
11823
|
-
if (unsupported)
|
|
11824
|
-
throw new Error(unsupported);
|
|
11825
|
-
vpsThreadStarted(readyBot.id, threadId);
|
|
11826
|
-
roomVpsBotId = readyBot.id;
|
|
11827
|
-
const mounted = await mountBotVps(readyBot, resourceOwner, {
|
|
11828
|
-
start: true,
|
|
11829
|
-
onClaimed: (capture) => {
|
|
11830
|
-
if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
|
|
11831
|
-
roomScreenBotId = readyBot.id;
|
|
11832
|
-
startScreenPoller(readyBot.id, threadId, { computer: capture });
|
|
11833
|
-
}
|
|
11834
|
-
},
|
|
11835
|
-
});
|
|
11738
|
+
// An explicit place that cannot be attached fails the member's turn with
|
|
11739
|
+
// its cause and the one setting that changes it.
|
|
11740
|
+
try {
|
|
11741
|
+
if (!roomTeamComputer && roomPlan.computer === "local") {
|
|
11742
|
+
integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
|
|
11836
11743
|
if (!roomSetupIsCurrent())
|
|
11837
11744
|
return false;
|
|
11838
|
-
|
|
11839
|
-
|
|
11840
|
-
|
|
11841
|
-
|
|
11745
|
+
roomComputerKind = "local";
|
|
11746
|
+
}
|
|
11747
|
+
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
11748
|
+
if (turnProvider(readyBot) === "vps") {
|
|
11749
|
+
vpsThreadStarted(readyBot.id, threadId);
|
|
11750
|
+
roomVpsBotId = readyBot.id;
|
|
11751
|
+
const mounted = await mountBotVps(readyBot, resourceOwner, {
|
|
11752
|
+
start: true,
|
|
11753
|
+
onClaimed: (capture) => {
|
|
11754
|
+
if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
|
|
11755
|
+
roomScreenBotId = readyBot.id;
|
|
11756
|
+
startScreenPoller(readyBot.id, threadId, { computer: capture });
|
|
11757
|
+
}
|
|
11758
|
+
},
|
|
11759
|
+
});
|
|
11760
|
+
if (!roomSetupIsCurrent())
|
|
11761
|
+
return false;
|
|
11762
|
+
if (!("integration" in mounted))
|
|
11763
|
+
throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
|
|
11764
|
+
integrations.localComputer = mounted.integration;
|
|
11765
|
+
roomComputerKind = "vps";
|
|
11766
|
+
}
|
|
11767
|
+
else {
|
|
11768
|
+
if (!boat.boatConfigured(cfg))
|
|
11769
|
+
throw new Error(BOAT_NOT_CONFIGURED);
|
|
11770
|
+
const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
|
|
11771
|
+
const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true, remoteAgent });
|
|
11772
|
+
if (!roomSetupIsCurrent())
|
|
11773
|
+
return false;
|
|
11774
|
+
if (!attached)
|
|
11775
|
+
throw new Error("the cloud computer could not be created or reached");
|
|
11776
|
+
Object.assign(integrations, attached.mount);
|
|
11777
|
+
roomScreenBotId = readyBot.id;
|
|
11778
|
+
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
|
|
11779
|
+
roomComputerKind = "box";
|
|
11780
|
+
}
|
|
11842
11781
|
}
|
|
11843
|
-
|
|
11844
|
-
|
|
11845
|
-
|
|
11846
|
-
|
|
11847
|
-
|
|
11848
|
-
|
|
11849
|
-
|
|
11782
|
+
// Room and Goal turns use the speaker's desktop, never the coordinator's.
|
|
11783
|
+
// Claim the same lease as direct turns before asynchronous VM setup.
|
|
11784
|
+
if (readyBot.computer === "vm") {
|
|
11785
|
+
if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
|
|
11786
|
+
throw new Error("this model cannot use the Local VM");
|
|
11787
|
+
}
|
|
11788
|
+
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
11789
|
+
const target = { ...localVmTargetForThread(readyBot.id, threadId) };
|
|
11790
|
+
await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
11791
|
+
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
|
|
11792
|
+
throw new Error("this Local VM is being started, stopped, or replaced");
|
|
11793
|
+
}
|
|
11794
|
+
if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
|
|
11795
|
+
throw new Error("this Local VM is already being used by another turn");
|
|
11796
|
+
}
|
|
11797
|
+
if (localVmMode(cfg) === "pool")
|
|
11798
|
+
localVmSeatPool.touch(threadId);
|
|
11799
|
+
roomVmTarget = target;
|
|
11800
|
+
localVmThreadTargets.set(threadId, target);
|
|
11801
|
+
localVmActiveThreads.set(target.key, threadId);
|
|
11802
|
+
localVmIdleFor(target).touch();
|
|
11803
|
+
const setupIsCurrent = () => !isCancelled?.() &&
|
|
11804
|
+
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
11805
|
+
activeInternalGenerationByThread.get(threadId) === internalGeneration &&
|
|
11806
|
+
store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
|
|
11807
|
+
store.bot(readyBot.id)?.busy === true &&
|
|
11808
|
+
store.group(readyGroup.id)?.busyBotId === readyBot.id;
|
|
11809
|
+
const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
|
|
11810
|
+
if (!setupIsCurrent()) {
|
|
11850
11811
|
return false;
|
|
11851
|
-
|
|
11852
|
-
|
|
11853
|
-
|
|
11854
|
-
|
|
11855
|
-
|
|
11856
|
-
|
|
11812
|
+
}
|
|
11813
|
+
if (!vm.ready || !vm.runtime)
|
|
11814
|
+
throw new Error(vm.problem ?? "the Local VM is not ready");
|
|
11815
|
+
const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
|
|
11816
|
+
if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
|
|
11817
|
+
throw new Error("the Local VM lease expired while preparing the turn");
|
|
11818
|
+
}
|
|
11819
|
+
integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, { localVmTarget: target }), target);
|
|
11857
11820
|
}
|
|
11858
11821
|
}
|
|
11859
|
-
|
|
11860
|
-
|
|
11861
|
-
|
|
11862
|
-
|
|
11863
|
-
|
|
11864
|
-
|
|
11865
|
-
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
11866
|
-
const target = { ...localVmTargetForThread(readyBot.id, threadId) };
|
|
11867
|
-
await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
11868
|
-
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
|
|
11869
|
-
throw new Error("this Local VM is being started, stopped, or replaced");
|
|
11870
|
-
}
|
|
11871
|
-
if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
|
|
11872
|
-
throw new Error("this Local VM is already being used by another turn");
|
|
11873
|
-
}
|
|
11874
|
-
if (localVmMode(cfg) === "pool")
|
|
11875
|
-
localVmSeatPool.touch(threadId);
|
|
11876
|
-
roomVmTarget = target;
|
|
11877
|
-
localVmThreadTargets.set(threadId, target);
|
|
11878
|
-
localVmActiveThreads.set(target.key, threadId);
|
|
11879
|
-
localVmIdleFor(target).touch();
|
|
11880
|
-
const setupIsCurrent = () => !isCancelled?.() &&
|
|
11881
|
-
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
11882
|
-
activeInternalGenerationByThread.get(threadId) === internalGeneration &&
|
|
11883
|
-
store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
|
|
11884
|
-
store.bot(readyBot.id)?.busy === true &&
|
|
11885
|
-
store.group(readyGroup.id)?.busyBotId === readyBot.id;
|
|
11886
|
-
const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
|
|
11887
|
-
if (!setupIsCurrent()) {
|
|
11888
|
-
return false;
|
|
11889
|
-
}
|
|
11890
|
-
if (!vm.ready || !vm.runtime)
|
|
11891
|
-
throw new Error(vm.problem ?? "the Local VM is not ready");
|
|
11892
|
-
const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
|
|
11893
|
-
if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
|
|
11894
|
-
throw new Error("the Local VM lease expired while preparing the turn");
|
|
11895
|
-
}
|
|
11896
|
-
integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
|
|
11822
|
+
catch (error) {
|
|
11823
|
+
const place = roomPlan.computer;
|
|
11824
|
+
if (place !== "cloud" && place !== "vm" && place !== "local")
|
|
11825
|
+
throw error;
|
|
11826
|
+
// A room row is not cut, so the cause keeps all its words.
|
|
11827
|
+
throw asPlaceFailure(error, place, "works-on", Infinity);
|
|
11897
11828
|
}
|
|
11898
11829
|
const roster = readyGroup.memberIds
|
|
11899
11830
|
.map((id) => store.bot(id))
|
|
@@ -11904,8 +11835,9 @@ setupRetry = 0) {
|
|
|
11904
11835
|
// are who it cannot. The 1:1 prompt has carried a peer roster since #774,
|
|
11905
11836
|
// and a room turn had nothing — the only advice it gave ("mention them
|
|
11906
11837
|
// like @Name") sends the model after a teammate who will never see it.
|
|
11907
|
-
// Same reachability rule as list_bots, minus the room's own members
|
|
11908
|
-
|
|
11838
|
+
// Same reachability rule as list_bots, minus the room's own members, and
|
|
11839
|
+
// only on a turn that is not answering a room request.
|
|
11840
|
+
const outsideRoom = integrations.agents && orchestration && !orchestration.roomHandoffId
|
|
11909
11841
|
? reachablePeers(store.bots, bot).filter((peer) => !readyGroup.memberIds.includes(peer.id))
|
|
11910
11842
|
: [];
|
|
11911
11843
|
const system = [
|
|
@@ -11915,7 +11847,7 @@ setupRetry = 0) {
|
|
|
11915
11847
|
`Room members: ${roster}, and ${userName} (the human).`,
|
|
11916
11848
|
readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
|
|
11917
11849
|
`Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
|
|
11918
|
-
outsideRoom.length > 0 &&
|
|
11850
|
+
outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
|
|
11919
11851
|
integrations.agents && (CREDENTIAL_PROMPT + (orchestration && !orchestration.roomHandoffId ? THREADS_PROMPT : "")).trim(),
|
|
11920
11852
|
integrations.agents && (!orchestration || orchestration.roomHandoffId) && "For actual OpenMausBot teamwork, discover IDs with list_room_targets and use coordinate_bots for advice or work in this or another room. Do not substitute native coding helpers for these named bots. Consult only when needed to make a decision; no discussion step is mandatory. Give concrete responsibilities, exact accessible paths and acceptance checks. End your turn after assigning; busy teammates queue and results automatically resume you. When they return, finish the requested verification and give the user one final answer. Native helper names are not evidence that an OpenMausBot teammate participated. Plain @mentions are only for conversational replies in this room.",
|
|
11921
11853
|
integrations.agents && ROUTINE_PROMPT.trim(),
|
|
@@ -11979,7 +11911,7 @@ setupRetry = 0) {
|
|
|
11979
11911
|
const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
|
|
11980
11912
|
const roomComputerPromptKind = resolveComputerPromptKind({
|
|
11981
11913
|
kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
|
|
11982
|
-
driverKind: instance.driverKind,
|
|
11914
|
+
driverKind: instance.driverKind,
|
|
11983
11915
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
11984
11916
|
});
|
|
11985
11917
|
{
|
|
@@ -12003,8 +11935,10 @@ setupRetry = 0) {
|
|
|
12003
11935
|
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
12004
11936
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
12005
11937
|
{ id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
11938
|
+
teamAvailabilityPart(outsideRoom),
|
|
12006
11939
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
12007
11940
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
11941
|
+
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
|
|
12008
11942
|
// the room path has always put a newline before memory and trimmed
|
|
12009
11943
|
// the block's leading space; keep that so existing prompts are
|
|
12010
11944
|
// byte-identical. The write guidance follows the tools actually
|
|
@@ -12137,7 +12071,6 @@ setupRetry = 0) {
|
|
|
12137
12071
|
threadId,
|
|
12138
12072
|
botId: readyBot.id,
|
|
12139
12073
|
text: withRecalled(roomRecalled, text),
|
|
12140
|
-
refreshSystemPrompt: true,
|
|
12141
12074
|
images: turnImages,
|
|
12142
12075
|
approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
|
|
12143
12076
|
toolScope: toolScopeForTurn(readyBot.id),
|
|
@@ -12183,7 +12116,7 @@ setupRetry = 0) {
|
|
|
12183
12116
|
role: "bot",
|
|
12184
12117
|
kind: "activity",
|
|
12185
12118
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
12186
|
-
tool:
|
|
12119
|
+
tool: failedTurnTool(message),
|
|
12187
12120
|
});
|
|
12188
12121
|
onDispatchError?.(message);
|
|
12189
12122
|
watchdog.settle(threadId);
|
|
@@ -12376,7 +12309,7 @@ setupRetry = 0) {
|
|
|
12376
12309
|
store.appendMessage(threadId, {
|
|
12377
12310
|
role: "bot", kind: "activity",
|
|
12378
12311
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
12379
|
-
tool:
|
|
12312
|
+
tool: failedTurnTool(message),
|
|
12380
12313
|
});
|
|
12381
12314
|
onDispatchError?.(message);
|
|
12382
12315
|
if (orchestration) {
|
|
@@ -12985,10 +12918,7 @@ function drainQueuedChannelSends() {
|
|
|
12985
12918
|
store.appendMessage(threadId, {
|
|
12986
12919
|
role: "bot",
|
|
12987
12920
|
kind: "activity",
|
|
12988
|
-
tool: {
|
|
12989
|
-
name: `error: queued channel message could not start — ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`,
|
|
12990
|
-
ok: false,
|
|
12991
|
-
},
|
|
12921
|
+
tool: failedTurnTool(`queued channel message could not start — ${error instanceof Error ? error.message : String(error)}`),
|
|
12992
12922
|
});
|
|
12993
12923
|
}
|
|
12994
12924
|
// A message with no eligible responder creates no operation. Continue
|
|
@@ -13195,9 +13125,9 @@ function proposalPersistence(botId, threadId) {
|
|
|
13195
13125
|
return { ok: true };
|
|
13196
13126
|
// Only cards on the visible branch can be acted on from the composer.
|
|
13197
13127
|
// Abandoned branches must not permanently consume the proposal quota.
|
|
13198
|
-
// Routine, profile, default-model
|
|
13128
|
+
// Routine, profile, default-model and team-setup proposals
|
|
13199
13129
|
// share one budget per bot per thread, so one thread cannot pile up 8 of each.
|
|
13200
|
-
const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.
|
|
13130
|
+
const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
|
|
13201
13131
|
!message.card.answered &&
|
|
13202
13132
|
!message.card.dismissed && !message.card.expired).length;
|
|
13203
13133
|
return openRequests >= 8
|
|
@@ -14059,7 +13989,8 @@ function configStatus() {
|
|
|
14059
13989
|
// not a saved key
|
|
14060
13990
|
box: boat.describeBoatAccount(cfg),
|
|
14061
13991
|
vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" },
|
|
14062
|
-
|
|
13992
|
+
// names only: every provider key stays write-only
|
|
13993
|
+
opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey), providerKeys: Object.keys(openCodeProviderKeys(cfg)) },
|
|
14063
13994
|
// the chosen voice is a setting, not a secret; the key is reported the
|
|
14064
13995
|
// same configured-or-not way as every other credential
|
|
14065
13996
|
tts: tts.describeVoice(cfg),
|
|
@@ -14141,6 +14072,7 @@ function configForAccess(status, admin) {
|
|
|
14141
14072
|
edition: { edition: status.edition.edition, features: status.edition.features },
|
|
14142
14073
|
signIn: { admins: [], members: [] },
|
|
14143
14074
|
vps: { configured: status.vps.configured, sshAlias: "" },
|
|
14075
|
+
opencodeGo: { configured: status.opencodeGo.configured, providerKeys: [] },
|
|
14144
14076
|
profile: { name: status.profile.name, email: "" },
|
|
14145
14077
|
browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
|
|
14146
14078
|
};
|
|
@@ -14368,7 +14300,7 @@ async function reloadProviders() {
|
|
|
14368
14300
|
if (store.taskByThread(botId, threadId)) {
|
|
14369
14301
|
store.appendMessage(threadId, {
|
|
14370
14302
|
role: "bot", kind: "activity",
|
|
14371
|
-
tool:
|
|
14303
|
+
tool: failedTurnTool("turn interrupted — provider settings changed"),
|
|
14372
14304
|
});
|
|
14373
14305
|
store.setTaskActivity(botId, threadId, "idle");
|
|
14374
14306
|
}
|
|
@@ -14471,7 +14403,7 @@ const workspaceBackupAccess = {
|
|
|
14471
14403
|
!routines?.isTicking && !calendarCalls?.isTicking &&
|
|
14472
14404
|
!localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
|
|
14473
14405
|
!localVmLifecycleBusy.size && !boatLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoatLifecycleBusyIds.size &&
|
|
14474
|
-
!computerProviderConfigTransitions.size &&
|
|
14406
|
+
!computerProviderConfigTransitions.size &&
|
|
14475
14407
|
teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
|
|
14476
14408
|
store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
|
|
14477
14409
|
store.groups.every((group) => !groupIsWorking(group)),
|
|
@@ -14494,18 +14426,24 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
14494
14426
|
restored: workspaceRestore,
|
|
14495
14427
|
...workspaceBackupAccess,
|
|
14496
14428
|
});
|
|
14497
|
-
//
|
|
14498
|
-
//
|
|
14499
|
-
//
|
|
14500
|
-
|
|
14429
|
+
// Copy this computer here (server/cloud-move-http.ts, docs/copy-workspace.md):
|
|
14430
|
+
// every server sizes its own workspace for its desktop, and every server the
|
|
14431
|
+
// person owns receives one, a Cloud home included. A workspace shared with
|
|
14432
|
+
// other people never does (workspaceShared): a hosted organisation
|
|
14433
|
+
// workspace, or a server whose email sign-in lets someone besides its owner
|
|
14434
|
+
// in. After the restore commits, the server
|
|
14435
|
+
// exits with RESTART_EXIT_CODE and its launcher starts it again, so startup
|
|
14436
|
+
// installs it (server/restart.ts).
|
|
14437
|
+
let restartRequested = false;
|
|
14501
14438
|
const cloudMoveRoutes = createCloudMoveRoutes({
|
|
14502
14439
|
dataDir: DATA_DIR,
|
|
14503
14440
|
appVersion: serverVersion(),
|
|
14504
|
-
|
|
14441
|
+
environmentId: ENVIRONMENT_ID,
|
|
14442
|
+
sharedWorkspace: () => workspaceShared({ hosted: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME), signIn: signInAllowList() }),
|
|
14505
14443
|
readBody,
|
|
14506
14444
|
restored: workspaceRestore,
|
|
14507
14445
|
...workspaceBackupAccess,
|
|
14508
|
-
restart: () => {
|
|
14446
|
+
restart: () => { restartRequested = true; gracefulShutdown(); },
|
|
14509
14447
|
});
|
|
14510
14448
|
// Route modules (server/routes/README.md). `workspaceAccess` is assigned at
|
|
14511
14449
|
// boot, after this line, so the dependency reads it per request.
|
|
@@ -14995,9 +14933,7 @@ const handleRequest = async (req, res) => {
|
|
|
14995
14933
|
// scheme the native companion scanners accept; it carries the
|
|
14996
14934
|
// credential encoding because those scanners cannot take a typed code.
|
|
14997
14935
|
const serverName = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label;
|
|
14998
|
-
const invite = base
|
|
14999
|
-
? `openmausbot://pair?address=${encodeURIComponent(base)}&token=${encodeURIComponent(opened.credential)}&name=${encodeURIComponent(serverName)}`
|
|
15000
|
-
: null;
|
|
14936
|
+
const invite = base ? phonePairingLink({ address: base, token: opened.credential, name: serverName }) : null;
|
|
15001
14937
|
return json(res, 200, {
|
|
15002
14938
|
id: opened.id,
|
|
15003
14939
|
code,
|
|
@@ -15212,7 +15148,7 @@ const handleRequest = async (req, res) => {
|
|
|
15212
15148
|
? "phone"
|
|
15213
15149
|
: path.startsWith("/api/internal/connectors/")
|
|
15214
15150
|
? "connectors"
|
|
15215
|
-
: path === "/api/internal/computer-control"
|
|
15151
|
+
: path === "/api/internal/computer-control" || path === "/api/internal/computer/mcp"
|
|
15216
15152
|
? "computer"
|
|
15217
15153
|
: "agents";
|
|
15218
15154
|
if (internalCapability.kind !== requiredCapabilityKind) {
|
|
@@ -15261,6 +15197,9 @@ const handleRequest = async (req, res) => {
|
|
|
15261
15197
|
throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
|
|
15262
15198
|
}
|
|
15263
15199
|
};
|
|
15200
|
+
const delegatedThisTurn = (taskId) => {
|
|
15201
|
+
(internalCapability.delegatedThisTurn ??= new Set()).add(taskId);
|
|
15202
|
+
};
|
|
15264
15203
|
// Where an entry came from, as the person will read it in MEMORY.md:
|
|
15265
15204
|
// the room or the thread title, never a bare id unless nothing else
|
|
15266
15205
|
// names the conversation.
|
|
@@ -15369,7 +15308,7 @@ const handleRequest = async (req, res) => {
|
|
|
15369
15308
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
15370
15309
|
const body = await readInternalBody();
|
|
15371
15310
|
const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
|
|
15372
|
-
return json(res, result.ok ? 200 : result.code === "conflict" ? 409 :
|
|
15311
|
+
return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : 400, result);
|
|
15373
15312
|
}
|
|
15374
15313
|
if (method === "POST" && path === "/api/internal/memory/log") {
|
|
15375
15314
|
const body = await readInternalBody();
|
|
@@ -15403,6 +15342,22 @@ const handleRequest = async (req, res) => {
|
|
|
15403
15342
|
requireActiveInternalCapability();
|
|
15404
15343
|
return json(res, 200, { result });
|
|
15405
15344
|
}
|
|
15345
|
+
// The cloud computer's tools (harness-mcp-proxy computer): the turn's
|
|
15346
|
+
// capability names its Boat, and the Boat credential stays here.
|
|
15347
|
+
if (method === "POST" && path === "/api/internal/computer/mcp") {
|
|
15348
|
+
const body = await readInternalBody();
|
|
15349
|
+
const boxId = internalCapability.boxId;
|
|
15350
|
+
if (!boxId)
|
|
15351
|
+
return json(res, 403, { error: "this turn has no cloud computer" });
|
|
15352
|
+
const abort = new AbortController();
|
|
15353
|
+
res.once("close", () => { if (!res.writableEnded)
|
|
15354
|
+
abort.abort(); });
|
|
15355
|
+
return json(res, 200, { result: await cloudComputerRpc(body, {
|
|
15356
|
+
cfg, boxId, signal: abort.signal,
|
|
15357
|
+
gate: () => computerCallGate(internalCapability),
|
|
15358
|
+
assertActive: requireActiveInternalCapability,
|
|
15359
|
+
}) });
|
|
15360
|
+
}
|
|
15406
15361
|
if (method === "POST" && path === "/api/internal/phone/claim") {
|
|
15407
15362
|
// Lazy phone exclusivity (issue #1663): the turn holds computer:phone
|
|
15408
15363
|
// from its first real tool call until it settles. The proxy answers a
|
|
@@ -15784,6 +15739,58 @@ const handleRequest = async (req, res) => {
|
|
|
15784
15739
|
});
|
|
15785
15740
|
return json(res, 201, proposed);
|
|
15786
15741
|
}
|
|
15742
|
+
if (method === "POST" && path === "/api/internal/team-memory") {
|
|
15743
|
+
const parsed = z.object({
|
|
15744
|
+
fromBotId: z.string().min(1).max(128),
|
|
15745
|
+
fromThreadId: z.string().min(1).max(128),
|
|
15746
|
+
kind: z.string(),
|
|
15747
|
+
name: z.string(),
|
|
15748
|
+
detail: z.string(),
|
|
15749
|
+
aliases: z.array(z.string()).optional(),
|
|
15750
|
+
}).strict().safeParse(await readInternalBody());
|
|
15751
|
+
if (!parsed.success)
|
|
15752
|
+
return json(res, 400, { error: "invalid team memory proposal" });
|
|
15753
|
+
const body = parsed.data;
|
|
15754
|
+
const from = store.bot(body.fromBotId);
|
|
15755
|
+
if (!from)
|
|
15756
|
+
return json(res, 403, { error: "unknown sender" });
|
|
15757
|
+
const owner = connectorThread(from.id, body.fromThreadId);
|
|
15758
|
+
if (!owner)
|
|
15759
|
+
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
15760
|
+
if (!sameAudience(from.visibility, undefined) || (owner.group && !roomFeedsBot(owner.group, from))) {
|
|
15761
|
+
return json(res, 403, { error: "restricted conversations cannot publish section-wide memory; ask an admin to add it" });
|
|
15762
|
+
}
|
|
15763
|
+
if (!TEAM_MEMORY_KINDS.includes(body.kind)) {
|
|
15764
|
+
return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
|
|
15765
|
+
}
|
|
15766
|
+
let proposed;
|
|
15767
|
+
try {
|
|
15768
|
+
proposed = teamMemory.propose(from.section, { kind: body.kind, name: body.name, detail: body.detail, aliases: body.aliases }, { botId: from.id, botName: from.name, threadId: body.fromThreadId, at: Date.now() });
|
|
15769
|
+
}
|
|
15770
|
+
catch (error) {
|
|
15771
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
15772
|
+
}
|
|
15773
|
+
const summary = `${proposed.entry.kind[0].toUpperCase()}${proposed.entry.kind.slice(1)}: ${proposed.entry.name}${proposed.entry.aliases.length ? ` (also ${proposed.entry.aliases.join(", ")})` : ""} — ${proposed.entry.detail}`;
|
|
15774
|
+
const section = sectionContextKey(from.section);
|
|
15775
|
+
const card = store.appendMessage(body.fromThreadId, {
|
|
15776
|
+
role: "bot",
|
|
15777
|
+
kind: "options",
|
|
15778
|
+
...(owner.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}),
|
|
15779
|
+
card: {
|
|
15780
|
+
title: "Remember this for the team?",
|
|
15781
|
+
subtitle: summary,
|
|
15782
|
+
options: ["Remember", "Skip"],
|
|
15783
|
+
requestId: proposed.entry.id,
|
|
15784
|
+
tool: "propose_team_memory",
|
|
15785
|
+
teamMemoryRequest: { section, entryId: proposed.entry.id, kind: proposed.entry.kind },
|
|
15786
|
+
},
|
|
15787
|
+
});
|
|
15788
|
+
appendDecision(DATA_DIR, {
|
|
15789
|
+
threadId: body.fromThreadId, requestId: proposed.entry.id, botId: from.id, botName: from.name,
|
|
15790
|
+
tool: "propose_team_memory", summary, decision: "card-shown", source: "team-memory",
|
|
15791
|
+
});
|
|
15792
|
+
return json(res, 201, { status: "proposed", requestId: proposed.entry.id, messageId: card.id, summary });
|
|
15793
|
+
}
|
|
15787
15794
|
if (method === "POST" && path === "/api/internal/profile-requests") {
|
|
15788
15795
|
const parsed = z.object({
|
|
15789
15796
|
fromBotId: z.string().min(1).max(128),
|
|
@@ -15849,39 +15856,6 @@ const handleRequest = async (req, res) => {
|
|
|
15849
15856
|
});
|
|
15850
15857
|
return json(res, 201, proposed);
|
|
15851
15858
|
}
|
|
15852
|
-
if (method === "POST" && path === "/api/internal/tightening-requests") {
|
|
15853
|
-
const parsed = z.object({
|
|
15854
|
-
fromBotId: z.string().min(1).max(128),
|
|
15855
|
-
fromThreadId: z.string().min(1).max(128),
|
|
15856
|
-
forBotId: z.string().max(128).optional(),
|
|
15857
|
-
intents: z.unknown(),
|
|
15858
|
-
reason: z.unknown(),
|
|
15859
|
-
}).strict().safeParse(await readInternalBody());
|
|
15860
|
-
if (!parsed.success)
|
|
15861
|
-
return json(res, 400, { error: "invalid tightening proposal" });
|
|
15862
|
-
const body = parsed.data;
|
|
15863
|
-
const from = store.bot(body.fromBotId);
|
|
15864
|
-
if (!from)
|
|
15865
|
-
return json(res, 403, { error: "unknown sender" });
|
|
15866
|
-
const owner = connectorThread(from.id, body.fromThreadId);
|
|
15867
|
-
if (!owner)
|
|
15868
|
-
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
15869
|
-
const targetBotId = body.forBotId?.trim() || from.id;
|
|
15870
|
-
const proposed = tighteningRequests.submit({
|
|
15871
|
-
botId: from.id,
|
|
15872
|
-
threadId: body.fromThreadId,
|
|
15873
|
-
targetBotId,
|
|
15874
|
-
intents: body.intents,
|
|
15875
|
-
reason: body.reason,
|
|
15876
|
-
from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
|
|
15877
|
-
});
|
|
15878
|
-
appendDecision(DATA_DIR, {
|
|
15879
|
-
threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
|
|
15880
|
-
tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
|
|
15881
|
-
source: proposed.state === "applied" ? "full-access" : "tightening",
|
|
15882
|
-
});
|
|
15883
|
-
return json(res, 201, proposed);
|
|
15884
|
-
}
|
|
15885
15859
|
if (method === "GET" && path === "/api/internal/team-setup-catalog") {
|
|
15886
15860
|
let chief = store.bot(internalCapability.botId);
|
|
15887
15861
|
if (!chief.chiefOfStaff || chief.hidden)
|
|
@@ -16234,6 +16208,7 @@ const handleRequest = async (req, res) => {
|
|
|
16234
16208
|
if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
|
|
16235
16209
|
drainThreadDelegations(fromThreadId);
|
|
16236
16210
|
}
|
|
16211
|
+
delegatedThisTurn(queued.id);
|
|
16237
16212
|
return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
|
|
16238
16213
|
botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
|
|
16239
16214
|
detail: "the teammate was busy; the ask was queued as a delegation instead",
|
|
@@ -16393,6 +16368,7 @@ const handleRequest = async (req, res) => {
|
|
|
16393
16368
|
kind: "activity",
|
|
16394
16369
|
tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
|
|
16395
16370
|
});
|
|
16371
|
+
delegatedThisTurn(taskId);
|
|
16396
16372
|
return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
|
|
16397
16373
|
receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
|
|
16398
16374
|
detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
|
|
@@ -16431,6 +16407,9 @@ const handleRequest = async (req, res) => {
|
|
|
16431
16407
|
const delegationMatch = method === "GET" ? path.match(/^\/api\/internal\/delegations\/([\w-]{4,64})$/) : null;
|
|
16432
16408
|
if (delegationMatch) {
|
|
16433
16409
|
const taskId = delegationMatch[1];
|
|
16410
|
+
if (internalCapability.delegatedThisTurn?.has(taskId)) {
|
|
16411
|
+
return json(res, 409, { error: `Task ${taskId} was delegated during this turn. Finish your response now so the other bot can work; its result will be delivered to this conversation automatically. Do not check or wait for a newly delegated task until a later turn.` });
|
|
16412
|
+
}
|
|
16434
16413
|
const fromThreadId = internalCapability.threadId;
|
|
16435
16414
|
const from = internalSender;
|
|
16436
16415
|
if (!connectorThread(from.id, fromThreadId))
|
|
@@ -16472,7 +16451,10 @@ const handleRequest = async (req, res) => {
|
|
|
16472
16451
|
if (!sender || (target && !canReachPeer(sender, target))) {
|
|
16473
16452
|
return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
|
|
16474
16453
|
}
|
|
16475
|
-
|
|
16454
|
+
// Nothing moves while the teammate's card waits on the person, so
|
|
16455
|
+
// say so now rather than holding the long-poll to its deadline.
|
|
16456
|
+
const awaitingPerson = running && runningEntry ? openPersonCard(runningEntry[0]) : undefined;
|
|
16457
|
+
if (Date.now() >= deadline || awaitingPerson) {
|
|
16476
16458
|
if (running && runningEntry) {
|
|
16477
16459
|
const recent = summarizeDelegatedActivity(store.messagesFor(runningEntry[0]), running.startedAtMs ?? Date.now());
|
|
16478
16460
|
return json(res, 200, {
|
|
@@ -16480,6 +16462,12 @@ const handleRequest = async (req, res) => {
|
|
|
16480
16462
|
toBotName: store.bot(toBotId)?.name ?? toBotId,
|
|
16481
16463
|
elapsedMs: Math.max(0, Date.now() - (running.startedAtMs ?? Date.now())),
|
|
16482
16464
|
recentActivity: recent,
|
|
16465
|
+
...(awaitingPerson ? {
|
|
16466
|
+
awaitingPerson: {
|
|
16467
|
+
...awaitingPerson,
|
|
16468
|
+
threadTitle: store.taskByThread(toBotId, runningEntry[0])?.title,
|
|
16469
|
+
},
|
|
16470
|
+
} : {}),
|
|
16483
16471
|
});
|
|
16484
16472
|
}
|
|
16485
16473
|
const queuedTarget = store.bot(toBotId);
|
|
@@ -16596,6 +16584,7 @@ const handleRequest = async (req, res) => {
|
|
|
16596
16584
|
detail: `${refusal} — nothing was queued`,
|
|
16597
16585
|
}) });
|
|
16598
16586
|
}
|
|
16587
|
+
delegatedThisTurn(queued.id);
|
|
16599
16588
|
const targetName = store.bot(toBotId)?.name ?? toBotId;
|
|
16600
16589
|
// The queue normally drains when the source thread's turn completes. A
|
|
16601
16590
|
// caller this server did not spawn (an external runtime) has no live
|
|
@@ -16648,20 +16637,17 @@ const handleRequest = async (req, res) => {
|
|
|
16648
16637
|
})).filter(g => g.members.length);
|
|
16649
16638
|
return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
|
|
16650
16639
|
bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
|
|
16651
|
-
rooms, note: "
|
|
16640
|
+
rooms, note: "Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
|
|
16652
16641
|
}
|
|
16653
16642
|
if (method === "POST" && path === "/api/internal/coordinate-bots") {
|
|
16654
16643
|
const parsed = z.object({
|
|
16655
16644
|
groupId: z.string().min(1).max(128).optional(),
|
|
16656
16645
|
botIds: z.array(z.string().min(1).max(128)).min(1).max(4).refine(ids => new Set(ids).size === ids.length),
|
|
16657
|
-
message: z.string().trim().min(1).max(4000),
|
|
16646
|
+
message: z.string().trim().min(1).max(4000),
|
|
16658
16647
|
rework: z.boolean().default(false),
|
|
16659
|
-
// Only ever a name for a thread, so it travels under the same
|
|
16660
|
-
// one-line rule as a peer thread title.
|
|
16661
|
-
label: z.string().trim().min(1).max(60).refine(fitsOnOneLine).optional(),
|
|
16662
16648
|
}).safeParse(await readInternalBody());
|
|
16663
16649
|
if (!parsed.success)
|
|
16664
|
-
return json(res, 400, { error: "Provide 1-4 distinct botIds
|
|
16650
|
+
return json(res, 400, { error: "Provide 1-4 distinct botIds and a message of 1-4000 characters." });
|
|
16665
16651
|
const groupId = parsed.data.groupId ?? source?.id;
|
|
16666
16652
|
const destination = groupId ? store.group(groupId) : undefined;
|
|
16667
16653
|
if (groupId && !destination)
|
|
@@ -16713,58 +16699,63 @@ const handleRequest = async (req, res) => {
|
|
|
16713
16699
|
});
|
|
16714
16700
|
approvalGranted = true;
|
|
16715
16701
|
}
|
|
16716
|
-
|
|
16717
|
-
const
|
|
16702
|
+
// A room shows one brief per call, addressed to the call's recipients.
|
|
16703
|
+
const requestBatchKey = destination ? randomUUID() : undefined;
|
|
16718
16704
|
// One receipt per recipient, in the order the caller addressed
|
|
16719
16705
|
// them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
|
|
16720
|
-
// enqueue is honestly "queued"; a
|
|
16721
|
-
//
|
|
16706
|
+
// enqueue is honestly "queued"; a repeat reports the request it
|
|
16707
|
+
// repeats.
|
|
16722
16708
|
const receipts = [];
|
|
16709
|
+
// Why nothing was sent, in full: a receipt's detail is clipped.
|
|
16710
|
+
const refused = [];
|
|
16723
16711
|
for (const target of targets) {
|
|
16724
16712
|
let createdThread;
|
|
16725
16713
|
try {
|
|
16726
16714
|
requireActiveInternalCapability();
|
|
16715
|
+
// Outside a room this conversation has one thread with each
|
|
16716
|
+
// teammate: the first request opens it, everything after
|
|
16717
|
+
// continues it and runs once the work already there is done.
|
|
16718
|
+
const continued = destination ? undefined : store.workThread(target.botId, internalSender.id, address.threadId);
|
|
16727
16719
|
if (!destination) {
|
|
16728
|
-
const task = store.createTask(target.botId, `@${internalSender.name}
|
|
16720
|
+
const task = continued ?? store.createTask(target.botId, `@${internalSender.name} · work`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", threadId: address.threadId, at: Date.now() });
|
|
16729
16721
|
if (!task)
|
|
16730
16722
|
throw new Error("The recipient no longer exists");
|
|
16731
|
-
target.threadId =
|
|
16723
|
+
target.threadId = task.threadId;
|
|
16724
|
+
if (!continued)
|
|
16725
|
+
createdThread = task.threadId;
|
|
16732
16726
|
applyDelegatedLevel(internalSender, internalCapability.threadId, store.bot(target.botId), target.threadId);
|
|
16733
16727
|
}
|
|
16734
|
-
const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target,
|
|
16735
|
-
// A
|
|
16736
|
-
|
|
16728
|
+
const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", requestBatchKey);
|
|
16729
|
+
// A repeat lands on the request already made, possibly in a
|
|
16730
|
+
// thread the person archived meanwhile; a thread opened for it
|
|
16731
|
+
// is never used.
|
|
16732
|
+
if (duplicate && createdThread)
|
|
16737
16733
|
store.deleteTask(target.botId, createdThread);
|
|
16738
|
-
|
|
16739
|
-
if (!duplicate && createdThread)
|
|
16734
|
+
else if (createdThread)
|
|
16740
16735
|
threadStarters.set(createdThread, openerFrom(address.threadId));
|
|
16741
16736
|
createdThread = undefined; // The durable coordinator now owns this task.
|
|
16742
|
-
|
|
16743
|
-
//
|
|
16744
|
-
//
|
|
16745
|
-
|
|
16746
|
-
|
|
16747
|
-
|
|
16737
|
+
// Only a live request can still report: a finished one already
|
|
16738
|
+
// fired its report and tick() skips it, so a repeat of it sends
|
|
16739
|
+
// nothing and says how to run it again.
|
|
16740
|
+
const title = destination ? undefined : store.taskByThread(node.botId, node.threadId)?.title;
|
|
16741
|
+
const finished = {
|
|
16742
|
+
completed: "it already finished and its result stands; send it with rework=true to run it again",
|
|
16743
|
+
failed: "it failed; send it with rework=true to retry", cancelled: "it was cancelled; send it with rework=true to retry",
|
|
16744
|
+
};
|
|
16745
|
+
const notSent = duplicate && finished[node.status] ? `not sent again: ${finished[node.status]}` : undefined;
|
|
16746
|
+
if (notSent)
|
|
16747
|
+
refused.push(notSent);
|
|
16748
16748
|
receipts.push(peerDeliveryReceipt({
|
|
16749
16749
|
botId: node.botId,
|
|
16750
16750
|
botName: store.bot(node.botId)?.name,
|
|
16751
|
-
outcome: duplicate
|
|
16752
|
-
|
|
16753
|
-
|
|
16754
|
-
|
|
16755
|
-
|
|
16756
|
-
|
|
16757
|
-
: "queued",
|
|
16758
|
-
detail: duplicate
|
|
16759
|
-
? node.status === "running"
|
|
16760
|
-
? "duplicate request_key — this teammate is already running that exact assignment"
|
|
16761
|
-
: node.status === "completed"
|
|
16762
|
-
? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
|
|
16763
|
-
: node.status === "failed" || node.status === "cancelled"
|
|
16764
|
-
? `duplicate request_key — the earlier assignment ${node.status === "cancelled" ? "was cancelled" : "failed"} and will not rerun or resume you; resend with a new request_key to retry`
|
|
16765
|
-
: "duplicate request_key — already in flight; its result will resume you automatically"
|
|
16766
|
-
: "handed to the coordinator; the teammate's turn has not started yet",
|
|
16751
|
+
outcome: notSent ? "failed" : !duplicate || node.status === "queued" ? "queued" : "injected",
|
|
16752
|
+
detail: notSent ?? (duplicate
|
|
16753
|
+
? `already sent: it is ${node.status === "queued" ? "queued" : "under way"} and its result will resume you`
|
|
16754
|
+
: title
|
|
16755
|
+
? `sent to "${title}"; it runs after anything still running there`
|
|
16756
|
+
: "handed to the coordinator; the teammate's turn has not started yet"),
|
|
16767
16757
|
requestId: node.id,
|
|
16758
|
+
...(destination ? {} : { threadId: node.threadId }),
|
|
16768
16759
|
}));
|
|
16769
16760
|
if (!duplicate) {
|
|
16770
16761
|
const recipient = store.bot(target.botId);
|
|
@@ -16781,14 +16772,13 @@ const handleRequest = async (req, res) => {
|
|
|
16781
16772
|
store.deleteTask(target.botId, createdThread);
|
|
16782
16773
|
}
|
|
16783
16774
|
const said = error instanceof Error ? error.message : String(error);
|
|
16784
|
-
|
|
16785
|
-
receipts.push(peerDeliveryReceipt({
|
|
16786
|
-
botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
|
|
16787
|
-
}));
|
|
16775
|
+
refused.push(said);
|
|
16776
|
+
receipts.push(peerDeliveryReceipt({ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said }));
|
|
16788
16777
|
}
|
|
16789
16778
|
}
|
|
16790
|
-
|
|
16791
|
-
|
|
16779
|
+
const sent = refused.length < receipts.length;
|
|
16780
|
+
return json(res, sent ? 200 : 409, { receipts,
|
|
16781
|
+
...(sent ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: refused.join("; ") }),
|
|
16792
16782
|
});
|
|
16793
16783
|
}
|
|
16794
16784
|
}
|
|
@@ -16823,6 +16813,12 @@ const handleRequest = async (req, res) => {
|
|
|
16823
16813
|
if (!groupId || !message) {
|
|
16824
16814
|
return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
|
|
16825
16815
|
}
|
|
16816
|
+
// One turn's worth of posts is a handful; past it the turn has
|
|
16817
|
+
// stopped reporting and started broadcasting. Counted per turn, on
|
|
16818
|
+
// top of the room's own budget below.
|
|
16819
|
+
if ((internalCapability.roomPosts ?? 0) >= MAX_ROOM_POSTS_PER_TURN) {
|
|
16820
|
+
return json(res, 429, { error: `You have already posted ${MAX_ROOM_POSTS_PER_TURN} times this turn, which is the limit. Do not retry — finish your turn and say anything further to the user directly.` });
|
|
16821
|
+
}
|
|
16826
16822
|
if (message.length > ROOM_POST_MAX_CHARS) {
|
|
16827
16823
|
return json(res, 400, {
|
|
16828
16824
|
error: `a room post is at most ${ROOM_POST_MAX_CHARS} characters — post the short version and keep the detail in your own reply`,
|
|
@@ -16951,6 +16947,7 @@ const handleRequest = async (req, res) => {
|
|
|
16951
16947
|
peerPost: unattended ? { unattended: true } : {},
|
|
16952
16948
|
...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
|
|
16953
16949
|
});
|
|
16950
|
+
internalCapability.roomPosts = (internalCapability.roomPosts ?? 0) + 1;
|
|
16954
16951
|
detachParkedAudio?.();
|
|
16955
16952
|
store.patchGroup(room.id, { unread: true });
|
|
16956
16953
|
// The same visibility contract the peer tools keep: whatever a bot
|
|
@@ -16994,7 +16991,7 @@ const handleRequest = async (req, res) => {
|
|
|
16994
16991
|
if (!fitsOnOneLine(title))
|
|
16995
16992
|
return json(res, 400, { error: "title must fit on one line" });
|
|
16996
16993
|
if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
|
|
16997
|
-
return json(res, 429, { error: `
|
|
16994
|
+
return json(res, 429, { error: `You have already opened ${MAX_THREADS_OPENED_PER_TURN} threads this turn, which is the limit. Do not retry — finish your turn and tell the person which threads you still wanted to open, so they can open them or ask you again.` });
|
|
16998
16995
|
}
|
|
16999
16996
|
const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
|
|
17000
16997
|
const target = store.bot(toBotId);
|
|
@@ -17093,6 +17090,7 @@ const handleRequest = async (req, res) => {
|
|
|
17093
17090
|
}
|
|
17094
17091
|
store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
|
|
17095
17092
|
internalCapability.openedThreads += 1;
|
|
17093
|
+
delegatedThisTurn(queued.id);
|
|
17096
17094
|
// An honest forecast, not a promise: the handoff starts when this
|
|
17097
17095
|
// turn ends, and by then the target's slots are taken by whatever is
|
|
17098
17096
|
// running there plus the threads this turn already opened ahead.
|
|
@@ -17122,8 +17120,8 @@ const handleRequest = async (req, res) => {
|
|
|
17122
17120
|
if (!chief.chiefOfStaff) {
|
|
17123
17121
|
return json(res, 403, { error: "only a section's Chief of Staff can create operator bots" });
|
|
17124
17122
|
}
|
|
17125
|
-
if (internalCapability.createdBots >=
|
|
17126
|
-
return json(res, 429, { error:
|
|
17123
|
+
if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
|
|
17124
|
+
return json(res, 429, { error: `You can create at most ${MAX_CREATED_BOTS_PER_TURN} bots in one turn. Use the team you have before adding more.` });
|
|
17127
17125
|
}
|
|
17128
17126
|
if (store.bots.length >= MAX_WORKSPACE_BOTS) {
|
|
17129
17127
|
return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
|
|
@@ -17172,8 +17170,9 @@ const handleRequest = async (req, res) => {
|
|
|
17172
17170
|
if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
|
|
17173
17171
|
return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
|
|
17174
17172
|
}
|
|
17175
|
-
if (internalCapability.createdBots >=
|
|
17176
|
-
return json(res, 429, { error:
|
|
17173
|
+
if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
|
|
17174
|
+
return json(res, 429, { error: `You can create at most ${MAX_CREATED_BOTS_PER_TURN} bots in one turn. Use the team you have before adding more.` });
|
|
17175
|
+
}
|
|
17177
17176
|
if (store.bots.length >= MAX_WORKSPACE_BOTS)
|
|
17178
17177
|
return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
|
|
17179
17178
|
const duplicate = store.bots.find((candidate) => !candidate.hidden &&
|
|
@@ -17292,6 +17291,36 @@ const handleRequest = async (req, res) => {
|
|
|
17292
17291
|
const updated = updateChannel(room.id, patch);
|
|
17293
17292
|
return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
|
|
17294
17293
|
}
|
|
17294
|
+
if (method === "POST" && path === "/api/internal/mcp-servers") {
|
|
17295
|
+
// Same single-flight lock as POST /api/mcp/servers: two writers must
|
|
17296
|
+
// not interleave a read of cfg.mcpServers with a save.
|
|
17297
|
+
if (mcpConfigBusy)
|
|
17298
|
+
return json(res, 409, { error: "MCP servers are already being updated." });
|
|
17299
|
+
mcpConfigBusy = true;
|
|
17300
|
+
try {
|
|
17301
|
+
const body = await readInternalBody();
|
|
17302
|
+
const added = addDisabledMcpServer(cfg.mcpServers ?? {}, body);
|
|
17303
|
+
if (!added.ok)
|
|
17304
|
+
return json(res, added.status, { error: added.error });
|
|
17305
|
+
const refusal = mcpPolicyRefusal(added.name, added.server);
|
|
17306
|
+
if (refusal)
|
|
17307
|
+
return json(res, 403, { error: refusal, code: "managed_policy" });
|
|
17308
|
+
persistMcpServers(added.next);
|
|
17309
|
+
const server = added.server;
|
|
17310
|
+
const remote = isRemoteMcpServer(server);
|
|
17311
|
+
return json(res, 201, {
|
|
17312
|
+
name: added.name,
|
|
17313
|
+
enabled: false,
|
|
17314
|
+
transport: remote ? server.type : "command",
|
|
17315
|
+
target: remote ? server.url : server.command,
|
|
17316
|
+
envKeys: remote ? [] : Object.keys(server.env).sort(),
|
|
17317
|
+
headerKeys: remote ? Object.keys(server.headers).sort() : [],
|
|
17318
|
+
});
|
|
17319
|
+
}
|
|
17320
|
+
finally {
|
|
17321
|
+
mcpConfigBusy = false;
|
|
17322
|
+
}
|
|
17323
|
+
}
|
|
17295
17324
|
if (method === "POST" && path === "/api/internal/request-credential") {
|
|
17296
17325
|
const body = await readInternalBody();
|
|
17297
17326
|
const from = internalSender;
|
|
@@ -17386,7 +17415,7 @@ const handleRequest = async (req, res) => {
|
|
|
17386
17415
|
// Reading a streamed MCP body yields to ordinary settings requests.
|
|
17387
17416
|
// Re-read the live bot immediately before relay so turning Connected
|
|
17388
17417
|
// Apps off wins over a request that authenticated under the old value.
|
|
17389
|
-
|
|
17418
|
+
let currentSender = store.bot(internalCapability.botId);
|
|
17390
17419
|
if (!currentSender || currentSender.composio === false || !composio.configured(cfg)) {
|
|
17391
17420
|
return json(res, 403, { error: "connected apps are not enabled for this bot" });
|
|
17392
17421
|
}
|
|
@@ -17397,6 +17426,7 @@ const handleRequest = async (req, res) => {
|
|
|
17397
17426
|
// Rows are fire-and-forget: a log failure must never take the call
|
|
17398
17427
|
// (or its refusal) down with it.
|
|
17399
17428
|
const call = connectorCallFromFrame(body);
|
|
17429
|
+
let serviceSlugs = [];
|
|
17400
17430
|
// A bot with no grants record keeps the pre-grants relay: even an
|
|
17401
17431
|
// unreadable frame passes through exactly as it always did.
|
|
17402
17432
|
if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
|
|
@@ -17423,7 +17453,11 @@ const handleRequest = async (req, res) => {
|
|
|
17423
17453
|
// underscored service (bland_ai) keeps its own tools instead of
|
|
17424
17454
|
// a plain-prefix grant (bland) capturing them; an unreachable
|
|
17425
17455
|
// catalog reads as empty and the plain split stands.
|
|
17426
|
-
|
|
17456
|
+
serviceSlugs = await composio.connectedServiceSlugs(cfg);
|
|
17457
|
+
requireActiveInternalCapability();
|
|
17458
|
+
currentSender = store.bot(internalCapability.botId);
|
|
17459
|
+
if (!currentSender || currentSender.composio === false || !composio.configured(cfg))
|
|
17460
|
+
return json(res, 403, { error: "connected apps are no longer enabled for this bot" });
|
|
17427
17461
|
const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
|
|
17428
17462
|
if (!verdict.allowed) {
|
|
17429
17463
|
for (const denial of verdict.denials) {
|
|
@@ -17463,9 +17497,174 @@ const handleRequest = async (req, res) => {
|
|
|
17463
17497
|
rule: verdict.rule,
|
|
17464
17498
|
});
|
|
17465
17499
|
}
|
|
17466
|
-
|
|
17467
|
-
|
|
17468
|
-
|
|
17500
|
+
// ── the outbound gate ──
|
|
17501
|
+
// A tools/call that sends something (shared/outbound.ts) is its own
|
|
17502
|
+
// confirmation in every approval mode, Full included: it waits on a
|
|
17503
|
+
// card, or spends the bot's daily allowance. Everything else relays
|
|
17504
|
+
// untouched. A refusal is an ordinary tool error so the bot reads
|
|
17505
|
+
// it and carries on, rather than a transport failure it retries.
|
|
17506
|
+
const rpc = body && typeof body === "object" && !Array.isArray(body)
|
|
17507
|
+
? body
|
|
17508
|
+
: null;
|
|
17509
|
+
const rpcParams = rpc?.params && typeof rpc.params === "object" && !Array.isArray(rpc.params)
|
|
17510
|
+
? rpc.params
|
|
17511
|
+
: null;
|
|
17512
|
+
// The app tools this call would actually run, seen through Composio's
|
|
17513
|
+
// meta tools (a multi-execute list, workbench code), and the ones
|
|
17514
|
+
// among them that send.
|
|
17515
|
+
const outboundCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
|
|
17516
|
+
? outboundCallsIn(rpcParams.name, rpcParams.arguments)
|
|
17517
|
+
: [];
|
|
17518
|
+
const outboundTool = outboundCalls.length ? outboundCalls[0].slug : null;
|
|
17519
|
+
const refuseOutbound = (text) => {
|
|
17520
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
17521
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc?.id ?? null, result: { content: [{ type: "text", text }], isError: true } }));
|
|
17522
|
+
};
|
|
17523
|
+
// ── connection scopes ──
|
|
17524
|
+
// Checked first: an app this bot may not touch is refused outright,
|
|
17525
|
+
// never turned into a question for the person.
|
|
17526
|
+
const connectorCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
|
|
17527
|
+
? connectorCallsIn(rpcParams.name, rpcParams.arguments)
|
|
17528
|
+
: [];
|
|
17529
|
+
const access = connectorAccessDecision(currentSender.connectorScopes, connectorCalls, serviceSlugs);
|
|
17530
|
+
if (!access.ok) {
|
|
17531
|
+
const described = describeTool(access.slug);
|
|
17532
|
+
const appName = described.app ?? access.toolkit;
|
|
17533
|
+
appendDecision(DATA_DIR, {
|
|
17534
|
+
threadId: internalCapability.threadId, botId: currentSender.id, botName: currentSender.name,
|
|
17535
|
+
tool: access.slug, decision: "auto-denied", source: "connector-scope", rule: `scope:${access.toolkit}:${access.reason}`,
|
|
17536
|
+
});
|
|
17537
|
+
return refuseOutbound(access.reason === "app"
|
|
17538
|
+
? `OpenMausBot did not run this: ${currentSender.name} has not been given access to ${appName}. Ask the user to allow it under the bot's Access settings, and do not retry.`
|
|
17539
|
+
: `OpenMausBot did not run this: ${appName} is read-only for ${currentSender.name}, and ${described.label} would write to it. Ask the user before trying again.`);
|
|
17540
|
+
}
|
|
17541
|
+
const opaque = connectorCalls.some(call => call.slug === "COMPOSIO_PROXY_EXECUTE");
|
|
17542
|
+
if (opaque && currentSender.connectorTools !== undefined)
|
|
17543
|
+
return refuseOutbound("OpenMausBot cannot verify this code against this bot's tool grants. Use a direct app tool.");
|
|
17544
|
+
let outboundApproved = false;
|
|
17545
|
+
if (outboundTool) {
|
|
17546
|
+
const policy = currentSender.outbound ?? DEFAULT_OUTBOUND_POLICY;
|
|
17547
|
+
const threadId = internalCapability.threadId;
|
|
17548
|
+
const { app } = describeTool(outboundTool);
|
|
17549
|
+
const summary = outboundCalls.map((call) => {
|
|
17550
|
+
const described = describeTool(call.slug);
|
|
17551
|
+
const argsText = call.arguments === undefined ? "" : JSON.stringify(call.arguments);
|
|
17552
|
+
return `${described.app ? `${described.app} · ` : ""}${described.label}${argsText ? `\n${argsText.slice(0, 400)}${argsText.length > 400 ? "… [arguments truncated]" : ""}` : ""}`;
|
|
17553
|
+
}).join("\n\n");
|
|
17554
|
+
const capNote = (count) => `OpenMausBot did not send this. ${currentSender.name} has reached its daily limit of ${count} outbound action${count === 1 ? "" : "s"}. Ask the user to raise the limit under the bot's Permissions before trying again.`;
|
|
17555
|
+
if (policy.policy === "allow" && !opaque) {
|
|
17556
|
+
const today = outboundCounts.today(currentSender.id);
|
|
17557
|
+
if (today + outboundCalls.length > policy.dailyCap) {
|
|
17558
|
+
appendDecision(DATA_DIR, {
|
|
17559
|
+
threadId, botId: currentSender.id, botName: currentSender.name,
|
|
17560
|
+
tool: outboundTool, summary, decision: "auto-denied", source: "outbound", rule: `daily-cap:${policy.dailyCap}`,
|
|
17561
|
+
});
|
|
17562
|
+
return refuseOutbound(capNote(policy.dailyCap));
|
|
17563
|
+
}
|
|
17564
|
+
}
|
|
17565
|
+
else {
|
|
17566
|
+
const owner = connectorThread(currentSender.id, threadId);
|
|
17567
|
+
const held = outboundRequests.open({ botId: currentSender.id, threadId, tool: outboundTool, timeoutMs: OUTBOUND_HOLD_MS });
|
|
17568
|
+
const card = store.appendMessage(threadId, {
|
|
17569
|
+
role: "bot",
|
|
17570
|
+
kind: "options",
|
|
17571
|
+
...(owner?.group ? { from: { botId: currentSender.id, name: currentSender.name, color: currentSender.color } } : {}),
|
|
17572
|
+
card: {
|
|
17573
|
+
title: "Send on your behalf?",
|
|
17574
|
+
subtitle: summary,
|
|
17575
|
+
options: ["Allow", "Deny"],
|
|
17576
|
+
requestId: held.requestId,
|
|
17577
|
+
tool: outboundTool,
|
|
17578
|
+
held: HELD_NOTE["approval.held.outbound"],
|
|
17579
|
+
heldCode: "approval.held.outbound",
|
|
17580
|
+
outboundRequest: { tool: outboundTool, app },
|
|
17581
|
+
},
|
|
17582
|
+
});
|
|
17583
|
+
appendDecision(DATA_DIR, {
|
|
17584
|
+
threadId, requestId: held.requestId, botId: currentSender.id, botName: currentSender.name,
|
|
17585
|
+
tool: outboundTool, summary, decision: "card-shown", source: "outbound",
|
|
17586
|
+
});
|
|
17587
|
+
if (owner?.group) {
|
|
17588
|
+
if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId)
|
|
17589
|
+
store.setActivity(currentSender.id, "waiting-on-you");
|
|
17590
|
+
}
|
|
17591
|
+
else if (store.taskByThread(currentSender.id, threadId)?.busy) {
|
|
17592
|
+
store.setTaskActivity(currentSender.id, threadId, "waiting-on-you");
|
|
17593
|
+
}
|
|
17594
|
+
notify(buildNotification("approval", currentSender, threadId, summary, { avatarUrl: currentSender.avatarUrl }));
|
|
17595
|
+
const answer = await held.answer;
|
|
17596
|
+
outboundRequests.forget(held.requestId);
|
|
17597
|
+
if (internalCapabilityIsActive(internalCapability)) {
|
|
17598
|
+
const waiting = store.bot(currentSender.id);
|
|
17599
|
+
if (owner?.group) {
|
|
17600
|
+
if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId && waiting?.activity === "waiting-on-you")
|
|
17601
|
+
store.setActivity(waiting.id, "working");
|
|
17602
|
+
}
|
|
17603
|
+
else if (store.taskByThread(currentSender.id, threadId)?.activity === "waiting-on-you") {
|
|
17604
|
+
store.setTaskActivity(currentSender.id, threadId, "working");
|
|
17605
|
+
}
|
|
17606
|
+
}
|
|
17607
|
+
if (answer !== "allow") {
|
|
17608
|
+
if (answer === "timeout") {
|
|
17609
|
+
const stale = store.messagesFor(threadId).find((message) => message.id === card.id);
|
|
17610
|
+
if (stale?.card && !stale.card.answered) {
|
|
17611
|
+
store.patchMessage(threadId, stale.id, { card: { ...stale.card, answered: "unavailable", dismissed: true } });
|
|
17612
|
+
}
|
|
17613
|
+
}
|
|
17614
|
+
return refuseOutbound(answer === "timeout"
|
|
17615
|
+
? "OpenMausBot did not send this: nobody answered the approval in time. Ask the user before trying again."
|
|
17616
|
+
: "OpenMausBot did not send this: the user declined. Do not retry it; ask the user what they would like instead.");
|
|
17617
|
+
}
|
|
17618
|
+
outboundApproved = true;
|
|
17619
|
+
requireActiveInternalCapability();
|
|
17620
|
+
}
|
|
17621
|
+
}
|
|
17622
|
+
let dispatchRefusal;
|
|
17623
|
+
const refuseDispatch = (text) => {
|
|
17624
|
+
dispatchRefusal = text;
|
|
17625
|
+
throw new Error(text);
|
|
17626
|
+
};
|
|
17627
|
+
let upstream;
|
|
17628
|
+
try {
|
|
17629
|
+
upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
|
|
17630
|
+
? req.headers["mcp-session-id"][0]
|
|
17631
|
+
: req.headers["mcp-session-id"], () => {
|
|
17632
|
+
requireActiveInternalCapability();
|
|
17633
|
+
const fresh = store.bot(internalCapability.botId);
|
|
17634
|
+
if (!fresh || fresh.composio === false || !composio.configured(cfg))
|
|
17635
|
+
refuseDispatch("Connected apps were disabled while this call waited.");
|
|
17636
|
+
const freshAccess = connectorAccessDecision(fresh.connectorScopes, connectorCalls, serviceSlugs);
|
|
17637
|
+
if (!freshAccess.ok || (opaque && fresh.connectorTools !== undefined) ||
|
|
17638
|
+
(call.kind === "tools" && !evaluateConnectorTools(call.names, fresh.connectorTools, serviceSlugs).allowed) ||
|
|
17639
|
+
(call.kind === "unrecognized" && fresh.connectorTools !== undefined)) {
|
|
17640
|
+
refuseDispatch("This bot's connected-app permissions changed. The call was not run.");
|
|
17641
|
+
}
|
|
17642
|
+
if (outboundTool) {
|
|
17643
|
+
const policy = fresh.outbound ?? DEFAULT_OUTBOUND_POLICY;
|
|
17644
|
+
if (policy.policy === "allow" && !opaque) {
|
|
17645
|
+
let reserved;
|
|
17646
|
+
try {
|
|
17647
|
+
reserved = outboundCounts.reserve(fresh.id, outboundCalls.length, policy.dailyCap);
|
|
17648
|
+
}
|
|
17649
|
+
catch {
|
|
17650
|
+
refuseDispatch("The daily allowance could not be saved. Nothing was sent.");
|
|
17651
|
+
}
|
|
17652
|
+
if (reserved === null)
|
|
17653
|
+
refuseDispatch("This bot has reached its daily outbound limit. Nothing was sent.");
|
|
17654
|
+
appendDecision(DATA_DIR, { threadId: internalCapability.threadId, botId: fresh.id, botName: fresh.name,
|
|
17655
|
+
tool: outboundTool, decision: "auto-approved", source: "outbound", rule: `daily-allowance:${reserved}/${policy.dailyCap}` });
|
|
17656
|
+
}
|
|
17657
|
+
else if (!outboundApproved) {
|
|
17658
|
+
refuseDispatch("The outbound policy changed. Ask for approval again before sending.");
|
|
17659
|
+
}
|
|
17660
|
+
}
|
|
17661
|
+
});
|
|
17662
|
+
}
|
|
17663
|
+
catch (error) {
|
|
17664
|
+
if (dispatchRefusal)
|
|
17665
|
+
return refuseOutbound(dispatchRefusal);
|
|
17666
|
+
throw error;
|
|
17667
|
+
}
|
|
17469
17668
|
const headers = {
|
|
17470
17669
|
"content-type": upstream.contentType,
|
|
17471
17670
|
"cache-control": "no-store",
|
|
@@ -17543,6 +17742,16 @@ const handleRequest = async (req, res) => {
|
|
|
17543
17742
|
if (!composio.configured(cfg) || owner.bot.composio === false) {
|
|
17544
17743
|
return json(res, 409, { error: "connected apps are not enabled for this bot" });
|
|
17545
17744
|
}
|
|
17745
|
+
if (owner.bot.connectorScopes) {
|
|
17746
|
+
const outside = slugs.find((slug) => !owner.bot.connectorScopes?.apps[slug]);
|
|
17747
|
+
if (outside) {
|
|
17748
|
+
appendDecision(DATA_DIR, {
|
|
17749
|
+
threadId, botId: owner.bot.id, botName: owner.bot.name,
|
|
17750
|
+
tool: "COMPOSIO_MANAGE_CONNECTIONS", summary: outside, decision: "auto-denied", source: "connector-scope", rule: `scope:${outside}:app`,
|
|
17751
|
+
});
|
|
17752
|
+
return json(res, 403, { error: `${owner.bot.name} has not been given access to ${outside}; allow it under the bot's Access settings first` });
|
|
17753
|
+
}
|
|
17754
|
+
}
|
|
17546
17755
|
const connectionState = await composio.connectionStatus(cfg, slugs).catch(() => ({}));
|
|
17547
17756
|
requireActiveInternalCapability();
|
|
17548
17757
|
const messageIds = [];
|
|
@@ -17964,12 +18173,10 @@ const handleRequest = async (req, res) => {
|
|
|
17964
18173
|
const limit = pageSize(url.searchParams.get("messages"));
|
|
17965
18174
|
if (limit === null)
|
|
17966
18175
|
return json(res, 400, { error: "messages must be a non-negative whole number" });
|
|
17967
|
-
// wireBot(), not publicBot():
|
|
17968
|
-
//
|
|
17969
|
-
//
|
|
17970
|
-
//
|
|
17971
|
-
// that messagePage() does not: wireBot() omits the key entirely for a
|
|
17972
|
-
// bot record carrying no tasks, where publicBot() always sent [].
|
|
18176
|
+
// wireBot(), not publicBot(): this snapshot answers with the page it
|
|
18177
|
+
// was asked for, not the default one. tasks stays explicit, as in
|
|
18178
|
+
// publicBot(): wireBot() omits the key entirely for a bot record
|
|
18179
|
+
// carrying no tasks, where clients expect [].
|
|
17973
18180
|
// A member on a workspace with a restricted bot gets only what they may
|
|
17974
18181
|
// see (bot-visibility.ts); for everyone else these filters keep all.
|
|
17975
18182
|
const shownBots = store.bots.filter((bot) => visible.bot(bot.id));
|
|
@@ -17993,47 +18200,6 @@ const handleRequest = async (req, res) => {
|
|
|
17993
18200
|
});
|
|
17994
18201
|
}
|
|
17995
18202
|
// scrollback: the page before a message the client already holds
|
|
17996
|
-
// #1655: the consent card's answer, per conversation. Explicit and
|
|
17997
|
-
// revocable; a thread allowlisted in config carries the same standing
|
|
17998
|
-
// consent without ever seeing a card.
|
|
17999
|
-
m = path.match(/^\/api\/threads\/([\w-]+)\/cloud-overflow$/);
|
|
18000
|
-
if (m && method === "POST") {
|
|
18001
|
-
const threadId = m[1];
|
|
18002
|
-
if (!store.botByThread(threadId))
|
|
18003
|
-
return json(res, 404, { error: "no such conversation" });
|
|
18004
|
-
const body = await readBody(req);
|
|
18005
|
-
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
18006
|
-
return json(res, 400, { error: "body must be a JSON object" });
|
|
18007
|
-
if (typeof body.consent !== "boolean")
|
|
18008
|
-
return json(res, 400, { error: "consent must be a boolean" });
|
|
18009
|
-
if (body.consent) {
|
|
18010
|
-
// Consent answers the priced card, never a guess: without a prior
|
|
18011
|
-
// offer the rate was never shown, so a seat must not start yet.
|
|
18012
|
-
// The grant binds to the rate the card showed (#1655): a config
|
|
18013
|
-
// change re-offers instead of starting at a new price silently.
|
|
18014
|
-
const offeredRate = cloudOverflowConsent.offeredRate(threadId);
|
|
18015
|
-
if (offeredRate === null)
|
|
18016
|
-
return json(res, 409, { error: "no cloud overflow offer" });
|
|
18017
|
-
cloudOverflowConsent.grant(threadId, offeredRate);
|
|
18018
|
-
}
|
|
18019
|
-
else {
|
|
18020
|
-
cloudOverflowConsent.revoke(threadId);
|
|
18021
|
-
// Revocation must reach the machine (#1655): the ledger alone
|
|
18022
|
-
// would leave this conversation's seat billing to its idle
|
|
18023
|
-
// timeout. A start still waking is caught by its own revoke
|
|
18024
|
-
// check once readyBox resolves.
|
|
18025
|
-
const seatBot = store.botByThread(threadId);
|
|
18026
|
-
const lease = seatBot ? cloudSeatLeases.get(seatBot.id) : undefined;
|
|
18027
|
-
if (seatBot && lease && lease.threadId === threadId) {
|
|
18028
|
-
stopCloudSeat(seatBot.id, lease, "Cloud computer stopped because overflow consent was revoked — billing pauses while it sleeps.");
|
|
18029
|
-
}
|
|
18030
|
-
}
|
|
18031
|
-
store.appendMessage(threadId, {
|
|
18032
|
-
role: "bot", kind: "activity",
|
|
18033
|
-
tool: { name: cloudOverflowConsentText(body.consent, cloudOverflowIdleStopMs(cfg)), ok: true },
|
|
18034
|
-
});
|
|
18035
|
-
return json(res, 200, { consented: body.consent });
|
|
18036
|
-
}
|
|
18037
18203
|
m = path.match(/^\/api\/threads\/([\w-]+)\/messages$/);
|
|
18038
18204
|
if (m && method === "GET") {
|
|
18039
18205
|
const threadId = m[1];
|
|
@@ -18783,8 +18949,11 @@ const handleRequest = async (req, res) => {
|
|
|
18783
18949
|
return json(res, 400, { error: "Import backups alongside your existing bots; project mode is only for templates" });
|
|
18784
18950
|
try {
|
|
18785
18951
|
const imported = importTeamBackup(store, routines, body, await defaultSelection(), { visibility: importVisibility });
|
|
18952
|
+
// A restore writes its transcripts without store events, after the
|
|
18953
|
+
// store's own frames announced each bot and room, so these frames
|
|
18954
|
+
// are what carries the restored pages to other clients.
|
|
18786
18955
|
const bots = imported.bots.map((bot) => publicBot(bot));
|
|
18787
|
-
const groups = imported.groups.map((group) => (
|
|
18956
|
+
const groups = imported.groups.map((group) => groupWithThread(group));
|
|
18788
18957
|
for (const bot of bots)
|
|
18789
18958
|
broadcast({ kind: "bot", bot });
|
|
18790
18959
|
for (const group of groups)
|
|
@@ -18934,9 +19103,7 @@ const handleRequest = async (req, res) => {
|
|
|
18934
19103
|
// Who opened it decides who may answer its cards on a shared workspace.
|
|
18935
19104
|
if (auth.kind === "session")
|
|
18936
19105
|
threadStarters.set(task.threadId, actorKey(auth));
|
|
18937
|
-
|
|
18938
|
-
broadcast({ kind: "group", group: fresh });
|
|
18939
|
-
return json(res, 201, { group: fresh, task });
|
|
19106
|
+
return json(res, 201, { group: groupWithThread(store.group(group.id)), task });
|
|
18940
19107
|
}
|
|
18941
19108
|
m = path.match(/^\/api\/groups\/([\w-]+)\/tasks\/([\w-]+)$/);
|
|
18942
19109
|
if (m && method === "POST") {
|
|
@@ -18961,15 +19128,6 @@ const handleRequest = async (req, res) => {
|
|
|
18961
19128
|
if (!switched)
|
|
18962
19129
|
return json(res, 404, { error: "no such channel task" });
|
|
18963
19130
|
const switchedSettings = { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) };
|
|
18964
|
-
// The frame says the channel moved; it is not a transcript delivery.
|
|
18965
|
-
// A long room's whole history over SSE is the payload `?messages=`
|
|
18966
|
-
// exists to avoid, and it would also overwrite a bounded snapshot on
|
|
18967
|
-
// every other client. One default page is enough to render the switch;
|
|
18968
|
-
// anything earlier pages back through /api/threads/:id/messages.
|
|
18969
|
-
broadcast({
|
|
18970
|
-
kind: "group",
|
|
18971
|
-
group: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
|
|
18972
|
-
});
|
|
18973
19131
|
// "0" predates paging and means settings only — no `messages` key at
|
|
18974
19132
|
// all, which clients tell apart from an empty page. A positive page is
|
|
18975
19133
|
// the transcript a client can actually hold; omitting the parameter
|
|
@@ -18977,9 +19135,7 @@ const handleRequest = async (req, res) => {
|
|
|
18977
19135
|
// materialises it.
|
|
18978
19136
|
const responseGroup = requestedMessages === "0"
|
|
18979
19137
|
? switchedSettings
|
|
18980
|
-
: switchLimit
|
|
18981
|
-
? groupWithThread(switched)
|
|
18982
|
-
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
19138
|
+
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
18983
19139
|
return json(res, 200, { group: responseGroup });
|
|
18984
19140
|
}
|
|
18985
19141
|
if (m && method === "PATCH") {
|
|
@@ -19052,14 +19208,12 @@ const handleRequest = async (req, res) => {
|
|
|
19052
19208
|
lastReply.delete(m[2]);
|
|
19053
19209
|
cancelTeamSetupResumesForThread(m[2]);
|
|
19054
19210
|
const updated = store.deleteGroupTask(group.id, m[2]);
|
|
19055
|
-
if (updated)
|
|
19056
|
-
clearTurnDigestState(m[2]);
|
|
19057
19211
|
if (!updated)
|
|
19058
19212
|
return json(res, 400, { error: "a channel keeps at least one task" });
|
|
19213
|
+
clearTurnDigestState(m[2]);
|
|
19214
|
+
revokeInternalCapabilitiesForThread(m[2]);
|
|
19059
19215
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
19060
|
-
|
|
19061
|
-
broadcast({ kind: "group", group: fresh });
|
|
19062
|
-
return json(res, 200, { group: fresh });
|
|
19216
|
+
return json(res, 200, { group: groupWithThread(updated) });
|
|
19063
19217
|
}
|
|
19064
19218
|
m = path.match(/^\/api\/groups\/([\w-]+)$/);
|
|
19065
19219
|
if (m && method === "PATCH") {
|
|
@@ -20045,6 +20199,43 @@ const handleRequest = async (req, res) => {
|
|
|
20045
20199
|
patch.section = section ?? undefined;
|
|
20046
20200
|
if (body.chiefOfStaff === false)
|
|
20047
20201
|
patch.chiefOfStaff = false;
|
|
20202
|
+
// where a task carries on when this engine is unavailable, in order
|
|
20203
|
+
if (body.fallback !== undefined) {
|
|
20204
|
+
if (!Array.isArray(body.fallback) || body.fallback.length > 5) {
|
|
20205
|
+
return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
|
|
20206
|
+
}
|
|
20207
|
+
const chain = [];
|
|
20208
|
+
for (const entry of body.fallback) {
|
|
20209
|
+
const row = entry && typeof entry === "object" ? entry : null;
|
|
20210
|
+
if (!row || typeof row.instanceId !== "string" || !/^[\w.-]+$/.test(row.instanceId) || typeof row.model !== "string" || !row.model.trim() || row.model.length > 500) {
|
|
20211
|
+
return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
|
|
20212
|
+
}
|
|
20213
|
+
if (!registry.get(row.instanceId))
|
|
20214
|
+
return json(res, 400, { error: `fallback engine "${row.instanceId}" is not available` });
|
|
20215
|
+
if (chain.some((seen) => seen.instanceId === row.instanceId))
|
|
20216
|
+
continue;
|
|
20217
|
+
chain.push({ instanceId: row.instanceId, model: row.model.trim() });
|
|
20218
|
+
}
|
|
20219
|
+
patch.fallback = chain;
|
|
20220
|
+
}
|
|
20221
|
+
// which connected apps this bot may use; null clears back to all of them
|
|
20222
|
+
if (body.connectorScopes !== undefined) {
|
|
20223
|
+
if (body.connectorScopes === null)
|
|
20224
|
+
patch.connectorScopes = undefined;
|
|
20225
|
+
else {
|
|
20226
|
+
const scopes = normalizeConnectorScopes(body.connectorScopes);
|
|
20227
|
+
if (!scopes)
|
|
20228
|
+
return json(res, 400, { error: "connectorScopes must be { apps: { <toolkit>: read | write } } or null" });
|
|
20229
|
+
patch.connectorScopes = scopes;
|
|
20230
|
+
}
|
|
20231
|
+
}
|
|
20232
|
+
// sending on the person's behalf: ask every time, or a daily allowance
|
|
20233
|
+
if (body.outbound !== undefined) {
|
|
20234
|
+
const policy = normalizeOutboundPolicy(body.outbound);
|
|
20235
|
+
if (!policy)
|
|
20236
|
+
return json(res, 400, { error: "outbound must be { policy: ask | allow, dailyCap: 1..1000 }" });
|
|
20237
|
+
patch.outbound = policy;
|
|
20238
|
+
}
|
|
20048
20239
|
// per-bot gate on the workspace's connected apps (Composio)
|
|
20049
20240
|
if (body.composio !== undefined) {
|
|
20050
20241
|
if (typeof body.composio !== "boolean")
|
|
@@ -20356,6 +20547,16 @@ const handleRequest = async (req, res) => {
|
|
|
20356
20547
|
// desktop capability or a paired session, which is what the refusal
|
|
20357
20548
|
// points at.
|
|
20358
20549
|
const loosened = [];
|
|
20550
|
+
// These three fields were validated above before joining the broad patch.
|
|
20551
|
+
const trustPatch = patch;
|
|
20552
|
+
if (body.outbound !== undefined && trustPatch.outbound?.policy === "allow" &&
|
|
20553
|
+
(existingBot?.outbound?.policy !== "allow" || trustPatch.outbound.dailyCap > existingBot.outbound.dailyCap))
|
|
20554
|
+
loosened.push("outbound");
|
|
20555
|
+
if (body.connectorScopes !== undefined && existingBot?.connectorScopes &&
|
|
20556
|
+
(!trustPatch.connectorScopes || Object.entries(trustPatch.connectorScopes.apps).some(([slug, scope]) => !existingBot.connectorScopes.apps[slug] || (scope === "write" && existingBot.connectorScopes.apps[slug] === "read"))))
|
|
20557
|
+
loosened.push("connectorScopes");
|
|
20558
|
+
if (body.fallback !== undefined && trustPatch.fallback?.some(candidate => !existingBot?.fallback?.some(previous => previous.instanceId === candidate.instanceId && previous.model === candidate.model)))
|
|
20559
|
+
loosened.push("fallback");
|
|
20359
20560
|
if (Array.isArray(patch.managedSections) && patch.managedSections.some(section => !(existingBot?.managedSections ?? []).includes(section))) {
|
|
20360
20561
|
loosened.push("managedSections");
|
|
20361
20562
|
}
|
|
@@ -20731,6 +20932,71 @@ const handleRequest = async (req, res) => {
|
|
|
20731
20932
|
// it. That keeps one bot from silently changing every teammate's future
|
|
20732
20933
|
// turns. The section query parameter is required even for General (""),
|
|
20733
20934
|
// so a malformed client cannot accidentally read or replace that brief.
|
|
20935
|
+
// ── team memory: the section's shared people, places, decisions, terms ──
|
|
20936
|
+
// The person's view and their edits. Same section rule as the brief:
|
|
20937
|
+
// the query parameter is required even for General.
|
|
20938
|
+
if (path === "/api/team-memory" && (method === "GET" || method === "POST")) {
|
|
20939
|
+
if (!url.searchParams.has("section"))
|
|
20940
|
+
return json(res, 400, { error: "section is required" });
|
|
20941
|
+
const section = sectionContextKey(url.searchParams.get("section") ?? "");
|
|
20942
|
+
if (section.length > 60)
|
|
20943
|
+
return json(res, 400, { error: "section must be at most 60 characters" });
|
|
20944
|
+
if (method === "GET") {
|
|
20945
|
+
return json(res, 200, { section, label: sectionContextLabel(section), entries: teamMemory.list(section) });
|
|
20946
|
+
}
|
|
20947
|
+
const body = await readBody(req);
|
|
20948
|
+
if (!body || typeof body !== "object" || !TEAM_MEMORY_KINDS.includes(String(body.kind))) {
|
|
20949
|
+
return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
|
|
20950
|
+
}
|
|
20951
|
+
try {
|
|
20952
|
+
const proposed = teamMemory.propose(section, { kind: body.kind, name: String(body.name ?? ""), detail: String(body.detail ?? ""), aliases: Array.isArray(body.aliases) ? body.aliases.map(String) : undefined }, { botId: "", botName: "you", threadId: "", at: Date.now() });
|
|
20953
|
+
// the person's own entry never waits on the person
|
|
20954
|
+
if (proposed.status === "proposed")
|
|
20955
|
+
teamMemory.resolve(section, proposed.entry.id, "accept");
|
|
20956
|
+
return json(res, 201, { entries: teamMemory.list(section) });
|
|
20957
|
+
}
|
|
20958
|
+
catch (error) {
|
|
20959
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
20960
|
+
}
|
|
20961
|
+
}
|
|
20962
|
+
m = path.match(/^\/api\/team-memory\/([\w-]+)$/);
|
|
20963
|
+
if (m && (method === "PATCH" || method === "DELETE")) {
|
|
20964
|
+
if (!url.searchParams.has("section"))
|
|
20965
|
+
return json(res, 400, { error: "section is required" });
|
|
20966
|
+
const section = sectionContextKey(url.searchParams.get("section") ?? "");
|
|
20967
|
+
if (method === "DELETE") {
|
|
20968
|
+
if (!teamMemory.remove(section, m[1]))
|
|
20969
|
+
return json(res, 404, { error: "no such entry" });
|
|
20970
|
+
return json(res, 200, { entries: teamMemory.list(section) });
|
|
20971
|
+
}
|
|
20972
|
+
const body = await readBody(req);
|
|
20973
|
+
if (!body || typeof body !== "object")
|
|
20974
|
+
return json(res, 400, { error: "body must be a JSON object" });
|
|
20975
|
+
if (body.accept === true) {
|
|
20976
|
+
const result = teamMemory.resolve(section, m[1], "accept");
|
|
20977
|
+
if (!result.claimed)
|
|
20978
|
+
return json(res, 404, { error: "no such entry" });
|
|
20979
|
+
return json(res, 200, { entries: teamMemory.list(section) });
|
|
20980
|
+
}
|
|
20981
|
+
const patch = {};
|
|
20982
|
+
if (typeof body.name === "string")
|
|
20983
|
+
patch.name = body.name;
|
|
20984
|
+
if (typeof body.detail === "string")
|
|
20985
|
+
patch.detail = body.detail;
|
|
20986
|
+
if (Array.isArray(body.aliases))
|
|
20987
|
+
patch.aliases = body.aliases.map(String);
|
|
20988
|
+
if (typeof body.kind === "string" && TEAM_MEMORY_KINDS.includes(body.kind))
|
|
20989
|
+
patch.kind = body.kind;
|
|
20990
|
+
try {
|
|
20991
|
+
const updated = teamMemory.update(section, m[1], patch);
|
|
20992
|
+
if (!updated)
|
|
20993
|
+
return json(res, 404, { error: "no such entry" });
|
|
20994
|
+
return json(res, 200, { entry: updated, entries: teamMemory.list(section) });
|
|
20995
|
+
}
|
|
20996
|
+
catch (error) {
|
|
20997
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
20998
|
+
}
|
|
20999
|
+
}
|
|
20734
21000
|
if (path === "/api/section-context" && (method === "GET" || method === "PUT")) {
|
|
20735
21001
|
if (!url.searchParams.has("section"))
|
|
20736
21002
|
return json(res, 400, { error: "section is required" });
|
|
@@ -20911,54 +21177,6 @@ const handleRequest = async (req, res) => {
|
|
|
20911
21177
|
broadcast({ kind: "bot", bot: visible });
|
|
20912
21178
|
return json(res, 200, { bot: visible });
|
|
20913
21179
|
}
|
|
20914
|
-
// ── workspace checkpoints: per-turn shadow-git snapshots ────────────
|
|
20915
|
-
// The list endpoint is the source of truth (turns store nothing), and
|
|
20916
|
-
// `enabled` tells the UI whether snapshots can happen here at all —
|
|
20917
|
-
// false for refused folders (home, Desktop…), a missing git, or a bot
|
|
20918
|
-
// whose checkpoints failed earlier this session.
|
|
20919
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints$/);
|
|
20920
|
-
if (m && method === "GET") {
|
|
20921
|
-
if (!store.bot(m[1]))
|
|
20922
|
-
return json(res, 404, { error: "no such bot" });
|
|
20923
|
-
const cwd = url.searchParams.get("cwd") ?? "";
|
|
20924
|
-
if (!cwd.trim())
|
|
20925
|
-
return json(res, 400, { error: "cwd query parameter required" });
|
|
20926
|
-
return json(res, 200, {
|
|
20927
|
-
checkpoints: await checkpoints.listCheckpoints(m[1], cwd),
|
|
20928
|
-
enabled: await checkpoints.checkpointsEnabled(m[1], cwd),
|
|
20929
|
-
});
|
|
20930
|
-
}
|
|
20931
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints\/restore$/);
|
|
20932
|
-
if (m && method === "POST") {
|
|
20933
|
-
const bot = store.bot(m[1]);
|
|
20934
|
-
if (!bot)
|
|
20935
|
-
return json(res, 404, { error: "no such bot" });
|
|
20936
|
-
const parsed = z
|
|
20937
|
-
.object({ cwd: z.string().min(1), hash: z.string().regex(/^[0-9a-f]{40}$/) })
|
|
20938
|
-
.safeParse(await readBody(req));
|
|
20939
|
-
if (!parsed.success) {
|
|
20940
|
-
return json(res, 400, { error: "cwd (absolute path) and hash (full 40-character checkpoint hash) required" });
|
|
20941
|
-
}
|
|
20942
|
-
// Claim synchronously with the busy check. startTurn checks the same
|
|
20943
|
-
// lease before reserving the bot, so no turn can enter during the
|
|
20944
|
-
// awaited Git operation.
|
|
20945
|
-
if (bot.busy)
|
|
20946
|
-
return json(res, 409, { error: "the bot is working — stop the turn before restoring files" });
|
|
20947
|
-
if (checkpointRestoreLeases.has(bot.id)) {
|
|
20948
|
-
return json(res, 409, { error: "this bot's project files are already being restored" });
|
|
20949
|
-
}
|
|
20950
|
-
checkpointRestoreLeases.add(bot.id);
|
|
20951
|
-
let result;
|
|
20952
|
-
try {
|
|
20953
|
-
result = await checkpoints.restore(bot.id, parsed.data.cwd, parsed.data.hash);
|
|
20954
|
-
}
|
|
20955
|
-
finally {
|
|
20956
|
-
checkpointRestoreLeases.delete(bot.id);
|
|
20957
|
-
}
|
|
20958
|
-
if (!result.ok)
|
|
20959
|
-
return json(res, 400, { error: result.error });
|
|
20960
|
-
return json(res, 200, { ok: true });
|
|
20961
|
-
}
|
|
20962
21180
|
// onboarding/ask cards persist their answered/dismissed state
|
|
20963
21181
|
m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
|
|
20964
21182
|
if (m && method === "PATCH") {
|
|
@@ -21332,6 +21550,8 @@ const handleRequest = async (req, res) => {
|
|
|
21332
21550
|
return json(res, 403, { error: refusal });
|
|
21333
21551
|
}
|
|
21334
21552
|
return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
|
|
21553
|
+
if (resolveAndSendTrust(res, bot.threadId, String(body.requestId), behavior))
|
|
21554
|
+
return;
|
|
21335
21555
|
if (await resolveAndSendTeamSetup(res, {
|
|
21336
21556
|
botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
|
|
21337
21557
|
}, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
|
|
@@ -21360,14 +21580,6 @@ const handleRequest = async (req, res) => {
|
|
|
21360
21580
|
behavior,
|
|
21361
21581
|
}))
|
|
21362
21582
|
return;
|
|
21363
|
-
if (resolveAndSendTightening(res, {
|
|
21364
|
-
botId: bot.id,
|
|
21365
|
-
botName: bot.name,
|
|
21366
|
-
threadId: bot.threadId,
|
|
21367
|
-
requestId: String(body.requestId),
|
|
21368
|
-
behavior,
|
|
21369
|
-
}))
|
|
21370
|
-
return;
|
|
21371
21583
|
if (sendSkillResolution(res, resolveSkillRequest({
|
|
21372
21584
|
botId: bot.id,
|
|
21373
21585
|
botName: bot.name,
|
|
@@ -21434,6 +21646,8 @@ const handleRequest = async (req, res) => {
|
|
|
21434
21646
|
return json(res, 403, { error: refusal });
|
|
21435
21647
|
}
|
|
21436
21648
|
return await answeringCardAs(auth, threadId, requestId, async () => {
|
|
21649
|
+
if (resolveAndSendTrust(res, threadId, requestId, behavior))
|
|
21650
|
+
return;
|
|
21437
21651
|
const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
|
|
21438
21652
|
if (skillCard?.card?.skillRequest) {
|
|
21439
21653
|
const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
@@ -21506,21 +21720,6 @@ const handleRequest = async (req, res) => {
|
|
|
21506
21720
|
}))
|
|
21507
21721
|
return;
|
|
21508
21722
|
}
|
|
21509
|
-
const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
|
|
21510
|
-
if (tighteningCard?.card?.tighteningRequest) {
|
|
21511
|
-
const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
21512
|
-
if (!tighteningBotId)
|
|
21513
|
-
return json(res, 400, { error: "this tightening request has no valid owner" });
|
|
21514
|
-
const tighteningOwner = store.bot(tighteningBotId);
|
|
21515
|
-
if (resolveAndSendTightening(res, {
|
|
21516
|
-
botId: tighteningBotId,
|
|
21517
|
-
botName: tighteningOwner?.name,
|
|
21518
|
-
threadId,
|
|
21519
|
-
requestId,
|
|
21520
|
-
behavior,
|
|
21521
|
-
}))
|
|
21522
|
-
return;
|
|
21523
|
-
}
|
|
21524
21723
|
// peer-approval intercept (see /api/bots/:id/respond above). A peer card
|
|
21525
21724
|
// belongs to the bus rather than to a speaker, so resolve it before we go
|
|
21526
21725
|
// looking for one — a room between turns has no speaker to find.
|
|
@@ -21643,15 +21842,8 @@ const handleRequest = async (req, res) => {
|
|
|
21643
21842
|
return json(res, 200, { ok: true });
|
|
21644
21843
|
}
|
|
21645
21844
|
// ── tasks: a bot's separate contexts ────────────────────────────────
|
|
21646
|
-
//
|
|
21647
|
-
//
|
|
21648
|
-
// the client showing the previous task's conversation.
|
|
21649
|
-
const botWithThread = (bot) => ({
|
|
21650
|
-
...wireBot(bot),
|
|
21651
|
-
messages: store.messagesFor(bot.threadId),
|
|
21652
|
-
activeLeafId: store.activeLeaf(bot.threadId),
|
|
21653
|
-
tasks: store.tasks(bot.id).map(wireTask),
|
|
21654
|
-
});
|
|
21845
|
+
// Replies carry the open thread's newest page: a create or delete can
|
|
21846
|
+
// change which transcript is live. The frames come from store.onChange.
|
|
21655
21847
|
// Folders organize one bot's threads; they never own settings,
|
|
21656
21848
|
// transcripts or working directories. The project wire names stay stable.
|
|
21657
21849
|
m = path.match(/^\/api\/bots\/([\w-]+)\/projects\/order$/);
|
|
@@ -21668,7 +21860,7 @@ const handleRequest = async (req, res) => {
|
|
|
21668
21860
|
const projects = store.reorderProjects(bot.id, body.projectIds);
|
|
21669
21861
|
if (!projects)
|
|
21670
21862
|
return json(res, 400, { error: "projectIds must include each of this bot's folders exactly once" });
|
|
21671
|
-
return json(res, 200, { projects, bot:
|
|
21863
|
+
return json(res, 200, { projects, bot: publicBot(bot) });
|
|
21672
21864
|
}
|
|
21673
21865
|
m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
|
|
21674
21866
|
if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
|
|
@@ -21679,7 +21871,7 @@ const handleRequest = async (req, res) => {
|
|
|
21679
21871
|
return json(res, 404, { error: "no such folder" });
|
|
21680
21872
|
if (method === "DELETE") {
|
|
21681
21873
|
const updated = store.deleteProject(bot.id, m[2]);
|
|
21682
|
-
return json(res, 200, { bot:
|
|
21874
|
+
return json(res, 200, { bot: publicBot(updated) });
|
|
21683
21875
|
}
|
|
21684
21876
|
const body = await readBody(req);
|
|
21685
21877
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
@@ -21702,7 +21894,7 @@ const handleRequest = async (req, res) => {
|
|
|
21702
21894
|
const project = method === "POST"
|
|
21703
21895
|
? store.createProject(bot.id, patch.name, patch.emoji)
|
|
21704
21896
|
: store.patchProject(bot.id, m[2], patch);
|
|
21705
|
-
return json(res, method === "POST" ? 201 : 200, { project, bot:
|
|
21897
|
+
return json(res, method === "POST" ? 201 : 200, { project, bot: publicBot(bot) });
|
|
21706
21898
|
}
|
|
21707
21899
|
m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
|
|
21708
21900
|
if (m && method === "POST") {
|
|
@@ -21737,9 +21929,7 @@ const handleRequest = async (req, res) => {
|
|
|
21737
21929
|
// Who opened it decides who may answer its cards on a shared workspace.
|
|
21738
21930
|
if (auth.kind === "session")
|
|
21739
21931
|
threadStarters.set(task.threadId, actorKey(auth));
|
|
21740
|
-
|
|
21741
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
21742
|
-
return json(res, 201, { bot: fresh, task: wireTask(task) });
|
|
21932
|
+
return json(res, 201, { bot: publicBot(store.bot(bot.id)), task: wireTask(task) });
|
|
21743
21933
|
}
|
|
21744
21934
|
m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)$/);
|
|
21745
21935
|
if (m && method === "POST") {
|
|
@@ -21758,18 +21948,13 @@ const handleRequest = async (req, res) => {
|
|
|
21758
21948
|
if (!switched)
|
|
21759
21949
|
return json(res, 404, { error: "no such task" });
|
|
21760
21950
|
const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) };
|
|
21761
|
-
// Bounded for the same reason as the channel switch above.
|
|
21762
|
-
broadcast({
|
|
21763
|
-
kind: "bot",
|
|
21764
|
-
bot: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
|
|
21765
|
-
});
|
|
21766
21951
|
// "0" is settings only, a positive page is a bounded transcript, and no
|
|
21767
21952
|
// parameter is the whole thread — the one branch that materialises it.
|
|
21953
|
+
// Search results land through that branch (src/lib/focus-message.ts):
|
|
21954
|
+
// the hit can be older than one page.
|
|
21768
21955
|
const responseBot = requestedMessages === "0"
|
|
21769
21956
|
? switchedSettings
|
|
21770
|
-
: switchLimit
|
|
21771
|
-
? botWithThread(switched)
|
|
21772
|
-
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
21957
|
+
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
21773
21958
|
return json(res, 200, { bot: responseBot });
|
|
21774
21959
|
}
|
|
21775
21960
|
if (m && method === "PATCH") {
|
|
@@ -21844,9 +22029,7 @@ const handleRequest = async (req, res) => {
|
|
|
21844
22029
|
const task = store.refreshTaskPermissions(profile.id, m[2]);
|
|
21845
22030
|
if (!task)
|
|
21846
22031
|
return json(res, 404, { error: "no such task" });
|
|
21847
|
-
|
|
21848
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
21849
|
-
return json(res, 200, { task: wireTask(task), bot: fresh });
|
|
22032
|
+
return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(profile.id)) });
|
|
21850
22033
|
}
|
|
21851
22034
|
const patch = {};
|
|
21852
22035
|
if (body.projectId !== undefined) {
|
|
@@ -21952,9 +22135,7 @@ const handleRequest = async (req, res) => {
|
|
|
21952
22135
|
const task = patch.modelSelection
|
|
21953
22136
|
? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
|
|
21954
22137
|
: store.patchTask(m[1], m[2], patch);
|
|
21955
|
-
|
|
21956
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
21957
|
-
return json(res, 200, { task: wireTask(task), bot: fresh });
|
|
22138
|
+
return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(m[1])) });
|
|
21958
22139
|
}
|
|
21959
22140
|
if (m && method === "DELETE") {
|
|
21960
22141
|
const bot = store.bot(m[1]);
|
|
@@ -21977,12 +22158,11 @@ const handleRequest = async (req, res) => {
|
|
|
21977
22158
|
if (!updated)
|
|
21978
22159
|
return json(res, 404, { error: "no such task" });
|
|
21979
22160
|
clearTurnDigestState(m[2]);
|
|
22161
|
+
revokeInternalCapabilitiesForThread(m[2]);
|
|
21980
22162
|
handoffs.forget(m[2]);
|
|
21981
22163
|
settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
|
|
21982
22164
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
21983
|
-
|
|
21984
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
21985
|
-
return json(res, 200, { bot: fresh });
|
|
22165
|
+
return json(res, 200, { bot: publicBot(updated) });
|
|
21986
22166
|
}
|
|
21987
22167
|
// Regenerate title (#1858): name the thread again from its conversation
|
|
21988
22168
|
// as it is now. The person asked, so the result replaces any title, but
|
|
@@ -22663,6 +22843,34 @@ const handleRequest = async (req, res) => {
|
|
|
22663
22843
|
res.end(decisionsCsv(readDecisionRange(DATA_DIR, range)));
|
|
22664
22844
|
return;
|
|
22665
22845
|
}
|
|
22846
|
+
// ── a bot's activity: what it did, with the outcome ──
|
|
22847
|
+
// Read-only over the same two logs as the inspector and the decision
|
|
22848
|
+
// route above. The bot's own threads (its DM and every task) carry the
|
|
22849
|
+
// tool runs; the decision log carries the bot's requests wherever they
|
|
22850
|
+
// happened, since those rows name the bot.
|
|
22851
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/activity$/);
|
|
22852
|
+
if (m && method === "GET") {
|
|
22853
|
+
const bot = store.bot(m[1]);
|
|
22854
|
+
if (!bot)
|
|
22855
|
+
return json(res, 404, { error: "no such bot" });
|
|
22856
|
+
const rawLimit = url.searchParams.get("limit");
|
|
22857
|
+
const parsedLimit = rawLimit === null ? undefined : Number(rawLimit);
|
|
22858
|
+
if (parsedLimit !== undefined && (!Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
|
|
22859
|
+
return json(res, 400, { error: "limit must be a positive whole number" });
|
|
22860
|
+
}
|
|
22861
|
+
const threadIds = [...new Set([bot.threadId, ...store.tasks(bot.id).map((task) => task.threadId)])];
|
|
22862
|
+
return json(res, 200, {
|
|
22863
|
+
rows: readBotActivity({ dataDir: DATA_DIR, eventsDir: EVENTS_DIR, botId: bot.id, threadIds, limit: parsedLimit ?? 300 }),
|
|
22864
|
+
});
|
|
22865
|
+
}
|
|
22866
|
+
// ── a bot's outbound allowance: the policy, and how much of it is spent ──
|
|
22867
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/outbound$/);
|
|
22868
|
+
if (m && method === "GET") {
|
|
22869
|
+
const bot = store.bot(m[1]);
|
|
22870
|
+
if (!bot)
|
|
22871
|
+
return json(res, 404, { error: "no such bot" });
|
|
22872
|
+
return json(res, 200, { policy: bot.outbound ?? DEFAULT_OUTBOUND_POLICY, today: outboundCounts.today(bot.id) });
|
|
22873
|
+
}
|
|
22666
22874
|
// ── provider instances (model picker) ──
|
|
22667
22875
|
if (method === "GET" && path === "/api/instances") {
|
|
22668
22876
|
// Rescan PATH first: this endpoint is how the app answers "what can I
|
|
@@ -22947,7 +23155,7 @@ const handleRequest = async (req, res) => {
|
|
|
22947
23155
|
catch { /* Allow repairing an unused malformed account. */ }
|
|
22948
23156
|
entry.config = { ...entry.config, configDir: body.configDir };
|
|
22949
23157
|
if (previousDir !== configuredAccountDirectory(entry)) {
|
|
22950
|
-
const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
|
|
23158
|
+
const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || bot.fallback?.some(candidate => candidate.instanceId === instanceId) || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
|
|
22951
23159
|
if (used)
|
|
22952
23160
|
return json(res, 409, { error: "This account is used by bots or conversation history. Add another account and select it for the bot instead." });
|
|
22953
23161
|
assertSeparateClaudeAccount(instances, instanceId, entry);
|
|
@@ -22971,7 +23179,7 @@ const handleRequest = async (req, res) => {
|
|
|
22971
23179
|
if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
|
|
22972
23180
|
return json(res, 400, { error: "Only added Claude accounts can be removed here." });
|
|
22973
23181
|
}
|
|
22974
|
-
if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
|
|
23182
|
+
if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || bot.fallback?.some(candidate => candidate.instanceId === instanceId) || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
|
|
22975
23183
|
busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
|
|
22976
23184
|
return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
|
|
22977
23185
|
}
|
|
@@ -23579,6 +23787,15 @@ const handleRequest = async (req, res) => {
|
|
|
23579
23787
|
if (conflict)
|
|
23580
23788
|
return json(res, 409, { error: conflict });
|
|
23581
23789
|
}
|
|
23790
|
+
// A change names only the OpenCode provider keys it saves or removes;
|
|
23791
|
+
// the rest stay. Merged with no await before the save below, so it
|
|
23792
|
+
// starts from what is saved at that moment.
|
|
23793
|
+
if (patch.opencodeGo?.providerKeys) {
|
|
23794
|
+
const merged = mergeOpenCodeProviderKeys(cfg, patch.opencodeGo.providerKeys);
|
|
23795
|
+
if (!merged.ok)
|
|
23796
|
+
return json(res, 400, { error: merged.error });
|
|
23797
|
+
patch.opencodeGo = { ...patch.opencodeGo, providerKeys: merged.keys };
|
|
23798
|
+
}
|
|
23582
23799
|
const browserCleanupRequests = [];
|
|
23583
23800
|
if (profileControlConflict())
|
|
23584
23801
|
return json(res, 409, { error: "Release browser control before deleting its profile." });
|
|
@@ -24517,6 +24734,8 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
24517
24734
|
// the shutdown deadline), immediately before the process exits, so no new
|
|
24518
24735
|
// server can overlap with a still-mutating old one.
|
|
24519
24736
|
exit: (code) => {
|
|
24737
|
+
// Streamed text the bus is still merging reaches the log and clients.
|
|
24738
|
+
bus.flush();
|
|
24520
24739
|
try {
|
|
24521
24740
|
sessions.close();
|
|
24522
24741
|
}
|
|
@@ -24529,8 +24748,8 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
24529
24748
|
closeMessageDb();
|
|
24530
24749
|
mcpOAuth.dispose();
|
|
24531
24750
|
releaseDataDirLeaseAtExit();
|
|
24532
|
-
//
|
|
24533
|
-
process.exit(
|
|
24751
|
+
// Every launcher in this repo starts the server again on this code (server/restart.ts).
|
|
24752
|
+
process.exit(restartRequested && code === 0 ? RESTART_EXIT_CODE : code);
|
|
24534
24753
|
},
|
|
24535
24754
|
});
|
|
24536
24755
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|