openmausbot 0.1.93 → 0.1.95
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-CKNR3SEW.js → DesktopViewer-BwWpc4RK.js} +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-jsQkIqTn.js} +1 -1
- package/dist/assets/{arc-BBdBuCgB.js → arc-7kei4Hsu.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-CRWDFDMq.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-CTKW_IKs.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-BBTW7-6M.js} +1 -1
- package/dist/assets/channel-DS2NbT5x.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-M_Ox-MkD.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-D2YiN-Ud.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-BmVYJ0f8.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtiLVVnZ.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-DFYSb6J6.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-mknKBzgV.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-DgSYGiOu.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-C7ZvuguH.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-CPqxINpA.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-Cd6xfJaC.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BSXY5qRh.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-D_lhEMj1.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-Da9FrFwk.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-T77jMOXe.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-Dkj96X5E.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-CMi9zlD_.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-CXK6YUfS.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-BE7B1u9Q.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-CCMQphKr.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-iHJ_-tGH.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-DONsCWQ7.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-CU_WOaXk.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-BeYwM04G.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-Bdw9DqEl.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-C9iwq3qm.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-Dx-VWzUg.js} +1 -1
- package/dist/assets/{index-BkYwilkG.js → index-KygHRE3E.js} +1 -1
- package/dist/assets/index-NF7XCHAy.css +1 -0
- package/dist/assets/index-X_5ydXIE.js +610 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-BG7uaEd4.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-D72WAXnM.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-DKH1gjYT.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-3upm9JUT.js} +1 -1
- package/dist/assets/{linear-bq1PTDTW.js → linear-CcR4pc0-.js} +1 -1
- package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-C6yU5FSS.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DOI2xEuv.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BVWJSgLd.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-jtbYezhq.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-g0u8mtPX.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-k8689Wny.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DsJ22PDt.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CneWBRgG.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-DWsFCWap.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-Bvf0YE3S.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-Bxb3Iei8.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.js → swimlanes-2SLR337P-DLkk7W4A.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BKpQmzrT.js → timeline-definition-EJHVYXUP-KR9bIHYC.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-DfsOrj8t.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-DRSTzG3h.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-DtEdhPgT.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.js → xychartDiagram-PMCCYNJV-B-XhTxjG.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +3 -1
- package/dist-server/companion/src/routes.js +60 -0
- package/dist-server/container-mcp.js +94 -77
- package/dist-server/drivers/agents-proxy.js +96 -39
- package/dist-server/drivers/pi-mcp-extension.ts +245 -299
- package/dist-server/enterprise/server/index.js +3 -1
- package/dist-server/{browser-proxy.js → harness-mcp-proxy.js} +35 -19
- package/dist-server/index.js +18242 -13645
- package/dist-server/local-computer.js +3 -1
- package/dist-server/mcp-gate.js +147 -19
- package/dist-server/mcp-remote-proxy.js +437 -0
- package/dist-server/openmausbot.js +9618 -10058
- package/dist-server/pair-cli.js +9618 -10058
- package/dist-server/permission-proxy.js +65 -1
- package/dist-server/proxy-paths.js +3 -1
- package/dist-server/server/account-signin.js +0 -2
- package/dist-server/server/activity.js +201 -0
- package/dist-server/server/auto-approve.js +39 -11
- 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/bot-package.js +1 -19
- package/dist-server/server/browser-live.js +14 -5
- package/dist-server/server/browser-runtime.js +7 -2
- package/dist-server/server/channel-queue.js +4 -1
- package/dist-server/server/chief-of-staff.js +2 -3
- package/dist-server/server/cli.js +32 -12
- 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.js +0 -1
- package/dist-server/server/cloud-lending.js +0 -3
- package/dist-server/server/cloud-move-http.js +9 -3
- package/dist-server/server/cloud-move.js +18 -0
- package/dist-server/server/composio.js +31 -2
- package/dist-server/server/computer-control.js +0 -15
- package/dist-server/server/computer-observation.js +0 -74
- package/dist-server/server/computer-wait.js +33 -13
- package/dist-server/server/config.js +73 -11
- package/dist-server/server/container-computer.js +7 -19
- package/dist-server/server/contracts.js +9 -0
- package/dist-server/server/control-client.js +4 -10
- package/dist-server/server/decision-log.js +9 -6
- package/dist-server/server/delegations.js +47 -22
- package/dist-server/server/drivers/acp/core.js +64 -13
- package/dist-server/server/drivers/acp/grok.js +208 -3
- package/dist-server/server/drivers/agents-call.js +70 -29
- package/dist-server/server/drivers/agents-catalog.js +34 -12
- package/dist-server/server/drivers/agents-proxy.js +8 -30
- package/dist-server/server/drivers/antigravity.js +1 -4
- package/dist-server/server/drivers/boatagent.js +25 -4
- package/dist-server/server/drivers/cerebras.js +3 -3
- package/dist-server/server/drivers/chat-mcp-tools.js +29 -19
- package/dist-server/server/drivers/claude.js +33 -19
- package/dist-server/server/drivers/codex.js +84 -10
- package/dist-server/server/drivers/openai-chat.js +14 -9
- package/dist-server/server/drivers/pi.js +48 -4
- package/dist-server/server/drivers/retry.js +8 -4
- package/dist-server/server/engine-install.js +1 -5
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/guarded-requests.js +2 -1
- 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 +1929 -507
- package/dist-server/server/lending-memory.js +12 -8
- package/dist-server/server/live-call-controller.js +868 -0
- package/dist-server/server/live-call.js +216 -0
- package/dist-server/server/mcp-gate-config.js +43 -12
- package/dist-server/server/mcp-gate.js +127 -25
- package/dist-server/server/mcp-http.js +46 -8
- package/dist-server/server/mcp-remote-proxy.js +108 -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 +5 -9
- package/dist-server/server/new-bot-defaults.js +3 -0
- 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 +1 -0
- package/dist-server/server/peer-provenance.js +4 -2
- package/dist-server/server/permission-proxy.js +15 -1
- package/dist-server/server/plan-usage.js +797 -0
- package/dist-server/server/proxy-paths.js +3 -1
- package/dist-server/server/redact.js +2 -0
- package/dist-server/server/request-auth.js +3 -2
- package/dist-server/server/room-handoffs.js +5 -13
- package/dist-server/server/routes/live.js +98 -0
- package/dist-server/server/routine-requests.js +1 -1
- package/dist-server/server/routines.js +5 -1
- 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 +195 -3
- package/dist-server/server/skills-library-migration.js +235 -0
- package/dist-server/server/skills.js +66 -3
- package/dist-server/server/steer-queue.js +13 -1
- package/dist-server/server/store.js +41 -5
- package/dist-server/server/surface.js +74 -23
- package/dist-server/server/system-prompt.js +4 -4
- package/dist-server/server/team-backup.js +2 -1
- package/dist-server/server/team-memory.js +209 -0
- package/dist-server/server/thread-events.js +3 -3
- package/dist-server/server/tunnel.js +20 -1
- package/dist-server/server/turn-log.js +10 -0
- package/dist-server/server/turn-resources.js +124 -0
- package/dist-server/server/vps-computer.js +4 -14
- package/dist-server/server/workspace.js +85 -47
- package/dist-server/shared/activity.js +3 -0
- package/dist-server/shared/ask-question.js +13 -0
- package/dist-server/shared/bot-avatar.js +8 -0
- package/dist-server/shared/call-consent.js +28 -0
- package/dist-server/shared/connector-scopes.js +78 -0
- package/dist-server/shared/live-approval.js +124 -0
- package/dist-server/shared/live-call.js +176 -0
- package/dist-server/shared/outbound.js +128 -0
- package/dist-server/shared/skill-md.js +10 -0
- package/dist-server/shared/team-backup.js +3 -0
- package/dist-server/shared/tool-scope-support.js +25 -0
- package/dist-server/shared/tool-scope.js +136 -0
- package/dist-server/shared/tool-surface.js +3 -2
- package/dist-server/src/lib/thread-refs.js +406 -0
- package/dist-server/vps-container-mcp.js +94 -77
- package/dist-server/workspace-backup.worker.js +89 -100
- package/enterprise/server/index.js +3 -1
- package/package.json +1 -1
- package/dist/assets/channel-BU94VpxE.js +0 -1
- package/dist/assets/index-Biji-bfL.css +0 -1
- package/dist/assets/index-DZlhoES0.js +0 -597
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +0 -8
- 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
|
@@ -30,8 +30,9 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
|
|
|
30
30
|
import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
|
|
31
31
|
import { createApprovalModeSupport } from "./harness-capabilities.js";
|
|
32
32
|
import { escapeAttribute } from "../src/lib/composer-attachments.js";
|
|
33
|
+
import { threadRefUrl } from "../src/lib/thread-refs.js";
|
|
33
34
|
import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
|
|
34
|
-
import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval,
|
|
35
|
+
import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
|
|
35
36
|
import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
|
|
36
37
|
import { updateClaudeCli } from "./claude-update.js";
|
|
37
38
|
import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
|
|
@@ -50,10 +51,14 @@ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
|
|
|
50
51
|
import { groupTurnCwd } from "./room-cwd.js";
|
|
51
52
|
import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
|
|
52
53
|
import * as boat from "./boat.js";
|
|
54
|
+
import { cloudComputerRpc } from "./cloud-computer-tools.js";
|
|
53
55
|
import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
|
|
54
56
|
import { isEffortLevel } from "../shared/wire.js";
|
|
57
|
+
import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequestCard } from "../shared/ask-question.js";
|
|
58
|
+
import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
|
|
55
59
|
import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
|
|
56
60
|
import { boatDeletionSnapshot } from "./boat-delete-journal.js";
|
|
61
|
+
import { liveDecisionRefusal } from "../shared/live-approval.js";
|
|
57
62
|
import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
|
|
58
63
|
import * as composio from "./composio.js";
|
|
59
64
|
import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
|
|
@@ -62,15 +67,15 @@ import { buildRecall } from "./recall.js";
|
|
|
62
67
|
import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
|
|
63
68
|
import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
|
|
64
69
|
import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
|
|
65
|
-
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
66
70
|
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";
|
|
67
71
|
import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
|
|
68
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, } from "./config.js";
|
|
72
|
+
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, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, 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, } from "./config.js";
|
|
69
73
|
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
70
74
|
import { ComputerControl } from "./computer-control.js";
|
|
71
75
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
72
76
|
import { registerEnginesBinDir } from "./engine-install.js";
|
|
73
77
|
import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
|
|
78
|
+
import { loadPlanUsage, planAccountsFromInstances } from "./plan-usage.js";
|
|
74
79
|
import { GroupUsageReader } from "./group-thread-usage.js";
|
|
75
80
|
import { ledgerCost } from "./model-prices.js";
|
|
76
81
|
import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
|
|
@@ -80,7 +85,7 @@ import { entitled } from "./enterprise.js";
|
|
|
80
85
|
import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
|
|
81
86
|
import { describeSpawnFailure, execCli } from "./procs.js";
|
|
82
87
|
import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
|
|
83
|
-
import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
|
|
88
|
+
import { isModelVariant, TurnNotStartedError, newId, usesCloudComputer, } from "./contracts.js";
|
|
84
89
|
import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
|
|
85
90
|
import { recoveryCapabilityError } from "./automatic-recovery.js";
|
|
86
91
|
import { decodeGeneratedImage } from "./generated-image.js";
|
|
@@ -102,7 +107,7 @@ import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAs
|
|
|
102
107
|
import { promptWithReply, transcriptText } from "./replies.js";
|
|
103
108
|
import { admit, DRAIN_COALESCE_MAX_ITEMS } from "./admission.js";
|
|
104
109
|
import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
|
|
105
|
-
import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
|
|
110
|
+
import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, isSteeredMessageQueued, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
|
|
106
111
|
import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, headChannelGroup, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
|
|
107
112
|
import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
|
|
108
113
|
import { EventBus } from "./harness/bus.js";
|
|
@@ -123,7 +128,10 @@ import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sec
|
|
|
123
128
|
import * as tts from "./tts/index.js";
|
|
124
129
|
import { createDecider, deciderIncludedHere, deciderReady, deciderSavePatch, describeDecider } from "./decider/index.js";
|
|
125
130
|
import { decideRoomResponder } from "./decider/room-routing.js";
|
|
131
|
+
import { createLiveSession, liveAttachUrl, LiveSessionError } from "./live-call.js";
|
|
132
|
+
import { LiveCallController, LiveCallSignedOutError } from "./live-call-controller.js";
|
|
126
133
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
134
|
+
import { turnStartLogLine } from "./turn-log.js";
|
|
127
135
|
import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
|
|
128
136
|
import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
|
|
129
137
|
import { extractTurnImages } from "./turn-images.js";
|
|
@@ -135,14 +143,14 @@ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, support
|
|
|
135
143
|
import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
|
|
136
144
|
import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
|
|
137
145
|
import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
|
|
138
|
-
import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
|
|
146
|
+
import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, listBotSkillsWithOrigin, resolveBotSkills, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
|
|
139
147
|
import { fetchSkillFromSource } from "./skill-fetch.js";
|
|
140
148
|
import { pickBotName } from "./names.js";
|
|
141
149
|
import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
|
|
142
150
|
import { expandLearnTurnText, learnSource } from "./skill-learn.js";
|
|
143
151
|
import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
|
|
144
152
|
import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
|
|
145
|
-
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";
|
|
153
|
+
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, } from "./system-prompt.js";
|
|
146
154
|
import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
|
|
147
155
|
import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
|
|
148
156
|
import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
|
|
@@ -175,12 +183,19 @@ import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-libr
|
|
|
175
183
|
import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageSummary, parsePackageDocument, renderBotPackageMarkdown } from "./bot-package.js";
|
|
176
184
|
import { createTeamManifest, parseTeamManifest } from "./team-manifest.js";
|
|
177
185
|
import { readThreadEvents } from "./thread-events.js";
|
|
186
|
+
import { TeamMemory, TEAM_MEMORY_KINDS } from "./team-memory.js";
|
|
187
|
+
import { describeTool, readBotActivity } from "./activity.js";
|
|
188
|
+
import { OutboundCounts } from "./outbound-counts.js";
|
|
189
|
+
import { OutboundRequestService } from "./outbound-requests.js";
|
|
190
|
+
import { DEFAULT_OUTBOUND_POLICY, connectorCallsIn, normalizeOutboundPolicy, outboundCallsIn } from "../shared/outbound.js";
|
|
191
|
+
import { connectorAccessDecision, describeConnectorScopes, normalizeConnectorScopes } from "../shared/connector-scopes.js";
|
|
178
192
|
import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
|
|
179
193
|
import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
|
|
180
194
|
import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
|
|
181
195
|
import { WebhookManager } from "./webhooks.js";
|
|
182
196
|
import { SPAWNED_PROXIES } from "./proxy-paths.js";
|
|
183
|
-
import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.js";
|
|
197
|
+
import { installLibrarySkill, listLibrarySkills, loadBundledSkills, loadUserSkills, mergeSkills, readLibrarySkillFile, readSkillLibraryIndex, renderSkillInstructions, selectBundledSkills, setLibrarySkillReviewState, } from "./skill-library.js";
|
|
198
|
+
import { runSkillsLibraryBootSweep } from "./skills-library-migration.js";
|
|
184
199
|
import { installedPlaybookInstructions } from "./installed-playbooks.js";
|
|
185
200
|
import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
|
|
186
201
|
import { importPackageDocument, importTeamManifest, PackageImportError } from "./package-import.js";
|
|
@@ -192,10 +207,10 @@ import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
|
|
|
192
207
|
import { shouldMountLocalComputer } from "./local-routing.js";
|
|
193
208
|
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
194
209
|
import { startAutoVmClaim } from "./auto-vm-claims.js";
|
|
195
|
-
import { computerFreeAfterText,
|
|
210
|
+
import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
|
|
196
211
|
import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
|
|
197
212
|
import { modelContextWindow } from "./model-context-window.js";
|
|
198
|
-
import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
213
|
+
import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
199
214
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
200
215
|
import { createGracefulShutdown } from "./graceful-shutdown.js";
|
|
201
216
|
import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
|
|
@@ -227,6 +242,7 @@ import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.j
|
|
|
227
242
|
import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
|
|
228
243
|
import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
|
|
229
244
|
import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
|
|
245
|
+
import { createLiveRoutes } from "./routes/live.js";
|
|
230
246
|
const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
|
|
231
247
|
const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
|
|
232
248
|
// Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
|
|
@@ -282,10 +298,12 @@ const workspaceMaintenance = new WorkspaceBackupMaintenance();
|
|
|
282
298
|
// Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
|
|
283
299
|
// for this server, the paired sessions, and the cookie the served UI uses.
|
|
284
300
|
const ENVIRONMENT_ID = loadEnvironmentId(DATA_DIR);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
301
|
+
// Every stream frame and request rechecks email sessions against this list,
|
|
302
|
+
// so config.json is parsed again only when the file changes.
|
|
303
|
+
const signInAllowList = cacheUntilConfigChanges((config) => ({
|
|
304
|
+
admins: parseAllowList(config.signIn?.admins?.join(",")),
|
|
305
|
+
members: parseAllowList(config.signIn?.members?.join(",")),
|
|
306
|
+
}));
|
|
289
307
|
const sessions = new SessionRegistry({
|
|
290
308
|
file: join(DATA_DIR, "sessions.json"),
|
|
291
309
|
emailScopesSnapshot: () => {
|
|
@@ -930,6 +948,9 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
|
|
|
930
948
|
? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
|
|
931
949
|
: null;
|
|
932
950
|
}
|
|
951
|
+
if (!auth.scopes.includes("admin") && store.messagesFor(threadId).some(message => message.card?.requestId === requestId && message.card.teamMemoryRequest)) {
|
|
952
|
+
return "Only a workspace admin can review shared team memory.";
|
|
953
|
+
}
|
|
933
954
|
// A Cloud home is one person's: a device paired with chat-only access (a
|
|
934
955
|
// guest) may read along but never answer, or its words would reach the
|
|
935
956
|
// owner's turn, and through it a lent Mac (docs/cloud-pro.md).
|
|
@@ -993,9 +1014,10 @@ function cloudCardAnswerer(card) {
|
|
|
993
1014
|
return card.requestId ? cloudCardAnswersInFlight.get(card.requestId) : undefined;
|
|
994
1015
|
}
|
|
995
1016
|
/** Answer a card as `auth`: the decision rows written meanwhile name the
|
|
996
|
-
* answerer, and a card this answer settled records who settled it
|
|
997
|
-
*
|
|
998
|
-
|
|
1017
|
+
* answerer, and a card this answer settled records who settled it (and
|
|
1018
|
+
* `via: "call"` when it was decided by voice on a Live call). A card that
|
|
1019
|
+
* was already settled keeps whatever it said. */
|
|
1020
|
+
async function answeringCardAs(auth, threadId, requestId, work, via) {
|
|
999
1021
|
const open = (() => {
|
|
1000
1022
|
const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
|
|
1001
1023
|
return Boolean(card && !card.answered && !card.dismissed && !card.expired);
|
|
@@ -1003,13 +1025,13 @@ async function answeringCardAs(auth, threadId, requestId, work) {
|
|
|
1003
1025
|
if (CLOUD_HOME && open)
|
|
1004
1026
|
cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
|
|
1005
1027
|
try {
|
|
1006
|
-
await withDecisionActor(decisionActorFor(auth), work);
|
|
1028
|
+
await withDecisionActor(decisionActorFor(auth), work, via);
|
|
1007
1029
|
}
|
|
1008
1030
|
finally {
|
|
1009
1031
|
const message = open ? store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId) : undefined;
|
|
1010
1032
|
const card = message?.card;
|
|
1011
1033
|
if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
|
|
1012
|
-
store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
|
|
1034
|
+
store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: { ...cardAnswererFor(auth), ...(via ? { via } : {}) } } });
|
|
1013
1035
|
}
|
|
1014
1036
|
if (CLOUD_HOME && open)
|
|
1015
1037
|
cloudCardAnswersInFlight.delete(requestId);
|
|
@@ -1519,6 +1541,25 @@ function resyncOpenCodeProviderKeys() {
|
|
|
1519
1541
|
await registry.load(providerConfigs(), decorateHostedProvider);
|
|
1520
1542
|
const bundledSkills = loadBundledSkills();
|
|
1521
1543
|
const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
|
|
1544
|
+
// Skills library (features.skillsLibrary). While the flag is off both
|
|
1545
|
+
// helpers reduce exactly to today's per-bot calls; assignments recorded on
|
|
1546
|
+
// bot records stay inert until it is on.
|
|
1547
|
+
const listBotSkills = (bot) => skillsLibraryEnabled(cfg) ? resolveBotSkills(bot.id, bot.assignedSkills) : listSkills(bot.id);
|
|
1548
|
+
const readBotSkillFile = (bot, name) => {
|
|
1549
|
+
const own = readSkillFile(bot.id, name);
|
|
1550
|
+
if (own !== null || !skillsLibraryEnabled(cfg))
|
|
1551
|
+
return own;
|
|
1552
|
+
// A private listing whose stored bytes no longer verify must not fall
|
|
1553
|
+
// through to a same-named library skill: private wins collisions, so a
|
|
1554
|
+
// broken private file stays unreadable instead of exporting another
|
|
1555
|
+
// bot's library instructions under this bot's name.
|
|
1556
|
+
if (listSkills(bot.id).some((skill) => skill.name === name))
|
|
1557
|
+
return null;
|
|
1558
|
+
// The library fallback is by assignment only, never by name alone.
|
|
1559
|
+
if (!bot.assignedSkills?.includes(name))
|
|
1560
|
+
return null;
|
|
1561
|
+
return readLibrarySkillFile(name);
|
|
1562
|
+
};
|
|
1522
1563
|
// SAFETY: Electron's utility-process runtime is the only environment that
|
|
1523
1564
|
// supplies parentPort; plain Node intentionally leaves it absent.
|
|
1524
1565
|
const utilityParentPort = process.parentPort;
|
|
@@ -1975,7 +2016,20 @@ function hooksIntegration(botId, threadId, generation) {
|
|
|
1975
2016
|
}
|
|
1976
2017
|
class DirectTurnSetupCancelled extends Error {
|
|
1977
2018
|
}
|
|
1978
|
-
|
|
2019
|
+
/** The computer wait ceiling fired (ADR-2, #1651). The turn parks — settled
|
|
2020
|
+
* with a registered resume — instead of failing; the carried resource names
|
|
2021
|
+
* the seat the resume must wait to free. */
|
|
2022
|
+
class ComputerWaitParked extends Error {
|
|
2023
|
+
resource;
|
|
2024
|
+
/** The parked turn's generation: the drain distinguishes the turn's own
|
|
2025
|
+
* settle window (keep, retry later) from a newer turn that superseded it
|
|
2026
|
+
* (delete). */
|
|
2027
|
+
generation;
|
|
2028
|
+
constructor(message, resource, generation) {
|
|
2029
|
+
super(message);
|
|
2030
|
+
this.resource = resource;
|
|
2031
|
+
this.generation = generation;
|
|
2032
|
+
}
|
|
1979
2033
|
}
|
|
1980
2034
|
const directTurnDispatchClaims = new Map();
|
|
1981
2035
|
const directTurnGenerationByThread = new Map();
|
|
@@ -2019,6 +2073,12 @@ const turnResources = new TurnResources();
|
|
|
2019
2073
|
const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
|
|
2020
2074
|
const turnResourceOwners = new Map();
|
|
2021
2075
|
const turnComputerResources = new Map();
|
|
2076
|
+
/** Screen-touching computer calls that started before their turn's computer
|
|
2077
|
+
* claim was bound (#1653 over the lazy attach of #1361): the start event
|
|
2078
|
+
* finds no claim to fence, so it waits here and bindTurnComputer carries it
|
|
2079
|
+
* into the fresh claim, keeping the quiet window from releasing a seat
|
|
2080
|
+
* under a call the desktop is already executing. */
|
|
2081
|
+
const pendingComputerCallStarts = new Map();
|
|
2022
2082
|
const teamComputerTurns = new Map();
|
|
2023
2083
|
const settlingResourceOwners = new Map();
|
|
2024
2084
|
/** Idle release for computer claims (#1653), disabled until the feature
|
|
@@ -2028,10 +2088,11 @@ const settlingResourceOwners = new Map();
|
|
|
2028
2088
|
* yields once another turn holds the seat. The durable VM profile keeps
|
|
2029
2089
|
* its login state, so that re-claim is cheap. Read per claim: a flag
|
|
2030
2090
|
* change applies to new claims, never to a seat held under the old
|
|
2031
|
-
* setting.
|
|
2091
|
+
* setting. computer:phone is excluded: it is the #1663 exclusivity lock,
|
|
2092
|
+
* claimed once per turn with no activity signal to renew it. */
|
|
2032
2093
|
const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
|
|
2033
2094
|
function computerClaimIdlePolicy(resource) {
|
|
2034
|
-
return resource.startsWith("computer:") && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
|
|
2095
|
+
return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
|
|
2035
2096
|
}
|
|
2036
2097
|
/** #1655: the consent ledger for local-wait cloud overflow, and the live
|
|
2037
2098
|
* cloud seats it has started. One seat per bot — it is the bot's own Box,
|
|
@@ -2191,11 +2252,17 @@ function releaseTurnResources(owner) {
|
|
|
2191
2252
|
turnResourceOwners.delete(owner.threadId);
|
|
2192
2253
|
if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
|
|
2193
2254
|
turnComputerResources.delete(owner.threadId);
|
|
2255
|
+
if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
|
|
2256
|
+
pendingComputerCallStarts.delete(owner.threadId);
|
|
2194
2257
|
const teamTurn = teamComputerTurns.get(owner.threadId);
|
|
2195
2258
|
if (teamTurn?.owner.generation === owner.generation) {
|
|
2196
2259
|
stopScreenPoller(teamTurn.botId, owner.threadId);
|
|
2197
2260
|
teamComputerTurns.delete(owner.threadId);
|
|
2198
2261
|
}
|
|
2262
|
+
// A freed seat is the parked-resume drain's trigger (#1651). Deferred past
|
|
2263
|
+
// this synchronous settle so the drain reads post-cleanup state.
|
|
2264
|
+
if (pendingComputerResumes.size > 0)
|
|
2265
|
+
queueMicrotask(drainComputerResumes);
|
|
2199
2266
|
}
|
|
2200
2267
|
async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
2201
2268
|
// Every computer claim passes here: a kind the organisation disallows, or a
|
|
@@ -2210,7 +2277,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2210
2277
|
// bot running a titled thread, or a room. Read once, when the wait begins.
|
|
2211
2278
|
let holder;
|
|
2212
2279
|
let holderThreadRef;
|
|
2213
|
-
const deadline = Date.now() +
|
|
2280
|
+
const deadline = Date.now() + COMPUTER_WAIT_MAX_MS;
|
|
2214
2281
|
// The wait is history the transcript keeps (#1647): the waiting chip stays
|
|
2215
2282
|
// exactly as written, a resolution line is appended beside it, and the
|
|
2216
2283
|
// turn.wait_* events carry the same facts for the inspector log.
|
|
@@ -2236,15 +2303,18 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2236
2303
|
return;
|
|
2237
2304
|
waitEnded = true;
|
|
2238
2305
|
const waitedMs = waitedSince === undefined ? 0 : Date.now() - waitedSince;
|
|
2306
|
+
// Acquisition is the only wait that completes: it is the sample the
|
|
2307
|
+
// per-resource estimate is built from (#1652).
|
|
2308
|
+
if (outcome === "acquired")
|
|
2309
|
+
turnResources.noteWait(resource, waitedMs);
|
|
2239
2310
|
const text = outcome === "acquired"
|
|
2240
2311
|
? computerFreeAfterText(holder, waitedMs)
|
|
2241
|
-
: outcome === "
|
|
2242
|
-
?
|
|
2312
|
+
: outcome === "parked"
|
|
2313
|
+
? computerParkedText(holder, COMPUTER_WAIT_MAX_MS)
|
|
2243
2314
|
: computerStoppedWaitingText(holder, waitedMs);
|
|
2244
2315
|
store.appendMessage(owner.threadId, {
|
|
2245
2316
|
role: "bot", kind: "activity",
|
|
2246
|
-
|
|
2247
|
-
tool: { name: text, ok: outcome !== "gave_up" },
|
|
2317
|
+
tool: { name: text, ok: true },
|
|
2248
2318
|
...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
|
|
2249
2319
|
});
|
|
2250
2320
|
bus.publish({
|
|
@@ -2263,6 +2333,10 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2263
2333
|
if (!exclusive || claimTurnResource(owner, resource))
|
|
2264
2334
|
break;
|
|
2265
2335
|
if (!waitingMessage) {
|
|
2336
|
+
// Arrival in the waitlist is the chip's timestamp: the position it
|
|
2337
|
+
// reads is where this turn sits the moment it starts waiting (#1652).
|
|
2338
|
+
const position = turnResources.startWaiting(resource, owner);
|
|
2339
|
+
const estimateMs = turnResources.waitEstimateMs(resource);
|
|
2266
2340
|
const blocker = turnResources.blocker(resource, owner);
|
|
2267
2341
|
const holderBot = blocker && store.botByThread(blocker.threadId);
|
|
2268
2342
|
const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
|
|
@@ -2275,7 +2349,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2275
2349
|
: undefined;
|
|
2276
2350
|
waitingMessage = store.appendMessage(owner.threadId, {
|
|
2277
2351
|
role: "bot", kind: "activity",
|
|
2278
|
-
tool: { name: computerWaitingText(holder) },
|
|
2352
|
+
tool: { name: computerWaitingText(holder, { position, estimateMs }) },
|
|
2279
2353
|
...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
|
|
2280
2354
|
});
|
|
2281
2355
|
waitedSince = Date.now();
|
|
@@ -2283,6 +2357,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2283
2357
|
...waitEventBase(),
|
|
2284
2358
|
type: "turn.wait_started",
|
|
2285
2359
|
resource,
|
|
2360
|
+
position,
|
|
2286
2361
|
...(holder ? { holder } : {}),
|
|
2287
2362
|
});
|
|
2288
2363
|
// #1655: the wait is where an overflow choice gets made. The card
|
|
@@ -2312,8 +2387,12 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2312
2387
|
}
|
|
2313
2388
|
sweepIdleCloudSeats();
|
|
2314
2389
|
if (Date.now() >= deadline) {
|
|
2315
|
-
|
|
2316
|
-
|
|
2390
|
+
// ADR-2 (#1651): the ceiling parks the turn — settled, with a
|
|
2391
|
+
// registered resume — instead of failing it. The owner mark lets the
|
|
2392
|
+
// live lazy-claim rejection path park the same way.
|
|
2393
|
+
endWait("parked");
|
|
2394
|
+
owner.computerParkedOn = resource;
|
|
2395
|
+
throw new ComputerWaitParked(computerParkedText(holder, COMPUTER_WAIT_MAX_MS), resource, owner.generation);
|
|
2317
2396
|
}
|
|
2318
2397
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2319
2398
|
}
|
|
@@ -2322,9 +2401,21 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2322
2401
|
// Never patch the waiting chip over: the queue position this turn held
|
|
2323
2402
|
// stays visible beside what the wait came to.
|
|
2324
2403
|
endWait(active() && turnResources.owns(resource, owner) ? "acquired" : "stopped");
|
|
2404
|
+
// Whatever the wait came to, this turn is no longer waiting: an owner
|
|
2405
|
+
// left at the front would block the grant for everyone behind it.
|
|
2406
|
+
turnResources.stopWaiting(resource, owner);
|
|
2325
2407
|
}
|
|
2326
2408
|
turnResourceOwners.set(owner.threadId, owner);
|
|
2327
2409
|
turnComputerResources.set(owner.threadId, { owner, resource });
|
|
2410
|
+
// A screen call that started before this bind (#1653) fenced nothing
|
|
2411
|
+
// because no claim existed yet: carry its pending start into the fresh
|
|
2412
|
+
// claim so the idle window still defers to the running call.
|
|
2413
|
+
const pendingStarts = pendingComputerCallStarts.get(owner.threadId);
|
|
2414
|
+
if (pendingStarts?.generation === owner.generation && pendingStarts.count > 0) {
|
|
2415
|
+
pendingComputerCallStarts.delete(owner.threadId);
|
|
2416
|
+
for (let i = 0; i < pendingStarts.count; i += 1)
|
|
2417
|
+
turnResources.beginComputerCall(resource, owner);
|
|
2418
|
+
}
|
|
2328
2419
|
}
|
|
2329
2420
|
function botForThread(botId, threadId) {
|
|
2330
2421
|
return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
|
|
@@ -2413,7 +2504,13 @@ function requestedTaskBot(botId, rawThreadId) {
|
|
|
2413
2504
|
throw Object.assign(new Error("no such task"), { status: 404 });
|
|
2414
2505
|
return task;
|
|
2415
2506
|
}
|
|
2416
|
-
async function interruptDirectThread(botId, threadId) {
|
|
2507
|
+
async function interruptDirectThread(botId, threadId, options) {
|
|
2508
|
+
// A parked turn has settled its provider but still owns a future resume.
|
|
2509
|
+
// Stop cancels that work too. The parking teardown itself preserves it.
|
|
2510
|
+
if (!options?.preserveComputerResume && pendingComputerResumes.delete(threadId)
|
|
2511
|
+
&& store.taskByThread(botId, threadId)?.activity === "parked.computer") {
|
|
2512
|
+
store.setTaskActivity(botId, threadId, "idle");
|
|
2513
|
+
}
|
|
2417
2514
|
const requestOwner = directRequestOwners.get(threadId);
|
|
2418
2515
|
if (requestOwner) {
|
|
2419
2516
|
requestOwner.stopped = true;
|
|
@@ -2455,7 +2552,7 @@ function noteTeammatesLeftRunning(botId, threadId, running) {
|
|
|
2455
2552
|
}
|
|
2456
2553
|
}
|
|
2457
2554
|
async function interruptAllDirectThreads(botId) {
|
|
2458
|
-
const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId) || roomHandoffs.activeDirect(task.threadId));
|
|
2555
|
+
const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId) || roomHandoffs.activeDirect(task.threadId) || pendingComputerResumes.has(task.threadId));
|
|
2459
2556
|
// Revoke every sibling before yielding to any provider teardown.
|
|
2460
2557
|
for (const task of threads) {
|
|
2461
2558
|
cancelDirectTurnDispatch(botId, task.threadId);
|
|
@@ -2638,8 +2735,8 @@ async function browserIntegration(botId, profile, turn) {
|
|
|
2638
2735
|
const token = mintInternalCapability({ botId, ...turn, browserSession: session,
|
|
2639
2736
|
kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
|
|
2640
2737
|
return { profile: partitionId, session, spec, integration: {
|
|
2641
|
-
command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
|
|
2642
|
-
...AGENTS_NODE_FLAG,
|
|
2738
|
+
command: process.execPath, args: [SPAWNED_PROXIES.harnessMcp, "browser"], env: {
|
|
2739
|
+
...AGENTS_NODE_FLAG, OMB_MCP_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
|
|
2643
2740
|
},
|
|
2644
2741
|
} };
|
|
2645
2742
|
}
|
|
@@ -2740,8 +2837,10 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
|
|
|
2740
2837
|
}).strict(),
|
|
2741
2838
|
...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown(), forBotId: z.unknown().optional() }).strict()),
|
|
2742
2839
|
]);
|
|
2743
|
-
/** The loopback endpoint a bot's computer proxy polls before acting.
|
|
2744
|
-
|
|
2840
|
+
/** The loopback endpoint a bot's computer proxy polls before acting. With a
|
|
2841
|
+
* Boat id, the same turn-scoped token also serves that Boat's tools at
|
|
2842
|
+
* /api/internal/computer/mcp, and is revoked when the turn ends. */
|
|
2843
|
+
function controlIntegration(botId, threadId, generation, localVmTarget, boxId) {
|
|
2745
2844
|
return {
|
|
2746
2845
|
url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
|
|
2747
2846
|
token: mintInternalCapability({
|
|
@@ -2751,6 +2850,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
|
|
|
2751
2850
|
depth: 0,
|
|
2752
2851
|
kind: "computer",
|
|
2753
2852
|
...(localVmTarget ? { localVmTarget } : {}),
|
|
2853
|
+
...(boxId ? { boxId } : {}),
|
|
2754
2854
|
...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
|
|
2755
2855
|
skillAuthoring: false,
|
|
2756
2856
|
createdBots: 0,
|
|
@@ -2937,7 +3037,7 @@ function checkedExportSkillNames(value, bots) {
|
|
|
2937
3037
|
return { ok: false, error: "skillIds must not contain duplicates" };
|
|
2938
3038
|
if (names.length > BOT_PACKAGE_MAX_SKILLS)
|
|
2939
3039
|
return { ok: false, error: `skillIds must contain at most ${BOT_PACKAGE_MAX_SKILLS} names` };
|
|
2940
|
-
const available = new Set(bots.flatMap((bot) =>
|
|
3040
|
+
const available = new Set(bots.flatMap((bot) => listBotSkills(bot).map((skill) => skill.name)));
|
|
2941
3041
|
const unknown = names.find((name) => !available.has(name));
|
|
2942
3042
|
if (unknown)
|
|
2943
3043
|
return { ok: false, error: `skillIds contains unknown imported skill "${unknown}"` };
|
|
@@ -2950,10 +3050,10 @@ function collectExportSkills(bots, names) {
|
|
|
2950
3050
|
return byBot;
|
|
2951
3051
|
for (const bot of bots) {
|
|
2952
3052
|
const assigned = [];
|
|
2953
|
-
for (const listing of
|
|
3053
|
+
for (const listing of listBotSkills(bot)) {
|
|
2954
3054
|
if (!selected.has(listing.name))
|
|
2955
3055
|
continue;
|
|
2956
|
-
const instructions =
|
|
3056
|
+
const instructions = readBotSkillFile(bot, listing.name);
|
|
2957
3057
|
if (instructions === null) {
|
|
2958
3058
|
throw new Error(`Skill "${listing.name}" changed or is unavailable and cannot be exported safely`);
|
|
2959
3059
|
}
|
|
@@ -2981,7 +3081,7 @@ function collectExportSkills(bots, names) {
|
|
|
2981
3081
|
* skill choices from it, so a refused choice can always be changed. */
|
|
2982
3082
|
function collectTeamSkills(bots, selection) {
|
|
2983
3083
|
const all = selection === undefined || selection === "all";
|
|
2984
|
-
const available = [...new Set(bots.flatMap((bot) =>
|
|
3084
|
+
const available = [...new Set(bots.flatMap((bot) => listBotSkills(bot).map((skill) => skill.name)))].sort();
|
|
2985
3085
|
if (!all && (!Array.isArray(selection) || selection.some((name) => typeof name !== "string" || !isSkillName(name)))) {
|
|
2986
3086
|
return { ok: false, error: "skills must be \"all\" or a list of skill names", available };
|
|
2987
3087
|
}
|
|
@@ -2993,10 +3093,10 @@ function collectTeamSkills(bots, selection) {
|
|
|
2993
3093
|
const skipped = [];
|
|
2994
3094
|
for (const bot of bots) {
|
|
2995
3095
|
const assigned = [];
|
|
2996
|
-
for (const listing of
|
|
3096
|
+
for (const listing of listBotSkills(bot)) {
|
|
2997
3097
|
if (!chosen.has(listing.name))
|
|
2998
3098
|
continue;
|
|
2999
|
-
const instructions =
|
|
3099
|
+
const instructions = readBotSkillFile(bot, listing.name);
|
|
3000
3100
|
if (instructions === null) {
|
|
3001
3101
|
if (!all)
|
|
3002
3102
|
return { ok: false, error: `Skill "${listing.name}" changed or is unavailable and cannot be shared safely`, available };
|
|
@@ -3133,6 +3233,26 @@ const sendSequencer = new SendSequencer();
|
|
|
3133
3233
|
bootSelection = await defaultSelection();
|
|
3134
3234
|
store.seedIfEmpty();
|
|
3135
3235
|
hostedModels?.reconcile(store);
|
|
3236
|
+
// Skills library boot sweep (features.skillsLibrary): migrate per-bot
|
|
3237
|
+
// copies into the shared library and apply assignments. Flag-off boots
|
|
3238
|
+
// never enter this block, keeping per-bot behavior byte-identical.
|
|
3239
|
+
if (skillsLibraryEnabled(cfg)) {
|
|
3240
|
+
try {
|
|
3241
|
+
const sweep = runSkillsLibraryBootSweep({
|
|
3242
|
+
bots: store.bots,
|
|
3243
|
+
patch: (botId, assignedSkills) => {
|
|
3244
|
+
store.patchBot(botId, { assignedSkills });
|
|
3245
|
+
},
|
|
3246
|
+
});
|
|
3247
|
+
const applied = sweep.outcomes.filter((outcome) => outcome.outcome !== "skipped").length;
|
|
3248
|
+
if (sweep.outcomes.length) {
|
|
3249
|
+
console.log(`[skills-library] boot sweep: ${applied} skill(s) migrated or deduplicated, ${sweep.outcomes.length - applied} skipped`);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
catch (error) {
|
|
3253
|
+
console.error(`[skills-library] boot sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3136
3256
|
// A committed profile cleanup means both its config deletion and bot-reference
|
|
3137
3257
|
// cleanup were intended to be durable. Reconcile stale secondary references
|
|
3138
3258
|
// before Electron can ACK and remove the journal: a crash between those writes
|
|
@@ -3174,7 +3294,7 @@ const wireTask = (task) => {
|
|
|
3174
3294
|
return asleep ? { ...coordinated, snoozedUntil } : coordinated;
|
|
3175
3295
|
};
|
|
3176
3296
|
const wireBot = (bot) => {
|
|
3177
|
-
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
|
|
3297
|
+
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
|
|
3178
3298
|
// An elevated selection is inert until the desktop confirms its exact
|
|
3179
3299
|
// private reply. Every ordinary client sees the effective Ask state during
|
|
3180
3300
|
// that two-phase window, never a grant that may still roll back.
|
|
@@ -3184,10 +3304,19 @@ const wireBot = (bot) => {
|
|
|
3184
3304
|
return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
|
|
3185
3305
|
avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
|
|
3186
3306
|
};
|
|
3307
|
+
function toolScopeForTurn(botId) {
|
|
3308
|
+
const bot = store.bot(botId);
|
|
3309
|
+
if (!bot)
|
|
3310
|
+
throw Object.assign(new Error("no such bot"), { status: 404 });
|
|
3311
|
+
const parsed = parseToolScope(bot.toolScope);
|
|
3312
|
+
if (!parsed.ok)
|
|
3313
|
+
throw Object.assign(new Error("This bot's tool selection is invalid. Open Access settings and save a valid selection before starting a turn."), { status: 409, code: "tool_scope" });
|
|
3314
|
+
return parsed.scope;
|
|
3315
|
+
}
|
|
3187
3316
|
/** The correlated private response carries the requested value so Electron
|
|
3188
3317
|
* can validate it before sending the confirmation that makes it effective. */
|
|
3189
3318
|
const wireTrustedApprovalBot = (bot) => {
|
|
3190
|
-
const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
|
|
3319
|
+
const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
|
|
3191
3320
|
return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
|
|
3192
3321
|
};
|
|
3193
3322
|
/** A settings-based preview, not a receipt of a dispatched turn. No
|
|
@@ -3203,7 +3332,7 @@ function previewSystemPrompt(bot) {
|
|
|
3203
3332
|
]
|
|
3204
3333
|
.filter(Boolean)
|
|
3205
3334
|
.join(" ");
|
|
3206
|
-
const instance =
|
|
3335
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
3207
3336
|
const caps = instance?.adapter.capabilities;
|
|
3208
3337
|
const teamComputer = inheritedTeamComputer(bot);
|
|
3209
3338
|
const previewComputer = teamComputer ? "cloud" : bot.computer;
|
|
@@ -3211,12 +3340,11 @@ function previewSystemPrompt(bot) {
|
|
|
3211
3340
|
kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
|
|
3212
3341
|
? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
|
|
3213
3342
|
driverKind: instance?.driverKind,
|
|
3214
|
-
cloudComputerMcp: caps?.cloudComputerMcp,
|
|
3215
3343
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
3216
3344
|
});
|
|
3217
3345
|
const peers = reachablePeers(store.bots, bot);
|
|
3218
3346
|
const coordination = bot.chiefOfStaff
|
|
3219
|
-
? chiefOfStaffSystemPrompt(bot.id, store.bots, true
|
|
3347
|
+
? chiefOfStaffSystemPrompt(bot.id, store.bots, true)
|
|
3220
3348
|
: peers.length > 0
|
|
3221
3349
|
? peerRosterSystemPrompt(peers)
|
|
3222
3350
|
: "";
|
|
@@ -3252,7 +3380,7 @@ function previewSystemPrompt(bot) {
|
|
|
3252
3380
|
browser: previewPlan.computer === undefined ? false : previewPlan.browser,
|
|
3253
3381
|
}, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
|
|
3254
3382
|
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
|
|
3255
|
-
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
|
|
3383
|
+
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
|
|
3256
3384
|
{ id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
|
|
3257
3385
|
{ id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
3258
3386
|
{ id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
|
|
@@ -3260,8 +3388,9 @@ function previewSystemPrompt(bot) {
|
|
|
3260
3388
|
{ id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
|
|
3261
3389
|
{ id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
|
|
3262
3390
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
3391
|
+
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (agentsMounted ? TEAM_MEMORY_PROMPT : "") },
|
|
3263
3392
|
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
|
|
3264
|
-
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
|
|
3393
|
+
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
|
|
3265
3394
|
]);
|
|
3266
3395
|
const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
|
|
3267
3396
|
return {
|
|
@@ -3326,7 +3455,7 @@ async function botOverview(bot) {
|
|
|
3326
3455
|
webhooks: webhooks.list()
|
|
3327
3456
|
.filter((webhook) => webhook.botId === bot.id)
|
|
3328
3457
|
.map((webhook) => ({ name: webhook.name, enabled: webhook.enabled })),
|
|
3329
|
-
skills:
|
|
3458
|
+
skills: listBotSkills(bot).map((skill) => ({
|
|
3330
3459
|
name: skill.name,
|
|
3331
3460
|
description: skill.description,
|
|
3332
3461
|
enabled: skill.enabled,
|
|
@@ -3345,9 +3474,9 @@ async function botOverview(bot) {
|
|
|
3345
3474
|
* this too, but no provider dispatch or later permission callback relies on
|
|
3346
3475
|
* persistence having been produced exclusively by that route. Delegation
|
|
3347
3476
|
* uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
|
|
3348
|
-
* with one deliberate exception: a Chief of Staff
|
|
3349
|
-
* threads it delegates
|
|
3350
|
-
*
|
|
3477
|
+
* with one deliberate exception: a Chief of Staff's level flows down to the
|
|
3478
|
+
* threads it delegates (delegatedLevel), so the level the person gave the
|
|
3479
|
+
* Chief covers the work the Chief hands out. */
|
|
3351
3480
|
const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
|
|
3352
3481
|
// On a Cloud home a turn a guest drives runs in Ask, whatever the bot's
|
|
3353
3482
|
// own level. Judged by the conversation the turn runs in (a room's, for a
|
|
@@ -3360,51 +3489,62 @@ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId
|
|
|
3360
3489
|
}
|
|
3361
3490
|
return mode;
|
|
3362
3491
|
};
|
|
3363
|
-
/**
|
|
3492
|
+
/** A level belongs to the requesting conversation, not whichever sibling is
|
|
3364
3493
|
* selected in the UI or the bot's default for future conversations. */
|
|
3365
|
-
function
|
|
3494
|
+
function sourceApprovalMode(botId, threadId) {
|
|
3366
3495
|
const owner = connectorThread(botId, threadId);
|
|
3367
3496
|
if (!owner)
|
|
3368
|
-
return
|
|
3497
|
+
return "ask";
|
|
3369
3498
|
const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
|
|
3370
3499
|
// Origin changes Custom to Auto, never Full; no live-turn state is needed.
|
|
3371
|
-
return approvalModeForTurn(bot, false, threadId)
|
|
3500
|
+
return approvalModeForTurn(bot, false, threadId);
|
|
3501
|
+
}
|
|
3502
|
+
function fullAccessForSource(botId, threadId) {
|
|
3503
|
+
return sourceApprovalMode(botId, threadId) === "full";
|
|
3372
3504
|
}
|
|
3373
3505
|
function peerReviewRequired(bot, threadId) {
|
|
3374
3506
|
return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
|
|
3375
3507
|
}
|
|
3376
|
-
/**
|
|
3377
|
-
* Chief
|
|
3378
|
-
*
|
|
3379
|
-
*
|
|
3380
|
-
*
|
|
3381
|
-
*
|
|
3382
|
-
*
|
|
3383
|
-
*
|
|
3384
|
-
*
|
|
3385
|
-
function
|
|
3386
|
-
return
|
|
3508
|
+
/** A Chief of Staff's level flows down its delegation. The person set the
|
|
3509
|
+
* Chief's level so its work runs that way; a teammate the Chief brings in
|
|
3510
|
+
* starting at "Ask for approval" meant switching every such thread by hand,
|
|
3511
|
+
* all day, for the whole team. So a teammate a Chief delegates to starts at
|
|
3512
|
+
* the Chief's level for that work: Auto-accept edits, Approve for me or Full
|
|
3513
|
+
* access (delegatedApprovalMode: never lower than the teammate's own level,
|
|
3514
|
+
* capped at what its engine implements). Only a Chief passes its level on —
|
|
3515
|
+
* an ordinary bot's delegation still uses the recipient's setting. Returns
|
|
3516
|
+
* null to keep the recipient's own level. */
|
|
3517
|
+
function delegatedLevel(from, fromThreadId, target, recipientMode) {
|
|
3518
|
+
return delegatedApprovalMode({
|
|
3387
3519
|
senderIsChief: Boolean(from.chiefOfStaff),
|
|
3388
|
-
|
|
3520
|
+
senderMode: sourceApprovalMode(from.id, fromThreadId),
|
|
3389
3521
|
sameBot: from.id === target.id,
|
|
3522
|
+
recipientMode,
|
|
3390
3523
|
recipientDriverKind: registry.cliTarget(target.modelSelection.instanceId)?.driverKind,
|
|
3391
3524
|
});
|
|
3392
3525
|
}
|
|
3393
|
-
/**
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3526
|
+
/** The teammate's own level for a thread of its own, before any delegation. */
|
|
3527
|
+
function ownThreadLevel(target, threadId) {
|
|
3528
|
+
return approvalModeForTurn(store.projectBotForTask(target.id, threadId) ?? target, false, threadId);
|
|
3529
|
+
}
|
|
3530
|
+
const DELEGATED_LEVEL_NAMES = { edits: "Auto-accept edits", auto: "Approve for me", full: "Full access" };
|
|
3531
|
+
/** Start a thread the Chief opened at the Chief's level, and say so in it
|
|
3532
|
+
* once, so the level the chip shows and the level the turns run at agree,
|
|
3533
|
+
* and the person can see where it came from (and change it there). */
|
|
3534
|
+
function applyDelegatedLevel(from, fromThreadId, target, threadId) {
|
|
3535
|
+
const level = delegatedLevel(from, fromThreadId, target, ownThreadLevel(target, threadId));
|
|
3536
|
+
if (!level || store.taskByThread(target.id, threadId)?.approvalMode === level)
|
|
3398
3537
|
return;
|
|
3399
|
-
store.patchTask(target.id, threadId, { approvalMode:
|
|
3538
|
+
store.patchTask(target.id, threadId, { approvalMode: level, autoApprove: false, alwaysAllow: [] });
|
|
3539
|
+
const name = DELEGATED_LEVEL_NAMES[level] ?? level;
|
|
3400
3540
|
store.appendMessage(threadId, {
|
|
3401
3541
|
role: "bot",
|
|
3402
3542
|
kind: "activity",
|
|
3403
|
-
tool: { name:
|
|
3543
|
+
tool: { name: `${name} — delegated by ${from.name}, a Chief of Staff on ${name}`, ok: true },
|
|
3404
3544
|
});
|
|
3405
3545
|
}
|
|
3406
|
-
/** A room member's level for one turn. Work a
|
|
3407
|
-
*
|
|
3546
|
+
/** A room member's level for one turn. Work a Chief hands out in a room runs
|
|
3547
|
+
* at the Chief's level for that turn: the room thread is shared, so the
|
|
3408
3548
|
* level is not stored on it — it rides the handoff. */
|
|
3409
3549
|
function roomTurnApprovalMode(bot, threadId, orchestration) {
|
|
3410
3550
|
// On a Cloud home a room turn a guest drives runs in Ask (cloudGuestDriven).
|
|
@@ -3413,9 +3553,8 @@ function roomTurnApprovalMode(bot, threadId, orchestration) {
|
|
|
3413
3553
|
const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
|
|
3414
3554
|
const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
|
|
3415
3555
|
const from = source ? store.bot(source.botId) : undefined;
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
|
|
3556
|
+
const own = approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
|
|
3557
|
+
return (from && source && delegatedLevel(from, source.threadId, bot, own)) || own;
|
|
3419
3558
|
}
|
|
3420
3559
|
/** Privileged approval-mode transitions are deliberately absent from the
|
|
3421
3560
|
* loopback HTTP authority model: a bot with shell access can curl that
|
|
@@ -4081,6 +4220,7 @@ function updateChannel(groupId, value) {
|
|
|
4081
4220
|
const channelTaskBlocked = (group) => groupIsWorking(group) ||
|
|
4082
4221
|
store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
|
|
4083
4222
|
message.card?.requestId &&
|
|
4223
|
+
(!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
|
|
4084
4224
|
!message.card.answered &&
|
|
4085
4225
|
!message.card.dismissed && !message.card.expired));
|
|
4086
4226
|
// Recovery can synchronously emit room changes. Load coordination state
|
|
@@ -4153,7 +4293,7 @@ function outstandingAssignmentsPrompt(threadId) {
|
|
|
4153
4293
|
assignment: node.text.slice(0, 1_000),
|
|
4154
4294
|
status: node.status === "queued" ? "waiting for that teammate to be free" : "working on it now",
|
|
4155
4295
|
}));
|
|
4156
|
-
return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not
|
|
4296
|
+
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.`;
|
|
4157
4297
|
}
|
|
4158
4298
|
const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
4159
4299
|
validate: (node, parent) => roomHandoffProblem(node, parent) ??
|
|
@@ -4227,9 +4367,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
4227
4367
|
}
|
|
4228
4368
|
// A successful direct work thread closes after its result is reported:
|
|
4229
4369
|
// folded out of the sidebar, never deleted, and reopened if addressed.
|
|
4230
|
-
// Failed or withheld work stays visible
|
|
4370
|
+
// Failed or withheld work stays visible, and so does a thread whose
|
|
4371
|
+
// next request is already waiting there.
|
|
4231
4372
|
const childTask = store.taskByThread(child.botId, child.threadId);
|
|
4232
|
-
if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
|
|
4373
|
+
if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy && !roomHandoffs.activeDirect(child.threadId)
|
|
4233
4374
|
&& childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
|
|
4234
4375
|
store.setTaskClosedBy(child.botId, child.threadId, { botId: parent.botId, name: store.bot(parent.botId)?.name ?? childTask.openedBy.name, at: Date.now() });
|
|
4235
4376
|
}
|
|
@@ -4617,6 +4758,7 @@ function cancelGroupTurnOperations(groupId, threadId, outcome = {
|
|
|
4617
4758
|
status: "stopped",
|
|
4618
4759
|
detail: "Stopped by you.",
|
|
4619
4760
|
}) {
|
|
4761
|
+
pendingComputerResumes.delete(threadId);
|
|
4620
4762
|
cancelTeamSetupResumesForThread(threadId);
|
|
4621
4763
|
roomHandoffs.cancelRoom(groupId, threadId);
|
|
4622
4764
|
for (const operation of groupTurnOperations.get(groupId) ?? []) {
|
|
@@ -5115,7 +5257,7 @@ always, rememberCommand) {
|
|
|
5115
5257
|
// An answered question keeps its words. `request.resolved` only records
|
|
5116
5258
|
// the behavior, so without this the card reads "answer" forever and the
|
|
5117
5259
|
// person can no longer see what they told the bot.
|
|
5118
|
-
if (outcome
|
|
5260
|
+
if (outcome === "answered" && behavior === "answer" && message && cardMessage) {
|
|
5119
5261
|
const settled = store.messagesFor(threadId).find((m) => m.id === cardMessage.id)?.card;
|
|
5120
5262
|
if (settled) {
|
|
5121
5263
|
store.patchMessage(threadId, cardMessage.id, { card: { ...settled, answeredText: message } });
|
|
@@ -5147,19 +5289,81 @@ always, rememberCommand) {
|
|
|
5147
5289
|
const existing = messageId
|
|
5148
5290
|
? thread.find((m) => m.id === messageId)
|
|
5149
5291
|
: thread.find((m) => m.card?.requestId === requestId);
|
|
5150
|
-
|
|
5292
|
+
const persistentQuestion = isPersistentQuestionCard(existing?.card);
|
|
5293
|
+
if (existing?.card && !existing.card.answered && !persistentQuestion) {
|
|
5151
5294
|
store.patchMessage(threadId, existing.id, { card: { ...existing.card, answered: "unavailable", dismissed: true } });
|
|
5152
5295
|
}
|
|
5153
5296
|
if (messageId)
|
|
5154
5297
|
askMessageByRequest.delete(`${threadId}:${requestId}`);
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5298
|
+
// The card's own answered state gates the message too: a replayed respond
|
|
5299
|
+
// for a card that already settled must not read as a failed delivery —
|
|
5300
|
+
// nothing was lost, the earlier answer already ran.
|
|
5301
|
+
if (!persistentQuestion && !existing?.card?.answered)
|
|
5302
|
+
store.appendMessage(threadId, {
|
|
5303
|
+
role: "bot",
|
|
5304
|
+
kind: "activity",
|
|
5305
|
+
tool: { name: "Couldn't deliver that answer — the request is no longer open, so the action was not run", ok: false },
|
|
5306
|
+
});
|
|
5160
5307
|
}
|
|
5161
5308
|
return outcome;
|
|
5162
5309
|
}
|
|
5310
|
+
/** A decision or answer on a provider/peer card, made on behalf of `auth`
|
|
5311
|
+
* (a Live call answers as the person who started the call). Harness-native
|
|
5312
|
+
* proposals (skill, routine, profile, default model, tightening, team setup)
|
|
5313
|
+
* are not handled here; they are reviewed on screen (liveDecisionRefusal).
|
|
5314
|
+
* Mirrors POST /api/threads/:id/respond. */
|
|
5315
|
+
async function respondToCard(input) {
|
|
5316
|
+
const { auth, threadId, requestId, behavior, message } = input;
|
|
5317
|
+
// A call outlives the request that started it: the session must still be valid.
|
|
5318
|
+
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
5319
|
+
return { ok: false, status: 401, error: "The session that started this call has ended." };
|
|
5320
|
+
}
|
|
5321
|
+
const refusal = cardAnswerRefusal(auth, threadId, requestId, behavior);
|
|
5322
|
+
if (refusal)
|
|
5323
|
+
return { ok: false, status: 403, error: refusal };
|
|
5324
|
+
const bot = store.botByThread(threadId);
|
|
5325
|
+
if (!bot)
|
|
5326
|
+
return { ok: false, status: 404, error: "The chat is gone." };
|
|
5327
|
+
const cardMessage = store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId);
|
|
5328
|
+
const card = cardMessage?.card;
|
|
5329
|
+
// Refused before anything runs: answerRequest would close a proposal it
|
|
5330
|
+
// cannot deliver as "unavailable", and would add a false "not run" line to
|
|
5331
|
+
// a card someone settled on screen a moment ago.
|
|
5332
|
+
const refused = liveDecisionRefusal(card);
|
|
5333
|
+
if (refused)
|
|
5334
|
+
return { ok: false, status: 409, error: refused };
|
|
5335
|
+
let result = { ok: true };
|
|
5336
|
+
await answeringCardAs(auth, threadId, requestId, async () => {
|
|
5337
|
+
// peer-approval intercept, as the route: only a card on this thread
|
|
5338
|
+
if (card && resolvePeerComms(approvalBus, requestId, behavior))
|
|
5339
|
+
return;
|
|
5340
|
+
const outcome = await answerRequest(threadId, botForThread(bot.id, threadId)?.modelSelection.instanceId ?? "", requestId, behavior, message, { id: bot.id, name: bot.name });
|
|
5341
|
+
if (outcome === "unavailable" && isPersistentQuestionCard(card) && behavior === "answer" && cardMessage) {
|
|
5342
|
+
const answer = message?.trim() ?? "";
|
|
5343
|
+
if (!answer) {
|
|
5344
|
+
result = { ok: false, status: 400, error: "A question answer is required." };
|
|
5345
|
+
}
|
|
5346
|
+
else if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
5347
|
+
result = { ok: false, status: 401, error: "The session that started this call has ended." };
|
|
5348
|
+
}
|
|
5349
|
+
else {
|
|
5350
|
+
try {
|
|
5351
|
+
await deliverLateQuestionAnswer(auth, threadId, requestId, answer, cardMessage, bot, "call");
|
|
5352
|
+
}
|
|
5353
|
+
catch (error) {
|
|
5354
|
+
result = { ok: false, status: 409, error: error instanceof Error ? error.message : "The late answer could not be queued." };
|
|
5355
|
+
}
|
|
5356
|
+
}
|
|
5357
|
+
}
|
|
5358
|
+
else if (outcome === "unavailable") {
|
|
5359
|
+
result = { ok: false, status: 409, error: "The request is no longer open." };
|
|
5360
|
+
}
|
|
5361
|
+
else if (behavior === "answer" && outcome !== "answered") {
|
|
5362
|
+
result = { ok: false, status: 409, error: "The provider did not accept that answer. Try an offered option." };
|
|
5363
|
+
}
|
|
5364
|
+
}, "call");
|
|
5365
|
+
return result;
|
|
5366
|
+
}
|
|
5163
5367
|
/** Close every provider-owned approval still open on a thread. Interrupting a
|
|
5164
5368
|
* turn kills the process that raised its questions, so those cards can never
|
|
5165
5369
|
* be answered. Routine proposals are harness-owned and durable, so they stay
|
|
@@ -5177,6 +5381,12 @@ function closeOpenApprovals(threadId) {
|
|
|
5177
5381
|
continue;
|
|
5178
5382
|
if (card.routineRequest || card.skillRequest)
|
|
5179
5383
|
continue;
|
|
5384
|
+
if (isPersistentQuestionCard(card)) {
|
|
5385
|
+
// Keep the durable card; its in-flight adapter mapping cannot survive
|
|
5386
|
+
// turn cleanup, and the response route can resume it from the transcript.
|
|
5387
|
+
askMessageByRequest.delete(`${threadId}:${card.requestId}`);
|
|
5388
|
+
continue;
|
|
5389
|
+
}
|
|
5180
5390
|
store.patchMessage(threadId, message.id, { card: { ...card, answered: "unavailable", dismissed: true } });
|
|
5181
5391
|
askMessageByRequest.delete(`${threadId}:${card.requestId}`);
|
|
5182
5392
|
}
|
|
@@ -5238,6 +5448,9 @@ const ASK_BOT_TIMEOUT_MS = Math.max(5_000, Number(process.env.OMB_ASK_BOT_TIMEOU
|
|
|
5238
5448
|
// free up and reassigns, and a chat round moves on with a chip that says so —
|
|
5239
5449
|
// the wait ends as data, not as a dead room. Tests shrink it.
|
|
5240
5450
|
const GROUP_GOAL_WAIT_MAX_MS = Math.max(1_000, Number(process.env.OMB_GOAL_WAIT_MAX_MS) || 30 * 60_000);
|
|
5451
|
+
// ADR-2 (#1651): the computer wait has its own ceiling, decoupled from the
|
|
5452
|
+
// room cap above, and the deadline parks the turn instead of failing it.
|
|
5453
|
+
const COMPUTER_WAIT_MAX_MS = Math.max(1_000, Number(process.env.OMB_COMPUTER_WAIT_MAX_MS) || 30 * 60_000);
|
|
5241
5454
|
// Reassigning around a busy teammate is bounded too: after this many
|
|
5242
5455
|
// exhausted waits in one run the team is blocked on availability, not stuck.
|
|
5243
5456
|
const GROUP_GOAL_MAX_WAIT_EXHAUSTIONS = 3;
|
|
@@ -5256,8 +5469,8 @@ const watchdog = new TurnWatchdog({
|
|
|
5256
5469
|
const bot = botForThread(turn.botId, turn.threadId);
|
|
5257
5470
|
const routineRun = activeRoutineRunForThread(turn.threadId);
|
|
5258
5471
|
const instance = bot
|
|
5259
|
-
? runningTurnInstance(bot, turn.threadId
|
|
5260
|
-
:
|
|
5472
|
+
? runningTurnInstance(bot, turn.threadId)
|
|
5473
|
+
: runningTurnEngines.get(turn.threadId) ?? null;
|
|
5261
5474
|
void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
|
|
5262
5475
|
const minutes = Math.round(TURN_STALL_MS / 60_000);
|
|
5263
5476
|
if (routineRun?.target === "bot") {
|
|
@@ -5328,6 +5541,7 @@ const watchdog = new TurnWatchdog({
|
|
|
5328
5541
|
// connector and credential continuations.
|
|
5329
5542
|
drainQueuedSends();
|
|
5330
5543
|
drainConnectorResumes();
|
|
5544
|
+
drainComputerResumes();
|
|
5331
5545
|
drainSecretResumes();
|
|
5332
5546
|
drainTeamSetupResumes();
|
|
5333
5547
|
}
|
|
@@ -5990,10 +6204,29 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
|
|
|
5990
6204
|
!turnResources.owns(`computer:box:${machine.id}`, owner))
|
|
5991
6205
|
throw new Error("This computer turn ended while its machine was starting");
|
|
5992
6206
|
return {
|
|
5993
|
-
|
|
6207
|
+
mount: cloudComputerMount(botId, owner, machine.id, remoteAgent),
|
|
5994
6208
|
capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
|
|
5995
6209
|
};
|
|
5996
6210
|
}
|
|
6211
|
+
/** How a turn's engine reaches a cloud computer. The Computer engine runs its
|
|
6212
|
+
* whole turn on the Boat. Every other engine keeps its own model and sign-in
|
|
6213
|
+
* and gets the Boat as one more stdio computer server (harness-mcp-proxy
|
|
6214
|
+
* computer), in the same slot a Local VM or VPS uses: one mechanism on a
|
|
6215
|
+
* desktop, a headless server and an OMB Cloud. The agent process holds only a
|
|
6216
|
+
* turn-scoped capability naming this Boat, never a Boat credential. */
|
|
6217
|
+
function cloudComputerMount(botId, owner, boxId, remoteAgent) {
|
|
6218
|
+
if (remoteAgent)
|
|
6219
|
+
return { computer: { kind: "box", boxId } };
|
|
6220
|
+
const control = controlIntegration(botId, owner.threadId, owner.generation, undefined, boxId);
|
|
6221
|
+
return {
|
|
6222
|
+
localComputer: {
|
|
6223
|
+
command: process.execPath,
|
|
6224
|
+
args: [SPAWNED_PROXIES.harnessMcp, "computer"],
|
|
6225
|
+
env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
|
|
6226
|
+
platform: "linux",
|
|
6227
|
+
},
|
|
6228
|
+
};
|
|
6229
|
+
}
|
|
5997
6230
|
/** "Works on: This computer", strict. One mount for a bot thread and a group
|
|
5998
6231
|
* chat, so the two cannot drift on what they check or how they fail. */
|
|
5999
6232
|
async function mountHostComputer(owner, botId, providerSupportsLocal) {
|
|
@@ -6095,9 +6328,7 @@ async function attachBotBoat(bot, owner, opts) {
|
|
|
6095
6328
|
await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
|
|
6096
6329
|
return {
|
|
6097
6330
|
capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
|
|
6098
|
-
|
|
6099
|
-
? { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(bot.id, owner.threadId, owner.generation) }
|
|
6100
|
-
: null,
|
|
6331
|
+
mount: opts.canMount ? cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent) : null,
|
|
6101
6332
|
};
|
|
6102
6333
|
}
|
|
6103
6334
|
function managedBoatOwners() {
|
|
@@ -6174,17 +6405,6 @@ function turnProvider(bot, runOn, threadId) {
|
|
|
6174
6405
|
return "box";
|
|
6175
6406
|
return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
|
|
6176
6407
|
}
|
|
6177
|
-
/** A driver with a Boat bridge keeps the selected model. Other engines use
|
|
6178
|
-
* Boat's native runner. Start and interrupt must resolve the same owner. */
|
|
6179
|
-
function turnInstance(bot, runOn, threadId) {
|
|
6180
|
-
const selected = registry.get(bot.modelSelection.instanceId);
|
|
6181
|
-
if (selected?.adapter.capabilities.cloudComputerMcp)
|
|
6182
|
-
return selected;
|
|
6183
|
-
const onBoat = turnProvider(bot, runOn, threadId) === "box";
|
|
6184
|
-
return onBoat
|
|
6185
|
-
? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
|
|
6186
|
-
: registry.get(bot.modelSelection.instanceId);
|
|
6187
|
-
}
|
|
6188
6408
|
/** Preview requests carry the selected conversation, not whichever thread
|
|
6189
6409
|
* happens to be the bot's default. A query never grants lifecycle authority. */
|
|
6190
6410
|
function computerPreviewBot(botId, url) {
|
|
@@ -6252,7 +6472,7 @@ async function selectableComputers(bot) {
|
|
|
6252
6472
|
canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
|
|
6253
6473
|
reason = status?.problem ?? reason;
|
|
6254
6474
|
}
|
|
6255
|
-
else if (boat.boatConfigured(cfg) &&
|
|
6475
|
+
else if (boat.boatConfigured(cfg) && caps !== undefined && usesCloudComputer(caps)) {
|
|
6256
6476
|
const status = await boat.boatStatus(cfg, bot.id);
|
|
6257
6477
|
const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
|
|
6258
6478
|
ready = lifecycle === "attach";
|
|
@@ -6331,8 +6551,9 @@ function continueComputerSelection(threadId, generation, succeeded) {
|
|
|
6331
6551
|
/** The engine that dispatched each live turn. A bot's settings may change
|
|
6332
6552
|
* mid-turn; an interrupt or steer must reach the engine actually running. */
|
|
6333
6553
|
const runningTurnEngines = new Map();
|
|
6334
|
-
function runningTurnInstance(bot, threadId
|
|
6335
|
-
|
|
6554
|
+
function runningTurnInstance(bot, threadId) {
|
|
6555
|
+
// A turn always runs on the bot's own engine, wherever it works.
|
|
6556
|
+
return runningTurnEngines.get(threadId) ?? registry.get(bot.modelSelection.instanceId);
|
|
6336
6557
|
}
|
|
6337
6558
|
function providerTransitionForTurn(bot, runOn, threadId) {
|
|
6338
6559
|
const provider = turnProvider(bot, runOn, threadId);
|
|
@@ -6719,8 +6940,23 @@ bus.subscribe((event) => {
|
|
|
6719
6940
|
// they cannot keep a quiet seat held.
|
|
6720
6941
|
if (touches && surface === "computer") {
|
|
6721
6942
|
const computer = turnComputerResources.get(event.threadId);
|
|
6722
|
-
if (computer)
|
|
6943
|
+
if (computer) {
|
|
6944
|
+
// A quiet window cannot expire while this call executes.
|
|
6945
|
+
turnResources.endComputerCall(computer.resource, computer.owner);
|
|
6723
6946
|
turnResources.activity(computer.resource, computer.owner);
|
|
6947
|
+
}
|
|
6948
|
+
else {
|
|
6949
|
+
// The call never forwarded onto a bound desktop, or the lazy
|
|
6950
|
+
// bind never landed: drop its pending start so it cannot
|
|
6951
|
+
// fence a claim taken later (#1653).
|
|
6952
|
+
const pending = pendingComputerCallStarts.get(event.threadId);
|
|
6953
|
+
if (pending) {
|
|
6954
|
+
if (pending.count > 1)
|
|
6955
|
+
pendingComputerCallStarts.set(event.threadId, { ...pending, count: pending.count - 1 });
|
|
6956
|
+
else
|
|
6957
|
+
pendingComputerCallStarts.delete(event.threadId);
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6724
6960
|
// Real cloud-screen work refreshes the #1655 idle stop for
|
|
6725
6961
|
// that seat; the poller's own frames never arrive here, so
|
|
6726
6962
|
// preview traffic cannot keep a paid machine awake.
|
|
@@ -6752,6 +6988,26 @@ bus.subscribe((event) => {
|
|
|
6752
6988
|
});
|
|
6753
6989
|
if (event.itemId)
|
|
6754
6990
|
toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
|
|
6991
|
+
// A screen-touching computer tool opening is an in-flight call on
|
|
6992
|
+
// that turn's claim (#1653): the seat cannot be idle-released out
|
|
6993
|
+
// from under I/O the desktop is still executing. Its completion —
|
|
6994
|
+
// or the turn settling — drops the fence.
|
|
6995
|
+
if (screenTouchingTool(name) && screenSurfaceForTool(name) === "computer") {
|
|
6996
|
+
const computer = turnComputerResources.get(event.threadId);
|
|
6997
|
+
if (computer) {
|
|
6998
|
+
turnResources.beginComputerCall(computer.resource, computer.owner);
|
|
6999
|
+
}
|
|
7000
|
+
else {
|
|
7001
|
+
// A lazy attach (#1361) may not have seated the claim yet: the
|
|
7002
|
+
// gate finishes the bind before the call forwards, so hold the
|
|
7003
|
+
// start here and let bindTurnComputer fence the fresh claim.
|
|
7004
|
+
const generation = activeInternalGenerationByThread.get(event.threadId);
|
|
7005
|
+
if (generation) {
|
|
7006
|
+
const pending = pendingComputerCallStarts.get(event.threadId);
|
|
7007
|
+
pendingComputerCallStarts.set(event.threadId, pending?.generation === generation ? { generation, count: pending.count + 1 } : { generation, count: 1 });
|
|
7008
|
+
}
|
|
7009
|
+
}
|
|
7010
|
+
}
|
|
6755
7011
|
}
|
|
6756
7012
|
break;
|
|
6757
7013
|
case "request.opened": {
|
|
@@ -6872,6 +7128,7 @@ bus.subscribe((event) => {
|
|
|
6872
7128
|
subtitle: event.summary,
|
|
6873
7129
|
options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
|
|
6874
7130
|
requestId: event.requestId,
|
|
7131
|
+
requestType: permission ? "permission" : "question",
|
|
6875
7132
|
tool: permission ? event.tool : undefined,
|
|
6876
7133
|
questionRequest: questions
|
|
6877
7134
|
? { version: 1, questions, ...(event.origin === "output" ? { origin: "output" } : {}) }
|
|
@@ -6939,9 +7196,15 @@ bus.subscribe((event) => {
|
|
|
6939
7196
|
if (messageId) {
|
|
6940
7197
|
const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId);
|
|
6941
7198
|
if (existing?.card && !existing.card.answered) {
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
7199
|
+
if (shouldSettleRequestCard(existing.card, event.source)) {
|
|
7200
|
+
store.patchMessage(event.threadId, messageId, {
|
|
7201
|
+
card: {
|
|
7202
|
+
...existing.card,
|
|
7203
|
+
answered: event.behavior,
|
|
7204
|
+
dismissed: isPersistentQuestionCard(existing.card) ? false : event.source !== "user",
|
|
7205
|
+
},
|
|
7206
|
+
});
|
|
7207
|
+
}
|
|
6945
7208
|
}
|
|
6946
7209
|
if (event.requestId)
|
|
6947
7210
|
askMessageByRequest.delete(`${event.threadId}:${event.requestId}`);
|
|
@@ -7069,9 +7332,13 @@ bus.subscribe((event) => {
|
|
|
7069
7332
|
// interrupted the turn; Claude settles that interrupt as
|
|
7070
7333
|
// exit_before_result, not "interrupted", so this generation's marker
|
|
7071
7334
|
// keeps the same failure from reporting a second incident.
|
|
7335
|
+
// A turn parked at the computer ceiling (#1651) settles itself the
|
|
7336
|
+
// same way: interrupted for a resume, not broken.
|
|
7072
7337
|
const lazyClaimAlreadyReported = event.stopReason === "exit_before_result" &&
|
|
7073
7338
|
turnResourceOwners.get(event.threadId)?.lazyClaimFailureReported === true;
|
|
7074
|
-
|
|
7339
|
+
const computerParked = event.stopReason === "exit_before_result" &&
|
|
7340
|
+
turnResourceOwners.get(event.threadId)?.computerParkedOn !== undefined;
|
|
7341
|
+
if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !computerParked && !routines?.runForThread(event.threadId)) {
|
|
7075
7342
|
const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
7076
7343
|
if (broken)
|
|
7077
7344
|
reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
|
|
@@ -7101,7 +7368,7 @@ bus.subscribe((event) => {
|
|
|
7101
7368
|
if (!isCurrent())
|
|
7102
7369
|
return;
|
|
7103
7370
|
if (store.taskByThread(bot.id, event.threadId)?.activity !== "dead") {
|
|
7104
|
-
store.setTaskActivity(bot.id, event.threadId, "idle");
|
|
7371
|
+
store.setTaskActivity(bot.id, event.threadId, resourceOwner?.computerParkedOn !== undefined ? "parked.computer" : "idle");
|
|
7105
7372
|
}
|
|
7106
7373
|
directTurnBots.delete(event.threadId);
|
|
7107
7374
|
if (continueComputerSelection(event.threadId, generation, event.ok))
|
|
@@ -7112,6 +7379,7 @@ bus.subscribe((event) => {
|
|
|
7112
7379
|
retryDelegationsWaitingOn(bot.id);
|
|
7113
7380
|
drainQueuedSends();
|
|
7114
7381
|
drainConnectorResumes();
|
|
7382
|
+
drainComputerResumes();
|
|
7115
7383
|
drainSecretResumes();
|
|
7116
7384
|
drainTeamSetupResumes();
|
|
7117
7385
|
drainDelegationWakes();
|
|
@@ -7495,7 +7763,7 @@ function markTaskContextExternallyUpdated(bot, threadId) {
|
|
|
7495
7763
|
const owner = task.lastInstanceId;
|
|
7496
7764
|
const record = owner ? task.handedMessages?.[owner] : undefined;
|
|
7497
7765
|
if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
|
|
7498
|
-
|
|
7766
|
+
(botForThread(bot.id, threadId) ?? bot).modelSelection.instanceId === owner &&
|
|
7499
7767
|
registry.get(owner)?.adapter.capabilities.strictResume) {
|
|
7500
7768
|
store.patchTask(bot.id, threadId, { unread: true });
|
|
7501
7769
|
return;
|
|
@@ -7619,7 +7887,7 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
|
|
|
7619
7887
|
const source = watched.sourceBotId
|
|
7620
7888
|
? store.bot(watched.sourceBotId)
|
|
7621
7889
|
: (watched.sourceThreadId ? store.botByThread(watched.sourceThreadId) : undefined);
|
|
7622
|
-
if (source && target && !canReachPeer(source, target)) {
|
|
7890
|
+
if (!watched.oneWay && source && target && !canReachPeer(source, target)) {
|
|
7623
7891
|
ok = false;
|
|
7624
7892
|
reply = "";
|
|
7625
7893
|
failureName = "Result withheld: team or peer access changed while the teammate was working";
|
|
@@ -7637,6 +7905,16 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
|
|
|
7637
7905
|
result: ok ? reply : failureName,
|
|
7638
7906
|
});
|
|
7639
7907
|
}
|
|
7908
|
+
if (watched.oneWay) {
|
|
7909
|
+
if (!ok && store.taskByThread(watched.toBotId, threadId)) {
|
|
7910
|
+
store.appendMessage(threadId, {
|
|
7911
|
+
role: "bot",
|
|
7912
|
+
kind: "activity",
|
|
7913
|
+
tool: { name: `Send failed — ${failureName}`, ok: false, ...(watched.taskId ? { handoffId: watched.taskId } : {}) },
|
|
7914
|
+
});
|
|
7915
|
+
}
|
|
7916
|
+
return true;
|
|
7917
|
+
}
|
|
7640
7918
|
let channel = watched.channelId ? store.group(watched.channelId) : undefined;
|
|
7641
7919
|
let terminalThreadId = watched.sourceThreadId;
|
|
7642
7920
|
if (source && watched.sourceThreadId) {
|
|
@@ -7780,7 +8058,7 @@ bus.subscribe((event) => {
|
|
|
7780
8058
|
/** How a drained delegation becomes a real turn on the target. Shared by
|
|
7781
8059
|
* the settle-time drain and the boot-time drain of what a previous process
|
|
7782
8060
|
* left queued. */
|
|
7783
|
-
const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId) => {
|
|
8061
|
+
const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay) => {
|
|
7784
8062
|
// startTurn REJECTS on an ordinary condition — busy target, deleted bot,
|
|
7785
8063
|
// unavailable provider. Unhandled, that rejection is fatal to the
|
|
7786
8064
|
// harness (Node's default), which in the packaged app kills the server
|
|
@@ -7801,7 +8079,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
|
|
|
7801
8079
|
? { botId: opener.id, name: opener.name, unattended: unattended || undefined }
|
|
7802
8080
|
: undefined;
|
|
7803
8081
|
const text = openedThreadId && opener
|
|
7804
|
-
? withPeerProvenance(rawText, { botName: opener.name, delivery: "start_thread", unattended })
|
|
8082
|
+
? withPeerProvenance(rawText, { botName: opener.name, delivery: oneWay ? "send_to_bot" : "start_thread", unattended })
|
|
7805
8083
|
: rawText;
|
|
7806
8084
|
if (targetThreadId) {
|
|
7807
8085
|
delegationWatch.set(targetThreadId, {
|
|
@@ -7813,6 +8091,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
|
|
|
7813
8091
|
sourceBotId,
|
|
7814
8092
|
routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
|
|
7815
8093
|
startedAtMs: Date.now(),
|
|
8094
|
+
...(oneWay ? { oneWay: true } : {}),
|
|
7816
8095
|
});
|
|
7817
8096
|
}
|
|
7818
8097
|
let failureReported = false;
|
|
@@ -7898,8 +8177,7 @@ bus.subscribe((event) => {
|
|
|
7898
8177
|
// that turn queued to run anyway, minutes later, on an unrelated turn.
|
|
7899
8178
|
if (!event.ok)
|
|
7900
8179
|
discardDelegations(commsBus, event.threadId);
|
|
7901
|
-
|
|
7902
|
-
drainThreadDelegations(event.threadId);
|
|
8180
|
+
drainThreadDelegations(event.threadId);
|
|
7903
8181
|
// A settling bot frees itself as a delegation TARGET too: handoffs that
|
|
7904
8182
|
// found it busy earlier were kept queued — waiting until it's free or the
|
|
7905
8183
|
// 24-hour expiry, not counting retries — on their own source threads, and
|
|
@@ -8020,7 +8298,7 @@ function drainAsideLane() {
|
|
|
8020
8298
|
}
|
|
8021
8299
|
/** Keep a person's words off the transcript until a direct-thread slot is
|
|
8022
8300
|
* available. Reuse the existing cancellable, idempotent composer queue. */
|
|
8023
|
-
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
|
|
8301
|
+
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via) {
|
|
8024
8302
|
const decision = admit("direct", {}, {
|
|
8025
8303
|
// A room turn holds the bot exactly like the sibling opened-thread queue
|
|
8026
8304
|
// below: the drain's own block check waits it out, so the words queue
|
|
@@ -8038,12 +8316,235 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId,
|
|
|
8038
8316
|
prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
|
|
8039
8317
|
sender,
|
|
8040
8318
|
trigger,
|
|
8319
|
+
via,
|
|
8041
8320
|
});
|
|
8042
8321
|
return { ok: true, queued: true, queueId: queued.id, threadId, reason: decision.reason };
|
|
8043
8322
|
}
|
|
8044
|
-
const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
|
|
8323
|
+
const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger, via });
|
|
8045
8324
|
return { ok: true, threadId, message };
|
|
8046
8325
|
}
|
|
8326
|
+
/** Refusal from acceptDirectSend: the route turns it into its HTTP answer. */
|
|
8327
|
+
class DirectSendRefused extends Error {
|
|
8328
|
+
status;
|
|
8329
|
+
body;
|
|
8330
|
+
constructor(status, body) {
|
|
8331
|
+
super(typeof body.error === "string" ? body.error : "send refused");
|
|
8332
|
+
this.status = status;
|
|
8333
|
+
this.body = body;
|
|
8334
|
+
}
|
|
8335
|
+
}
|
|
8336
|
+
/** The refusals a direct send meets before it is sequenced, or null. */
|
|
8337
|
+
function directSendRefusal(botId, threadId) {
|
|
8338
|
+
// The send is acknowledged before the turn starts, so a workspace at its
|
|
8339
|
+
// spend limit is refused here, where the person can see it.
|
|
8340
|
+
try {
|
|
8341
|
+
assertWithinBudget(cfg, DATA_DIR);
|
|
8342
|
+
}
|
|
8343
|
+
catch (error) {
|
|
8344
|
+
return new DirectSendRefused(409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
|
|
8345
|
+
}
|
|
8346
|
+
if (!store.taskByThread(botId, threadId)) {
|
|
8347
|
+
return new DirectSendRefused(409, { error: "the bot switched tasks before it could receive the message" });
|
|
8348
|
+
}
|
|
8349
|
+
return null;
|
|
8350
|
+
}
|
|
8351
|
+
/** The one path a person's direct message takes into a bot turn: spend cap,
|
|
8352
|
+
* idempotency, steer into a running turn, queue, or start. Used by
|
|
8353
|
+
* POST /api/bots/:id/messages and by Live calls (via "call").
|
|
8354
|
+
* `guardedStart` is POST /api/bots/:id/messages/guarded's own start, in
|
|
8355
|
+
* place of steer, queue or start: it checks its preconditions against the
|
|
8356
|
+
* task as it stands, then starts a turn or refuses. */
|
|
8357
|
+
async function acceptDirectSend(input, guardedStart) {
|
|
8358
|
+
const { botId, threadId, text, sendId, replyTo, sender, trigger, via, personPresent } = input;
|
|
8359
|
+
const refused = directSendRefusal(botId, threadId);
|
|
8360
|
+
if (refused)
|
|
8361
|
+
throw refused;
|
|
8362
|
+
return sendSequencer.run(sendId ? `bot:${botId}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
|
|
8363
|
+
if (sendId) {
|
|
8364
|
+
if (cancelledChatFollowup("bot", botId, threadId, sendId)) {
|
|
8365
|
+
throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
|
|
8366
|
+
}
|
|
8367
|
+
const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
|
|
8368
|
+
if (accepted.kind === "conflict") {
|
|
8369
|
+
throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
|
|
8370
|
+
}
|
|
8371
|
+
if (accepted.kind === "match") {
|
|
8372
|
+
const canonical = {
|
|
8373
|
+
ok: true,
|
|
8374
|
+
threadId,
|
|
8375
|
+
message: accepted.message,
|
|
8376
|
+
};
|
|
8377
|
+
return accepted.message.steered
|
|
8378
|
+
? { ...canonical, steered: true }
|
|
8379
|
+
: canonical;
|
|
8380
|
+
}
|
|
8381
|
+
const queued = queuedSteeredMessage(botId, threadId, sendId);
|
|
8382
|
+
if (queued) {
|
|
8383
|
+
if (queued.text !== text || queued.replyToId !== replyTo?.id) {
|
|
8384
|
+
throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
|
|
8385
|
+
}
|
|
8386
|
+
return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
|
|
8387
|
+
}
|
|
8388
|
+
}
|
|
8389
|
+
const currentAtStart = store.projectBotForTask(botId, threadId);
|
|
8390
|
+
if (!currentAtStart)
|
|
8391
|
+
throw Object.assign(new Error("no such bot"), { status: 404 });
|
|
8392
|
+
if (!store.taskByThread(currentAtStart.id, threadId)) {
|
|
8393
|
+
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
8394
|
+
}
|
|
8395
|
+
if (guardedStart)
|
|
8396
|
+
return guardedStart(currentAtStart);
|
|
8397
|
+
// Claude can accept the message inside its live turn. If the write
|
|
8398
|
+
// loses a race with turn settlement, or the engine cannot steer, the
|
|
8399
|
+
// existing server-side queue records it atomically for the next turn.
|
|
8400
|
+
if (currentAtStart.busy) {
|
|
8401
|
+
const instance = runningTurnInstance(currentAtStart, threadId);
|
|
8402
|
+
let steered = "refused";
|
|
8403
|
+
// A live text steer has no image side channel. Keep an attachment
|
|
8404
|
+
// message intact for the next ordinary turn, where central image
|
|
8405
|
+
// admission can hand it to the provider natively.
|
|
8406
|
+
const carriesImages = extractTurnImages(text).images.length > 0;
|
|
8407
|
+
const steerTarget = handoffs.current(threadId);
|
|
8408
|
+
const busyAdmission = admit("direct-busy", {
|
|
8409
|
+
carriesImages,
|
|
8410
|
+
pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
|
|
8411
|
+
engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
|
|
8412
|
+
});
|
|
8413
|
+
// steer was offered only when a live instance could take it;
|
|
8414
|
+
// the second check carries that fact to the type system.
|
|
8415
|
+
if (busyAdmission.action === "steer" && instance?.adapter.steer) {
|
|
8416
|
+
steered = await instance.adapter
|
|
8417
|
+
.steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
|
|
8418
|
+
.catch(() => "indeterminate");
|
|
8419
|
+
}
|
|
8420
|
+
// steer() is awaited adapter work. The turn can settle, the task can
|
|
8421
|
+
// switch, or the whole bot can be deleted before its acknowledgement
|
|
8422
|
+
// arrives. Re-read every ownership invariant before appending even a
|
|
8423
|
+
// successful steer; otherwise that late acknowledgement writes a user
|
|
8424
|
+
// message into a task the bot no longer owns. A conflict leaves the
|
|
8425
|
+
// text in the client's composer/outbox to resend deliberately.
|
|
8426
|
+
const current = store.projectBotForTask(botId, threadId);
|
|
8427
|
+
if (!current)
|
|
8428
|
+
throw Object.assign(new Error("no such bot"), { status: 404 });
|
|
8429
|
+
if (!store.taskByThread(botId, threadId)) {
|
|
8430
|
+
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
8431
|
+
}
|
|
8432
|
+
const delivered = steered !== "refused";
|
|
8433
|
+
if (delivered) {
|
|
8434
|
+
if (steered === "steered" && !current.busy) {
|
|
8435
|
+
throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
|
|
8436
|
+
}
|
|
8437
|
+
// "indeterminate" falls through to the same record: the words
|
|
8438
|
+
// may already be folded into a turn whose acknowledgement was
|
|
8439
|
+
// lost, and handing them back for a resend could run them
|
|
8440
|
+
// twice. Recording them once is the honest outcome.
|
|
8441
|
+
// A person steering a webhook turn is present, and auto mode may
|
|
8442
|
+
// follow them again; words that do not prove a person never lift it.
|
|
8443
|
+
if (personPresent)
|
|
8444
|
+
clearUnattended(threadId);
|
|
8445
|
+
const message = store.appendMessage(threadId, {
|
|
8446
|
+
role: "user",
|
|
8447
|
+
kind: "text",
|
|
8448
|
+
text,
|
|
8449
|
+
replyToId: replyTo?.id,
|
|
8450
|
+
sendId,
|
|
8451
|
+
steered: true,
|
|
8452
|
+
sender,
|
|
8453
|
+
...(via ? { via } : {}),
|
|
8454
|
+
});
|
|
8455
|
+
// Offered to the next turn again unless the person stops this one.
|
|
8456
|
+
handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
|
|
8457
|
+
return { ok: true, steered: true, threadId, message };
|
|
8458
|
+
}
|
|
8459
|
+
if (!current.busy) {
|
|
8460
|
+
return startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via);
|
|
8461
|
+
}
|
|
8462
|
+
const queued = queueSteeredMessage(current.id, threadId, text, {
|
|
8463
|
+
replyToId: replyTo?.id,
|
|
8464
|
+
sendId,
|
|
8465
|
+
prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
|
|
8466
|
+
sender,
|
|
8467
|
+
trigger,
|
|
8468
|
+
via,
|
|
8469
|
+
});
|
|
8470
|
+
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
8471
|
+
}
|
|
8472
|
+
return startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via);
|
|
8473
|
+
});
|
|
8474
|
+
}
|
|
8475
|
+
/** A provider can finish its run before the owner answers. Persist that answer
|
|
8476
|
+
* as a normal reply and run/queue it against the original conversation. */
|
|
8477
|
+
async function deliverLateQuestionAnswer(auth, threadId, requestId, answer, cardMessage, owner, via) {
|
|
8478
|
+
const group = store.groupByThread(threadId);
|
|
8479
|
+
const roomTarget = group
|
|
8480
|
+
? group.dm
|
|
8481
|
+
? group.threadId === threadId
|
|
8482
|
+
: Boolean(store.groupTaskByThread(group.id, threadId))
|
|
8483
|
+
: false;
|
|
8484
|
+
if (group && (!roomTarget || !group.memberIds.includes(owner.id))) {
|
|
8485
|
+
throw Object.assign(new Error("the bot that asked this question is no longer in this conversation"), { status: 409 });
|
|
8486
|
+
}
|
|
8487
|
+
if (!group && !store.taskByThread(owner.id, threadId)) {
|
|
8488
|
+
throw Object.assign(new Error("the question's conversation no longer exists"), { status: 409 });
|
|
8489
|
+
}
|
|
8490
|
+
const text = group ? `@${owner.name}: ${answer}` : answer;
|
|
8491
|
+
const sendId = `late-question-${createHash("sha256").update(`${threadId}:${requestId}`).digest("hex").slice(0, 32)}`;
|
|
8492
|
+
const key = group
|
|
8493
|
+
? `group:${group.id}:${threadId}:${sendId}`
|
|
8494
|
+
: `bot:${owner.id}:${threadId}:${sendId}`;
|
|
8495
|
+
const receipt = await sendSequencer.run(key, sendFingerprint(text, cardMessage.id, group ? "chat" : undefined), async () => {
|
|
8496
|
+
const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, cardMessage.id, group ? "chat" : undefined);
|
|
8497
|
+
if (accepted.kind === "conflict")
|
|
8498
|
+
throw Object.assign(new Error("this question already has a different recorded answer"), { status: 409 });
|
|
8499
|
+
if (accepted.kind === "match")
|
|
8500
|
+
return { queued: false };
|
|
8501
|
+
const queuedDirect = group ? null : queuedSteeredMessage(owner.id, threadId, sendId);
|
|
8502
|
+
if (queuedDirect) {
|
|
8503
|
+
if (queuedDirect.text !== text || queuedDirect.replyToId !== cardMessage.id) {
|
|
8504
|
+
throw Object.assign(new Error("this question already has a different queued answer"), { status: 409 });
|
|
8505
|
+
}
|
|
8506
|
+
return { queued: true };
|
|
8507
|
+
}
|
|
8508
|
+
const queuedRoom = group ? queuedChannelMessage(group.id, threadId, sendId) : null;
|
|
8509
|
+
if (queuedRoom) {
|
|
8510
|
+
if (queuedRoom.text !== text || queuedRoom.replyToId !== cardMessage.id || queuedRoom.mode !== "chat") {
|
|
8511
|
+
throw Object.assign(new Error("this question already has a different queued answer"), { status: 409 });
|
|
8512
|
+
}
|
|
8513
|
+
return { queued: true };
|
|
8514
|
+
}
|
|
8515
|
+
const latestCard = store.messagesFor(threadId).find((message) => message.id === cardMessage.id)?.card;
|
|
8516
|
+
if (!latestCard || latestCard.answered || latestCard.dismissed) {
|
|
8517
|
+
throw Object.assign(new Error("this question has already been answered"), { status: 409 });
|
|
8518
|
+
}
|
|
8519
|
+
assertWithinBudget(cfg, DATA_DIR);
|
|
8520
|
+
const sender = messageSender(auth);
|
|
8521
|
+
const trigger = usageTriggerFor(auth);
|
|
8522
|
+
if (group) {
|
|
8523
|
+
const current = store.group(group.id);
|
|
8524
|
+
if (!current || !roomTarget)
|
|
8525
|
+
throw Object.assign(new Error("the room switched tasks before it could receive the answer"), { status: 409 });
|
|
8526
|
+
if (groupIsWorking(current)) {
|
|
8527
|
+
const queued = queueChannelMessage(current.id, threadId, text, {
|
|
8528
|
+
replyToId: cardMessage.id,
|
|
8529
|
+
sendId,
|
|
8530
|
+
mode: "chat",
|
|
8531
|
+
sender,
|
|
8532
|
+
trigger,
|
|
8533
|
+
});
|
|
8534
|
+
return { queued: true, queueId: queued.id };
|
|
8535
|
+
}
|
|
8536
|
+
startGroupTurn(current.id, text, cardMessage, sendId, "chat", undefined, { threadId, sender, trigger });
|
|
8537
|
+
return { queued: false };
|
|
8538
|
+
}
|
|
8539
|
+
const direct = await startOrQueueDirectMessage(owner.id, threadId, text, cardMessage, sendId, sender, trigger, via);
|
|
8540
|
+
return { queued: "queued" in direct && Boolean(direct.queued) };
|
|
8541
|
+
});
|
|
8542
|
+
const latest = store.messagesFor(threadId).find((message) => message.id === cardMessage.id)?.card;
|
|
8543
|
+
if (latest && !latest.answered && !latest.dismissed) {
|
|
8544
|
+
store.patchMessage(threadId, cardMessage.id, { card: { ...latest, answered: "answer", answeredText: answer, dismissed: false } });
|
|
8545
|
+
}
|
|
8546
|
+
return receipt;
|
|
8547
|
+
}
|
|
8047
8548
|
/** How many start_thread calls one turn may make. Same spirit as the
|
|
8048
8549
|
* create-bot ceiling above: a handful is a plan, more is a fan-out. */
|
|
8049
8550
|
const MAX_THREADS_OPENED_PER_TURN = 5;
|
|
@@ -8320,15 +8821,14 @@ async function startTurn(botId, text, opts) {
|
|
|
8320
8821
|
if (hostedModels && !hostedModels.allows(bot.modelSelection))
|
|
8321
8822
|
throw Object.assign(new Error(hostedModels.error()), { status: 409 });
|
|
8322
8823
|
const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
|
|
8323
|
-
const instance =
|
|
8824
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
8324
8825
|
if (!instance) {
|
|
8325
|
-
throw Object.assign(new Error(
|
|
8326
|
-
? "the Cloud VM runner is unavailable — configure Boat in App Settings"
|
|
8327
|
-
: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
8826
|
+
throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
8328
8827
|
}
|
|
8329
8828
|
const policyRefusal = policyModelRefusal(instance);
|
|
8330
8829
|
if (policyRefusal)
|
|
8331
8830
|
throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
|
|
8831
|
+
const toolScope = toolScopeForTurn(bot.id);
|
|
8332
8832
|
// On a Cloud home a guest's turn never gets a shell or reads outside its
|
|
8333
8833
|
// own folder (docs/cloud-pro.md): an engine that cannot run it that way is
|
|
8334
8834
|
// refused, in one plain line, before anything is recorded.
|
|
@@ -8371,18 +8871,24 @@ async function startTurn(botId, text, opts) {
|
|
|
8371
8871
|
.catch(() => undefined);
|
|
8372
8872
|
}
|
|
8373
8873
|
}
|
|
8374
|
-
|
|
8874
|
+
// Spoken words never reach the log. A direct send says so itself; a
|
|
8875
|
+
// drained queue (or a continuation) carries it on the user lines it runs.
|
|
8876
|
+
const turnLineIds = new Set(opts?.excludeMessageIds ?? []);
|
|
8877
|
+
const spoken = opts?.via === "call" || opts?.userMessage?.via === "call" ||
|
|
8878
|
+
(turnLineIds.size > 0 && store.messagesFor(threadId).some((message) => turnLineIds.has(message.id) && message.via === "call"));
|
|
8879
|
+
console.error(turnStartLogLine({
|
|
8880
|
+
botId, text: resolvedImages.text, images: turnImages.length, depth: commsDepth, card: Boolean(opts?.cardContinuation), spoken,
|
|
8881
|
+
}));
|
|
8375
8882
|
const instanceId = instance.instanceId;
|
|
8376
8883
|
if (providerInstancesChanging.has(instanceId)) {
|
|
8377
8884
|
throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
|
|
8378
8885
|
}
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
const
|
|
8385
|
-
const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
|
|
8886
|
+
// A turn always runs on the bot's own engine, model, effort and variant,
|
|
8887
|
+
// wherever it works: a cloud computer is a tool it mounts, never a reason
|
|
8888
|
+
// to swap the engine (the provider_not_configured incident).
|
|
8889
|
+
const model = bot.modelSelection.model;
|
|
8890
|
+
const effort = bot.modelSelection.effort;
|
|
8891
|
+
const variant = bot.modelSelection.variant;
|
|
8386
8892
|
assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
|
|
8387
8893
|
// A selection can be persisted while its engine is offline. Re-check when
|
|
8388
8894
|
// the engine returns so an old or unsupported value never reaches a CLI.
|
|
@@ -8409,6 +8915,8 @@ async function startTurn(botId, text, opts) {
|
|
|
8409
8915
|
sendId: opts?.sendId,
|
|
8410
8916
|
peerAsk: opts?.peerAsk,
|
|
8411
8917
|
sender: opts?.sender,
|
|
8918
|
+
...(opts?.via ? { via: opts.via } : {}),
|
|
8919
|
+
...(opts?.relayed ? { relayed: true } : {}),
|
|
8412
8920
|
});
|
|
8413
8921
|
}
|
|
8414
8922
|
const recoveryUserMessageId = opts?.coordination
|
|
@@ -8523,6 +9031,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8523
9031
|
retryDelegationsWaitingOn(bot.id);
|
|
8524
9032
|
drainQueuedSends();
|
|
8525
9033
|
drainConnectorResumes();
|
|
9034
|
+
drainComputerResumes();
|
|
8526
9035
|
drainSecretResumes();
|
|
8527
9036
|
drainTeamSetupResumes();
|
|
8528
9037
|
drainDelegationWakes();
|
|
@@ -8703,24 +9212,23 @@ async function startTurn(botId, text, opts) {
|
|
|
8703
9212
|
}
|
|
8704
9213
|
const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
|
|
8705
9214
|
const skillInstructions = renderSkillInstructions(selectedSkills, {
|
|
8706
|
-
includeRoot: worksInWorkspace
|
|
9215
|
+
includeRoot: worksInWorkspace,
|
|
8707
9216
|
});
|
|
8708
9217
|
const packagePlaybooks = installedPlaybookInstructions(providerText, bot.playbooks);
|
|
8709
9218
|
// An explicit working folder wins for new tasks; otherwise they use
|
|
8710
9219
|
// the private bot workspace. A legacy task with an existing provider
|
|
8711
9220
|
// session deliberately pins to null (the old home-folder behavior),
|
|
8712
9221
|
// because moving a live session would break resume.
|
|
8713
|
-
// A cloud run
|
|
8714
|
-
//
|
|
8715
|
-
//
|
|
8716
|
-
|
|
8717
|
-
store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
|
|
9222
|
+
// A cloud run keeps the bot's own engine on this host, with the cloud
|
|
9223
|
+
// computer as one more tool, so it works in the same folder as any
|
|
9224
|
+
// other turn (only the Computer engine runs on the Boat, and it has no
|
|
9225
|
+
// workspace files).
|
|
8718
9226
|
// On a Cloud home a conversation a guest opened is pinned to its own
|
|
8719
9227
|
// folder when it first runs, not the bot's project folder the owner's
|
|
8720
9228
|
// conversations share (what it leaves there reaches none of them). One
|
|
8721
9229
|
// that ran before keeps its pin, unless the session that opened it was
|
|
8722
9230
|
// revoked: then it was unpinned at boot (server/cloud-owner.ts).
|
|
8723
|
-
const pinnedCwd = privateWorkspace
|
|
9231
|
+
const pinnedCwd = privateWorkspace
|
|
8724
9232
|
? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
|
|
8725
9233
|
: null;
|
|
8726
9234
|
const cwd = pinnedCwd ?? undefined;
|
|
@@ -8737,8 +9245,8 @@ async function startTurn(botId, text, opts) {
|
|
|
8737
9245
|
const dwebUrl = process.env.DWEB_URL?.trim();
|
|
8738
9246
|
if (dwebUrl)
|
|
8739
9247
|
integrations.dweb = { url: dwebUrl };
|
|
8740
|
-
// Cloud routines always use Boat
|
|
8741
|
-
// only to ordinary turns
|
|
9248
|
+
// Cloud routines always use the bot's Boat. The per-bot backend applies
|
|
9249
|
+
// only to ordinary turns.
|
|
8742
9250
|
const teamComputer = inheritedTeamComputer(bot);
|
|
8743
9251
|
const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
|
|
8744
9252
|
const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
|
|
@@ -8766,6 +9274,20 @@ async function startTurn(botId, text, opts) {
|
|
|
8766
9274
|
const placeRefusal = wantedKind && computerPlaceRefusal(wantedKind);
|
|
8767
9275
|
if (placeRefusal)
|
|
8768
9276
|
throw Object.assign(new Error(placeRefusal.message), { status: 409, code: placeRefusal.code });
|
|
9277
|
+
// Where an explicit place came from, so its failure names the one
|
|
9278
|
+
// control that changes it (and a failed Auto-recorded pin is cleared).
|
|
9279
|
+
// Auto and a team's shared computer are nobody's per-turn choice.
|
|
9280
|
+
const placeSource = teamComputer || wants === undefined || wants === "off" ? undefined
|
|
9281
|
+
: opts?.runOn === "cloud" ? "routine"
|
|
9282
|
+
: plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
|
|
9283
|
+
: "works-on";
|
|
9284
|
+
// An engine that cannot use the cloud computer is refused here, before
|
|
9285
|
+
// anything is created or woken — never handed to another engine.
|
|
9286
|
+
if (wants === "cloud" && placeSource) {
|
|
9287
|
+
const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, cloudBackend, placeSource);
|
|
9288
|
+
if (unsupported)
|
|
9289
|
+
throw new PlaceUnavailableError("cloud", placeSource, unsupported);
|
|
9290
|
+
}
|
|
8769
9291
|
let previewCapture = null;
|
|
8770
9292
|
let browserCapture = null;
|
|
8771
9293
|
let computerKind = null;
|
|
@@ -8893,6 +9415,22 @@ async function startTurn(botId, text, opts) {
|
|
|
8893
9415
|
const surfaceLazyClaimRejection = (label) => (failure) => {
|
|
8894
9416
|
if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
|
|
8895
9417
|
return;
|
|
9418
|
+
// The wait ceiling parked this turn (#1651): register the resume and
|
|
9419
|
+
// settle the live turn. No error entry, no incident — the parked chip
|
|
9420
|
+
// the wait appended is the whole story. The slot still fails, so the
|
|
9421
|
+
// gate keeps refusing screen calls for the rest of this generation
|
|
9422
|
+
// while the interrupt lands.
|
|
9423
|
+
if (resourceOwner.computerParkedOn) {
|
|
9424
|
+
registerComputerResume({
|
|
9425
|
+
botId: bot.id,
|
|
9426
|
+
threadId,
|
|
9427
|
+
resource: resourceOwner.computerParkedOn,
|
|
9428
|
+
generation: resourceOwner.generation,
|
|
9429
|
+
afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
|
|
9430
|
+
});
|
|
9431
|
+
void interruptDirectThread(bot.id, threadId, { preserveComputerResume: true }).catch(() => { });
|
|
9432
|
+
return;
|
|
9433
|
+
}
|
|
8896
9434
|
const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
|
|
8897
9435
|
store.appendMessage(threadId, {
|
|
8898
9436
|
role: "bot",
|
|
@@ -8993,31 +9531,33 @@ async function startTurn(botId, text, opts) {
|
|
|
8993
9531
|
return true;
|
|
8994
9532
|
}
|
|
8995
9533
|
catch (error) {
|
|
8996
|
-
|
|
9534
|
+
// A wait ceiling settled this turn as parked, not as an unavailable
|
|
9535
|
+
// Auto destination. Let dispatch register its resume instead of
|
|
9536
|
+
// continuing the provider without the requested computer.
|
|
9537
|
+
if (strict || error instanceof ComputerWaitParked)
|
|
8997
9538
|
throw error;
|
|
8998
9539
|
if (directTurnGenerationByThread.get(threadId) === dispatchClaimId)
|
|
8999
9540
|
releaseLocalVmThread(threadId);
|
|
9000
9541
|
return false;
|
|
9001
9542
|
}
|
|
9002
9543
|
};
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
if (
|
|
9019
|
-
|
|
9020
|
-
if (!unsupported) {
|
|
9544
|
+
// An explicit place that cannot be attached fails the turn with its
|
|
9545
|
+
// cause and the next action; it never quietly moves the work elsewhere.
|
|
9546
|
+
try {
|
|
9547
|
+
if (wants === "vm") {
|
|
9548
|
+
if (await attachLocalVm(true))
|
|
9549
|
+
computerKind = "vm";
|
|
9550
|
+
}
|
|
9551
|
+
else if (wants === "local") {
|
|
9552
|
+
integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
|
|
9553
|
+
computerKind = "local";
|
|
9554
|
+
}
|
|
9555
|
+
// A VPS is a local-agent computer mount, never a remote agent runner.
|
|
9556
|
+
// Explicit Cloud may prepare/start it. Auto remains read-only unless
|
|
9557
|
+
// the person explicitly opted this bot into remote lifecycle actions.
|
|
9558
|
+
// On Auto an engine that cannot use a computer simply skips it.
|
|
9559
|
+
if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
|
|
9560
|
+
!cloudPlaceDriverError(instance.adapter.capabilities, "vps")) {
|
|
9021
9561
|
// The VPS "computer" is the desktop inside this bot's managed
|
|
9022
9562
|
// container, and only screen work needs that desktop to itself.
|
|
9023
9563
|
// So the lease is claimed on the first computer call, through the
|
|
@@ -9055,34 +9595,40 @@ async function startTurn(botId, text, opts) {
|
|
|
9055
9595
|
autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
|
|
9056
9596
|
}
|
|
9057
9597
|
}
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
integrations.computer = attached.integration;
|
|
9064
|
-
previewCapture = attached.capture;
|
|
9065
|
-
computerKind = "box";
|
|
9066
|
-
}
|
|
9067
|
-
if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
|
|
9068
|
-
const attached = await attachBotBoat(bot, resourceOwner, {
|
|
9069
|
-
explicitCloud: wants === "cloud",
|
|
9070
|
-
canMount: instance.adapter.capabilities.usesCloudComputer === true,
|
|
9071
|
-
remoteAgent: instance.adapter.capabilities.remoteAgent === true,
|
|
9072
|
-
});
|
|
9073
|
-
if (attached) {
|
|
9598
|
+
// Cloud is strict when selected. Every engine that can use it may also
|
|
9599
|
+
// reuse an already-ready Boat on Auto.
|
|
9600
|
+
if (teamComputer) {
|
|
9601
|
+
const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
|
|
9602
|
+
Object.assign(integrations, attached.mount);
|
|
9074
9603
|
previewCapture = attached.capture;
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9604
|
+
computerKind = "box";
|
|
9605
|
+
}
|
|
9606
|
+
if (!teamComputer && usesCloudComputer(instance.adapter.capabilities) && (wants === "cloud" || (wants === undefined && !computerPlaceRefusal("box"))) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
|
|
9607
|
+
const attached = await attachBotBoat(bot, resourceOwner, {
|
|
9608
|
+
explicitCloud: wants === "cloud",
|
|
9609
|
+
canMount: usesCloudComputer(instance.adapter.capabilities),
|
|
9610
|
+
remoteAgent: instance.adapter.capabilities.remoteAgent === true,
|
|
9611
|
+
});
|
|
9612
|
+
if (attached) {
|
|
9613
|
+
previewCapture = attached.capture;
|
|
9614
|
+
if (attached.mount) {
|
|
9615
|
+
Object.assign(integrations, attached.mount);
|
|
9616
|
+
computerKind = "box";
|
|
9617
|
+
}
|
|
9078
9618
|
}
|
|
9079
9619
|
}
|
|
9620
|
+
if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
|
|
9621
|
+
throw new Error(BOAT_NOT_CONFIGURED);
|
|
9622
|
+
}
|
|
9623
|
+
if (wants === "cloud" && cloudBackend === "box" && computerKind !== "box") {
|
|
9624
|
+
throw new Error("the cloud computer could not be created or reached");
|
|
9625
|
+
}
|
|
9080
9626
|
}
|
|
9081
|
-
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
throw
|
|
9627
|
+
catch (error) {
|
|
9628
|
+
if (!placeSource || wants === undefined || wants === "off" || error instanceof DirectTurnSetupCancelled ||
|
|
9629
|
+
error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
|
|
9630
|
+
throw error;
|
|
9631
|
+
throw placeUnavailable(wants, placeSource, error instanceof Error ? error.message : String(error));
|
|
9086
9632
|
}
|
|
9087
9633
|
// Auto-only host fallback. Electron owns cua-driver/TCC attribution;
|
|
9088
9634
|
// the harness only reads its already-running connection descriptor.
|
|
@@ -9173,7 +9719,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9173
9719
|
? mentionedBots(providerText, sectionPeers)
|
|
9174
9720
|
: [];
|
|
9175
9721
|
const coordinationPrompt = bot.chiefOfStaff
|
|
9176
|
-
? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents),
|
|
9722
|
+
? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), boundedCoordination)
|
|
9177
9723
|
: integrations.agents && sectionPeers.length > 0
|
|
9178
9724
|
// Ordinary bots could always CALL the peer tools; until now the
|
|
9179
9725
|
// one generic sentence they got never named a teammate, so the
|
|
@@ -9282,7 +9828,6 @@ async function startTurn(botId, text, opts) {
|
|
|
9282
9828
|
const computerPromptKind = resolveComputerPromptKind({
|
|
9283
9829
|
kind: computerKind,
|
|
9284
9830
|
driverKind: instance.driverKind,
|
|
9285
|
-
cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
|
|
9286
9831
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
9287
9832
|
});
|
|
9288
9833
|
const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
|
|
@@ -9295,14 +9840,14 @@ async function startTurn(botId, text, opts) {
|
|
|
9295
9840
|
// to a turn whose engine actually mounted them (setupMode is already
|
|
9296
9841
|
// false when they are not — see agentsMounted above)
|
|
9297
9842
|
{ id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
|
|
9298
|
-
{ id: "files", label: "File locations", text: worksInWorkspace
|
|
9843
|
+
{ id: "files", label: "File locations", text: worksInWorkspace ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
|
|
9299
9844
|
{ id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
|
|
9300
9845
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
|
|
9301
9846
|
{ 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) }) },
|
|
9302
9847
|
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
9303
9848
|
// gated on the integration, not the key: the hint only goes to a
|
|
9304
9849
|
// bot whose driver actually mounted the tools
|
|
9305
|
-
{ id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
|
|
9850
|
+
{ id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) + describeConnectorScopes((liveBot ?? bot).connectorScopes) : "" },
|
|
9306
9851
|
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
9307
9852
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
9308
9853
|
{ id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
|
|
@@ -9315,11 +9860,14 @@ async function startTurn(botId, text, opts) {
|
|
|
9315
9860
|
{ id: "profile", label: "Profile changes", text: profilePrompt },
|
|
9316
9861
|
{ id: "learn", label: "Skill authoring", text: learnPrompt },
|
|
9317
9862
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
9863
|
+
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
|
|
9318
9864
|
// what the bot said lately in its other conversations, so a task
|
|
9319
9865
|
// never redoes — or forgets — what another one already did
|
|
9320
9866
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
|
|
9321
9867
|
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false }) },
|
|
9322
|
-
|
|
9868
|
+
// liveBot was captured before awaited setup work; an assignment PUT
|
|
9869
|
+
// in that window must still reach this turn's prompt.
|
|
9870
|
+
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? (store.bot(bot.id)?.assignedSkills ?? bot.assignedSkills) : undefined) : "" },
|
|
9323
9871
|
{ id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
|
|
9324
9872
|
{ id: "playbooks", label: "Playbooks", text: packagePlaybooks },
|
|
9325
9873
|
{ id: "webhook", label: "Webhook provenance", text: opts?.automationSource === "webhook" ? WEBHOOK_PROMPT : "" },
|
|
@@ -9358,11 +9906,12 @@ async function startTurn(botId, text, opts) {
|
|
|
9358
9906
|
const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
|
|
9359
9907
|
threadId,
|
|
9360
9908
|
botId: bot.id,
|
|
9361
|
-
startupRecovery: cfg.automaticRecovery?.enabled === true &&
|
|
9909
|
+
startupRecovery: cfg.automaticRecovery?.enabled === true &&
|
|
9910
|
+
(opts?.automaticRecoveryIndex ?? 0) < ((liveBot ?? bot).fallback?.length || (cfg.automaticRecovery.backup ? 1 : 0)),
|
|
9362
9911
|
text: withRecalled(recalled, dispatchContext.turnText),
|
|
9363
|
-
refreshSystemPrompt: true,
|
|
9364
9912
|
images: turnImages,
|
|
9365
9913
|
approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
|
|
9914
|
+
toolScope,
|
|
9366
9915
|
...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
|
|
9367
9916
|
model,
|
|
9368
9917
|
effort,
|
|
@@ -9437,6 +9986,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9437
9986
|
retryDelegationsWaitingOn(bot.id);
|
|
9438
9987
|
drainQueuedSends();
|
|
9439
9988
|
drainConnectorResumes();
|
|
9989
|
+
drainComputerResumes();
|
|
9440
9990
|
drainSecretResumes();
|
|
9441
9991
|
drainTeamSetupResumes();
|
|
9442
9992
|
drainDelegationWakes();
|
|
@@ -9482,6 +10032,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9482
10032
|
if (ownsLatestGeneration) {
|
|
9483
10033
|
drainQueuedSends();
|
|
9484
10034
|
drainConnectorResumes();
|
|
10035
|
+
drainComputerResumes();
|
|
9485
10036
|
drainSecretResumes();
|
|
9486
10037
|
drainTeamSetupResumes();
|
|
9487
10038
|
drainDelegationWakes();
|
|
@@ -9492,10 +10043,51 @@ async function startTurn(botId, text, opts) {
|
|
|
9492
10043
|
settleDirectFollowup(dispatchClaimId);
|
|
9493
10044
|
return;
|
|
9494
10045
|
}
|
|
10046
|
+
if (e instanceof ComputerWaitParked) {
|
|
10047
|
+
settleDirectFollowup(dispatchClaimId, { ok: false, text: e.message });
|
|
10048
|
+
registerComputerResume({
|
|
10049
|
+
botId: bot.id,
|
|
10050
|
+
threadId,
|
|
10051
|
+
resource: e.resource,
|
|
10052
|
+
generation: e.generation,
|
|
10053
|
+
afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
|
|
10054
|
+
});
|
|
10055
|
+
// Settled as parked, not failed (#1651): the chip the wait appended
|
|
10056
|
+
// is the whole transcript story, and the registered resume owns what
|
|
10057
|
+
// happens next on this thread.
|
|
10058
|
+
if (store.taskByThread(bot.id, threadId)?.activity !== "dead") {
|
|
10059
|
+
store.setTaskActivity(bot.id, threadId, "parked.computer");
|
|
10060
|
+
}
|
|
10061
|
+
directTurnBots.delete(threadId);
|
|
10062
|
+
retryDelegationsWaitingOn(bot.id);
|
|
10063
|
+
drainQueuedSends();
|
|
10064
|
+
drainConnectorResumes();
|
|
10065
|
+
drainComputerResumes();
|
|
10066
|
+
drainSecretResumes();
|
|
10067
|
+
drainTeamSetupResumes();
|
|
10068
|
+
drainDelegationWakes();
|
|
10069
|
+
return;
|
|
10070
|
+
}
|
|
10071
|
+
// A failed place never sticks. An Auto-recorded pin to it (a
|
|
10072
|
+
// select_computer switch, or where an earlier Auto turn landed) is the
|
|
10073
|
+
// machine's memory, not a choice: clear it so the next message runs on
|
|
10074
|
+
// Auto. A person's pin stays, and the message names the composer.
|
|
10075
|
+
if (e instanceof PlaceUnavailableError) {
|
|
10076
|
+
const pinned = store.taskByThread(bot.id, threadId);
|
|
10077
|
+
if (pinned?.surface === e.place && pinned.surfaceSource === "auto") {
|
|
10078
|
+
store.patchTask(bot.id, threadId, { surface: undefined, surfaceSource: undefined });
|
|
10079
|
+
}
|
|
10080
|
+
}
|
|
9495
10081
|
let message = e instanceof Error ? e.message : String(e);
|
|
9496
|
-
const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
|
|
9497
10082
|
const current = store.projectBotForTask(bot.id, threadId);
|
|
9498
|
-
|
|
10083
|
+
const backups = cfg.automaticRecovery?.enabled
|
|
10084
|
+
? current?.fallback?.length ? current.fallback : cfg.automaticRecovery.backup ? [cfg.automaticRecovery.backup] : []
|
|
10085
|
+
: [];
|
|
10086
|
+
const nextIndex = backups.findIndex((candidate, index) => index >= (opts?.automaticRecoveryIndex ?? 0) &&
|
|
10087
|
+
registry.get(candidate.instanceId)?.enabled &&
|
|
10088
|
+
(candidate.instanceId !== instanceId || candidate.model !== model));
|
|
10089
|
+
const backup = backups[nextIndex];
|
|
10090
|
+
if (mayRecover && backup && current && (!opts?.cardContinuation || opts.coordination) &&
|
|
9499
10091
|
!directRequestOwners.get(threadId)?.stopped && !hasQueuedSteeredMessages(bot.id, threadId) &&
|
|
9500
10092
|
JSON.stringify(current.modelSelection) === JSON.stringify(bot.modelSelection) &&
|
|
9501
10093
|
(backup.instanceId !== instanceId || backup.model !== model) &&
|
|
@@ -9519,7 +10111,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9519
10111
|
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,
|
|
9520
10112
|
} });
|
|
9521
10113
|
await startTurn(bot.id, text, { ...opts, threadId, userMessage, editedMessageId: undefined,
|
|
9522
|
-
automaticRecoveryAttempted: true });
|
|
10114
|
+
automaticRecoveryAttempted: true, automaticRecoveryIndex: nextIndex + 1 });
|
|
9523
10115
|
// The backup now owns these callbacks; don't publish a failed
|
|
9524
10116
|
// routine/peer result or release a queued receipt in between.
|
|
9525
10117
|
directFollowupSettlers.delete(dispatchClaimId);
|
|
@@ -9536,13 +10128,12 @@ async function startTurn(botId, text, opts) {
|
|
|
9536
10128
|
settleDirectFollowup(dispatchClaimId, { ok: false, text: message });
|
|
9537
10129
|
// The wait already wrote its failure resolution; keep all dispatch
|
|
9538
10130
|
// failure bookkeeping below without adding the same error twice.
|
|
9539
|
-
|
|
9540
|
-
|
|
9541
|
-
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
});
|
|
10131
|
+
store.appendMessage(threadId, {
|
|
10132
|
+
role: "bot",
|
|
10133
|
+
kind: "activity",
|
|
10134
|
+
turnSucceeded: false,
|
|
10135
|
+
tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
|
|
10136
|
+
});
|
|
9546
10137
|
// Worth a buzz for the same reason a routine failure is, and the rule
|
|
9547
10138
|
// notify.ts encodes: the bot is not working, and the cause is usually
|
|
9548
10139
|
// a setting only a person can change — an unattended user would
|
|
@@ -9565,6 +10156,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9565
10156
|
// drain would strand anything queued behind this turn
|
|
9566
10157
|
drainQueuedSends();
|
|
9567
10158
|
drainConnectorResumes();
|
|
10159
|
+
drainComputerResumes();
|
|
9568
10160
|
drainSecretResumes();
|
|
9569
10161
|
drainTeamSetupResumes();
|
|
9570
10162
|
drainDelegationWakes();
|
|
@@ -9859,15 +10451,15 @@ routines = new RoutineManager({
|
|
|
9859
10451
|
goalRunId: runId,
|
|
9860
10452
|
});
|
|
9861
10453
|
},
|
|
9862
|
-
interruptTurn: async (botId, threadId
|
|
10454
|
+
interruptTurn: async (botId, threadId) => {
|
|
9863
10455
|
const bot = botForThread(botId, threadId);
|
|
9864
10456
|
pendingDelegationWakes.delete(threadId);
|
|
9865
10457
|
discardDelegations(commsBus, threadId);
|
|
9866
10458
|
cancelDirectTurnDispatch(botId, threadId);
|
|
9867
10459
|
revokeInternalCapabilitiesForThread(threadId);
|
|
9868
10460
|
const instance = bot
|
|
9869
|
-
? runningTurnInstance(bot, threadId
|
|
9870
|
-
:
|
|
10461
|
+
? runningTurnInstance(bot, threadId)
|
|
10462
|
+
: runningTurnEngines.get(threadId) ?? null;
|
|
9871
10463
|
try {
|
|
9872
10464
|
await instance?.adapter.interruptTurn(threadId);
|
|
9873
10465
|
}
|
|
@@ -10028,9 +10620,13 @@ async function cloudRoutineReadiness(botId, threadId) {
|
|
|
10028
10620
|
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.',
|
|
10029
10621
|
};
|
|
10030
10622
|
}
|
|
10031
|
-
const instance =
|
|
10623
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
10032
10624
|
if (!instance)
|
|
10033
|
-
return { ready: false, reason: "The
|
|
10625
|
+
return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
|
|
10626
|
+
// The same rule a cloud turn applies, before anything is provisioned.
|
|
10627
|
+
const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, "box");
|
|
10628
|
+
if (unsupported)
|
|
10629
|
+
return { ready: false, reason: "This model can't use a computer. Choose another model, or change where this routine runs." };
|
|
10034
10630
|
try {
|
|
10035
10631
|
if ((await instance.snapshot()).state !== "available") {
|
|
10036
10632
|
return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
|
|
@@ -10323,6 +10919,69 @@ const validateModelProposal = (selection, current) => {
|
|
|
10323
10919
|
}
|
|
10324
10920
|
return null;
|
|
10325
10921
|
};
|
|
10922
|
+
const teamMemory = new TeamMemory(join(DATA_DIR, "team-memory.json"));
|
|
10923
|
+
const outboundRequests = new OutboundRequestService();
|
|
10924
|
+
const outboundCounts = new OutboundCounts(join(DATA_DIR, "outbound-counts.json"));
|
|
10925
|
+
const OUTBOUND_HOLD_MS = 9 * 60_000;
|
|
10926
|
+
function resolveAndSendTrust(res, threadId, requestId, behavior) {
|
|
10927
|
+
const teamMemoryCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamMemoryRequest);
|
|
10928
|
+
if (teamMemoryCard?.card?.teamMemoryRequest) {
|
|
10929
|
+
const request = teamMemoryCard.card.teamMemoryRequest;
|
|
10930
|
+
const proposer = teamMemoryCard.from?.botId ? store.bot(teamMemoryCard.from.botId) : store.botByThread(threadId);
|
|
10931
|
+
const result = teamMemory.resolve(request.section, request.entryId, behavior === "allow" ? "accept" : "reject");
|
|
10932
|
+
if (!result.claimed) {
|
|
10933
|
+
if (!teamMemoryCard.card.answered) {
|
|
10934
|
+
store.patchMessage(threadId, teamMemoryCard.id, { card: { ...teamMemoryCard.card, answered: "unavailable", dismissed: true } });
|
|
10935
|
+
}
|
|
10936
|
+
json(res, 200, { ok: true, outcome: "unavailable" });
|
|
10937
|
+
return true;
|
|
10938
|
+
}
|
|
10939
|
+
if (result.state === "already_settled") {
|
|
10940
|
+
json(res, 200, { ok: true, outcome: "allowed-once", alreadySettled: true });
|
|
10941
|
+
return true;
|
|
10942
|
+
}
|
|
10943
|
+
store.patchMessage(threadId, teamMemoryCard.id, {
|
|
10944
|
+
card: { ...teamMemoryCard.card, answered: result.state === "accepted" ? "allow" : "deny" },
|
|
10945
|
+
});
|
|
10946
|
+
appendDecision(DATA_DIR, {
|
|
10947
|
+
threadId, requestId, botId: proposer?.id, botName: proposer?.name,
|
|
10948
|
+
tool: "propose_team_memory", summary: teamMemoryCard.card.subtitle,
|
|
10949
|
+
decision: result.state === "accepted" ? "user-approved" : "user-denied", source: "user",
|
|
10950
|
+
});
|
|
10951
|
+
json(res, 200, { ok: true, outcome: result.state === "accepted" ? "allowed-once" : "rejected" });
|
|
10952
|
+
return true;
|
|
10953
|
+
}
|
|
10954
|
+
const outboundCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.outboundRequest);
|
|
10955
|
+
if (outboundCard?.card?.outboundRequest) {
|
|
10956
|
+
const outboundBot = outboundCard.from?.botId ? store.bot(outboundCard.from.botId) : store.botByThread(threadId);
|
|
10957
|
+
const result = outboundRequests.resolve({ threadId, requestId, behavior });
|
|
10958
|
+
if (!result.claimed) {
|
|
10959
|
+
// The hold is memory-only: after a restart the card outlives the
|
|
10960
|
+
// relay that was waiting on it, so close it rather than leave an
|
|
10961
|
+
// approval nobody can deliver owning the composer.
|
|
10962
|
+
if (!outboundCard.card.answered) {
|
|
10963
|
+
store.patchMessage(threadId, outboundCard.id, { card: { ...outboundCard.card, answered: "unavailable", dismissed: true } });
|
|
10964
|
+
}
|
|
10965
|
+
json(res, 200, { ok: true, outcome: "unavailable" });
|
|
10966
|
+
return true;
|
|
10967
|
+
}
|
|
10968
|
+
if (result.state === "already_settled") {
|
|
10969
|
+
json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
|
|
10970
|
+
return true;
|
|
10971
|
+
}
|
|
10972
|
+
store.patchMessage(threadId, outboundCard.id, {
|
|
10973
|
+
card: { ...outboundCard.card, answered: result.state === "allowed" ? "allow" : "deny" },
|
|
10974
|
+
});
|
|
10975
|
+
appendDecision(DATA_DIR, {
|
|
10976
|
+
threadId, requestId, botId: outboundBot?.id, botName: outboundBot?.name,
|
|
10977
|
+
tool: outboundCard.card.tool, summary: outboundCard.card.subtitle,
|
|
10978
|
+
decision: result.state === "allowed" ? "user-approved" : "user-denied", source: "user",
|
|
10979
|
+
});
|
|
10980
|
+
json(res, 200, { ok: true, outcome: result.state === "allowed" ? "allowed-once" : "rejected" });
|
|
10981
|
+
return true;
|
|
10982
|
+
}
|
|
10983
|
+
return false;
|
|
10984
|
+
}
|
|
10326
10985
|
const profileRequests = new ProfileRequestService({
|
|
10327
10986
|
store,
|
|
10328
10987
|
autoApply: fullAccessForSource,
|
|
@@ -10359,8 +11018,24 @@ const tighteningRequests = new TighteningRequestService({
|
|
|
10359
11018
|
// The card judges the effective mounts, so the snapshot resolves the same
|
|
10360
11019
|
// way the engine does: through the config-aware custom-server filter.
|
|
10361
11020
|
mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
|
|
10362
|
-
enabledSkills: (botId) =>
|
|
11021
|
+
enabledSkills: (botId) => {
|
|
11022
|
+
const record = store.bot(botId);
|
|
11023
|
+
return (record ? listBotSkills(record) : listSkills(botId)).filter((skill) => skill.enabled).map((skill) => skill.name);
|
|
11024
|
+
},
|
|
10363
11025
|
disableSkill: (botId, name) => {
|
|
11026
|
+
const record = store.bot(botId);
|
|
11027
|
+
// Private skills win collisions, including disabled/private copies.
|
|
11028
|
+
// Disabling one must not reveal a same-named shared skill instead.
|
|
11029
|
+
if (skillsLibraryEnabled(cfg) && !listSkills(botId).some((skill) => skill.name === name)
|
|
11030
|
+
&& record?.assignedSkills?.includes(name)) {
|
|
11031
|
+
try {
|
|
11032
|
+
const saved = store.patchBot(botId, { assignedSkills: record.assignedSkills.filter((assigned) => assigned !== name) });
|
|
11033
|
+
return saved ? { ok: true } : { ok: false, error: "that bot no longer exists" };
|
|
11034
|
+
}
|
|
11035
|
+
catch (error) {
|
|
11036
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
11037
|
+
}
|
|
11038
|
+
}
|
|
10364
11039
|
const disabled = setSkillEnabled(botId, name, false);
|
|
10365
11040
|
return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
|
|
10366
11041
|
},
|
|
@@ -10923,7 +11598,7 @@ setupRetry = 0) {
|
|
|
10923
11598
|
const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
|
|
10924
11599
|
const preparedSelection = { ...bot.modelSelection };
|
|
10925
11600
|
const preparedComposio = bot.composio;
|
|
10926
|
-
const instance =
|
|
11601
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
10927
11602
|
const userName = cfg.profile?.name?.trim() || "User";
|
|
10928
11603
|
if (providerInstancesChanging.has(bot.modelSelection.instanceId)) {
|
|
10929
11604
|
onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
|
|
@@ -11096,13 +11771,14 @@ setupRetry = 0) {
|
|
|
11096
11771
|
const readyBot = store.bot(bot.id);
|
|
11097
11772
|
if (!readyBot)
|
|
11098
11773
|
return false;
|
|
11774
|
+
toolScopeForTurn(readyBot.id);
|
|
11099
11775
|
const readyGroup = store.group(group.id);
|
|
11100
11776
|
const stillOwnsThread = readyGroup?.dm
|
|
11101
11777
|
? readyGroup.threadId === threadId
|
|
11102
11778
|
: Boolean(readyGroup && store.groupTaskByThread(readyGroup.id, threadId));
|
|
11103
11779
|
if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
|
|
11104
11780
|
return false;
|
|
11105
|
-
const setupChanged =
|
|
11781
|
+
const setupChanged = registry.get(readyBot.modelSelection.instanceId) !== instance ||
|
|
11106
11782
|
roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
|
|
11107
11783
|
readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
|
|
11108
11784
|
readyBot.modelSelection.model !== preparedSelection.model ||
|
|
@@ -11227,6 +11903,13 @@ setupRetry = 0) {
|
|
|
11227
11903
|
if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
|
|
11228
11904
|
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
11229
11905
|
}
|
|
11906
|
+
// The same one rule as a bot thread, before anything is provisioned: an
|
|
11907
|
+
// engine that cannot use the cloud computer is refused, never swapped.
|
|
11908
|
+
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
11909
|
+
const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, turnProvider(readyBot) === "vps" ? "vps" : "box");
|
|
11910
|
+
if (unsupported)
|
|
11911
|
+
throw new PlaceUnavailableError("cloud", "works-on", unsupported);
|
|
11912
|
+
}
|
|
11230
11913
|
// One place per room turn as well: a team computer reached on Auto means
|
|
11231
11914
|
// no separate built-in browser.
|
|
11232
11915
|
if (roomPlan.browser && !(roomPlan.computer === undefined && roomTeamComputer)) {
|
|
@@ -11245,11 +11928,11 @@ setupRetry = 0) {
|
|
|
11245
11928
|
return false;
|
|
11246
11929
|
}
|
|
11247
11930
|
if (roomTeamComputer) {
|
|
11248
|
-
const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities
|
|
11931
|
+
const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
|
|
11249
11932
|
if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
|
|
11250
11933
|
activeInternalGenerationByThread.get(threadId) !== internalGeneration)
|
|
11251
11934
|
return false;
|
|
11252
|
-
integrations
|
|
11935
|
+
Object.assign(integrations, attached.mount);
|
|
11253
11936
|
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
|
|
11254
11937
|
}
|
|
11255
11938
|
// The speaker's own explicit place, mounted exactly as its bot thread
|
|
@@ -11258,89 +11941,99 @@ setupRetry = 0) {
|
|
|
11258
11941
|
const roomSetupIsCurrent = () => !isCancelled?.() &&
|
|
11259
11942
|
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
11260
11943
|
activeInternalGenerationByThread.get(threadId) === internalGeneration;
|
|
11261
|
-
|
|
11262
|
-
|
|
11263
|
-
|
|
11264
|
-
|
|
11265
|
-
|
|
11266
|
-
}
|
|
11267
|
-
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
11268
|
-
if (turnProvider(readyBot) === "vps") {
|
|
11269
|
-
const unsupported = vps.vpsDriverError(instance.driverKind, instance.adapter.capabilities.computerMcp === true);
|
|
11270
|
-
if (unsupported)
|
|
11271
|
-
throw new Error(unsupported);
|
|
11272
|
-
vpsThreadStarted(readyBot.id, threadId);
|
|
11273
|
-
roomVpsBotId = readyBot.id;
|
|
11274
|
-
const mounted = await mountBotVps(readyBot, resourceOwner, {
|
|
11275
|
-
start: true,
|
|
11276
|
-
onClaimed: (capture) => {
|
|
11277
|
-
if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
|
|
11278
|
-
roomScreenBotId = readyBot.id;
|
|
11279
|
-
startScreenPoller(readyBot.id, threadId, { computer: capture });
|
|
11280
|
-
}
|
|
11281
|
-
},
|
|
11282
|
-
});
|
|
11944
|
+
// An explicit place that cannot be attached fails the member's turn with
|
|
11945
|
+
// its cause and the one setting that changes it.
|
|
11946
|
+
try {
|
|
11947
|
+
if (!roomTeamComputer && roomPlan.computer === "local") {
|
|
11948
|
+
integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
|
|
11283
11949
|
if (!roomSetupIsCurrent())
|
|
11284
11950
|
return false;
|
|
11285
|
-
|
|
11286
|
-
|
|
11287
|
-
|
|
11288
|
-
|
|
11951
|
+
roomComputerKind = "local";
|
|
11952
|
+
}
|
|
11953
|
+
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
11954
|
+
if (turnProvider(readyBot) === "vps") {
|
|
11955
|
+
vpsThreadStarted(readyBot.id, threadId);
|
|
11956
|
+
roomVpsBotId = readyBot.id;
|
|
11957
|
+
const mounted = await mountBotVps(readyBot, resourceOwner, {
|
|
11958
|
+
start: true,
|
|
11959
|
+
onClaimed: (capture) => {
|
|
11960
|
+
if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
|
|
11961
|
+
roomScreenBotId = readyBot.id;
|
|
11962
|
+
startScreenPoller(readyBot.id, threadId, { computer: capture });
|
|
11963
|
+
}
|
|
11964
|
+
},
|
|
11965
|
+
});
|
|
11966
|
+
if (!roomSetupIsCurrent())
|
|
11967
|
+
return false;
|
|
11968
|
+
if (!("integration" in mounted))
|
|
11969
|
+
throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
|
|
11970
|
+
integrations.localComputer = mounted.integration;
|
|
11971
|
+
roomComputerKind = "vps";
|
|
11972
|
+
}
|
|
11973
|
+
else {
|
|
11974
|
+
if (!boat.boatConfigured(cfg))
|
|
11975
|
+
throw new Error(BOAT_NOT_CONFIGURED);
|
|
11976
|
+
const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
|
|
11977
|
+
const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
|
|
11978
|
+
canMount: usesCloudComputer(instance.adapter.capabilities), remoteAgent });
|
|
11979
|
+
if (!roomSetupIsCurrent())
|
|
11980
|
+
return false;
|
|
11981
|
+
if (!attached?.mount)
|
|
11982
|
+
throw new Error("the cloud computer could not be created or reached");
|
|
11983
|
+
Object.assign(integrations, attached.mount);
|
|
11984
|
+
roomScreenBotId = readyBot.id;
|
|
11985
|
+
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
|
|
11986
|
+
roomComputerKind = "box";
|
|
11987
|
+
}
|
|
11289
11988
|
}
|
|
11290
|
-
|
|
11291
|
-
|
|
11292
|
-
|
|
11293
|
-
|
|
11294
|
-
|
|
11295
|
-
|
|
11296
|
-
|
|
11989
|
+
// Room and Goal turns use the speaker's desktop, never the coordinator's.
|
|
11990
|
+
// Claim the same lease as direct turns before asynchronous VM setup.
|
|
11991
|
+
if (readyBot.computer === "vm") {
|
|
11992
|
+
if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
|
|
11993
|
+
throw new Error("this model cannot use the Local VM");
|
|
11994
|
+
}
|
|
11995
|
+
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
11996
|
+
const target = { ...localVmTargetForThread(readyBot.id, threadId) };
|
|
11997
|
+
await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
11998
|
+
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
|
|
11999
|
+
throw new Error("this Local VM is being started, stopped, or replaced");
|
|
12000
|
+
}
|
|
12001
|
+
if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
|
|
12002
|
+
throw new Error("this Local VM is already being used by another turn");
|
|
12003
|
+
}
|
|
12004
|
+
if (localVmMode(cfg) === "pool")
|
|
12005
|
+
localVmSeatPool.touch(threadId);
|
|
12006
|
+
roomVmTarget = target;
|
|
12007
|
+
localVmThreadTargets.set(threadId, target);
|
|
12008
|
+
localVmActiveThreads.set(target.key, threadId);
|
|
12009
|
+
localVmIdleFor(target).touch();
|
|
12010
|
+
const setupIsCurrent = () => !isCancelled?.() &&
|
|
12011
|
+
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
12012
|
+
activeInternalGenerationByThread.get(threadId) === internalGeneration &&
|
|
12013
|
+
store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
|
|
12014
|
+
store.bot(readyBot.id)?.busy === true &&
|
|
12015
|
+
store.group(readyGroup.id)?.busyBotId === readyBot.id;
|
|
12016
|
+
const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
|
|
12017
|
+
if (!setupIsCurrent()) {
|
|
11297
12018
|
return false;
|
|
11298
|
-
|
|
11299
|
-
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
|
|
11303
|
-
|
|
12019
|
+
}
|
|
12020
|
+
if (!vm.ready || !vm.runtime)
|
|
12021
|
+
throw new Error(vm.problem ?? "the Local VM is not ready");
|
|
12022
|
+
const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
|
|
12023
|
+
if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
|
|
12024
|
+
throw new Error("the Local VM lease expired while preparing the turn");
|
|
12025
|
+
}
|
|
12026
|
+
integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
|
|
11304
12027
|
}
|
|
11305
12028
|
}
|
|
11306
|
-
|
|
11307
|
-
|
|
11308
|
-
|
|
11309
|
-
|
|
11310
|
-
|
|
11311
|
-
|
|
11312
|
-
// A
|
|
11313
|
-
|
|
11314
|
-
await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
11315
|
-
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
|
|
11316
|
-
throw new Error("this Local VM is being started, stopped, or replaced");
|
|
11317
|
-
}
|
|
11318
|
-
if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
|
|
11319
|
-
throw new Error("this Local VM is already being used by another turn");
|
|
11320
|
-
}
|
|
11321
|
-
if (localVmMode(cfg) === "pool")
|
|
11322
|
-
localVmSeatPool.touch(threadId);
|
|
11323
|
-
roomVmTarget = target;
|
|
11324
|
-
localVmThreadTargets.set(threadId, target);
|
|
11325
|
-
localVmActiveThreads.set(target.key, threadId);
|
|
11326
|
-
localVmIdleFor(target).touch();
|
|
11327
|
-
const setupIsCurrent = () => !isCancelled?.() &&
|
|
11328
|
-
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
11329
|
-
activeInternalGenerationByThread.get(threadId) === internalGeneration &&
|
|
11330
|
-
store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
|
|
11331
|
-
store.bot(readyBot.id)?.busy === true &&
|
|
11332
|
-
store.group(readyGroup.id)?.busyBotId === readyBot.id;
|
|
11333
|
-
const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
|
|
11334
|
-
if (!setupIsCurrent()) {
|
|
11335
|
-
return false;
|
|
11336
|
-
}
|
|
11337
|
-
if (!vm.ready || !vm.runtime)
|
|
11338
|
-
throw new Error(vm.problem ?? "the Local VM is not ready");
|
|
11339
|
-
const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
|
|
11340
|
-
if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
|
|
11341
|
-
throw new Error("the Local VM lease expired while preparing the turn");
|
|
11342
|
-
}
|
|
11343
|
-
integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
|
|
12029
|
+
catch (error) {
|
|
12030
|
+
const place = roomPlan.computer;
|
|
12031
|
+
if (place !== "cloud" && place !== "vm" && place !== "local")
|
|
12032
|
+
throw error;
|
|
12033
|
+
if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
|
|
12034
|
+
throw error;
|
|
12035
|
+
// A room row is not cut, so the cause keeps all its words.
|
|
12036
|
+
throw placeUnavailable(place, "works-on", error instanceof Error ? error.message : String(error), Infinity);
|
|
11344
12037
|
}
|
|
11345
12038
|
const roster = readyGroup.memberIds
|
|
11346
12039
|
.map((id) => store.bot(id))
|
|
@@ -11426,7 +12119,7 @@ setupRetry = 0) {
|
|
|
11426
12119
|
const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
|
|
11427
12120
|
const roomComputerPromptKind = resolveComputerPromptKind({
|
|
11428
12121
|
kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
|
|
11429
|
-
driverKind: instance.driverKind,
|
|
12122
|
+
driverKind: instance.driverKind,
|
|
11430
12123
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
11431
12124
|
});
|
|
11432
12125
|
{
|
|
@@ -11452,13 +12145,16 @@ setupRetry = 0) {
|
|
|
11452
12145
|
{ id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
11453
12146
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
11454
12147
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
12148
|
+
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
|
|
11455
12149
|
// the room path has always put a newline before memory and trimmed
|
|
11456
12150
|
// the block's leading space; keep that so existing prompts are
|
|
11457
12151
|
// byte-identical. The write guidance follows the tools actually
|
|
11458
12152
|
// mounted, exactly as the 1:1 path decides it: memory_update is on the
|
|
11459
12153
|
// agents server, so a room turn with it must be told to use it too.
|
|
11460
12154
|
{ id: "memory", label: "Memory", text: roomMemory ? `\n${roomMemory.trim()}` : "" },
|
|
11461
|
-
|
|
12155
|
+
// readyBot was captured before awaited setup work; an assignment PUT in
|
|
12156
|
+
// that window must still reach this turn's prompt.
|
|
12157
|
+
{ id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? (store.bot(bot.id)?.assignedSkills ?? bot.assignedSkills) : undefined) : "" },
|
|
11462
12158
|
{ id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
|
|
11463
12159
|
{ id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
|
|
11464
12160
|
]);
|
|
@@ -11582,9 +12278,9 @@ setupRetry = 0) {
|
|
|
11582
12278
|
threadId,
|
|
11583
12279
|
botId: readyBot.id,
|
|
11584
12280
|
text: withRecalled(roomRecalled, text),
|
|
11585
|
-
refreshSystemPrompt: true,
|
|
11586
12281
|
images: turnImages,
|
|
11587
12282
|
approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
|
|
12283
|
+
toolScope: toolScopeForTurn(readyBot.id),
|
|
11588
12284
|
...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
|
|
11589
12285
|
system: roomSystem.text,
|
|
11590
12286
|
systemStable: roomSystem.stable,
|
|
@@ -11667,6 +12363,7 @@ setupRetry = 0) {
|
|
|
11667
12363
|
watchdog.settle(threadId);
|
|
11668
12364
|
drainQueuedSends();
|
|
11669
12365
|
drainConnectorResumes();
|
|
12366
|
+
drainComputerResumes();
|
|
11670
12367
|
drainSecretResumes();
|
|
11671
12368
|
drainTeamSetupResumes();
|
|
11672
12369
|
return false;
|
|
@@ -11697,6 +12394,7 @@ setupRetry = 0) {
|
|
|
11697
12394
|
retryDelegationsWaitingOn(bot.id);
|
|
11698
12395
|
drainQueuedSends();
|
|
11699
12396
|
drainConnectorResumes();
|
|
12397
|
+
drainComputerResumes();
|
|
11700
12398
|
drainSecretResumes();
|
|
11701
12399
|
drainTeamSetupResumes();
|
|
11702
12400
|
}
|
|
@@ -11725,6 +12423,7 @@ setupRetry = 0) {
|
|
|
11725
12423
|
// queued while this bot briefly owned the room must be retried now.
|
|
11726
12424
|
drainQueuedSends();
|
|
11727
12425
|
drainConnectorResumes();
|
|
12426
|
+
drainComputerResumes();
|
|
11728
12427
|
drainSecretResumes();
|
|
11729
12428
|
drainTeamSetupResumes();
|
|
11730
12429
|
}
|
|
@@ -11790,15 +12489,35 @@ setupRetry = 0) {
|
|
|
11790
12489
|
return false;
|
|
11791
12490
|
const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
|
|
11792
12491
|
const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
|
|
11793
|
-
|
|
12492
|
+
const isToolScopeError = typeof error === "object" && error !== null && error.code === "tool_scope";
|
|
12493
|
+
if (error instanceof ComputerWaitParked) {
|
|
12494
|
+
registerComputerResume({
|
|
12495
|
+
botId: bot.id,
|
|
12496
|
+
threadId,
|
|
12497
|
+
resource: error.resource,
|
|
12498
|
+
generation: error.generation,
|
|
12499
|
+
afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
|
|
12500
|
+
});
|
|
12501
|
+
// The room settles with the parked chip the wait appended (#1651);
|
|
12502
|
+
// the registered resume re-runs the speaker turn through the group
|
|
12503
|
+
// queue once the seat frees. "parked" is its own outcome, not "busy":
|
|
12504
|
+
// the member never ran, an in-step retry would only wait the ceiling
|
|
12505
|
+
// out again and burn goal turns, and the goal loop routes around the
|
|
12506
|
+
// member while the registered resume owns the re-run.
|
|
12507
|
+
if (orchestration) {
|
|
12508
|
+
orchestration.result.outcome = "parked";
|
|
12509
|
+
orchestration.result.stopReason = `${bot.name} parked waiting for the computer`;
|
|
12510
|
+
}
|
|
12511
|
+
return false;
|
|
12512
|
+
}
|
|
12513
|
+
if (!roomSpeaker && !isSpendCap && !isVariantError && !isToolScopeError)
|
|
11794
12514
|
throw error;
|
|
11795
12515
|
const message = error instanceof Error ? error.message : "Local VM setup failed";
|
|
11796
|
-
|
|
11797
|
-
|
|
11798
|
-
|
|
11799
|
-
|
|
11800
|
-
|
|
11801
|
-
});
|
|
12516
|
+
store.appendMessage(threadId, {
|
|
12517
|
+
role: "bot", kind: "activity",
|
|
12518
|
+
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
12519
|
+
tool: { name: `error: ${message}`, ok: false },
|
|
12520
|
+
});
|
|
11802
12521
|
onDispatchError?.(message);
|
|
11803
12522
|
if (orchestration) {
|
|
11804
12523
|
// A spend-cap refusal is deterministic: record it as its own
|
|
@@ -11808,7 +12527,7 @@ setupRetry = 0) {
|
|
|
11808
12527
|
if (isSpendCap)
|
|
11809
12528
|
orchestration.result.stopReason = message;
|
|
11810
12529
|
}
|
|
11811
|
-
return
|
|
12530
|
+
return isToolScopeError;
|
|
11812
12531
|
}
|
|
11813
12532
|
finally {
|
|
11814
12533
|
// Covers connector/setup failures, cancellation before dispatch, and all
|
|
@@ -11829,6 +12548,7 @@ setupRetry = 0) {
|
|
|
11829
12548
|
}
|
|
11830
12549
|
drainQueuedSends();
|
|
11831
12550
|
drainConnectorResumes();
|
|
12551
|
+
drainComputerResumes();
|
|
11832
12552
|
drainSecretResumes();
|
|
11833
12553
|
drainTeamSetupResumes();
|
|
11834
12554
|
}
|
|
@@ -11887,6 +12607,12 @@ async function runGroupGoalStep(args) {
|
|
|
11887
12607
|
coordinatorTurn.turnId = turnId;
|
|
11888
12608
|
},
|
|
11889
12609
|
});
|
|
12610
|
+
// Busy here is a lost claim race — a 1:1 re-claiming the bot between
|
|
12611
|
+
// the wait settling and this turn starting — so wait and retry
|
|
12612
|
+
// in-step. A parked member is deliberately absent from both this
|
|
12613
|
+
// retry and the transient one below: the computer seat is still held,
|
|
12614
|
+
// an immediate retry would wait the ceiling out again while the
|
|
12615
|
+
// registered resume already owns re-running the member.
|
|
11890
12616
|
if (result.outcome === "busy")
|
|
11891
12617
|
continue;
|
|
11892
12618
|
// One retry for a transient provider failure: a 13-turn goal must not
|
|
@@ -11990,6 +12716,13 @@ async function runGroupGoalOperation(args) {
|
|
|
11990
12716
|
finishGroupGoalRun(args.groupId, args.operation, "blocked", `${coordinatorResult.stopReason ?? `${args.coordinator.name} stayed busy`} — send the goal again when they are free.`);
|
|
11991
12717
|
return;
|
|
11992
12718
|
}
|
|
12719
|
+
if (coordinatorResult.outcome === "parked") {
|
|
12720
|
+
// The lead parked at the computer wait ceiling: like lead-busy, the
|
|
12721
|
+
// run cannot route around them, but the parked lead turn itself
|
|
12722
|
+
// resumes through the registered entry once the seat frees.
|
|
12723
|
+
finishGroupGoalRun(args.groupId, args.operation, "blocked", `${coordinatorResult.stopReason ?? `${args.coordinator.name} parked waiting for the computer`} — send the goal again when the computer is free.`);
|
|
12724
|
+
return;
|
|
12725
|
+
}
|
|
11993
12726
|
if (!coordinatorResult.ran || coordinatorResult.outcome !== "settled") {
|
|
11994
12727
|
const reason = coordinatorResult.stopReason?.trim().slice(0, 120);
|
|
11995
12728
|
finishGroupGoalRun(args.groupId, args.operation, "failed", `${args.coordinator.name} could not complete the coordination step${reason ? ` — ${reason}` : ""}.`);
|
|
@@ -12047,9 +12780,10 @@ async function runGroupGoalOperation(args) {
|
|
|
12047
12780
|
finishGroupGoalRun(args.groupId, args.operation, "blocked", workerResult.stopReason ?? `${workerBot.name} hit the workspace spend cap.`);
|
|
12048
12781
|
return;
|
|
12049
12782
|
}
|
|
12050
|
-
if (workerResult.outcome === "busy") {
|
|
12051
|
-
// bounded: a team that keeps landing on busy
|
|
12052
|
-
// looping — three exhausted waits per run,
|
|
12783
|
+
if (workerResult.outcome === "busy" || workerResult.outcome === "parked") {
|
|
12784
|
+
// bounded: a team that keeps landing on busy or computer-parked
|
|
12785
|
+
// teammates is blocked, not looping — three exhausted waits per run,
|
|
12786
|
+
// then stop and say so
|
|
12053
12787
|
waitExhaustions += 1;
|
|
12054
12788
|
if (waitExhaustions >= GROUP_GOAL_MAX_WAIT_EXHAUSTIONS) {
|
|
12055
12789
|
finishGroupGoalRun(args.groupId, args.operation, "blocked", `Teammates stayed busy past the wait limit ${waitExhaustions} times — try again when the team is free.`);
|
|
@@ -12944,6 +13678,95 @@ function drainConnectorResumes() {
|
|
|
12944
13678
|
dispatchConnectorResume(entry);
|
|
12945
13679
|
}
|
|
12946
13680
|
}
|
|
13681
|
+
const pendingComputerResumes = new Map();
|
|
13682
|
+
function registerComputerResume(entry) {
|
|
13683
|
+
// One per thread: the newest park replaces an older one.
|
|
13684
|
+
pendingComputerResumes.set(entry.threadId, entry);
|
|
13685
|
+
// The seat may already be free (it freed before this park registered):
|
|
13686
|
+
// an idle install would otherwise never drain. #1651.
|
|
13687
|
+
queueMicrotask(drainComputerResumes);
|
|
13688
|
+
}
|
|
13689
|
+
function markComputerResumeFailed(entry, message) {
|
|
13690
|
+
const owner = connectorThread(entry.botId, entry.threadId);
|
|
13691
|
+
if (!owner)
|
|
13692
|
+
return;
|
|
13693
|
+
store.appendMessage(entry.threadId, {
|
|
13694
|
+
role: "bot", kind: "activity",
|
|
13695
|
+
...(owner.group ? { from: { botId: owner.bot.id, name: owner.bot.name, color: owner.bot.color } } : {}),
|
|
13696
|
+
tool: { name: `Parked turn could not continue: ${message.slice(0, 160)}`, ok: false },
|
|
13697
|
+
});
|
|
13698
|
+
}
|
|
13699
|
+
function dispatchComputerResume(entry) {
|
|
13700
|
+
const owner = connectorThread(entry.botId, entry.threadId);
|
|
13701
|
+
if (!owner)
|
|
13702
|
+
return;
|
|
13703
|
+
const prompt = "OpenMausBot computer update: the computer this conversation waited for is free again. Continue the task that parked waiting for it.";
|
|
13704
|
+
if (owner.group) {
|
|
13705
|
+
const groupId = owner.group.id;
|
|
13706
|
+
const operation = beginGroupTurnOperation(groupId, entry.threadId, [entry.botId]);
|
|
13707
|
+
const previous = groupQueues.get(groupId) ?? Promise.resolve();
|
|
13708
|
+
const next = previous.then(async () => {
|
|
13709
|
+
if (operation.cancelled)
|
|
13710
|
+
return;
|
|
13711
|
+
const current = connectorThread(entry.botId, entry.threadId);
|
|
13712
|
+
if (!current?.group)
|
|
13713
|
+
return;
|
|
13714
|
+
if (current.bot.busy) {
|
|
13715
|
+
pendingComputerResumes.set(entry.threadId, entry);
|
|
13716
|
+
return;
|
|
13717
|
+
}
|
|
13718
|
+
await runGroupMemberTurn(current.group.id, entry.threadId, entry.botId, 0, new Set(), prompt, (message) => markComputerResumeFailed(entry, message), () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation));
|
|
13719
|
+
});
|
|
13720
|
+
const tracked = next.finally(() => finishGroupTurnOperation(groupId, operation));
|
|
13721
|
+
groupQueues.set(groupId, tracked.catch((error) => {
|
|
13722
|
+
markComputerResumeFailed(entry, error instanceof Error ? error.message : String(error));
|
|
13723
|
+
}));
|
|
13724
|
+
return;
|
|
13725
|
+
}
|
|
13726
|
+
void startTurn(entry.botId, prompt, {
|
|
13727
|
+
threadId: entry.threadId,
|
|
13728
|
+
cardContinuation: true,
|
|
13729
|
+
onDispatchError: (message) => markComputerResumeFailed(entry, message),
|
|
13730
|
+
}).catch((error) => {
|
|
13731
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
13732
|
+
if (isTurnAdmissionBlocked(error))
|
|
13733
|
+
pendingComputerResumes.set(entry.threadId, entry);
|
|
13734
|
+
else
|
|
13735
|
+
markComputerResumeFailed(entry, message);
|
|
13736
|
+
});
|
|
13737
|
+
}
|
|
13738
|
+
function drainComputerResumes() {
|
|
13739
|
+
for (const [key, entry] of pendingComputerResumes) {
|
|
13740
|
+
if (!connectorThread(entry.botId, entry.threadId)) {
|
|
13741
|
+
pendingComputerResumes.delete(key);
|
|
13742
|
+
continue;
|
|
13743
|
+
}
|
|
13744
|
+
// A newer ask replaced the parked work: the resume is stale, not blocked.
|
|
13745
|
+
const latestUser = store.activePath(entry.threadId).findLast((message) => message.role === "user")?.id;
|
|
13746
|
+
if (entry.afterMessageId !== undefined && latestUser !== entry.afterMessageId) {
|
|
13747
|
+
pendingComputerResumes.delete(key);
|
|
13748
|
+
continue;
|
|
13749
|
+
}
|
|
13750
|
+
// A newer generation owns the thread — a new turn superseded the parked
|
|
13751
|
+
// one — so the parked resume is stale too. The parked turn's OWN
|
|
13752
|
+
// generation is still current while it settles: the lazy-claim park
|
|
13753
|
+
// registers its resume before the interrupt lands, and a drain in that
|
|
13754
|
+
// window must keep the entry (#1651), not read busy as superseded.
|
|
13755
|
+
const activeGeneration = activeInternalGenerationByThread.get(entry.threadId);
|
|
13756
|
+
if (activeGeneration !== undefined && activeGeneration !== entry.generation) {
|
|
13757
|
+
pendingComputerResumes.delete(key);
|
|
13758
|
+
continue;
|
|
13759
|
+
}
|
|
13760
|
+
// Busy under the parked generation: the turn is still settling. Keep the
|
|
13761
|
+
// entry; the next drain (its completion, the next release) dispatches.
|
|
13762
|
+
if (threadBusy(entry.botId, entry.threadId))
|
|
13763
|
+
continue;
|
|
13764
|
+
if (!turnResources.free(entry.resource))
|
|
13765
|
+
continue;
|
|
13766
|
+
pendingComputerResumes.delete(key);
|
|
13767
|
+
dispatchComputerResume(entry);
|
|
13768
|
+
}
|
|
13769
|
+
}
|
|
12947
13770
|
const pendingSecretResumes = new Map();
|
|
12948
13771
|
const phoneSecretSubmissions = new PhoneSecretSubmissionRegistry();
|
|
12949
13772
|
function claimPhoneSecretBotDeletion(botId) {
|
|
@@ -13175,6 +13998,7 @@ bus.subscribe((event) => {
|
|
|
13175
13998
|
return;
|
|
13176
13999
|
if (event.type === "turn.completed") {
|
|
13177
14000
|
drainConnectorResumes();
|
|
14001
|
+
drainComputerResumes();
|
|
13178
14002
|
drainSecretResumes();
|
|
13179
14003
|
drainTeamSetupResumes();
|
|
13180
14004
|
}
|
|
@@ -13382,6 +14206,8 @@ function configStatus() {
|
|
|
13382
14206
|
// the decision model: switches and configured-or-not, never the key
|
|
13383
14207
|
decider: describeDecider(cfg),
|
|
13384
14208
|
imageGen: avatarImageStatus(cfg),
|
|
14209
|
+
// Live calls: configured-or-not only; the voice name is a setting
|
|
14210
|
+
live: liveSettingsFor(cfg),
|
|
13385
14211
|
// not a secret — the sidebar shows it
|
|
13386
14212
|
profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
|
|
13387
14213
|
// the enrolled organisation's read-only desktop policy; null when not enrolled
|
|
@@ -13407,6 +14233,7 @@ function configStatus() {
|
|
|
13407
14233
|
},
|
|
13408
14234
|
features: {
|
|
13409
14235
|
skillAuthoring: skillAuthoringEnabled(cfg),
|
|
14236
|
+
skillsLibrary: skillsLibraryEnabled(cfg),
|
|
13410
14237
|
showToolCalls: showToolCallsEnabled(cfg),
|
|
13411
14238
|
routinesInConversation: routinesInConversationEnabled(cfg),
|
|
13412
14239
|
browser: builtInBrowserEnabled(cfg),
|
|
@@ -13708,6 +14535,7 @@ async function reloadProviders() {
|
|
|
13708
14535
|
// queued behind them drains now — onto the freshly loaded fleet
|
|
13709
14536
|
drainQueuedSends();
|
|
13710
14537
|
drainConnectorResumes();
|
|
14538
|
+
drainComputerResumes();
|
|
13711
14539
|
drainSecretResumes();
|
|
13712
14540
|
drainTeamSetupResumes();
|
|
13713
14541
|
}
|
|
@@ -13864,6 +14692,102 @@ ROUTES.push(createAntigravityLeftoverRoutes({
|
|
|
13864
14692
|
remove: () => removeAntigravityLeftovers(),
|
|
13865
14693
|
}));
|
|
13866
14694
|
ROUTES.push(desktopViewer.route);
|
|
14695
|
+
// Live calls (GPT-Live as the voice, the bot as the brain). A client holds
|
|
14696
|
+
// the WebRTC audio; the harness creates the session with the key (which
|
|
14697
|
+
// never leaves it) and runs the call in LiveCallController.
|
|
14698
|
+
/** Who the voice speaks for, for its instructions. */
|
|
14699
|
+
function liveBotFor(botId) {
|
|
14700
|
+
const bot = store.bot(botId);
|
|
14701
|
+
if (!bot)
|
|
14702
|
+
throw new LiveSessionError("That bot no longer exists.", 404);
|
|
14703
|
+
return { name: bot.name, title: bot.title, description: bot.description };
|
|
14704
|
+
}
|
|
14705
|
+
/** The chat's text so far, so the voice can follow "and the other one?". */
|
|
14706
|
+
function liveHistoryFor(threadId) {
|
|
14707
|
+
return store.activePath(threadId)
|
|
14708
|
+
.filter((message) => message.kind === "text" && typeof message.text === "string" && (message.role === "user" || message.role === "bot"))
|
|
14709
|
+
.map((message) => ({ role: message.role === "user" ? "user" : "assistant", text: message.text ?? "" }));
|
|
14710
|
+
}
|
|
14711
|
+
/** A call outlives the request that started it: a signed-out or removed
|
|
14712
|
+
* person's call must not keep reaching the bot (or spending the key). */
|
|
14713
|
+
function liveSignedIn(auth) {
|
|
14714
|
+
return auth.kind !== "session" || sessions.isLive(auth.session.id);
|
|
14715
|
+
}
|
|
14716
|
+
const liveCalls = new LiveCallController({
|
|
14717
|
+
store,
|
|
14718
|
+
send: async ({ auth, botId, threadId, text }) => {
|
|
14719
|
+
// the controller ends the call on this error
|
|
14720
|
+
if (!liveSignedIn(auth))
|
|
14721
|
+
throw new LiveCallSignedOutError();
|
|
14722
|
+
const receipt = await acceptDirectSend({
|
|
14723
|
+
botId, threadId, text,
|
|
14724
|
+
sendId: randomUUID().replaceAll("-", ""),
|
|
14725
|
+
sender: messageSender(auth),
|
|
14726
|
+
trigger: usageTriggerFor(auth),
|
|
14727
|
+
via: "call",
|
|
14728
|
+
// a person is on the call: these are their words
|
|
14729
|
+
personPresent: true,
|
|
14730
|
+
});
|
|
14731
|
+
if ("queued" in receipt)
|
|
14732
|
+
return { kind: "queued", queueId: receipt.queueId };
|
|
14733
|
+
return { kind: receipt.steered ? "steered" : "started", messageId: receipt.message.id };
|
|
14734
|
+
},
|
|
14735
|
+
respond: async (input) => {
|
|
14736
|
+
const result = await respondToCard(input);
|
|
14737
|
+
if (result.ok)
|
|
14738
|
+
return { ok: true };
|
|
14739
|
+
// respondToCard's 401: the session that started the call has ended
|
|
14740
|
+
if (result.status === 401)
|
|
14741
|
+
throw new LiveCallSignedOutError();
|
|
14742
|
+
return { ok: false, error: result.error };
|
|
14743
|
+
},
|
|
14744
|
+
// send() queues through the steer queue; an edit or cancel there removes a request undelivered
|
|
14745
|
+
queued: isSteeredMessageQueued,
|
|
14746
|
+
signedIn: liveSignedIn,
|
|
14747
|
+
// the caller's own typed lines carry this sender (none for the owner)
|
|
14748
|
+
personKey: (auth) => messageSender(auth)?.id,
|
|
14749
|
+
activity: (botId, threadId) => {
|
|
14750
|
+
const task = store.taskByThread(botId, threadId);
|
|
14751
|
+
if (!task?.busy)
|
|
14752
|
+
return "idle";
|
|
14753
|
+
return task.activity === "waiting-on-you" ? "waiting" : "working";
|
|
14754
|
+
},
|
|
14755
|
+
broadcast: (frame) => broadcast(frame, { adminOnly: true }),
|
|
14756
|
+
settings: () => ({ key: cfg.live?.key ?? "", ...liveSettingsFor(cfg) }),
|
|
14757
|
+
createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId) }),
|
|
14758
|
+
// Node's WebSocket (undici) accepts headers in its second argument.
|
|
14759
|
+
openSocket: (url, key) => new WebSocket(url, { headers: { authorization: `Bearer ${key}` } }),
|
|
14760
|
+
attachUrl: (sessionId) => liveAttachUrl(sessionId),
|
|
14761
|
+
speakable: (text) => toUtterances(text),
|
|
14762
|
+
log: (line) => console.log(line),
|
|
14763
|
+
});
|
|
14764
|
+
// A signed-out or revoked sign-in ends the call it started at once (the idle
|
|
14765
|
+
// check would only notice within 15 s). A paired phone's unpairing arrives
|
|
14766
|
+
// from the companion instead (POST /api/live/device-revoked).
|
|
14767
|
+
sessions.onSessionRevoked((sessionId) => liveCalls.sessionRevoked(sessionId));
|
|
14768
|
+
ROUTES.push(createLiveRoutes({
|
|
14769
|
+
calls: liveCalls,
|
|
14770
|
+
resolveTarget: (botId, threadId) => {
|
|
14771
|
+
const bot = store.bot(botId);
|
|
14772
|
+
if (!bot)
|
|
14773
|
+
return null;
|
|
14774
|
+
const target = threadId ?? bot.threadId;
|
|
14775
|
+
if (store.botByThread(target)?.id !== bot.id)
|
|
14776
|
+
return null;
|
|
14777
|
+
return { botId: bot.id, botName: bot.name, threadId: target };
|
|
14778
|
+
},
|
|
14779
|
+
settings: () => liveSettingsFor(cfg),
|
|
14780
|
+
// Non-secret settings only, written the way PUT /api/config writes a
|
|
14781
|
+
// section: saveConfig merges into `live`, so the key stays where it is.
|
|
14782
|
+
saveSettings: async (patch) => {
|
|
14783
|
+
if (providerConfigBusy)
|
|
14784
|
+
throw Object.assign(new Error("Settings are already being updated. Try again in a moment."), { status: 409 });
|
|
14785
|
+
saveConfig({ live: patch });
|
|
14786
|
+
Object.assign(cfg, loadConfig());
|
|
14787
|
+
broadcast({ kind: "config", ...configStatus() });
|
|
14788
|
+
return liveSettingsFor(cfg);
|
|
14789
|
+
},
|
|
14790
|
+
}));
|
|
13867
14791
|
const toolResults = new ToolResults();
|
|
13868
14792
|
const handleRequest = async (req, res) => {
|
|
13869
14793
|
let url;
|
|
@@ -13920,7 +14844,7 @@ const handleRequest = async (req, res) => {
|
|
|
13920
14844
|
if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
|
|
13921
14845
|
return;
|
|
13922
14846
|
if (method === "GET" && path === "/.well-known/openmausbot/environment") {
|
|
13923
|
-
return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
|
|
14847
|
+
return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled(), cloudHome: Boolean(CLOUD_HOME) }));
|
|
13924
14848
|
}
|
|
13925
14849
|
const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
|
|
13926
14850
|
if (method === "GET" && domainCheck) {
|
|
@@ -14019,7 +14943,7 @@ const handleRequest = async (req, res) => {
|
|
|
14019
14943
|
console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
|
|
14020
14944
|
return json(res, result.status, { error: result.error });
|
|
14021
14945
|
}
|
|
14022
|
-
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() });
|
|
14946
|
+
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled(), cloudHome: Boolean(CLOUD_HOME) });
|
|
14023
14947
|
if (wantsCookie) {
|
|
14024
14948
|
// A browser sign-in replaces this browser's own session here, if it had one, rather than leaving it behind.
|
|
14025
14949
|
const previous = browser ? sessions.authenticate(parseCookies(req.headers.cookie).get(SESSION_COOKIE)) : null;
|
|
@@ -14428,7 +15352,7 @@ const handleRequest = async (req, res) => {
|
|
|
14428
15352
|
? "phone"
|
|
14429
15353
|
: path.startsWith("/api/internal/connectors/")
|
|
14430
15354
|
? "connectors"
|
|
14431
|
-
: path === "/api/internal/computer-control"
|
|
15355
|
+
: path === "/api/internal/computer-control" || path === "/api/internal/computer/mcp"
|
|
14432
15356
|
? "computer"
|
|
14433
15357
|
: "agents";
|
|
14434
15358
|
if (internalCapability.kind !== requiredCapabilityKind) {
|
|
@@ -14585,7 +15509,7 @@ const handleRequest = async (req, res) => {
|
|
|
14585
15509
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
14586
15510
|
const body = await readInternalBody();
|
|
14587
15511
|
const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
|
|
14588
|
-
return json(res, result.ok ? 200 : result.code === "conflict" ? 409 :
|
|
15512
|
+
return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : 400, result);
|
|
14589
15513
|
}
|
|
14590
15514
|
if (method === "POST" && path === "/api/internal/memory/log") {
|
|
14591
15515
|
const body = await readInternalBody();
|
|
@@ -14619,6 +15543,22 @@ const handleRequest = async (req, res) => {
|
|
|
14619
15543
|
requireActiveInternalCapability();
|
|
14620
15544
|
return json(res, 200, { result });
|
|
14621
15545
|
}
|
|
15546
|
+
// The cloud computer's tools (harness-mcp-proxy computer): the turn's
|
|
15547
|
+
// capability names its Boat, and the Boat credential stays here.
|
|
15548
|
+
if (method === "POST" && path === "/api/internal/computer/mcp") {
|
|
15549
|
+
const body = await readInternalBody();
|
|
15550
|
+
const boxId = internalCapability.boxId;
|
|
15551
|
+
if (!boxId)
|
|
15552
|
+
return json(res, 403, { error: "this turn has no cloud computer" });
|
|
15553
|
+
const abort = new AbortController();
|
|
15554
|
+
res.once("close", () => { if (!res.writableEnded)
|
|
15555
|
+
abort.abort(); });
|
|
15556
|
+
return json(res, 200, { result: await cloudComputerRpc(body, {
|
|
15557
|
+
cfg, boxId, signal: abort.signal,
|
|
15558
|
+
gate: () => computerCallGate(internalCapability),
|
|
15559
|
+
assertActive: requireActiveInternalCapability,
|
|
15560
|
+
}) });
|
|
15561
|
+
}
|
|
14622
15562
|
if (method === "POST" && path === "/api/internal/phone/claim") {
|
|
14623
15563
|
// Lazy phone exclusivity (issue #1663): the turn holds computer:phone
|
|
14624
15564
|
// from its first real tool call until it settles. The proxy answers a
|
|
@@ -15000,6 +15940,58 @@ const handleRequest = async (req, res) => {
|
|
|
15000
15940
|
});
|
|
15001
15941
|
return json(res, 201, proposed);
|
|
15002
15942
|
}
|
|
15943
|
+
if (method === "POST" && path === "/api/internal/team-memory") {
|
|
15944
|
+
const parsed = z.object({
|
|
15945
|
+
fromBotId: z.string().min(1).max(128),
|
|
15946
|
+
fromThreadId: z.string().min(1).max(128),
|
|
15947
|
+
kind: z.string(),
|
|
15948
|
+
name: z.string(),
|
|
15949
|
+
detail: z.string(),
|
|
15950
|
+
aliases: z.array(z.string()).optional(),
|
|
15951
|
+
}).strict().safeParse(await readInternalBody());
|
|
15952
|
+
if (!parsed.success)
|
|
15953
|
+
return json(res, 400, { error: "invalid team memory proposal" });
|
|
15954
|
+
const body = parsed.data;
|
|
15955
|
+
const from = store.bot(body.fromBotId);
|
|
15956
|
+
if (!from)
|
|
15957
|
+
return json(res, 403, { error: "unknown sender" });
|
|
15958
|
+
const owner = connectorThread(from.id, body.fromThreadId);
|
|
15959
|
+
if (!owner)
|
|
15960
|
+
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
15961
|
+
if (!sameAudience(from.visibility, undefined) || (owner.group && !roomFeedsBot(owner.group, from))) {
|
|
15962
|
+
return json(res, 403, { error: "restricted conversations cannot publish section-wide memory; ask an admin to add it" });
|
|
15963
|
+
}
|
|
15964
|
+
if (!TEAM_MEMORY_KINDS.includes(body.kind)) {
|
|
15965
|
+
return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
|
|
15966
|
+
}
|
|
15967
|
+
let proposed;
|
|
15968
|
+
try {
|
|
15969
|
+
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() });
|
|
15970
|
+
}
|
|
15971
|
+
catch (error) {
|
|
15972
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
15973
|
+
}
|
|
15974
|
+
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}`;
|
|
15975
|
+
const section = sectionContextKey(from.section);
|
|
15976
|
+
const card = store.appendMessage(body.fromThreadId, {
|
|
15977
|
+
role: "bot",
|
|
15978
|
+
kind: "options",
|
|
15979
|
+
...(owner.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}),
|
|
15980
|
+
card: {
|
|
15981
|
+
title: "Remember this for the team?",
|
|
15982
|
+
subtitle: summary,
|
|
15983
|
+
options: ["Remember", "Skip"],
|
|
15984
|
+
requestId: proposed.entry.id,
|
|
15985
|
+
tool: "propose_team_memory",
|
|
15986
|
+
teamMemoryRequest: { section, entryId: proposed.entry.id, kind: proposed.entry.kind },
|
|
15987
|
+
},
|
|
15988
|
+
});
|
|
15989
|
+
appendDecision(DATA_DIR, {
|
|
15990
|
+
threadId: body.fromThreadId, requestId: proposed.entry.id, botId: from.id, botName: from.name,
|
|
15991
|
+
tool: "propose_team_memory", summary, decision: "card-shown", source: "team-memory",
|
|
15992
|
+
});
|
|
15993
|
+
return json(res, 201, { status: "proposed", requestId: proposed.entry.id, messageId: card.id, summary });
|
|
15994
|
+
}
|
|
15003
15995
|
if (method === "POST" && path === "/api/internal/profile-requests") {
|
|
15004
15996
|
const parsed = z.object({
|
|
15005
15997
|
fromBotId: z.string().min(1).max(128),
|
|
@@ -15290,7 +16282,7 @@ const handleRequest = async (req, res) => {
|
|
|
15290
16282
|
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
15291
16283
|
}
|
|
15292
16284
|
return json(res, 200, {
|
|
15293
|
-
skills:
|
|
16285
|
+
skills: listBotSkills(from),
|
|
15294
16286
|
staged: listStagedSkillWrites(from.id).map(stagedSkillListing),
|
|
15295
16287
|
});
|
|
15296
16288
|
}
|
|
@@ -15864,20 +16856,17 @@ const handleRequest = async (req, res) => {
|
|
|
15864
16856
|
})).filter(g => g.members.length);
|
|
15865
16857
|
return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
|
|
15866
16858
|
bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
|
|
15867
|
-
rooms, note: "
|
|
16859
|
+
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." });
|
|
15868
16860
|
}
|
|
15869
16861
|
if (method === "POST" && path === "/api/internal/coordinate-bots") {
|
|
15870
16862
|
const parsed = z.object({
|
|
15871
16863
|
groupId: z.string().min(1).max(128).optional(),
|
|
15872
16864
|
botIds: z.array(z.string().min(1).max(128)).min(1).max(4).refine(ids => new Set(ids).size === ids.length),
|
|
15873
|
-
message: z.string().trim().min(1).max(4000),
|
|
16865
|
+
message: z.string().trim().min(1).max(4000),
|
|
15874
16866
|
rework: z.boolean().default(false),
|
|
15875
|
-
// Only ever a name for a thread, so it travels under the same
|
|
15876
|
-
// one-line rule as a peer thread title.
|
|
15877
|
-
label: z.string().trim().min(1).max(60).refine(fitsOnOneLine).optional(),
|
|
15878
16867
|
}).safeParse(await readInternalBody());
|
|
15879
16868
|
if (!parsed.success)
|
|
15880
|
-
return json(res, 400, { error: "Provide 1-4 distinct botIds
|
|
16869
|
+
return json(res, 400, { error: "Provide 1-4 distinct botIds and a message of 1-4000 characters." });
|
|
15881
16870
|
const groupId = parsed.data.groupId ?? source?.id;
|
|
15882
16871
|
const destination = groupId ? store.group(groupId) : undefined;
|
|
15883
16872
|
if (groupId && !destination)
|
|
@@ -15929,60 +16918,63 @@ const handleRequest = async (req, res) => {
|
|
|
15929
16918
|
});
|
|
15930
16919
|
approvalGranted = true;
|
|
15931
16920
|
}
|
|
15932
|
-
|
|
15933
|
-
const
|
|
16921
|
+
// A room shows one brief per call, addressed to the call's recipients.
|
|
16922
|
+
const requestBatchKey = destination ? randomUUID() : undefined;
|
|
15934
16923
|
// One receipt per recipient, in the order the caller addressed
|
|
15935
16924
|
// them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
|
|
15936
|
-
// enqueue is honestly "queued"; a
|
|
15937
|
-
//
|
|
16925
|
+
// enqueue is honestly "queued"; a repeat reports the request it
|
|
16926
|
+
// repeats.
|
|
15938
16927
|
const receipts = [];
|
|
16928
|
+
// Why nothing was sent, in full: a receipt's detail is clipped.
|
|
16929
|
+
const refused = [];
|
|
15939
16930
|
for (const target of targets) {
|
|
15940
16931
|
let createdThread;
|
|
15941
16932
|
try {
|
|
15942
16933
|
requireActiveInternalCapability();
|
|
16934
|
+
// Outside a room this conversation has one thread with each
|
|
16935
|
+
// teammate: the first request opens it, everything after
|
|
16936
|
+
// continues it and runs once the work already there is done.
|
|
16937
|
+
const continued = destination ? undefined : store.workThread(target.botId, internalSender.id, address.threadId);
|
|
15943
16938
|
if (!destination) {
|
|
15944
|
-
const task = store.createTask(target.botId, `@${internalSender.name}
|
|
16939
|
+
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() });
|
|
15945
16940
|
if (!task)
|
|
15946
16941
|
throw new Error("The recipient no longer exists");
|
|
15947
|
-
target.threadId =
|
|
15948
|
-
if (
|
|
15949
|
-
|
|
15950
|
-
|
|
16942
|
+
target.threadId = task.threadId;
|
|
16943
|
+
if (!continued)
|
|
16944
|
+
createdThread = task.threadId;
|
|
16945
|
+
applyDelegatedLevel(internalSender, internalCapability.threadId, store.bot(target.botId), target.threadId);
|
|
15951
16946
|
}
|
|
15952
|
-
const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target,
|
|
15953
|
-
// A
|
|
15954
|
-
|
|
16947
|
+
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);
|
|
16948
|
+
// A repeat lands on the request already made, possibly in a
|
|
16949
|
+
// thread the person archived meanwhile; a thread opened for it
|
|
16950
|
+
// is never used.
|
|
16951
|
+
if (duplicate && createdThread)
|
|
15955
16952
|
store.deleteTask(target.botId, createdThread);
|
|
15956
|
-
|
|
15957
|
-
if (!duplicate && createdThread)
|
|
16953
|
+
else if (createdThread)
|
|
15958
16954
|
threadStarters.set(createdThread, openerFrom(address.threadId));
|
|
15959
16955
|
createdThread = undefined; // The durable coordinator now owns this task.
|
|
15960
|
-
|
|
15961
|
-
//
|
|
15962
|
-
//
|
|
15963
|
-
|
|
15964
|
-
|
|
15965
|
-
|
|
16956
|
+
// Only a live request can still report: a finished one already
|
|
16957
|
+
// fired its report and tick() skips it, so a repeat of it sends
|
|
16958
|
+
// nothing and says how to run it again.
|
|
16959
|
+
const title = destination ? undefined : store.taskByThread(node.botId, node.threadId)?.title;
|
|
16960
|
+
const finished = {
|
|
16961
|
+
completed: "it already finished and its result stands; send it with rework=true to run it again",
|
|
16962
|
+
failed: "it failed; send it with rework=true to retry", cancelled: "it was cancelled; send it with rework=true to retry",
|
|
16963
|
+
};
|
|
16964
|
+
const notSent = duplicate && finished[node.status] ? `not sent again: ${finished[node.status]}` : undefined;
|
|
16965
|
+
if (notSent)
|
|
16966
|
+
refused.push(notSent);
|
|
15966
16967
|
receipts.push(peerDeliveryReceipt({
|
|
15967
16968
|
botId: node.botId,
|
|
15968
16969
|
botName: store.bot(node.botId)?.name,
|
|
15969
|
-
outcome: duplicate
|
|
15970
|
-
|
|
15971
|
-
|
|
15972
|
-
|
|
15973
|
-
|
|
15974
|
-
|
|
15975
|
-
: "queued",
|
|
15976
|
-
detail: duplicate
|
|
15977
|
-
? node.status === "running"
|
|
15978
|
-
? "duplicate request_key — this teammate is already running that exact assignment"
|
|
15979
|
-
: node.status === "completed"
|
|
15980
|
-
? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
|
|
15981
|
-
: node.status === "failed" || node.status === "cancelled"
|
|
15982
|
-
? `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`
|
|
15983
|
-
: "duplicate request_key — already in flight; its result will resume you automatically"
|
|
15984
|
-
: "handed to the coordinator; the teammate's turn has not started yet",
|
|
16970
|
+
outcome: notSent ? "failed" : !duplicate || node.status === "queued" ? "queued" : "injected",
|
|
16971
|
+
detail: notSent ?? (duplicate
|
|
16972
|
+
? `already sent: it is ${node.status === "queued" ? "queued" : "under way"} and its result will resume you`
|
|
16973
|
+
: title
|
|
16974
|
+
? `sent to "${title}"; it runs after anything still running there`
|
|
16975
|
+
: "handed to the coordinator; the teammate's turn has not started yet"),
|
|
15985
16976
|
requestId: node.id,
|
|
16977
|
+
...(destination ? {} : { threadId: node.threadId }),
|
|
15986
16978
|
}));
|
|
15987
16979
|
if (!duplicate) {
|
|
15988
16980
|
const recipient = store.bot(target.botId);
|
|
@@ -15999,14 +16991,13 @@ const handleRequest = async (req, res) => {
|
|
|
15999
16991
|
store.deleteTask(target.botId, createdThread);
|
|
16000
16992
|
}
|
|
16001
16993
|
const said = error instanceof Error ? error.message : String(error);
|
|
16002
|
-
|
|
16003
|
-
receipts.push(peerDeliveryReceipt({
|
|
16004
|
-
botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
|
|
16005
|
-
}));
|
|
16994
|
+
refused.push(said);
|
|
16995
|
+
receipts.push(peerDeliveryReceipt({ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said }));
|
|
16006
16996
|
}
|
|
16007
16997
|
}
|
|
16008
|
-
|
|
16009
|
-
|
|
16998
|
+
const sent = refused.length < receipts.length;
|
|
16999
|
+
return json(res, sent ? 200 : 409, { receipts,
|
|
17000
|
+
...(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("; ") }),
|
|
16010
17001
|
});
|
|
16011
17002
|
}
|
|
16012
17003
|
}
|
|
@@ -16218,11 +17209,15 @@ const handleRequest = async (req, res) => {
|
|
|
16218
17209
|
const target = store.bot(toBotId);
|
|
16219
17210
|
if (!target)
|
|
16220
17211
|
return json(res, 404, { error: "no such bot" });
|
|
17212
|
+
const oneWay = body.oneWay === true;
|
|
17213
|
+
if (oneWay && target.id === from.id) {
|
|
17214
|
+
return json(res, 409, { error: "send_to_bot is cross-bot only; use start_thread to send independent work to yourself" });
|
|
17215
|
+
}
|
|
16221
17216
|
if (internalCapability.roomCoordination) {
|
|
16222
|
-
if (target.id !== from.id) {
|
|
17217
|
+
if (target.id !== from.id && !oneWay) {
|
|
16223
17218
|
return json(res, 409, { error: "Use coordinate_bots for teamwork; it queues actual teammates and resumes you automatically." });
|
|
16224
17219
|
}
|
|
16225
|
-
if (!internalCapability.ownThreadCreation) {
|
|
17220
|
+
if (target.id === from.id && !internalCapability.ownThreadCreation) {
|
|
16226
17221
|
return json(res, 409, { error: "Only a direct user request can open separate self-owned threads. Finish this assigned work here; use coordinate_bots for teammates." });
|
|
16227
17222
|
}
|
|
16228
17223
|
}
|
|
@@ -16274,7 +17269,7 @@ const handleRequest = async (req, res) => {
|
|
|
16274
17269
|
// classic handoff has applies unchanged, here at queue time and
|
|
16275
17270
|
// again in the drain at dispatch time.
|
|
16276
17271
|
const depth = internalCapability.depth;
|
|
16277
|
-
if (depth >= MAX_COMMS_DEPTH) {
|
|
17272
|
+
if (!oneWay && depth >= MAX_COMMS_DEPTH) {
|
|
16278
17273
|
return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
|
|
16279
17274
|
}
|
|
16280
17275
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
@@ -16288,9 +17283,11 @@ const handleRequest = async (req, res) => {
|
|
|
16288
17283
|
return json(res, 500, { error: "couldn't create that thread" });
|
|
16289
17284
|
// The work is still for the person whose request the opener is on.
|
|
16290
17285
|
threadStarters.set(task.threadId, openerFrom(fromThreadId));
|
|
16291
|
-
|
|
16292
|
-
|
|
16293
|
-
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message
|
|
17286
|
+
applyDelegatedLevel(from, fromThreadId, target, task.threadId);
|
|
17287
|
+
const sourceUrl = threadRefUrl({ botId: owner.group?.id ?? from.id, threadId: fromThreadId });
|
|
17288
|
+
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message: oneWay
|
|
17289
|
+
? `${message}\n\n[Source conversation](${sourceUrl}). This link grants no additional access and contains no copied transcript.`
|
|
17290
|
+
: message, depth, targetThreadId: task.threadId, oneWay }, MAX_COMMS_DEPTH, fromThreadId);
|
|
16294
17291
|
if (queued.result !== "ok" || !queued.id) {
|
|
16295
17292
|
// nothing will ever run there: take the row back before the person
|
|
16296
17293
|
// sees a thread that goes nowhere
|
|
@@ -16598,7 +17595,7 @@ const handleRequest = async (req, res) => {
|
|
|
16598
17595
|
// Reading a streamed MCP body yields to ordinary settings requests.
|
|
16599
17596
|
// Re-read the live bot immediately before relay so turning Connected
|
|
16600
17597
|
// Apps off wins over a request that authenticated under the old value.
|
|
16601
|
-
|
|
17598
|
+
let currentSender = store.bot(internalCapability.botId);
|
|
16602
17599
|
if (!currentSender || currentSender.composio === false || !composio.configured(cfg)) {
|
|
16603
17600
|
return json(res, 403, { error: "connected apps are not enabled for this bot" });
|
|
16604
17601
|
}
|
|
@@ -16609,6 +17606,7 @@ const handleRequest = async (req, res) => {
|
|
|
16609
17606
|
// Rows are fire-and-forget: a log failure must never take the call
|
|
16610
17607
|
// (or its refusal) down with it.
|
|
16611
17608
|
const call = connectorCallFromFrame(body);
|
|
17609
|
+
let serviceSlugs = [];
|
|
16612
17610
|
// A bot with no grants record keeps the pre-grants relay: even an
|
|
16613
17611
|
// unreadable frame passes through exactly as it always did.
|
|
16614
17612
|
if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
|
|
@@ -16635,7 +17633,11 @@ const handleRequest = async (req, res) => {
|
|
|
16635
17633
|
// underscored service (bland_ai) keeps its own tools instead of
|
|
16636
17634
|
// a plain-prefix grant (bland) capturing them; an unreachable
|
|
16637
17635
|
// catalog reads as empty and the plain split stands.
|
|
16638
|
-
|
|
17636
|
+
serviceSlugs = await composio.connectedServiceSlugs(cfg);
|
|
17637
|
+
requireActiveInternalCapability();
|
|
17638
|
+
currentSender = store.bot(internalCapability.botId);
|
|
17639
|
+
if (!currentSender || currentSender.composio === false || !composio.configured(cfg))
|
|
17640
|
+
return json(res, 403, { error: "connected apps are no longer enabled for this bot" });
|
|
16639
17641
|
const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
|
|
16640
17642
|
if (!verdict.allowed) {
|
|
16641
17643
|
for (const denial of verdict.denials) {
|
|
@@ -16675,9 +17677,174 @@ const handleRequest = async (req, res) => {
|
|
|
16675
17677
|
rule: verdict.rule,
|
|
16676
17678
|
});
|
|
16677
17679
|
}
|
|
16678
|
-
|
|
16679
|
-
|
|
16680
|
-
|
|
17680
|
+
// ── the outbound gate ──
|
|
17681
|
+
// A tools/call that sends something (shared/outbound.ts) is its own
|
|
17682
|
+
// confirmation in every approval mode, Full included: it waits on a
|
|
17683
|
+
// card, or spends the bot's daily allowance. Everything else relays
|
|
17684
|
+
// untouched. A refusal is an ordinary tool error so the bot reads
|
|
17685
|
+
// it and carries on, rather than a transport failure it retries.
|
|
17686
|
+
const rpc = body && typeof body === "object" && !Array.isArray(body)
|
|
17687
|
+
? body
|
|
17688
|
+
: null;
|
|
17689
|
+
const rpcParams = rpc?.params && typeof rpc.params === "object" && !Array.isArray(rpc.params)
|
|
17690
|
+
? rpc.params
|
|
17691
|
+
: null;
|
|
17692
|
+
// The app tools this call would actually run, seen through Composio's
|
|
17693
|
+
// meta tools (a multi-execute list, workbench code), and the ones
|
|
17694
|
+
// among them that send.
|
|
17695
|
+
const outboundCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
|
|
17696
|
+
? outboundCallsIn(rpcParams.name, rpcParams.arguments)
|
|
17697
|
+
: [];
|
|
17698
|
+
const outboundTool = outboundCalls.length ? outboundCalls[0].slug : null;
|
|
17699
|
+
const refuseOutbound = (text) => {
|
|
17700
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
17701
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc?.id ?? null, result: { content: [{ type: "text", text }], isError: true } }));
|
|
17702
|
+
};
|
|
17703
|
+
// ── connection scopes ──
|
|
17704
|
+
// Checked first: an app this bot may not touch is refused outright,
|
|
17705
|
+
// never turned into a question for the person.
|
|
17706
|
+
const connectorCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
|
|
17707
|
+
? connectorCallsIn(rpcParams.name, rpcParams.arguments)
|
|
17708
|
+
: [];
|
|
17709
|
+
const access = connectorAccessDecision(currentSender.connectorScopes, connectorCalls, serviceSlugs);
|
|
17710
|
+
if (!access.ok) {
|
|
17711
|
+
const described = describeTool(access.slug);
|
|
17712
|
+
const appName = described.app ?? access.toolkit;
|
|
17713
|
+
appendDecision(DATA_DIR, {
|
|
17714
|
+
threadId: internalCapability.threadId, botId: currentSender.id, botName: currentSender.name,
|
|
17715
|
+
tool: access.slug, decision: "auto-denied", source: "connector-scope", rule: `scope:${access.toolkit}:${access.reason}`,
|
|
17716
|
+
});
|
|
17717
|
+
return refuseOutbound(access.reason === "app"
|
|
17718
|
+
? `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.`
|
|
17719
|
+
: `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.`);
|
|
17720
|
+
}
|
|
17721
|
+
const opaque = connectorCalls.some(call => call.slug === "COMPOSIO_PROXY_EXECUTE");
|
|
17722
|
+
if (opaque && currentSender.connectorTools !== undefined)
|
|
17723
|
+
return refuseOutbound("OpenMausBot cannot verify this code against this bot's tool grants. Use a direct app tool.");
|
|
17724
|
+
let outboundApproved = false;
|
|
17725
|
+
if (outboundTool) {
|
|
17726
|
+
const policy = currentSender.outbound ?? DEFAULT_OUTBOUND_POLICY;
|
|
17727
|
+
const threadId = internalCapability.threadId;
|
|
17728
|
+
const { app } = describeTool(outboundTool);
|
|
17729
|
+
const summary = outboundCalls.map((call) => {
|
|
17730
|
+
const described = describeTool(call.slug);
|
|
17731
|
+
const argsText = call.arguments === undefined ? "" : JSON.stringify(call.arguments);
|
|
17732
|
+
return `${described.app ? `${described.app} · ` : ""}${described.label}${argsText ? `\n${argsText.slice(0, 400)}${argsText.length > 400 ? "… [arguments truncated]" : ""}` : ""}`;
|
|
17733
|
+
}).join("\n\n");
|
|
17734
|
+
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.`;
|
|
17735
|
+
if (policy.policy === "allow" && !opaque) {
|
|
17736
|
+
const today = outboundCounts.today(currentSender.id);
|
|
17737
|
+
if (today + outboundCalls.length > policy.dailyCap) {
|
|
17738
|
+
appendDecision(DATA_DIR, {
|
|
17739
|
+
threadId, botId: currentSender.id, botName: currentSender.name,
|
|
17740
|
+
tool: outboundTool, summary, decision: "auto-denied", source: "outbound", rule: `daily-cap:${policy.dailyCap}`,
|
|
17741
|
+
});
|
|
17742
|
+
return refuseOutbound(capNote(policy.dailyCap));
|
|
17743
|
+
}
|
|
17744
|
+
}
|
|
17745
|
+
else {
|
|
17746
|
+
const owner = connectorThread(currentSender.id, threadId);
|
|
17747
|
+
const held = outboundRequests.open({ botId: currentSender.id, threadId, tool: outboundTool, timeoutMs: OUTBOUND_HOLD_MS });
|
|
17748
|
+
const card = store.appendMessage(threadId, {
|
|
17749
|
+
role: "bot",
|
|
17750
|
+
kind: "options",
|
|
17751
|
+
...(owner?.group ? { from: { botId: currentSender.id, name: currentSender.name, color: currentSender.color } } : {}),
|
|
17752
|
+
card: {
|
|
17753
|
+
title: "Send on your behalf?",
|
|
17754
|
+
subtitle: summary,
|
|
17755
|
+
options: ["Allow", "Deny"],
|
|
17756
|
+
requestId: held.requestId,
|
|
17757
|
+
tool: outboundTool,
|
|
17758
|
+
held: HELD_NOTE["approval.held.outbound"],
|
|
17759
|
+
heldCode: "approval.held.outbound",
|
|
17760
|
+
outboundRequest: { tool: outboundTool, app },
|
|
17761
|
+
},
|
|
17762
|
+
});
|
|
17763
|
+
appendDecision(DATA_DIR, {
|
|
17764
|
+
threadId, requestId: held.requestId, botId: currentSender.id, botName: currentSender.name,
|
|
17765
|
+
tool: outboundTool, summary, decision: "card-shown", source: "outbound",
|
|
17766
|
+
});
|
|
17767
|
+
if (owner?.group) {
|
|
17768
|
+
if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId)
|
|
17769
|
+
store.setActivity(currentSender.id, "waiting-on-you");
|
|
17770
|
+
}
|
|
17771
|
+
else if (store.taskByThread(currentSender.id, threadId)?.busy) {
|
|
17772
|
+
store.setTaskActivity(currentSender.id, threadId, "waiting-on-you");
|
|
17773
|
+
}
|
|
17774
|
+
notify(buildNotification("approval", currentSender, threadId, summary, { avatarUrl: currentSender.avatarUrl }));
|
|
17775
|
+
const answer = await held.answer;
|
|
17776
|
+
outboundRequests.forget(held.requestId);
|
|
17777
|
+
if (internalCapabilityIsActive(internalCapability)) {
|
|
17778
|
+
const waiting = store.bot(currentSender.id);
|
|
17779
|
+
if (owner?.group) {
|
|
17780
|
+
if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId && waiting?.activity === "waiting-on-you")
|
|
17781
|
+
store.setActivity(waiting.id, "working");
|
|
17782
|
+
}
|
|
17783
|
+
else if (store.taskByThread(currentSender.id, threadId)?.activity === "waiting-on-you") {
|
|
17784
|
+
store.setTaskActivity(currentSender.id, threadId, "working");
|
|
17785
|
+
}
|
|
17786
|
+
}
|
|
17787
|
+
if (answer !== "allow") {
|
|
17788
|
+
if (answer === "timeout") {
|
|
17789
|
+
const stale = store.messagesFor(threadId).find((message) => message.id === card.id);
|
|
17790
|
+
if (stale?.card && !stale.card.answered) {
|
|
17791
|
+
store.patchMessage(threadId, stale.id, { card: { ...stale.card, answered: "unavailable", dismissed: true } });
|
|
17792
|
+
}
|
|
17793
|
+
}
|
|
17794
|
+
return refuseOutbound(answer === "timeout"
|
|
17795
|
+
? "OpenMausBot did not send this: nobody answered the approval in time. Ask the user before trying again."
|
|
17796
|
+
: "OpenMausBot did not send this: the user declined. Do not retry it; ask the user what they would like instead.");
|
|
17797
|
+
}
|
|
17798
|
+
outboundApproved = true;
|
|
17799
|
+
requireActiveInternalCapability();
|
|
17800
|
+
}
|
|
17801
|
+
}
|
|
17802
|
+
let dispatchRefusal;
|
|
17803
|
+
const refuseDispatch = (text) => {
|
|
17804
|
+
dispatchRefusal = text;
|
|
17805
|
+
throw new Error(text);
|
|
17806
|
+
};
|
|
17807
|
+
let upstream;
|
|
17808
|
+
try {
|
|
17809
|
+
upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
|
|
17810
|
+
? req.headers["mcp-session-id"][0]
|
|
17811
|
+
: req.headers["mcp-session-id"], () => {
|
|
17812
|
+
requireActiveInternalCapability();
|
|
17813
|
+
const fresh = store.bot(internalCapability.botId);
|
|
17814
|
+
if (!fresh || fresh.composio === false || !composio.configured(cfg))
|
|
17815
|
+
refuseDispatch("Connected apps were disabled while this call waited.");
|
|
17816
|
+
const freshAccess = connectorAccessDecision(fresh.connectorScopes, connectorCalls, serviceSlugs);
|
|
17817
|
+
if (!freshAccess.ok || (opaque && fresh.connectorTools !== undefined) ||
|
|
17818
|
+
(call.kind === "tools" && !evaluateConnectorTools(call.names, fresh.connectorTools, serviceSlugs).allowed) ||
|
|
17819
|
+
(call.kind === "unrecognized" && fresh.connectorTools !== undefined)) {
|
|
17820
|
+
refuseDispatch("This bot's connected-app permissions changed. The call was not run.");
|
|
17821
|
+
}
|
|
17822
|
+
if (outboundTool) {
|
|
17823
|
+
const policy = fresh.outbound ?? DEFAULT_OUTBOUND_POLICY;
|
|
17824
|
+
if (policy.policy === "allow" && !opaque) {
|
|
17825
|
+
let reserved;
|
|
17826
|
+
try {
|
|
17827
|
+
reserved = outboundCounts.reserve(fresh.id, outboundCalls.length, policy.dailyCap);
|
|
17828
|
+
}
|
|
17829
|
+
catch {
|
|
17830
|
+
refuseDispatch("The daily allowance could not be saved. Nothing was sent.");
|
|
17831
|
+
}
|
|
17832
|
+
if (reserved === null)
|
|
17833
|
+
refuseDispatch("This bot has reached its daily outbound limit. Nothing was sent.");
|
|
17834
|
+
appendDecision(DATA_DIR, { threadId: internalCapability.threadId, botId: fresh.id, botName: fresh.name,
|
|
17835
|
+
tool: outboundTool, decision: "auto-approved", source: "outbound", rule: `daily-allowance:${reserved}/${policy.dailyCap}` });
|
|
17836
|
+
}
|
|
17837
|
+
else if (!outboundApproved) {
|
|
17838
|
+
refuseDispatch("The outbound policy changed. Ask for approval again before sending.");
|
|
17839
|
+
}
|
|
17840
|
+
}
|
|
17841
|
+
});
|
|
17842
|
+
}
|
|
17843
|
+
catch (error) {
|
|
17844
|
+
if (dispatchRefusal)
|
|
17845
|
+
return refuseOutbound(dispatchRefusal);
|
|
17846
|
+
throw error;
|
|
17847
|
+
}
|
|
16681
17848
|
const headers = {
|
|
16682
17849
|
"content-type": upstream.contentType,
|
|
16683
17850
|
"cache-control": "no-store",
|
|
@@ -16755,6 +17922,16 @@ const handleRequest = async (req, res) => {
|
|
|
16755
17922
|
if (!composio.configured(cfg) || owner.bot.composio === false) {
|
|
16756
17923
|
return json(res, 409, { error: "connected apps are not enabled for this bot" });
|
|
16757
17924
|
}
|
|
17925
|
+
if (owner.bot.connectorScopes) {
|
|
17926
|
+
const outside = slugs.find((slug) => !owner.bot.connectorScopes?.apps[slug]);
|
|
17927
|
+
if (outside) {
|
|
17928
|
+
appendDecision(DATA_DIR, {
|
|
17929
|
+
threadId, botId: owner.bot.id, botName: owner.bot.name,
|
|
17930
|
+
tool: "COMPOSIO_MANAGE_CONNECTIONS", summary: outside, decision: "auto-denied", source: "connector-scope", rule: `scope:${outside}:app`,
|
|
17931
|
+
});
|
|
17932
|
+
return json(res, 403, { error: `${owner.bot.name} has not been given access to ${outside}; allow it under the bot's Access settings first` });
|
|
17933
|
+
}
|
|
17934
|
+
}
|
|
16758
17935
|
const connectionState = await composio.connectionStatus(cfg, slugs).catch(() => ({}));
|
|
16759
17936
|
requireActiveInternalCapability();
|
|
16760
17937
|
const messageIds = [];
|
|
@@ -18115,6 +19292,7 @@ const handleRequest = async (req, res) => {
|
|
|
18115
19292
|
return true;
|
|
18116
19293
|
const openApprovalThreads = store.groupTasks(group.id).flatMap((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
|
|
18117
19294
|
message.card?.requestId &&
|
|
19295
|
+
(!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
|
|
18118
19296
|
!message.card.answered &&
|
|
18119
19297
|
!message.card.dismissed && !message.card.expired) ? [task.threadId] : []);
|
|
18120
19298
|
return openApprovalThreads.length > 0 && !openApprovalThreads.includes(targetThreadId);
|
|
@@ -18866,10 +20044,11 @@ const handleRequest = async (req, res) => {
|
|
|
18866
20044
|
if (created && !created.ok)
|
|
18867
20045
|
return json(res, 400, { error: created.error });
|
|
18868
20046
|
const bot = store.createBot({ ...profile.patch, soul: settings.soul, section, modelSelection: selection,
|
|
20047
|
+
toolScope: settings.toolScope ?? undefined,
|
|
18869
20048
|
...(created?.ok ? { visibility: created.visibility } : {}) });
|
|
18870
20049
|
const createdRoutines = [];
|
|
18871
20050
|
try {
|
|
18872
|
-
const { chiefOfStaff: _chief, managedSections: _managed, ...ordinary } = settings;
|
|
20051
|
+
const { chiefOfStaff: _chief, managedSections: _managed, toolScope: _toolScope, ...ordinary } = settings;
|
|
18873
20052
|
store.patchBot(bot.id, {
|
|
18874
20053
|
// Store creation completes workspace defaults (including effort).
|
|
18875
20054
|
// Applying the rest of the template must not undo that selection.
|
|
@@ -19255,6 +20434,43 @@ const handleRequest = async (req, res) => {
|
|
|
19255
20434
|
patch.section = section ?? undefined;
|
|
19256
20435
|
if (body.chiefOfStaff === false)
|
|
19257
20436
|
patch.chiefOfStaff = false;
|
|
20437
|
+
// where a task carries on when this engine is unavailable, in order
|
|
20438
|
+
if (body.fallback !== undefined) {
|
|
20439
|
+
if (!Array.isArray(body.fallback) || body.fallback.length > 5) {
|
|
20440
|
+
return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
|
|
20441
|
+
}
|
|
20442
|
+
const chain = [];
|
|
20443
|
+
for (const entry of body.fallback) {
|
|
20444
|
+
const row = entry && typeof entry === "object" ? entry : null;
|
|
20445
|
+
if (!row || typeof row.instanceId !== "string" || !/^[\w.-]+$/.test(row.instanceId) || typeof row.model !== "string" || !row.model.trim() || row.model.length > 500) {
|
|
20446
|
+
return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
|
|
20447
|
+
}
|
|
20448
|
+
if (!registry.get(row.instanceId))
|
|
20449
|
+
return json(res, 400, { error: `fallback engine "${row.instanceId}" is not available` });
|
|
20450
|
+
if (chain.some((seen) => seen.instanceId === row.instanceId))
|
|
20451
|
+
continue;
|
|
20452
|
+
chain.push({ instanceId: row.instanceId, model: row.model.trim() });
|
|
20453
|
+
}
|
|
20454
|
+
patch.fallback = chain;
|
|
20455
|
+
}
|
|
20456
|
+
// which connected apps this bot may use; null clears back to all of them
|
|
20457
|
+
if (body.connectorScopes !== undefined) {
|
|
20458
|
+
if (body.connectorScopes === null)
|
|
20459
|
+
patch.connectorScopes = undefined;
|
|
20460
|
+
else {
|
|
20461
|
+
const scopes = normalizeConnectorScopes(body.connectorScopes);
|
|
20462
|
+
if (!scopes)
|
|
20463
|
+
return json(res, 400, { error: "connectorScopes must be { apps: { <toolkit>: read | write } } or null" });
|
|
20464
|
+
patch.connectorScopes = scopes;
|
|
20465
|
+
}
|
|
20466
|
+
}
|
|
20467
|
+
// sending on the person's behalf: ask every time, or a daily allowance
|
|
20468
|
+
if (body.outbound !== undefined) {
|
|
20469
|
+
const policy = normalizeOutboundPolicy(body.outbound);
|
|
20470
|
+
if (!policy)
|
|
20471
|
+
return json(res, 400, { error: "outbound must be { policy: ask | allow, dailyCap: 1..1000 }" });
|
|
20472
|
+
patch.outbound = policy;
|
|
20473
|
+
}
|
|
19258
20474
|
// per-bot gate on the workspace's connected apps (Composio)
|
|
19259
20475
|
if (body.composio !== undefined) {
|
|
19260
20476
|
if (typeof body.composio !== "boolean")
|
|
@@ -19309,6 +20525,12 @@ const handleRequest = async (req, res) => {
|
|
|
19309
20525
|
// Per-bot selection of app-wide MCP servers. Omitted keeps the current
|
|
19310
20526
|
// selection; null restores all enabled servers; [] explicitly mounts none.
|
|
19311
20527
|
let requestedMcpServers = existingBot?.mcpServers;
|
|
20528
|
+
if (Object.hasOwn(body, "toolScope")) {
|
|
20529
|
+
const parsed = parseToolScope(body.toolScope === null ? undefined : body.toolScope);
|
|
20530
|
+
if (!parsed.ok)
|
|
20531
|
+
return json(res, 400, { error: parsed.error });
|
|
20532
|
+
patch.toolScope = parsed.scope;
|
|
20533
|
+
}
|
|
19312
20534
|
if (body.mcpServers !== undefined) {
|
|
19313
20535
|
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
19314
20536
|
return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
|
|
@@ -19560,6 +20782,16 @@ const handleRequest = async (req, res) => {
|
|
|
19560
20782
|
// desktop capability or a paired session, which is what the refusal
|
|
19561
20783
|
// points at.
|
|
19562
20784
|
const loosened = [];
|
|
20785
|
+
// These three fields were validated above before joining the broad patch.
|
|
20786
|
+
const trustPatch = patch;
|
|
20787
|
+
if (body.outbound !== undefined && trustPatch.outbound?.policy === "allow" &&
|
|
20788
|
+
(existingBot?.outbound?.policy !== "allow" || trustPatch.outbound.dailyCap > existingBot.outbound.dailyCap))
|
|
20789
|
+
loosened.push("outbound");
|
|
20790
|
+
if (body.connectorScopes !== undefined && existingBot?.connectorScopes &&
|
|
20791
|
+
(!trustPatch.connectorScopes || Object.entries(trustPatch.connectorScopes.apps).some(([slug, scope]) => !existingBot.connectorScopes.apps[slug] || (scope === "write" && existingBot.connectorScopes.apps[slug] === "read"))))
|
|
20792
|
+
loosened.push("connectorScopes");
|
|
20793
|
+
if (body.fallback !== undefined && trustPatch.fallback?.some(candidate => !existingBot?.fallback?.some(previous => previous.instanceId === candidate.instanceId && previous.model === candidate.model)))
|
|
20794
|
+
loosened.push("fallback");
|
|
19563
20795
|
if (Array.isArray(patch.managedSections) && patch.managedSections.some(section => !(existingBot?.managedSections ?? []).includes(section))) {
|
|
19564
20796
|
loosened.push("managedSections");
|
|
19565
20797
|
}
|
|
@@ -19586,6 +20818,8 @@ const handleRequest = async (req, res) => {
|
|
|
19586
20818
|
(requestedMcpServers === undefined || requestedMcpServers.some((name) => !existingBot.mcpServers.includes(name)))) {
|
|
19587
20819
|
loosened.push("mcpServers");
|
|
19588
20820
|
}
|
|
20821
|
+
if (Object.hasOwn(body, "toolScope") && toolScopeWidens(existingBot?.toolScope, patch.toolScope))
|
|
20822
|
+
loosened.push("toolScope");
|
|
19589
20823
|
const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
|
|
19590
20824
|
if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
|
|
19591
20825
|
if (store.bots.some((candidate) => candidate.busy)) {
|
|
@@ -19621,6 +20855,18 @@ const handleRequest = async (req, res) => {
|
|
|
19621
20855
|
(freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
|
|
19622
20856
|
return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
|
|
19623
20857
|
}
|
|
20858
|
+
if (Object.hasOwn(body, "toolScope")) {
|
|
20859
|
+
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
20860
|
+
return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
|
|
20861
|
+
}
|
|
20862
|
+
if (toolScopeWidens(freshBrowserBot?.toolScope, patch.toolScope) && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin && store.bots.some((candidate) => candidate.busy)) {
|
|
20863
|
+
return json(res, 409, { error: "A bot is working right now, so this change has to come from the desktop app or a paired device. Try again once every bot is idle." });
|
|
20864
|
+
}
|
|
20865
|
+
if (freshBrowserBot && JSON.stringify(freshBrowserBot.toolScope) !== JSON.stringify(patch.toolScope)
|
|
20866
|
+
&& (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
|
|
20867
|
+
return json(res, 409, { error: "Stop this bot's turns before changing its tool selection." });
|
|
20868
|
+
}
|
|
20869
|
+
}
|
|
19624
20870
|
// Custom servers are process configuration: a running turn cannot
|
|
19625
20871
|
// unmount them. Recheck after any awaited runtime revocation above.
|
|
19626
20872
|
if (body.mcpServers !== undefined) {
|
|
@@ -19772,8 +21018,19 @@ const handleRequest = async (req, res) => {
|
|
|
19772
21018
|
if (m && method === "GET") {
|
|
19773
21019
|
if (!store.bot(m[1]))
|
|
19774
21020
|
return json(res, 404, { error: "no such bot" });
|
|
21021
|
+
if (skillsLibraryEnabled(cfg)) {
|
|
21022
|
+
const { skills, library } = listBotSkillsWithOrigin(m[1], store.bot(m[1]).assignedSkills);
|
|
21023
|
+
return json(res, 200, {
|
|
21024
|
+
skills,
|
|
21025
|
+
library,
|
|
21026
|
+
// the authoritative assignment list: merged rows hide
|
|
21027
|
+
// assignments a same-name private skill shadows
|
|
21028
|
+
assignedSkills: store.bot(m[1]).assignedSkills,
|
|
21029
|
+
staged: listStagedSkillWrites(m[1]).map(stagedSkillListing),
|
|
21030
|
+
});
|
|
21031
|
+
}
|
|
19775
21032
|
return json(res, 200, {
|
|
19776
|
-
skills:
|
|
21033
|
+
skills: listBotSkills(store.bot(m[1])),
|
|
19777
21034
|
staged: listStagedSkillWrites(m[1]).map(stagedSkillListing),
|
|
19778
21035
|
});
|
|
19779
21036
|
}
|
|
@@ -19795,7 +21052,8 @@ const handleRequest = async (req, res) => {
|
|
|
19795
21052
|
}
|
|
19796
21053
|
m = path.match(/^\/api\/bots\/([\w-]+)\/skills\/([a-z0-9-]+)$/);
|
|
19797
21054
|
if (m && method === "GET") {
|
|
19798
|
-
const
|
|
21055
|
+
const bot = store.bot(m[1]);
|
|
21056
|
+
const text = bot ? readBotSkillFile(bot, m[2]) : null;
|
|
19799
21057
|
if (text === null)
|
|
19800
21058
|
return json(res, 404, { error: "no such skill" });
|
|
19801
21059
|
return json(res, 200, { text });
|
|
@@ -19815,11 +21073,165 @@ const handleRequest = async (req, res) => {
|
|
|
19815
21073
|
return json(res, 404, { error: result.error });
|
|
19816
21074
|
return json(res, 200, { ok: true });
|
|
19817
21075
|
}
|
|
21076
|
+
// ── skills library (features.skillsLibrary): local browse surface ────
|
|
21077
|
+
// Everything here is local: the library on disk, assignments on bot
|
|
21078
|
+
// records, imports from pasted SKILL.md text. No registry, no network.
|
|
21079
|
+
m = path.match(/^\/api\/skills-library$/);
|
|
21080
|
+
if (m && method === "GET") {
|
|
21081
|
+
if (!skillsLibraryEnabled(cfg))
|
|
21082
|
+
return json(res, 404, { error: "skills library is not enabled" });
|
|
21083
|
+
const index = readSkillLibraryIndex();
|
|
21084
|
+
const skills = listLibrarySkills()
|
|
21085
|
+
.map((listing) => ({
|
|
21086
|
+
name: listing.name,
|
|
21087
|
+
description: listing.description,
|
|
21088
|
+
source: listing.source,
|
|
21089
|
+
enabled: listing.enabled,
|
|
21090
|
+
tags: listing.tags ?? [],
|
|
21091
|
+
version: index[listing.name]?.package?.release ?? null,
|
|
21092
|
+
importedAt: listing.importedAt,
|
|
21093
|
+
...(listing.license ? { license: listing.license } : {}),
|
|
21094
|
+
...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
|
|
21095
|
+
warnings: listing.warnings,
|
|
21096
|
+
assignedBots: store.bots
|
|
21097
|
+
.filter((bot) => bot.assignedSkills?.includes(listing.name))
|
|
21098
|
+
.map((bot) => ({ id: bot.id, name: bot.name })),
|
|
21099
|
+
}))
|
|
21100
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
21101
|
+
return json(res, 200, { skills });
|
|
21102
|
+
}
|
|
21103
|
+
if (m && method === "POST") {
|
|
21104
|
+
if (!skillsLibraryEnabled(cfg))
|
|
21105
|
+
return json(res, 404, { error: "skills library is not enabled" });
|
|
21106
|
+
const parsed = z.object({ text: z.string().min(1) }).safeParse(await readBody(req));
|
|
21107
|
+
if (!parsed.success)
|
|
21108
|
+
return json(res, 400, { error: "text must be the SKILL.md contents to import" });
|
|
21109
|
+
const skill = parseSkillMd(parsed.data.text);
|
|
21110
|
+
if ("error" in skill)
|
|
21111
|
+
return json(res, 400, { error: skill.error });
|
|
21112
|
+
// Imports land disabled with their scan warnings attached: enabling is
|
|
21113
|
+
// a separate, explicit review step on the row.
|
|
21114
|
+
const installed = installLibrarySkill({
|
|
21115
|
+
name: skill.name,
|
|
21116
|
+
instructions: parsed.data.text,
|
|
21117
|
+
source: "local-import",
|
|
21118
|
+
license: skill.license,
|
|
21119
|
+
compatibility: skill.compatibility,
|
|
21120
|
+
tags: skill.tags,
|
|
21121
|
+
warnings: scanSkillText(parsed.data.text),
|
|
21122
|
+
reviewState: "disabled",
|
|
21123
|
+
});
|
|
21124
|
+
if ("error" in installed)
|
|
21125
|
+
return json(res, 422, { error: installed.error });
|
|
21126
|
+
return json(res, 201, { skill: installed });
|
|
21127
|
+
}
|
|
21128
|
+
m = path.match(/^\/api\/skills-library\/([a-z0-9-]+)$/);
|
|
21129
|
+
if (m && method === "GET") {
|
|
21130
|
+
if (!skillsLibraryEnabled(cfg))
|
|
21131
|
+
return json(res, 404, { error: "skills library is not enabled" });
|
|
21132
|
+
const text = readLibrarySkillFile(m[1]);
|
|
21133
|
+
if (text === null)
|
|
21134
|
+
return json(res, 404, { error: "no such skill" });
|
|
21135
|
+
return json(res, 200, { text });
|
|
21136
|
+
}
|
|
21137
|
+
if (m && method === "PATCH") {
|
|
21138
|
+
if (!skillsLibraryEnabled(cfg))
|
|
21139
|
+
return json(res, 404, { error: "skills library is not enabled" });
|
|
21140
|
+
const parsed = z.object({ enabled: z.boolean() }).safeParse(await readBody(req));
|
|
21141
|
+
if (!parsed.success)
|
|
21142
|
+
return json(res, 400, { error: "enabled must be true or false" });
|
|
21143
|
+
const result = setLibrarySkillReviewState(m[1], parsed.data.enabled ? "approved" : "disabled");
|
|
21144
|
+
if ("error" in result)
|
|
21145
|
+
return json(res, 404, { error: result.error });
|
|
21146
|
+
return json(res, 200, { skill: result });
|
|
21147
|
+
}
|
|
21148
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/skills-library$/);
|
|
21149
|
+
if (m && method === "PUT") {
|
|
21150
|
+
if (!skillsLibraryEnabled(cfg))
|
|
21151
|
+
return json(res, 404, { error: "skills library is not enabled" });
|
|
21152
|
+
if (!store.bot(m[1]))
|
|
21153
|
+
return json(res, 404, { error: "no such bot" });
|
|
21154
|
+
const parsed = z.object({ skills: z.array(z.string().min(1).max(64)).max(200) }).safeParse(await readBody(req));
|
|
21155
|
+
if (!parsed.success)
|
|
21156
|
+
return json(res, 400, { error: "skills must be a list of skill names" });
|
|
21157
|
+
const index = readSkillLibraryIndex();
|
|
21158
|
+
const names = [...new Set(parsed.data.skills)];
|
|
21159
|
+
const unknown = names.filter((name) => !index[name]);
|
|
21160
|
+
if (unknown.length)
|
|
21161
|
+
return json(res, 422, { error: "not in the library: " + unknown.join(", ") });
|
|
21162
|
+
store.patchBot(m[1], { assignedSkills: names });
|
|
21163
|
+
return json(res, 200, { assignedSkills: names });
|
|
21164
|
+
}
|
|
19818
21165
|
// ── section context: a user-owned team brief ────────────────────────
|
|
19819
21166
|
// Bots receive this in their system context, but no agent tool can write
|
|
19820
21167
|
// it. That keeps one bot from silently changing every teammate's future
|
|
19821
21168
|
// turns. The section query parameter is required even for General (""),
|
|
19822
21169
|
// so a malformed client cannot accidentally read or replace that brief.
|
|
21170
|
+
// ── team memory: the section's shared people, places, decisions, terms ──
|
|
21171
|
+
// The person's view and their edits. Same section rule as the brief:
|
|
21172
|
+
// the query parameter is required even for General.
|
|
21173
|
+
if (path === "/api/team-memory" && (method === "GET" || method === "POST")) {
|
|
21174
|
+
if (!url.searchParams.has("section"))
|
|
21175
|
+
return json(res, 400, { error: "section is required" });
|
|
21176
|
+
const section = sectionContextKey(url.searchParams.get("section") ?? "");
|
|
21177
|
+
if (section.length > 60)
|
|
21178
|
+
return json(res, 400, { error: "section must be at most 60 characters" });
|
|
21179
|
+
if (method === "GET") {
|
|
21180
|
+
return json(res, 200, { section, label: sectionContextLabel(section), entries: teamMemory.list(section) });
|
|
21181
|
+
}
|
|
21182
|
+
const body = await readBody(req);
|
|
21183
|
+
if (!body || typeof body !== "object" || !TEAM_MEMORY_KINDS.includes(String(body.kind))) {
|
|
21184
|
+
return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
|
|
21185
|
+
}
|
|
21186
|
+
try {
|
|
21187
|
+
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() });
|
|
21188
|
+
// the person's own entry never waits on the person
|
|
21189
|
+
if (proposed.status === "proposed")
|
|
21190
|
+
teamMemory.resolve(section, proposed.entry.id, "accept");
|
|
21191
|
+
return json(res, 201, { entries: teamMemory.list(section) });
|
|
21192
|
+
}
|
|
21193
|
+
catch (error) {
|
|
21194
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
21195
|
+
}
|
|
21196
|
+
}
|
|
21197
|
+
m = path.match(/^\/api\/team-memory\/([\w-]+)$/);
|
|
21198
|
+
if (m && (method === "PATCH" || method === "DELETE")) {
|
|
21199
|
+
if (!url.searchParams.has("section"))
|
|
21200
|
+
return json(res, 400, { error: "section is required" });
|
|
21201
|
+
const section = sectionContextKey(url.searchParams.get("section") ?? "");
|
|
21202
|
+
if (method === "DELETE") {
|
|
21203
|
+
if (!teamMemory.remove(section, m[1]))
|
|
21204
|
+
return json(res, 404, { error: "no such entry" });
|
|
21205
|
+
return json(res, 200, { entries: teamMemory.list(section) });
|
|
21206
|
+
}
|
|
21207
|
+
const body = await readBody(req);
|
|
21208
|
+
if (!body || typeof body !== "object")
|
|
21209
|
+
return json(res, 400, { error: "body must be a JSON object" });
|
|
21210
|
+
if (body.accept === true) {
|
|
21211
|
+
const result = teamMemory.resolve(section, m[1], "accept");
|
|
21212
|
+
if (!result.claimed)
|
|
21213
|
+
return json(res, 404, { error: "no such entry" });
|
|
21214
|
+
return json(res, 200, { entries: teamMemory.list(section) });
|
|
21215
|
+
}
|
|
21216
|
+
const patch = {};
|
|
21217
|
+
if (typeof body.name === "string")
|
|
21218
|
+
patch.name = body.name;
|
|
21219
|
+
if (typeof body.detail === "string")
|
|
21220
|
+
patch.detail = body.detail;
|
|
21221
|
+
if (Array.isArray(body.aliases))
|
|
21222
|
+
patch.aliases = body.aliases.map(String);
|
|
21223
|
+
if (typeof body.kind === "string" && TEAM_MEMORY_KINDS.includes(body.kind))
|
|
21224
|
+
patch.kind = body.kind;
|
|
21225
|
+
try {
|
|
21226
|
+
const updated = teamMemory.update(section, m[1], patch);
|
|
21227
|
+
if (!updated)
|
|
21228
|
+
return json(res, 404, { error: "no such entry" });
|
|
21229
|
+
return json(res, 200, { entry: updated, entries: teamMemory.list(section) });
|
|
21230
|
+
}
|
|
21231
|
+
catch (error) {
|
|
21232
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
21233
|
+
}
|
|
21234
|
+
}
|
|
19823
21235
|
if (path === "/api/section-context" && (method === "GET" || method === "PUT")) {
|
|
19824
21236
|
if (!url.searchParams.has("section"))
|
|
19825
21237
|
return json(res, 400, { error: "section is required" });
|
|
@@ -20159,53 +21571,17 @@ const handleRequest = async (req, res) => {
|
|
|
20159
21571
|
const trigger = onBehalfOf
|
|
20160
21572
|
? { kind: "user", ...(onBehalfOf.email ? { email: onBehalfOf.email } : {}), ...(onBehalfOf.name ? { label: onBehalfOf.name } : {}) }
|
|
20161
21573
|
: usageTriggerFor(auth);
|
|
20162
|
-
// The
|
|
20163
|
-
//
|
|
20164
|
-
|
|
20165
|
-
|
|
20166
|
-
|
|
20167
|
-
|
|
20168
|
-
return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
|
|
20169
|
-
}
|
|
20170
|
-
if (!store.taskByThread(bot.id, threadId)) {
|
|
20171
|
-
return json(res, 409, { error: "the bot switched tasks before it could receive the message" });
|
|
20172
|
-
}
|
|
21574
|
+
// The spend cap and a task that is gone answer before a malformed
|
|
21575
|
+
// sendId or reply target does. acceptDirectSend checks both again for
|
|
21576
|
+
// callers that do not come through this route; nothing awaits between.
|
|
21577
|
+
const refused = directSendRefusal(bot.id, threadId);
|
|
21578
|
+
if (refused)
|
|
21579
|
+
return json(res, refused.status, refused.body);
|
|
20173
21580
|
const sendId = parseSendId(body.sendId);
|
|
20174
21581
|
const replyTo = resolveReplyTarget(threadId, body.replyToId);
|
|
20175
|
-
const
|
|
20176
|
-
|
|
20177
|
-
|
|
20178
|
-
throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
|
|
20179
|
-
}
|
|
20180
|
-
const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
|
|
20181
|
-
if (accepted.kind === "conflict") {
|
|
20182
|
-
throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
|
|
20183
|
-
}
|
|
20184
|
-
if (accepted.kind === "match") {
|
|
20185
|
-
const canonical = {
|
|
20186
|
-
ok: true,
|
|
20187
|
-
threadId,
|
|
20188
|
-
message: accepted.message,
|
|
20189
|
-
};
|
|
20190
|
-
return accepted.message.steered
|
|
20191
|
-
? { ...canonical, steered: true }
|
|
20192
|
-
: canonical;
|
|
20193
|
-
}
|
|
20194
|
-
const queued = queuedSteeredMessage(bot.id, threadId, sendId);
|
|
20195
|
-
if (queued) {
|
|
20196
|
-
if (queued.text !== text || queued.replyToId !== replyTo?.id) {
|
|
20197
|
-
throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
|
|
20198
|
-
}
|
|
20199
|
-
return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
|
|
20200
|
-
}
|
|
20201
|
-
}
|
|
20202
|
-
const currentAtStart = store.projectBotForTask(bot.id, threadId);
|
|
20203
|
-
if (!currentAtStart)
|
|
20204
|
-
throw Object.assign(new Error("no such bot"), { status: 404 });
|
|
20205
|
-
if (!store.taskByThread(currentAtStart.id, threadId)) {
|
|
20206
|
-
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
20207
|
-
}
|
|
20208
|
-
if (guarded) {
|
|
21582
|
+
const sender = messageSender(auth);
|
|
21583
|
+
const guardedStart = guarded
|
|
21584
|
+
? async (currentAtStart) => {
|
|
20209
21585
|
// There is no await between these checks and startTurn's
|
|
20210
21586
|
// synchronous transcript append / runtime reservation. In
|
|
20211
21587
|
// particular, never steer or enqueue under stale permissions.
|
|
@@ -20227,91 +21603,32 @@ const handleRequest = async (req, res) => {
|
|
|
20227
21603
|
if (guardedAdmission.action === "refuse") {
|
|
20228
21604
|
throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
|
|
20229
21605
|
}
|
|
20230
|
-
|
|
21606
|
+
// Stored as relayed: a worker's line for someone else, which a
|
|
21607
|
+
// Live call on this thread must not read back as typed there.
|
|
21608
|
+
const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender, trigger, relayed: true });
|
|
20231
21609
|
return { ok: true, threadId, message };
|
|
20232
21610
|
}
|
|
20233
|
-
|
|
20234
|
-
|
|
20235
|
-
|
|
20236
|
-
|
|
20237
|
-
|
|
20238
|
-
|
|
20239
|
-
//
|
|
20240
|
-
//
|
|
20241
|
-
//
|
|
20242
|
-
|
|
20243
|
-
|
|
20244
|
-
|
|
20245
|
-
|
|
20246
|
-
|
|
20247
|
-
|
|
20248
|
-
|
|
20249
|
-
|
|
20250
|
-
|
|
20251
|
-
|
|
20252
|
-
|
|
20253
|
-
|
|
20254
|
-
.catch(() => "indeterminate");
|
|
20255
|
-
}
|
|
20256
|
-
// steer() is awaited adapter work. The turn can settle, the task can
|
|
20257
|
-
// switch, or the whole bot can be deleted before its acknowledgement
|
|
20258
|
-
// arrives. Re-read every ownership invariant before appending even a
|
|
20259
|
-
// successful steer; otherwise that late acknowledgement writes a user
|
|
20260
|
-
// message into a task the bot no longer owns. A conflict leaves the
|
|
20261
|
-
// text in the client's composer/outbox to resend deliberately.
|
|
20262
|
-
const current = store.projectBotForTask(bot.id, threadId);
|
|
20263
|
-
if (!current)
|
|
20264
|
-
throw Object.assign(new Error("no such bot"), { status: 404 });
|
|
20265
|
-
if (!store.taskByThread(bot.id, threadId)) {
|
|
20266
|
-
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
20267
|
-
}
|
|
20268
|
-
const delivered = steered !== "refused";
|
|
20269
|
-
if (delivered) {
|
|
20270
|
-
if (steered === "steered" && !current.busy) {
|
|
20271
|
-
throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
|
|
20272
|
-
}
|
|
20273
|
-
// "indeterminate" falls through to the same record: the words
|
|
20274
|
-
// may already be folded into a turn whose acknowledgement was
|
|
20275
|
-
// lost, and handing them back for a resend could run them
|
|
20276
|
-
// twice. Recording them once is the honest outcome.
|
|
20277
|
-
// A person steering a webhook turn is present, and auto mode may
|
|
20278
|
-
// follow them again. But this route is also reachable from the
|
|
20279
|
-
// bot's own shell on a headless server (loopback is the owner
|
|
20280
|
-
// there), and "continue" typed by the turn itself must not be
|
|
20281
|
-
// the thing that lifts the block written against it — so only
|
|
20282
|
-
// a request that proves a person (a paired session, or the
|
|
20283
|
-
// desktop's owner capability, which every mutation there has
|
|
20284
|
-
// already shown) clears the mark.
|
|
20285
|
-
if (auth.kind === "session" || DESKTOP_MANAGED)
|
|
20286
|
-
clearUnattended(threadId);
|
|
20287
|
-
const message = store.appendMessage(threadId, {
|
|
20288
|
-
role: "user",
|
|
20289
|
-
kind: "text",
|
|
20290
|
-
text,
|
|
20291
|
-
replyToId: replyTo?.id,
|
|
20292
|
-
sendId,
|
|
20293
|
-
steered: true,
|
|
20294
|
-
sender: messageSender(auth),
|
|
20295
|
-
});
|
|
20296
|
-
// Offered to the next turn again unless the person stops this one.
|
|
20297
|
-
handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
|
|
20298
|
-
return { ok: true, steered: true, threadId, message };
|
|
20299
|
-
}
|
|
20300
|
-
if (!current.busy) {
|
|
20301
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
|
|
20302
|
-
}
|
|
20303
|
-
const queued = queueSteeredMessage(current.id, threadId, text, {
|
|
20304
|
-
replyToId: replyTo?.id,
|
|
20305
|
-
sendId,
|
|
20306
|
-
prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
|
|
20307
|
-
sender: messageSender(auth),
|
|
20308
|
-
trigger,
|
|
20309
|
-
});
|
|
20310
|
-
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
20311
|
-
}
|
|
20312
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
|
|
20313
|
-
});
|
|
20314
|
-
return json(res, 202, receipt);
|
|
21611
|
+
: undefined;
|
|
21612
|
+
try {
|
|
21613
|
+
const receipt = await acceptDirectSend({
|
|
21614
|
+
botId: bot.id, threadId, text, sendId, replyTo, sender, trigger,
|
|
21615
|
+
// A person steering a webhook turn is present, and auto mode may
|
|
21616
|
+
// follow them again. But this route is also reachable from the
|
|
21617
|
+
// bot's own shell on a headless server (loopback is the owner
|
|
21618
|
+
// there), and "continue" typed by the turn itself must not be the
|
|
21619
|
+
// thing that lifts the block written against it — so only a
|
|
21620
|
+
// request that proves a person (a paired session, or the desktop's
|
|
21621
|
+
// owner capability, which every mutation there has already shown)
|
|
21622
|
+
// clears the mark.
|
|
21623
|
+
personPresent: auth.kind === "session" || DESKTOP_MANAGED,
|
|
21624
|
+
}, guardedStart);
|
|
21625
|
+
return json(res, 202, receipt);
|
|
21626
|
+
}
|
|
21627
|
+
catch (error) {
|
|
21628
|
+
if (error instanceof DirectSendRefused)
|
|
21629
|
+
return json(res, error.status, error.body);
|
|
21630
|
+
throw error;
|
|
21631
|
+
}
|
|
20315
21632
|
}
|
|
20316
21633
|
m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)$/);
|
|
20317
21634
|
if (m && method === "DELETE") {
|
|
@@ -20378,6 +21695,7 @@ const handleRequest = async (req, res) => {
|
|
|
20378
21695
|
peerAsk: item.peerAsk,
|
|
20379
21696
|
steered: true,
|
|
20380
21697
|
sender: item.sender,
|
|
21698
|
+
...(item.via ? { via: item.via } : {}),
|
|
20381
21699
|
}));
|
|
20382
21700
|
// Offered to the next turn again unless the person stops this one.
|
|
20383
21701
|
for (const message of messages)
|
|
@@ -20515,6 +21833,8 @@ const handleRequest = async (req, res) => {
|
|
|
20515
21833
|
return json(res, 403, { error: refusal });
|
|
20516
21834
|
}
|
|
20517
21835
|
return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
|
|
21836
|
+
if (resolveAndSendTrust(res, bot.threadId, String(body.requestId), behavior))
|
|
21837
|
+
return;
|
|
20518
21838
|
if (await resolveAndSendTeamSetup(res, {
|
|
20519
21839
|
botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
|
|
20520
21840
|
}, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
|
|
@@ -20567,7 +21887,32 @@ const handleRequest = async (req, res) => {
|
|
|
20567
21887
|
resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
|
|
20568
21888
|
return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
|
|
20569
21889
|
}
|
|
20570
|
-
const
|
|
21890
|
+
const requestId = String(body.requestId);
|
|
21891
|
+
const pending = store.messagesFor(bot.threadId).find((message) => message.card?.requestId === requestId);
|
|
21892
|
+
if (isPersistentQuestionCard(pending?.card) && (body.dismiss === true || body.message === QUESTION_DISMISS_MESSAGE)) {
|
|
21893
|
+
if (!pending?.card)
|
|
21894
|
+
return json(res, 404, { error: "this question is no longer available" });
|
|
21895
|
+
if (pending.card.answered !== "answer" || pending.card.dismissed) {
|
|
21896
|
+
return json(res, 409, { error: "answer this question before dismissing it" });
|
|
21897
|
+
}
|
|
21898
|
+
store.patchMessage(bot.threadId, pending.id, { card: { ...pending.card, dismissed: true } });
|
|
21899
|
+
return json(res, 200, { ok: true, dismissed: true });
|
|
21900
|
+
}
|
|
21901
|
+
const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, requestId, behavior, body.message, { id: bot.id, name: bot.name }, body.always === true, body.rememberCommand === true);
|
|
21902
|
+
if (outcome === "unavailable" && isPersistentQuestionCard(pending?.card) && behavior === "answer") {
|
|
21903
|
+
if (!pending)
|
|
21904
|
+
return json(res, 404, { error: "this question is no longer available" });
|
|
21905
|
+
const answer = typeof body.message === "string" ? body.message.trim() : "";
|
|
21906
|
+
if (!answer)
|
|
21907
|
+
return json(res, 400, { error: "a question answer is required" });
|
|
21908
|
+
try {
|
|
21909
|
+
const reply = await deliverLateQuestionAnswer(auth, bot.threadId, requestId, answer, pending, bot);
|
|
21910
|
+
return json(res, 200, { ok: true, outcome: "answered", late: true, ...(reply.queued ? { queued: true } : {}) });
|
|
21911
|
+
}
|
|
21912
|
+
catch (error) {
|
|
21913
|
+
return json(res, 409, { error: error instanceof Error ? error.message : "the late answer could not be queued" });
|
|
21914
|
+
}
|
|
21915
|
+
}
|
|
20571
21916
|
return json(res, 200, { ok: true, outcome });
|
|
20572
21917
|
});
|
|
20573
21918
|
}
|
|
@@ -20592,6 +21937,8 @@ const handleRequest = async (req, res) => {
|
|
|
20592
21937
|
return json(res, 403, { error: refusal });
|
|
20593
21938
|
}
|
|
20594
21939
|
return await answeringCardAs(auth, threadId, requestId, async () => {
|
|
21940
|
+
if (resolveAndSendTrust(res, threadId, requestId, behavior))
|
|
21941
|
+
return;
|
|
20595
21942
|
const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
|
|
20596
21943
|
if (skillCard?.card?.skillRequest) {
|
|
20597
21944
|
const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
@@ -20693,14 +22040,46 @@ const handleRequest = async (req, res) => {
|
|
|
20693
22040
|
// answerRequest closes an unreachable card, and a pending approval owns
|
|
20694
22041
|
// the composer, so a dead end here locks the room for good.
|
|
20695
22042
|
const pending = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId);
|
|
22043
|
+
if (isPersistentQuestionCard(pending?.card) && (body.dismiss === true || body.message === QUESTION_DISMISS_MESSAGE)) {
|
|
22044
|
+
if (!pending?.card)
|
|
22045
|
+
return json(res, 404, { error: "this question is no longer available" });
|
|
22046
|
+
if (pending.card.answered !== "answer" || pending.card.dismissed) {
|
|
22047
|
+
return json(res, 409, { error: "answer this question before dismissing it" });
|
|
22048
|
+
}
|
|
22049
|
+
store.patchMessage(threadId, pending.id, { card: { ...pending.card, dismissed: true } });
|
|
22050
|
+
return json(res, 200, { ok: true, dismissed: true });
|
|
22051
|
+
}
|
|
22052
|
+
const persistentQuestion = isPersistentQuestionCard(pending?.card);
|
|
20696
22053
|
const owner = group
|
|
20697
|
-
?
|
|
20698
|
-
(pending?.from ? store.bot(pending.from.botId) : undefined)
|
|
22054
|
+
? persistentQuestion
|
|
22055
|
+
? (pending?.from ? store.bot(pending.from.botId) : undefined)
|
|
22056
|
+
: (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
|
|
22057
|
+
(pending?.from ? store.bot(pending.from.botId) : undefined)
|
|
20699
22058
|
: store.botByThread(threadId);
|
|
22059
|
+
// A surviving question belongs to its original asker, never a later speaker.
|
|
22060
|
+
if (group && persistentQuestion && !owner) {
|
|
22061
|
+
return json(res, 409, { error: "the bot that asked this question is no longer available" });
|
|
22062
|
+
}
|
|
20700
22063
|
if (!owner && !pending)
|
|
20701
22064
|
return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
|
|
20702
22065
|
const requestOwner = owner ? botForThread(owner.id, threadId) : null;
|
|
20703
22066
|
const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true, body.rememberCommand === true);
|
|
22067
|
+
if (outcome === "unavailable" && isPersistentQuestionCard(pending?.card) && behavior === "answer") {
|
|
22068
|
+
if (!pending?.card)
|
|
22069
|
+
return json(res, 404, { error: "this question is no longer available" });
|
|
22070
|
+
const answer = typeof body.message === "string" ? body.message.trim() : "";
|
|
22071
|
+
if (!answer)
|
|
22072
|
+
return json(res, 400, { error: "a question answer is required" });
|
|
22073
|
+
if (!owner)
|
|
22074
|
+
return json(res, 409, { error: "the bot that asked this question is no longer available" });
|
|
22075
|
+
try {
|
|
22076
|
+
const reply = await deliverLateQuestionAnswer(auth, threadId, requestId, answer, pending, owner);
|
|
22077
|
+
return json(res, 200, { ok: true, outcome: "answered", late: true, ...(reply.queued ? { queued: true } : {}) });
|
|
22078
|
+
}
|
|
22079
|
+
catch (error) {
|
|
22080
|
+
return json(res, 409, { error: error instanceof Error ? error.message : "the late answer could not be queued" });
|
|
22081
|
+
}
|
|
22082
|
+
}
|
|
20704
22083
|
return json(res, 200, { ok: true, outcome });
|
|
20705
22084
|
});
|
|
20706
22085
|
}
|
|
@@ -21697,6 +23076,16 @@ const handleRequest = async (req, res) => {
|
|
|
21697
23076
|
billing: prices ? { currency: cfg.billing?.currency ?? "USD" } : null,
|
|
21698
23077
|
});
|
|
21699
23078
|
}
|
|
23079
|
+
// Subscription windows (5-hour and weekly), not the token ledger above.
|
|
23080
|
+
// Same admin gate as /api/usage: this route is unlisted, so it stays admin.
|
|
23081
|
+
if (method === "GET" && path === "/api/plan-usage") {
|
|
23082
|
+
res.setHeader("cache-control", "no-store");
|
|
23083
|
+
const report = await loadPlanUsage({
|
|
23084
|
+
accounts: planAccountsFromInstances(instanceConfigs(cfg)),
|
|
23085
|
+
refresh: url.searchParams.get("refresh") === "1",
|
|
23086
|
+
});
|
|
23087
|
+
return json(res, 200, report);
|
|
23088
|
+
}
|
|
21700
23089
|
// ── provider key check: does a pasted or saved key open the provider's door ──
|
|
21701
23090
|
// One read-only request from this server; the verdict never carries the
|
|
21702
23091
|
// key. Admin scope by default, like every route not opened to clients.
|
|
@@ -21779,6 +23168,34 @@ const handleRequest = async (req, res) => {
|
|
|
21779
23168
|
res.end(decisionsCsv(readDecisionRange(DATA_DIR, range)));
|
|
21780
23169
|
return;
|
|
21781
23170
|
}
|
|
23171
|
+
// ── a bot's activity: what it did, with the outcome ──
|
|
23172
|
+
// Read-only over the same two logs as the inspector and the decision
|
|
23173
|
+
// route above. The bot's own threads (its DM and every task) carry the
|
|
23174
|
+
// tool runs; the decision log carries the bot's requests wherever they
|
|
23175
|
+
// happened, since those rows name the bot.
|
|
23176
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/activity$/);
|
|
23177
|
+
if (m && method === "GET") {
|
|
23178
|
+
const bot = store.bot(m[1]);
|
|
23179
|
+
if (!bot)
|
|
23180
|
+
return json(res, 404, { error: "no such bot" });
|
|
23181
|
+
const rawLimit = url.searchParams.get("limit");
|
|
23182
|
+
const parsedLimit = rawLimit === null ? undefined : Number(rawLimit);
|
|
23183
|
+
if (parsedLimit !== undefined && (!Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
|
|
23184
|
+
return json(res, 400, { error: "limit must be a positive whole number" });
|
|
23185
|
+
}
|
|
23186
|
+
const threadIds = [...new Set([bot.threadId, ...store.tasks(bot.id).map((task) => task.threadId)])];
|
|
23187
|
+
return json(res, 200, {
|
|
23188
|
+
rows: readBotActivity({ dataDir: DATA_DIR, eventsDir: EVENTS_DIR, botId: bot.id, threadIds, limit: parsedLimit ?? 300 }),
|
|
23189
|
+
});
|
|
23190
|
+
}
|
|
23191
|
+
// ── a bot's outbound allowance: the policy, and how much of it is spent ──
|
|
23192
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/outbound$/);
|
|
23193
|
+
if (m && method === "GET") {
|
|
23194
|
+
const bot = store.bot(m[1]);
|
|
23195
|
+
if (!bot)
|
|
23196
|
+
return json(res, 404, { error: "no such bot" });
|
|
23197
|
+
return json(res, 200, { policy: bot.outbound ?? DEFAULT_OUTBOUND_POLICY, today: outboundCounts.today(bot.id) });
|
|
23198
|
+
}
|
|
21782
23199
|
// ── provider instances (model picker) ──
|
|
21783
23200
|
if (method === "GET" && path === "/api/instances") {
|
|
21784
23201
|
// Rescan PATH first: this endpoint is how the app answers "what can I
|
|
@@ -22063,7 +23480,7 @@ const handleRequest = async (req, res) => {
|
|
|
22063
23480
|
catch { /* Allow repairing an unused malformed account. */ }
|
|
22064
23481
|
entry.config = { ...entry.config, configDir: body.configDir };
|
|
22065
23482
|
if (previousDir !== configuredAccountDirectory(entry)) {
|
|
22066
|
-
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));
|
|
23483
|
+
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));
|
|
22067
23484
|
if (used)
|
|
22068
23485
|
return json(res, 409, { error: "This account is used by bots or conversation history. Add another account and select it for the bot instead." });
|
|
22069
23486
|
assertSeparateClaudeAccount(instances, instanceId, entry);
|
|
@@ -22087,7 +23504,7 @@ const handleRequest = async (req, res) => {
|
|
|
22087
23504
|
if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
|
|
22088
23505
|
return json(res, 400, { error: "Only added Claude accounts can be removed here." });
|
|
22089
23506
|
}
|
|
22090
|
-
if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
|
|
23507
|
+
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)) ||
|
|
22091
23508
|
busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
|
|
22092
23509
|
return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
|
|
22093
23510
|
}
|
|
@@ -22746,6 +24163,8 @@ const handleRequest = async (req, res) => {
|
|
|
22746
24163
|
persisted.imageGen.key = "";
|
|
22747
24164
|
if (persisted.imageGen?.customApiKey !== undefined)
|
|
22748
24165
|
persisted.imageGen.customApiKey = "";
|
|
24166
|
+
if (persisted.live?.key !== undefined)
|
|
24167
|
+
persisted.live.key = "";
|
|
22749
24168
|
saveConfig(persisted);
|
|
22750
24169
|
configWriteCommitted = true;
|
|
22751
24170
|
syncCredentialEnv(patch);
|
|
@@ -22856,6 +24275,7 @@ const handleRequest = async (req, res) => {
|
|
|
22856
24275
|
drainQueuedSends();
|
|
22857
24276
|
drainDelegationWakes();
|
|
22858
24277
|
drainConnectorResumes();
|
|
24278
|
+
drainComputerResumes();
|
|
22859
24279
|
drainSecretResumes();
|
|
22860
24280
|
drainTeamSetupResumes();
|
|
22861
24281
|
}
|
|
@@ -23502,6 +24922,7 @@ for (const row of chatFollowups()) {
|
|
|
23502
24922
|
role: "user", kind: "text", text: row.kind === "aside" ? row.payload.prompt ?? row.payload.text : row.payload.text, replyToId: row.payload.replyToId,
|
|
23503
24923
|
sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
|
|
23504
24924
|
...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
|
|
24925
|
+
...(row.kind === "bot" && row.payload.via === "call" ? { via: "call" } : {}),
|
|
23505
24926
|
...(row.kind === "aside" ? {
|
|
23506
24927
|
aside: true,
|
|
23507
24928
|
peerAsk: row.payload.aside
|
|
@@ -23617,6 +25038,7 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
23617
25038
|
await Promise.all([...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)));
|
|
23618
25039
|
await browserRuntime.closeAll();
|
|
23619
25040
|
},
|
|
25041
|
+
() => liveCalls.shutdown(),
|
|
23620
25042
|
() => flushAllProfileHistory(),
|
|
23621
25043
|
() => flushAllMemoryJournals(),
|
|
23622
25044
|
() => flushUsageLedger(DATA_DIR),
|