openmausbot 0.1.83 → 0.1.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-Ba9G44HI.js +310 -0
- package/dist/assets/{index-nqTFZTxg.js → index-CTNNoqSJ.js} +1 -1
- package/dist/assets/index-CkRNp7wX.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +1 -0
- package/dist-server/container-mcp.js +212 -7
- package/dist-server/drivers/agents-proxy.js +161 -12
- package/dist-server/enterprise/server/index.js +1 -0
- package/dist-server/hooks/omb-hook.js +74 -0
- package/dist-server/index.js +8025 -5661
- package/dist-server/local-computer-proxy.js +83 -1
- package/dist-server/local-computer.js +14 -2
- package/dist-server/openmausbot.js +796 -309
- package/dist-server/pair-cli.js +796 -309
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/bot-package.js +2 -0
- package/dist-server/server/box.js +23 -4
- package/dist-server/server/browser-engine.js +87 -10
- package/dist-server/server/browser-live.js +7 -5
- package/dist-server/server/browser-runtime.js +65 -10
- package/dist-server/server/checkpoints.js +71 -23
- package/dist-server/server/chief-of-staff.js +3 -0
- package/dist-server/server/cli-prompts.js +3 -1
- package/dist-server/server/commands.js +27 -0
- package/dist-server/server/compaction-summary.js +78 -0
- package/dist-server/server/computer-wait.js +33 -0
- package/dist-server/server/config.js +84 -3
- package/dist-server/server/context-budget.js +29 -0
- package/dist-server/server/context-rebuild.js +72 -0
- package/dist-server/server/delta-context.js +258 -0
- package/dist-server/server/digest.js +130 -0
- package/dist-server/server/drivers/acp/core.js +540 -229
- package/dist-server/server/drivers/agents-proxy.js +79 -12
- package/dist-server/server/drivers/agents-result.js +23 -0
- package/dist-server/server/drivers/claude.js +84 -15
- package/dist-server/server/drivers/codex.js +19 -5
- package/dist-server/server/drivers/openai-chat.js +66 -45
- package/dist-server/server/drivers/openai-compat.js +1 -0
- package/dist-server/server/hooks/omb-hook.js +103 -0
- package/dist-server/server/incidents.js +104 -0
- package/dist-server/server/index.js +1382 -318
- package/dist-server/server/local-computer.js +21 -2
- package/dist-server/server/mcp-bridge.js +10 -2
- package/dist-server/server/mcp-tool-images.js +37 -0
- package/dist-server/server/mcp-tool-schema.js +108 -0
- package/dist-server/server/message-db.js +80 -21
- package/dist-server/server/message-file.js +3 -2
- package/dist-server/server/notify.js +3 -1
- package/dist-server/server/package-export.js +1 -0
- package/dist-server/server/peer-roster.js +4 -2
- package/dist-server/server/provider-icon.js +20 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/request-auth.js +1 -0
- package/dist-server/server/room-handoffs.js +16 -2
- package/dist-server/server/routine-requests.js +39 -1
- package/dist-server/server/routines.js +43 -9
- package/dist-server/server/shared-computer-control.js +31 -4
- package/dist-server/server/steer-queue.js +6 -0
- package/dist-server/server/store.js +111 -14
- package/dist-server/server/system-prompt.js +4 -4
- package/dist-server/server/team-backup.js +9 -1
- package/dist-server/server/tool-results.js +72 -0
- package/dist-server/server/tts/grok.js +74 -0
- package/dist-server/server/tts/index.js +19 -1
- package/dist-server/server/webhooks.js +28 -0
- package/dist-server/shared/approval-mode.js +7 -1
- package/dist-server/shared/digest.js +1 -0
- package/dist-server/shared/markdown-windows-paths.js +36 -0
- package/dist-server/shared/provider-icon.js +110 -0
- package/dist-server/shared/team-backup.js +3 -0
- package/dist-server/vps-container-mcp.js +217 -12
- package/enterprise/server/index.js +1 -0
- package/package.json +1 -1
- package/dist/assets/index-CKysBq-V.css +0 -1
- package/dist/assets/index-DSvqfBOf.js +0 -309
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
// (upstream rule): the React app dispatches typed commands over HTTP and
|
|
3
3
|
// folds one SSE event stream; every provider process runs here.
|
|
4
4
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
-
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
6
7
|
import { rm as removeDirectory } from "node:fs/promises";
|
|
7
8
|
import { createServer } from "node:http";
|
|
9
|
+
import { ToolResults, TOOL_RESULT_MAX_CHARS } from "./tool-results.js";
|
|
8
10
|
import { extname, join } from "node:path";
|
|
9
11
|
import { z } from "zod";
|
|
12
|
+
import { selectReplay, DEFAULT_REBUILD_BYTES, MAX_SUMMARY_BYTES } from "./context-rebuild.js";
|
|
13
|
+
import { draftSummary, foldPoint } from "./compaction-summary.js";
|
|
14
|
+
import { compactBudget, contextWindowFor, shouldCompact } from "./context-budget.js";
|
|
15
|
+
import { autoCompactWindow } from "./drivers/claude.js";
|
|
10
16
|
import { SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
|
|
11
17
|
import { SharedComputerControl } from "./shared-computer-control.js";
|
|
12
18
|
import { RoomHandoffs } from "./room-handoffs.js";
|
|
@@ -19,8 +25,12 @@ import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, is
|
|
|
19
25
|
import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
|
|
20
26
|
import { updateClaudeCli } from "./claude-update.js";
|
|
21
27
|
import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
|
|
28
|
+
import { providerIconPatchSchema, withInstanceIcon } from "./provider-icon.js";
|
|
29
|
+
import { providerIconError } from "../shared/provider-icon.js";
|
|
22
30
|
import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
|
|
23
31
|
import * as checkpoints from "./checkpoints.js";
|
|
32
|
+
import { commandReceipt } from "./commands.js";
|
|
33
|
+
import { buildTurnDigest, coverageForDriver, digestPromptLine, renderDigest, toolEvidence } from "./digest.js";
|
|
24
34
|
import { appendDecision, readDecisions, flushDecisionLog } from "./decision-log.js";
|
|
25
35
|
import { validateBotCwd } from "./bot-cwd.js";
|
|
26
36
|
import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
|
|
@@ -37,10 +47,10 @@ import { boxDeletionSnapshot } from "./box-delete-journal.js";
|
|
|
37
47
|
import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
|
|
38
48
|
import * as composio from "./composio.js";
|
|
39
49
|
import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
|
|
40
|
-
import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
|
|
50
|
+
import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
|
|
41
51
|
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
42
52
|
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
|
|
43
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, } from "./config.js";
|
|
53
|
+
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, } from "./config.js";
|
|
44
54
|
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
45
55
|
import { ComputerControl } from "./computer-control.js";
|
|
46
56
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
@@ -64,12 +74,13 @@ import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from
|
|
|
64
74
|
import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
|
|
65
75
|
import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
66
76
|
import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
|
|
77
|
+
import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
|
|
67
78
|
/** A session_read answer competes with the transcript for the context
|
|
68
79
|
* window; a computer-use turn's output can run to hundreds of KB. */
|
|
69
80
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
70
81
|
import { promptWithReply, transcriptText } from "./replies.js";
|
|
71
82
|
import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
|
|
72
|
-
import { cancelSteeredMessage, drainSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
|
|
83
|
+
import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
|
|
73
84
|
import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
|
|
74
85
|
import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
|
|
75
86
|
import { EventBus } from "./harness/bus.js";
|
|
@@ -79,10 +90,11 @@ import { selectDefaultModelSelection } from "./default-model-selection.js";
|
|
|
79
90
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
80
91
|
import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
|
|
81
92
|
import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
|
|
82
|
-
import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, toWireTask, } from "./store.js";
|
|
93
|
+
import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
|
|
83
94
|
import * as tts from "./tts/index.js";
|
|
84
95
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
85
96
|
import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
|
|
97
|
+
import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
|
|
86
98
|
import { extractTurnImages } from "./turn-images.js";
|
|
87
99
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
88
100
|
import { TurnResources, workspaceResource } from "./turn-resources.js";
|
|
@@ -138,6 +150,7 @@ import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
|
|
|
138
150
|
import { shouldMountLocalComputer } from "./local-routing.js";
|
|
139
151
|
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
140
152
|
import { startAutoVmClaim } from "./auto-vm-claims.js";
|
|
153
|
+
import { computerFreeText, computerStillBusyText, computerWaitEndedText, computerWaitingText } from "./computer-wait.js";
|
|
141
154
|
import { modelContextWindow } from "./model-context-window.js";
|
|
142
155
|
import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
143
156
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
@@ -269,6 +282,17 @@ registerEnginesBinDir();
|
|
|
269
282
|
// still belongs to the person who wrote, and routine or peer turns are
|
|
270
283
|
// told apart before this is consulted.
|
|
271
284
|
const turnTriggers = new Map();
|
|
285
|
+
/** Who a user message is from, when that is someone other than the desktop
|
|
286
|
+
* owner. Loopback is the owner by design, so it stays unstamped and reads as
|
|
287
|
+
* the profile name; a paired or signed-in session names the person, by
|
|
288
|
+
* account email where there is one and otherwise by the device label they
|
|
289
|
+
* chose while pairing. */
|
|
290
|
+
function messageSender(auth) {
|
|
291
|
+
if (auth.kind !== "session")
|
|
292
|
+
return undefined;
|
|
293
|
+
const name = (auth.session.email ?? auth.session.label ?? "").trim();
|
|
294
|
+
return name ? { name } : undefined;
|
|
295
|
+
}
|
|
272
296
|
function noteTurnTrigger(threadId, auth) {
|
|
273
297
|
turnTriggers.set(threadId, auth.kind === "session"
|
|
274
298
|
? { kind: "user", ...(auth.session.email ? { email: auth.session.email } : {}), label: auth.session.label }
|
|
@@ -314,7 +338,13 @@ function applyDesktopMutationTokenMessage(raw) {
|
|
|
314
338
|
const browserCleanup = new BrowserCleanupCoordinator({
|
|
315
339
|
file: join(DATA_DIR, "browser-cleanups.json"),
|
|
316
340
|
send: (request) => {
|
|
317
|
-
|
|
341
|
+
// Cleanup may only run the engine OpenMausBot itself configured or
|
|
342
|
+
// downloaded. A binary the ambient PATH turned up — on a dev machine, a
|
|
343
|
+
// global wrapper that shadows the harness PATH and rewrites the session
|
|
344
|
+
// key — is not that engine: a close through it can fail and wedge the
|
|
345
|
+
// journal on retries. Without a managed engine no daemon could still
|
|
346
|
+
// autosave the session, so the erase below can acknowledge directly.
|
|
347
|
+
const status = browserEngineStatus({ managedOnly: true });
|
|
318
348
|
// Guest sessions are throwaway and never saved, so only the bot's own
|
|
319
349
|
// session and shared profile sessions have state to clear.
|
|
320
350
|
const sessions = request.type === "openmausbot:browser-bot-deleted" && request.botId
|
|
@@ -558,6 +588,22 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
|
|
|
558
588
|
},
|
|
559
589
|
};
|
|
560
590
|
}
|
|
591
|
+
/** Engine lifecycle hooks (item 0.2): a turn-scoped bearer the engine's hook
|
|
592
|
+
* helper presents on /api/internal/hook. OMB_HOOKS=0 turns the channel off. */
|
|
593
|
+
const hooksEnabled = () => process.env.OMB_HOOKS !== "0";
|
|
594
|
+
function hooksIntegration(botId, threadId, generation) {
|
|
595
|
+
const token = mintInternalCapability({
|
|
596
|
+
botId,
|
|
597
|
+
threadId,
|
|
598
|
+
generation,
|
|
599
|
+
depth: 0,
|
|
600
|
+
kind: "hooks",
|
|
601
|
+
skillAuthoring: false,
|
|
602
|
+
createdBots: 0,
|
|
603
|
+
openedThreads: 0,
|
|
604
|
+
});
|
|
605
|
+
return { url: `http://127.0.0.1:${PORT}`, token };
|
|
606
|
+
}
|
|
561
607
|
class DirectTurnSetupCancelled extends Error {
|
|
562
608
|
}
|
|
563
609
|
const directTurnDispatchClaims = new Map();
|
|
@@ -625,6 +671,9 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
625
671
|
const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
|
|
626
672
|
turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
|
|
627
673
|
let waitingMessage;
|
|
674
|
+
// Who holds the desktop, as the chip and the give-up error name them: a
|
|
675
|
+
// bot running a titled thread, or a room. Read once, when the wait begins.
|
|
676
|
+
let holder;
|
|
628
677
|
const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
|
|
629
678
|
try {
|
|
630
679
|
while (true) {
|
|
@@ -636,23 +685,25 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
636
685
|
const blocker = turnResources.blocker(resource, owner);
|
|
637
686
|
const holderBot = blocker && store.botByThread(blocker.threadId);
|
|
638
687
|
const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
|
|
639
|
-
const
|
|
640
|
-
|
|
688
|
+
const holderRoom = !holderBot && blocker ? store.groupByThread(blocker.threadId) : null;
|
|
689
|
+
holder = holderBot
|
|
690
|
+
? { name: holderBot.name, ...(holderTask?.title ? { task: holderTask.title } : {}) }
|
|
691
|
+
: holderRoom ? { name: holderRoom.name } : undefined;
|
|
641
692
|
waitingMessage = store.appendMessage(owner.threadId, {
|
|
642
693
|
role: "bot", kind: "activity",
|
|
643
|
-
tool: { name:
|
|
694
|
+
tool: { name: computerWaitingText(holder) },
|
|
644
695
|
...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
|
|
645
696
|
});
|
|
646
697
|
}
|
|
647
698
|
if (Date.now() >= deadline)
|
|
648
|
-
throw new Error(
|
|
699
|
+
throw new Error(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
|
|
649
700
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
650
701
|
}
|
|
651
702
|
}
|
|
652
703
|
finally {
|
|
653
704
|
if (waitingMessage)
|
|
654
705
|
store.patchMessage(owner.threadId, waitingMessage.id, {
|
|
655
|
-
tool: { name: active() && turnResources.owns(resource, owner) ?
|
|
706
|
+
tool: { name: active() && turnResources.owns(resource, owner) ? computerFreeText() : computerWaitEndedText(), ok: true },
|
|
656
707
|
});
|
|
657
708
|
}
|
|
658
709
|
turnResourceOwners.set(owner.threadId, owner);
|
|
@@ -813,6 +864,7 @@ function clearDirectTurnDispatch(threadId, claimId) {
|
|
|
813
864
|
if (directTurnDispatchClaims.get(threadId)?.id === claimId)
|
|
814
865
|
directTurnDispatchClaims.delete(threadId);
|
|
815
866
|
}
|
|
867
|
+
const compactionControllers = new Map();
|
|
816
868
|
function cancelDirectTurnDispatch(botId, expectedThreadId) {
|
|
817
869
|
const threadId = expectedThreadId ?? store.bot(botId)?.threadId;
|
|
818
870
|
if (!threadId)
|
|
@@ -821,7 +873,12 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
|
|
|
821
873
|
const claim = directTurnDispatchClaims.get(threadId);
|
|
822
874
|
if (!claim || claim.botId !== botId)
|
|
823
875
|
return null;
|
|
876
|
+
const compacting = compactionControllers.get(threadId);
|
|
877
|
+
if (compacting?.generation === claim.id)
|
|
878
|
+
compacting.controller.abort(new DirectTurnSetupCancelled("context summarization stopped"));
|
|
824
879
|
directTurnDispatchClaims.delete(threadId);
|
|
880
|
+
if (directTurnGenerationByThread.get(threadId) === claim.id)
|
|
881
|
+
clearTurnDigestState(threadId);
|
|
825
882
|
// Setup has not called the adapter yet, so there is no provider handshake
|
|
826
883
|
// (and no unknown turn id) to quarantine. Dispatching is the only phase in
|
|
827
884
|
// which a late provider event can exist.
|
|
@@ -889,6 +946,7 @@ async function browserIntegration(botId, profile, turn) {
|
|
|
889
946
|
encryptionKey: browserEngineEncryptionKey(),
|
|
890
947
|
persistent: profile !== "guest",
|
|
891
948
|
env: { ...process.env, PATH: augmentedPath() },
|
|
949
|
+
attachCdpUrl: browserEngineAttachCdpUrl(cfg) ?? undefined,
|
|
892
950
|
});
|
|
893
951
|
await prepareBrowserSessionState(status.binaryPath, session, { env: spec.env, persistent: profile !== "guest", isCurrent: () => {
|
|
894
952
|
const current = store.bot(botId);
|
|
@@ -2175,15 +2233,11 @@ function outstandingAssignmentsPrompt(threadId) {
|
|
|
2175
2233
|
const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
2176
2234
|
validate: (node, parent) => roomHandoffProblem(node, parent) ??
|
|
2177
2235
|
(parent && store.bot(parent.botId)?.approvePeerComms && !fullAccessForSource(parent.botId, parent.threadId) && !node.approvalGranted ? "Sender now requires peer approval; submit a new approved request" : undefined),
|
|
2178
|
-
//
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
// busy teammate's whole bot (#1238); an owed resume only needs its own
|
|
2184
|
-
// thread free and a thread slot to admit it.
|
|
2185
|
-
busy: n => !n.groupId && n.status === "resume"
|
|
2186
|
-
? threadBusy(n.botId, n.threadId) || botAtThreadCapacity(n.botId)
|
|
2236
|
+
// Direct assignments and follow-ups use independent threads. Match direct
|
|
2237
|
+
// turn admission: unrelated work need not block a free thread slot, but
|
|
2238
|
+
// never overlap the addressed thread, exceed capacity, or race a group turn.
|
|
2239
|
+
busy: n => !n.groupId
|
|
2240
|
+
? threadBusy(n.botId, n.threadId) || botAtThreadCapacity(n.botId) || Boolean(activeGroupTurnForBot(n.botId))
|
|
2187
2241
|
: Boolean(store.bot(n.botId)?.busy || (n.groupId && store.group(n.groupId) && groupIsWorking(store.group(n.groupId)))),
|
|
2188
2242
|
changed: (groupIds, directThreadIds) => {
|
|
2189
2243
|
for (const id of groupIds) {
|
|
@@ -2953,7 +3007,7 @@ const turnContext = new Map();
|
|
|
2953
3007
|
// unique arguments would let one pathological turn grow the server forever.
|
|
2954
3008
|
const repeats = new RepeatDetector({ thresholds: [5, 10, 20], maxKeysPerThread: 256 });
|
|
2955
3009
|
// ── stall watchdog ─────────────────────────────────────────────────────
|
|
2956
|
-
// ask_bot has a
|
|
3010
|
+
// ask_bot has a short inline wait budget, while room turns have a separately
|
|
2957
3011
|
// configurable absolute ceiling. The main 1:1 path had none, so a wedged CLI
|
|
2958
3012
|
// left its bot busy forever. The watchdog stops a turn whose thread has emitted NOTHING for stallMs —
|
|
2959
3013
|
// activity-based, so an hour-long turn that keeps streaming is never
|
|
@@ -2961,7 +3015,7 @@ const repeats = new RepeatDetector({ thresholds: [5, 10, 20], maxKeysPerThread:
|
|
|
2961
3015
|
const TURN_STALL_MS = Math.max(60_000, Number(process.env.OMB_TURN_STALL_MS) || 20 * 60_000);
|
|
2962
3016
|
/** How long ask_bot waits synchronously before the ask is converted into a
|
|
2963
3017
|
* delegation claim ticket (the peer's turn keeps running either way). */
|
|
2964
|
-
const ASK_BOT_TIMEOUT_MS = Math.max(5_000, Number(process.env.OMB_ASK_BOT_TIMEOUT_MS) ||
|
|
3018
|
+
const ASK_BOT_TIMEOUT_MS = Math.max(5_000, Number(process.env.OMB_ASK_BOT_TIMEOUT_MS) || 15_000);
|
|
2965
3019
|
// A room waits for a busy teammate instead of dropping them, but never
|
|
2966
3020
|
// forever: a bot parked on a permission card in another chat is "busy" until
|
|
2967
3021
|
// a human returns. Past this cap a goal's lead is told the teammate could not
|
|
@@ -3001,6 +3055,10 @@ const watchdog = new TurnWatchdog({
|
|
|
3001
3055
|
kind: "activity",
|
|
3002
3056
|
tool: { name: `error: no activity for ${minutes} minutes — the turn was stopped`, ok: false },
|
|
3003
3057
|
});
|
|
3058
|
+
// a routine's stall reports through its own failure path
|
|
3059
|
+
if (bot && routineRun?.target !== "bot") {
|
|
3060
|
+
reportIncident({ kind: "stalled", bot, threadId: turn.threadId, detail: `no activity for ${minutes} minutes — the turn was stopped` });
|
|
3061
|
+
}
|
|
3004
3062
|
settleDirectFollowup(stalledGeneration);
|
|
3005
3063
|
finalizeDelegationWatch(turn.threadId, false, "", "Delegated turn stalled and was stopped");
|
|
3006
3064
|
turnUsage.delete(turn.threadId);
|
|
@@ -3039,8 +3097,7 @@ const watchdog = new TurnWatchdog({
|
|
|
3039
3097
|
const currentBot = store.bot(turn.botId);
|
|
3040
3098
|
if (currentBot?.busy) {
|
|
3041
3099
|
stopScreenPoller(currentBot.id, turn.threadId);
|
|
3042
|
-
|
|
3043
|
-
activeVpsThreads.delete(currentBot.id);
|
|
3100
|
+
vpsThreadEnded(currentBot.id, turn.threadId);
|
|
3044
3101
|
if (store.taskByThread(currentBot.id, turn.threadId))
|
|
3045
3102
|
store.setTaskActivity(currentBot.id, turn.threadId, "idle");
|
|
3046
3103
|
else
|
|
@@ -3090,12 +3147,20 @@ bus.subscribe((event) => {
|
|
|
3090
3147
|
// settles — session.exited too, because a turn that died may still have
|
|
3091
3148
|
// written. Fire-and-forget by construction: endMemoryTurn swallows its own
|
|
3092
3149
|
// failures and never reaches the fold below.
|
|
3150
|
+
// The rows are kept for the digest the fold writes at settle (registration
|
|
3151
|
+
// order: this subscriber runs before the fold, so the stash is ready).
|
|
3152
|
+
const memoryRowsByThread = new Map();
|
|
3093
3153
|
bus.subscribe((event) => {
|
|
3094
3154
|
if (shouldIgnoreProviderEvent(event))
|
|
3095
3155
|
return;
|
|
3096
3156
|
if (event.type === "turn.completed" || event.type === "session.exited") {
|
|
3097
3157
|
runningTurnEngines.delete(event.threadId);
|
|
3098
|
-
endMemoryTurn(event.threadId);
|
|
3158
|
+
const rows = endMemoryTurn(event.threadId);
|
|
3159
|
+
if (rows.length && (store.botByThread(event.threadId) || store.groupByThread(event.threadId))) {
|
|
3160
|
+
memoryRowsByThread.set(event.threadId, rows);
|
|
3161
|
+
}
|
|
3162
|
+
else
|
|
3163
|
+
memoryRowsByThread.delete(event.threadId);
|
|
3099
3164
|
}
|
|
3100
3165
|
});
|
|
3101
3166
|
// One line per finished turn in the bot's daily log (server/recent-work.ts):
|
|
@@ -3133,6 +3198,160 @@ bus.subscribe((event) => {
|
|
|
3133
3198
|
// a missing note never fails a turn
|
|
3134
3199
|
}
|
|
3135
3200
|
});
|
|
3201
|
+
// Per-thread state the digest needs from before the turn: when it started,
|
|
3202
|
+
// and the checkpoint taken at dispatch (hash + folder) so settle can diff.
|
|
3203
|
+
const turnStartedAt = new Map();
|
|
3204
|
+
const turnCheckpoints = new Map();
|
|
3205
|
+
// The turn a thread is in, from the last event that named one. Not every
|
|
3206
|
+
// driver stamps every item with a turnId (ACP fakes, some ACP agents), and
|
|
3207
|
+
// a digest can only count activity it can attribute — so an unstamped tool
|
|
3208
|
+
// row is attributed to the thread's live turn instead of to nothing.
|
|
3209
|
+
const liveTurnByThread = new Map();
|
|
3210
|
+
function clearTurnDigestState(threadId) {
|
|
3211
|
+
turnStartedAt.delete(threadId);
|
|
3212
|
+
turnCheckpoints.delete(threadId);
|
|
3213
|
+
liveTurnByThread.delete(threadId);
|
|
3214
|
+
memoryRowsByThread.delete(threadId);
|
|
3215
|
+
}
|
|
3216
|
+
const TOOL_RESULTS_DIR = join(DATA_DIR, "tool-results");
|
|
3217
|
+
const TOOL_RESULT_SPILL_MAX = 512 * 1024;
|
|
3218
|
+
/** A hook event from the engine (item 0.2). PostToolUse carries the tool's
|
|
3219
|
+
* full result: it is redacted, spilled to a private file, and attached to
|
|
3220
|
+
* the activity row the fold already wrote for that call, which flips the
|
|
3221
|
+
* turn's evidence to "full" for the digest. Idempotent on the tool_use_id
|
|
3222
|
+
* (a re-delivered hook never double-writes). Other events are accepted and
|
|
3223
|
+
* ignored here; item 0.4 gives PreCompact/SessionStart/Stop their meaning. */
|
|
3224
|
+
const DIGESTS_AFTER_COMPACTION = 2;
|
|
3225
|
+
function ingestEngineHook(capability, body) {
|
|
3226
|
+
const event = body && typeof body === "object" ? body : {};
|
|
3227
|
+
const name = typeof event.event === "string" ? event.event : "";
|
|
3228
|
+
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
|
3229
|
+
const threadId = capability.threadId;
|
|
3230
|
+
const chip = (text) => store.appendMessage(threadId, { role: "bot", kind: "activity", tool: { name: text, ok: true } });
|
|
3231
|
+
// Compaction is a harness event with a record, not something that silently
|
|
3232
|
+
// happens to the model: a chip before, and after it the latest digests go
|
|
3233
|
+
// back in as plain-text context so the compacted session still knows what
|
|
3234
|
+
// its earlier turns DID (the CLI's own summary keeps what was said).
|
|
3235
|
+
if (name === "PreCompact") {
|
|
3236
|
+
const trigger = payload.trigger === "manual" ? "manual" : "auto";
|
|
3237
|
+
chip(`context compaction started (${trigger})`);
|
|
3238
|
+
return { ok: true };
|
|
3239
|
+
}
|
|
3240
|
+
if (name === "SessionStart") {
|
|
3241
|
+
if (payload.source !== "compact")
|
|
3242
|
+
return { ok: true, ignored: `SessionStart ${String(payload.source ?? "")}` };
|
|
3243
|
+
const bot = store.bot(capability.botId);
|
|
3244
|
+
const digests = store.activePath(threadId).filter((m) => m.kind === "digest" && m.digest).slice(-DIGESTS_AFTER_COMPACTION);
|
|
3245
|
+
chip(`context compacted — re-sent the last ${digests.length} digest${digests.length === 1 ? "" : "s"}`);
|
|
3246
|
+
if (!digests.length)
|
|
3247
|
+
return { ok: true };
|
|
3248
|
+
const context = digests.map((m) => digestPromptLine(m.digest, m.from?.name ?? store.bot(m.digest.botId)?.name ?? bot?.name ?? "the bot")).join("\n");
|
|
3249
|
+
return { ok: true, context };
|
|
3250
|
+
}
|
|
3251
|
+
if (name === "Stop")
|
|
3252
|
+
return { ok: true };
|
|
3253
|
+
if (name !== "PostToolUse")
|
|
3254
|
+
return { ok: true, ignored: name || "unknown" };
|
|
3255
|
+
const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
|
|
3256
|
+
if (!toolUseId)
|
|
3257
|
+
return { ok: true, ignored: "PostToolUse without tool_use_id" };
|
|
3258
|
+
if (!Object.hasOwn(payload, "tool_response"))
|
|
3259
|
+
return { ok: true, ignored: "hook did not include a tool response" };
|
|
3260
|
+
const response = payload.tool_response;
|
|
3261
|
+
const text = typeof response === "string" ? response : JSON.stringify(response ?? null, null, 2);
|
|
3262
|
+
const turnId = liveTurnByThread.get(threadId);
|
|
3263
|
+
const row = store.activePath(threadId).findLast((m) => m.kind === "activity" && m.turnId === turnId && m.tool?.itemId === toolUseId);
|
|
3264
|
+
if (!turnId || !row?.tool)
|
|
3265
|
+
return { ok: true, ignored: "tool not observed in this turn" };
|
|
3266
|
+
const key = `${threadId}:${turnId}:${toolUseId}`;
|
|
3267
|
+
if (!commandReceipt("hook.ingest", key)) {
|
|
3268
|
+
const dir = join(TOOL_RESULTS_DIR, threadId.replace(/[^\w.-]/g, "_"));
|
|
3269
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
3270
|
+
const safeId = createHash("sha256").update(key).digest("hex");
|
|
3271
|
+
const file = join(dir, `${safeId}.txt`);
|
|
3272
|
+
const redacted = redactSecretsInText(text);
|
|
3273
|
+
const bounded = redacted.length > TOOL_RESULT_SPILL_MAX
|
|
3274
|
+
? `${redacted.slice(0, TOOL_RESULT_SPILL_MAX)}\n[… ${redacted.length - TOOL_RESULT_SPILL_MAX} more characters omitted]`
|
|
3275
|
+
: redacted;
|
|
3276
|
+
writeFileAtomic(file, bounded, { mode: 0o600 });
|
|
3277
|
+
store.patchMessage(threadId, row.id, { tool: { ...row.tool, fullResult: redacted.length <= TOOL_RESULT_SPILL_MAX } }, { kind: "hook.ingest", key });
|
|
3278
|
+
}
|
|
3279
|
+
return { ok: true };
|
|
3280
|
+
}
|
|
3281
|
+
/** Persist observed tools and memory immediately. Keep a direct turn's
|
|
3282
|
+
* workspace claimed while its bounded file snapshot settles, then enrich
|
|
3283
|
+
* that same row only if its task and active branch still exist. */
|
|
3284
|
+
async function scheduleTurnDigest(input) {
|
|
3285
|
+
const at = Date.now();
|
|
3286
|
+
const startedAt = turnStartedAt.get(input.threadId);
|
|
3287
|
+
turnStartedAt.delete(input.threadId);
|
|
3288
|
+
const checkpoint = turnCheckpoints.get(input.threadId);
|
|
3289
|
+
turnCheckpoints.delete(input.threadId);
|
|
3290
|
+
const memory = memoryRowsByThread.get(input.threadId) ?? [];
|
|
3291
|
+
memoryRowsByThread.delete(input.threadId);
|
|
3292
|
+
try {
|
|
3293
|
+
if (!input.isCurrent())
|
|
3294
|
+
return;
|
|
3295
|
+
const activities = store.activePath(input.threadId);
|
|
3296
|
+
const digest = buildTurnDigest({
|
|
3297
|
+
turnId: input.turnId,
|
|
3298
|
+
botId: input.botId,
|
|
3299
|
+
threadId: input.threadId,
|
|
3300
|
+
at,
|
|
3301
|
+
durationMs: startedAt ? Math.max(0, at - startedAt) : 0,
|
|
3302
|
+
activities,
|
|
3303
|
+
memory,
|
|
3304
|
+
reply: input.reply,
|
|
3305
|
+
...(input.usage ? { usage: input.usage } : {}),
|
|
3306
|
+
hookCoverage: coverageForDriver(input.driverKind, toolEvidence(activities, input.turnId), activities.some(m => m.kind === "activity" && m.turnId === input.turnId && m.tool?.itemId)),
|
|
3307
|
+
});
|
|
3308
|
+
const message = store.appendMessage(input.threadId, {
|
|
3309
|
+
role: "bot",
|
|
3310
|
+
kind: "digest",
|
|
3311
|
+
text: renderDigest(digest),
|
|
3312
|
+
digest,
|
|
3313
|
+
turnId: input.turnId,
|
|
3314
|
+
...(input.from ? { from: input.from } : {}),
|
|
3315
|
+
}, { kind: "digest.append", key: `${input.threadId}:${input.turnId}` });
|
|
3316
|
+
// This is derived from the native session's own work, not an unseen
|
|
3317
|
+
// teammate message. Record it alongside that turn's replies so normal
|
|
3318
|
+
// resumes stay delta-only. Other engines still receive it on replay.
|
|
3319
|
+
if (input.instanceId && !input.from) {
|
|
3320
|
+
const handed = store.taskByThread(input.botId, input.threadId)?.handedMessages?.[input.instanceId];
|
|
3321
|
+
if (handed)
|
|
3322
|
+
store.setHandedMessages(input.botId, input.threadId, input.instanceId, recordHanded(handed, store.activePath(input.threadId).filter(isContextMessage).map(row => row.id), [message.id]));
|
|
3323
|
+
}
|
|
3324
|
+
if (!checkpoint)
|
|
3325
|
+
return;
|
|
3326
|
+
const controller = new AbortController();
|
|
3327
|
+
let timer;
|
|
3328
|
+
const capture = async () => {
|
|
3329
|
+
const after = await checkpoints.snapshot(input.botId, checkpoint.cwd, `settle ${input.threadId.slice(0, 8)}`, controller.signal);
|
|
3330
|
+
if (!after || controller.signal.aborted)
|
|
3331
|
+
return null;
|
|
3332
|
+
if (checkpoint.hash === after)
|
|
3333
|
+
return { changed: [], added: [], deleted: [] };
|
|
3334
|
+
return checkpoints.diffStat(input.botId, checkpoint.cwd, checkpoint.hash, after, controller.signal);
|
|
3335
|
+
};
|
|
3336
|
+
let files;
|
|
3337
|
+
try {
|
|
3338
|
+
files = await Promise.race([
|
|
3339
|
+
capture(),
|
|
3340
|
+
new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(null); }, 3_000); }),
|
|
3341
|
+
]);
|
|
3342
|
+
}
|
|
3343
|
+
finally {
|
|
3344
|
+
clearTimeout(timer);
|
|
3345
|
+
}
|
|
3346
|
+
if (!files || !input.isCurrent() || !store.activePath(input.threadId).some(row => row.id === message.id))
|
|
3347
|
+
return;
|
|
3348
|
+
const withFiles = buildTurnDigest({ ...digest, activities, memory, files });
|
|
3349
|
+
store.patchMessage(input.threadId, message.id, { digest: withFiles, text: renderDigest(withFiles) }, { kind: "digest.files", key: `${input.threadId}:${input.turnId}` });
|
|
3350
|
+
}
|
|
3351
|
+
catch (error) {
|
|
3352
|
+
console.error(`digest: could not record turn ${input.turnId} on ${input.threadId}:`, error instanceof Error ? error.message : error);
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3136
3355
|
// Bots currently working with nobody at the keyboard — a webhook turn, or a
|
|
3137
3356
|
// turn a webhook-driven bot handed to a teammate. Auto mode is a decision
|
|
3138
3357
|
// someone made for turns they were present for, so these don't inherit it:
|
|
@@ -3233,7 +3452,25 @@ function noteLocalVmSeen(target, status) {
|
|
|
3233
3452
|
let localVmImageBusy = false;
|
|
3234
3453
|
let localVmProvisionBusy = false;
|
|
3235
3454
|
let localVmModeChangeBusy = false;
|
|
3455
|
+
/** Threads running with a bot's VPS computer mounted, per bot. Several run
|
|
3456
|
+
* at once — only the desktop lease is exclusive, and it is claimed on the
|
|
3457
|
+
* first screen call (see the VPS mount in dispatch) — so the alias and
|
|
3458
|
+
* backend guards ask "any thread?", and a settling thread removes only
|
|
3459
|
+
* itself, never a sibling still running. */
|
|
3236
3460
|
const activeVpsThreads = new Map();
|
|
3461
|
+
function vpsThreadStarted(botId, threadId) {
|
|
3462
|
+
const threads = activeVpsThreads.get(botId) ?? new Set();
|
|
3463
|
+
threads.add(threadId);
|
|
3464
|
+
activeVpsThreads.set(botId, threads);
|
|
3465
|
+
}
|
|
3466
|
+
function vpsThreadEnded(botId, threadId) {
|
|
3467
|
+
const threads = activeVpsThreads.get(botId);
|
|
3468
|
+
if (!threads)
|
|
3469
|
+
return;
|
|
3470
|
+
threads.delete(threadId);
|
|
3471
|
+
if (!threads.size)
|
|
3472
|
+
activeVpsThreads.delete(botId);
|
|
3473
|
+
}
|
|
3237
3474
|
const boxLifecycleBusyBots = new Set();
|
|
3238
3475
|
// A refresh is a reader, not a lifecycle change. Keep its reservation until
|
|
3239
3476
|
// the provider settles even if the HTTP client leaves, and share it on retry.
|
|
@@ -3344,6 +3581,107 @@ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
|
|
|
3344
3581
|
capture: () => box.screenshotBox(cfg, ownerId, machine.id),
|
|
3345
3582
|
};
|
|
3346
3583
|
}
|
|
3584
|
+
/** "Works on: This computer", strict. One mount for a bot thread and a group
|
|
3585
|
+
* chat, so the two cannot drift on what they check or how they fail. */
|
|
3586
|
+
async function mountHostComputer(owner, botId, providerSupportsLocal) {
|
|
3587
|
+
if (!shouldMountLocalComputer({ requested: "local", hostPlatform: process.platform, providerSupportsLocal })) {
|
|
3588
|
+
// Name the condition that actually failed: a person told "choose an
|
|
3589
|
+
// ACP engine" while already on one has nowhere to go.
|
|
3590
|
+
throw new Error(providerSupportsLocal
|
|
3591
|
+
? `local computer control is not available on ${process.platform} — select another destination`
|
|
3592
|
+
: "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
|
|
3593
|
+
}
|
|
3594
|
+
const cua = readCuaConnection();
|
|
3595
|
+
if (!cua)
|
|
3596
|
+
throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
|
|
3597
|
+
await bindTurnComputer(owner, "computer:host");
|
|
3598
|
+
return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
|
|
3599
|
+
}
|
|
3600
|
+
/** The bot's own VPS desktop, mounted into the local agent. The desktop lease
|
|
3601
|
+
* is claimed on the first computer call, through the computer-control gate
|
|
3602
|
+
* (the Local VM's seam, #1361), never at mount: turns that never touch the
|
|
3603
|
+
* computer tools run side by side. `start` may provision or start the
|
|
3604
|
+
* container; without it the VPS is only inspected. The caller has already
|
|
3605
|
+
* recorded the thread with vpsThreadStarted and ends it on a problem. */
|
|
3606
|
+
async function mountBotVps(bot, owner, opts) {
|
|
3607
|
+
const vpsResource = `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`;
|
|
3608
|
+
const remote = opts.start
|
|
3609
|
+
? await vps.vpsComputerAction("provision", cfg, bot.id)
|
|
3610
|
+
: await vps.inspectVpsForAuto(cfg, bot.id);
|
|
3611
|
+
if (!remote?.ready || !remote.sshAlias)
|
|
3612
|
+
return { problem: remote?.problem ?? null };
|
|
3613
|
+
const targetCfg = { ...cfg, vps: { sshAlias: remote.sshAlias } };
|
|
3614
|
+
const vpsMcp = vps.vpsComputerMcp(targetCfg, bot.id, remote.container_id ?? undefined);
|
|
3615
|
+
const vpsControl = controlIntegration(bot.id, owner.threadId, owner.generation);
|
|
3616
|
+
// Live frames only once this turn holds the desktop: a poller on a desktop
|
|
3617
|
+
// another turn is driving would publish that turn's screen as this one's.
|
|
3618
|
+
const vpsCapture = () => vps.vpsComputerScreenshot(targetCfg, bot.id);
|
|
3619
|
+
autoVmClaims.set(owner.threadId, {
|
|
3620
|
+
owner,
|
|
3621
|
+
lazy: true,
|
|
3622
|
+
label: "the VPS computer",
|
|
3623
|
+
claim: async () => {
|
|
3624
|
+
await bindTurnComputer(owner, vpsResource, true);
|
|
3625
|
+
opts.onClaimed(vpsCapture);
|
|
3626
|
+
},
|
|
3627
|
+
});
|
|
3628
|
+
return {
|
|
3629
|
+
integration: {
|
|
3630
|
+
...vpsMcp,
|
|
3631
|
+
env: { ...vpsMcp.env, OMB_CONTROL_URL: vpsControl.url, OMB_CONTROL_TOKEN: vpsControl.token },
|
|
3632
|
+
},
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
/** The bot's own Box. Explicit Cloud is the consent boundary: it may create a
|
|
3636
|
+
* missing machine and wake an archived one (~8s, and it un-pauses billing);
|
|
3637
|
+
* Auto only attaches a machine that is already running. */
|
|
3638
|
+
async function attachBotBox(bot, owner, opts) {
|
|
3639
|
+
// Explicit cloud turns can provision/wake the same bot's Box. Claim before
|
|
3640
|
+
// any network await so setup itself cannot race another turn.
|
|
3641
|
+
if (opts.explicitCloud)
|
|
3642
|
+
await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
|
|
3643
|
+
let b;
|
|
3644
|
+
try {
|
|
3645
|
+
b = await box.findBox(cfg, bot.id);
|
|
3646
|
+
}
|
|
3647
|
+
catch (error) {
|
|
3648
|
+
// Auto may fall through when an optional provider is offline, but a
|
|
3649
|
+
// durable deletion fence must never be mistaken for "no computer".
|
|
3650
|
+
if (opts.explicitCloud || error?.status === 409)
|
|
3651
|
+
throw error;
|
|
3652
|
+
b = null;
|
|
3653
|
+
}
|
|
3654
|
+
const lifecycleOf = (explicitCloud) => box.boxTurnLifecycleAction({
|
|
3655
|
+
explicitCloud,
|
|
3656
|
+
canMount: opts.canMount,
|
|
3657
|
+
state: typeof b?.state === "string" ? b.state : null,
|
|
3658
|
+
});
|
|
3659
|
+
let lifecycle = lifecycleOf(opts.explicitCloud);
|
|
3660
|
+
if (lifecycle === "provision") {
|
|
3661
|
+
broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
|
|
3662
|
+
await box.provisionBox(cfg, bot.id, bot.name);
|
|
3663
|
+
b = await box.findBox(cfg, bot.id);
|
|
3664
|
+
lifecycle = lifecycleOf(true);
|
|
3665
|
+
}
|
|
3666
|
+
// an archived box answers every action with an error until it resumes —
|
|
3667
|
+
// wake it here, once, instead of letting the agent discover it one failed
|
|
3668
|
+
// tool call at a time.
|
|
3669
|
+
if (lifecycle === "wake") {
|
|
3670
|
+
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
3671
|
+
b = (await box.readyBox(cfg, bot.id)) ?? b;
|
|
3672
|
+
lifecycle = lifecycleOf(true);
|
|
3673
|
+
}
|
|
3674
|
+
if (!b || lifecycle !== "attach")
|
|
3675
|
+
return null;
|
|
3676
|
+
const machine = b;
|
|
3677
|
+
await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
|
|
3678
|
+
return {
|
|
3679
|
+
capture: () => box.screenshotBox(cfg, bot.id, machine.id),
|
|
3680
|
+
integration: opts.canMount
|
|
3681
|
+
? { kind: "box", boxId: machine.id, token: cfg.box.token, control: controlIntegration(bot.id, owner.threadId, owner.generation) }
|
|
3682
|
+
: null,
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3347
3685
|
function managedBoxOwners() {
|
|
3348
3686
|
return [...store.bots.map((bot) => ({
|
|
3349
3687
|
botId: bot.id,
|
|
@@ -3524,9 +3862,9 @@ function continueComputerSelection(threadId, generation, succeeded) {
|
|
|
3524
3862
|
const selection = computerSelectionTurns.get(threadId);
|
|
3525
3863
|
if (!selection || selection.generation !== generation)
|
|
3526
3864
|
return false;
|
|
3527
|
-
if (!succeeded || !selection.selected) {
|
|
3865
|
+
if (!succeeded || !selection.selected || hasQueuedSteeredMessages(selection.botId, threadId)) {
|
|
3528
3866
|
computerSelectionTurns.delete(threadId);
|
|
3529
|
-
return false;
|
|
3867
|
+
return false; // Let ordinary settlement drain the person's newer request.
|
|
3530
3868
|
}
|
|
3531
3869
|
const surface = selection.selected;
|
|
3532
3870
|
setImmediate(() => {
|
|
@@ -3539,8 +3877,10 @@ function continueComputerSelection(threadId, generation, succeeded) {
|
|
|
3539
3877
|
return;
|
|
3540
3878
|
if (store.taskByThread(bot.id, threadId)?.surface !== selection.previousSurface)
|
|
3541
3879
|
return;
|
|
3542
|
-
if (
|
|
3880
|
+
if (hasQueuedSteeredMessages(bot.id, threadId)) {
|
|
3881
|
+
drainQueuedSends();
|
|
3543
3882
|
return;
|
|
3883
|
+
}
|
|
3544
3884
|
if (store.activePath(threadId).findLast(message => message.role === "user" && message.kind === "text")?.id !== selection.source.id)
|
|
3545
3885
|
return;
|
|
3546
3886
|
store.patchTask(bot.id, threadId, { surface });
|
|
@@ -3785,7 +4125,15 @@ bus.subscribe((event) => {
|
|
|
3785
4125
|
pushMessage({ role: "bot", kind: "text", text: coordinatorVisibleText, turnId: completedTurnId });
|
|
3786
4126
|
lastReply.set(event.threadId, coordinatorVisibleText);
|
|
3787
4127
|
}
|
|
4128
|
+
if (bot)
|
|
4129
|
+
handoffs.onEvent(event);
|
|
4130
|
+
if (event.turnId)
|
|
4131
|
+
liveTurnByThread.set(event.threadId, event.turnId);
|
|
4132
|
+
const liveTurnId = event.turnId ?? liveTurnByThread.get(event.threadId);
|
|
3788
4133
|
switch (event.type) {
|
|
4134
|
+
case "turn.started":
|
|
4135
|
+
turnStartedAt.set(event.threadId, Date.now());
|
|
4136
|
+
break;
|
|
3789
4137
|
case "session.started":
|
|
3790
4138
|
if (bot && event.sessionId && event.providerInstanceId) {
|
|
3791
4139
|
store.setResumeCursor(bot.id, event.providerInstanceId, event.sessionId, event.threadId);
|
|
@@ -3863,7 +4211,9 @@ bus.subscribe((event) => {
|
|
|
3863
4211
|
const message = pushMessage({
|
|
3864
4212
|
role: "bot",
|
|
3865
4213
|
kind: "activity",
|
|
3866
|
-
tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary, input: event.input },
|
|
4214
|
+
tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary, input: event.input, itemId: event.itemId ?? event.eventId },
|
|
4215
|
+
// attributed to its turn so the digest can count it
|
|
4216
|
+
turnId: liveTurnId,
|
|
3867
4217
|
});
|
|
3868
4218
|
if (event.itemId)
|
|
3869
4219
|
toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
|
|
@@ -4109,8 +4459,16 @@ bus.subscribe((event) => {
|
|
|
4109
4459
|
store.markTerminalAssistantMessage(event.threadId, completedTurnId);
|
|
4110
4460
|
const reply = lastReply.get(event.threadId) ?? "";
|
|
4111
4461
|
lastReply.delete(event.threadId);
|
|
4462
|
+
// A run that broke — not one the person stopped, and not a routine's,
|
|
4463
|
+
// which reports through its own failure path — is the Chief's to see.
|
|
4464
|
+
if (!event.ok && event.stopReason !== "interrupted" && !routines?.runForThread(event.threadId)) {
|
|
4465
|
+
const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
4466
|
+
if (broken)
|
|
4467
|
+
reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
|
|
4468
|
+
}
|
|
4112
4469
|
const lastReported = turnUsage.get(event.threadId);
|
|
4113
4470
|
turnUsage.delete(event.threadId);
|
|
4471
|
+
const lastContext = turnContext.get(event.threadId);
|
|
4114
4472
|
turnContext.delete(event.threadId);
|
|
4115
4473
|
// group turns run on the room's thread — the speaking bot's task
|
|
4116
4474
|
// tally is not the right home for a shared room's spend, so only
|
|
@@ -4126,8 +4484,7 @@ bus.subscribe((event) => {
|
|
|
4126
4484
|
const ownsResources = resourceOwner &&
|
|
4127
4485
|
turnResourceOwners.get(event.threadId)?.generation === resourceOwner.generation;
|
|
4128
4486
|
if (ownsResources) {
|
|
4129
|
-
|
|
4130
|
-
activeVpsThreads.delete(bot.id);
|
|
4487
|
+
vpsThreadEnded(bot.id, event.threadId);
|
|
4131
4488
|
releaseLocalVmThread(event.threadId);
|
|
4132
4489
|
}
|
|
4133
4490
|
releaseTurnResources(resourceOwner);
|
|
@@ -4157,8 +4514,6 @@ bus.subscribe((event) => {
|
|
|
4157
4514
|
const tokens = event.usage ?? lastReported;
|
|
4158
4515
|
// the context figure: what the last model call's prompt held, with
|
|
4159
4516
|
// the window from the driver or, failing that, the model's family
|
|
4160
|
-
const lastContext = turnContext.get(event.threadId);
|
|
4161
|
-
turnContext.delete(event.threadId);
|
|
4162
4517
|
const contextModel = store.taskByThread(bot.id, event.threadId)?.modelSelection?.model ?? bot.modelSelection.model;
|
|
4163
4518
|
store.addTaskUsage(bot.id, event.threadId, {
|
|
4164
4519
|
input: tokens?.input,
|
|
@@ -4171,6 +4526,11 @@ bus.subscribe((event) => {
|
|
|
4171
4526
|
// the task and answers "what did we spend, by whom" for a period
|
|
4172
4527
|
const settledTask = store.taskByThread(bot.id, event.threadId);
|
|
4173
4528
|
const selection = settledTask?.modelSelection ?? bot.modelSelection;
|
|
4529
|
+
// A provider that omitted a context measurement must not reuse a
|
|
4530
|
+
// stale reading from a different model or a pre-compaction session.
|
|
4531
|
+
const measuredSelection = directTurnBots.get(event.threadId)?.modelSelection ?? selection;
|
|
4532
|
+
store.patchTask(bot.id, event.threadId, { lastContextModel: Number.isFinite(lastContext?.tokens) && (lastContext?.tokens ?? 0) > 0
|
|
4533
|
+
? `${measuredSelection.instanceId}:${measuredSelection.model}` : undefined });
|
|
4174
4534
|
appendUsage(DATA_DIR, {
|
|
4175
4535
|
botId: bot.id,
|
|
4176
4536
|
botName: bot.name,
|
|
@@ -4189,6 +4549,20 @@ bus.subscribe((event) => {
|
|
|
4189
4549
|
: turnTriggers.get(event.threadId) ?? { kind: "owner" },
|
|
4190
4550
|
});
|
|
4191
4551
|
noteSpend(DATA_DIR, event.cost ?? null);
|
|
4552
|
+
const digestSettled = completedTurnId ? scheduleTurnDigest({
|
|
4553
|
+
botId: bot.id,
|
|
4554
|
+
botName: bot.name,
|
|
4555
|
+
threadId: event.threadId,
|
|
4556
|
+
turnId: completedTurnId,
|
|
4557
|
+
driverKind: registry.get(selection.instanceId)?.driverKind,
|
|
4558
|
+
instanceId: selection.instanceId,
|
|
4559
|
+
reply,
|
|
4560
|
+
isCurrent,
|
|
4561
|
+
...(tokens ? { usage: { input: tokens.input ?? 0, output: tokens.output ?? 0, ...(typeof tokens.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}), costUsd: event.cost ?? null } } : {}),
|
|
4562
|
+
}) : Promise.resolve();
|
|
4563
|
+
if (resourceOwner)
|
|
4564
|
+
settlingResourceOwners.set(resourceOwner.threadId, resourceOwner.generation);
|
|
4565
|
+
liveTurnByThread.delete(event.threadId);
|
|
4192
4566
|
const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
|
|
4193
4567
|
// A routine's result belongs to its reporting thread's unread state.
|
|
4194
4568
|
// Its internal execution should not light up the sidebar as well.
|
|
@@ -4210,8 +4584,6 @@ bus.subscribe((event) => {
|
|
|
4210
4584
|
notify(buildNotification("done", notificationBot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: notificationBot.avatarUrl }));
|
|
4211
4585
|
}
|
|
4212
4586
|
if (screenPollers.has(event.threadId)) {
|
|
4213
|
-
if (resourceOwner)
|
|
4214
|
-
settlingResourceOwners.set(resourceOwner.threadId, resourceOwner.generation);
|
|
4215
4587
|
// the last live frame becomes a settled inline screen message —
|
|
4216
4588
|
// the screenshot-in-chat moment. One fresh capture first, so the
|
|
4217
4589
|
// frame shows the turn's END state (the final tool's poke may
|
|
@@ -4222,7 +4594,7 @@ bus.subscribe((event) => {
|
|
|
4222
4594
|
// these claims and fails as though another thread owned its folder.
|
|
4223
4595
|
const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
|
|
4224
4596
|
let timeout;
|
|
4225
|
-
|
|
4597
|
+
const screenSettled = Promise.race([
|
|
4226
4598
|
finalScreenFrame(bot.id, event.threadId),
|
|
4227
4599
|
new Promise((resolve) => { timeout = setTimeout(() => resolve(null), SCREEN_SETTLE_TIMEOUT_MS); }),
|
|
4228
4600
|
]).then((frame) => {
|
|
@@ -4231,11 +4603,11 @@ bus.subscribe((event) => {
|
|
|
4231
4603
|
}
|
|
4232
4604
|
}).catch(() => { }).finally(() => {
|
|
4233
4605
|
clearTimeout(timeout);
|
|
4234
|
-
settleDirectTurn(true);
|
|
4235
4606
|
});
|
|
4607
|
+
void Promise.all([screenSettled, digestSettled]).finally(() => settleDirectTurn(true));
|
|
4236
4608
|
}
|
|
4237
4609
|
else {
|
|
4238
|
-
settleDirectTurn();
|
|
4610
|
+
void digestSettled.finally(() => settleDirectTurn(true));
|
|
4239
4611
|
}
|
|
4240
4612
|
}
|
|
4241
4613
|
else if (group && speaker) {
|
|
@@ -4260,6 +4632,17 @@ bus.subscribe((event) => {
|
|
|
4260
4632
|
: turnTriggers.get(event.threadId) ?? { kind: "owner" },
|
|
4261
4633
|
});
|
|
4262
4634
|
noteSpend(DATA_DIR, event.cost ?? null);
|
|
4635
|
+
if (completedTurnId) {
|
|
4636
|
+
void scheduleTurnDigest({
|
|
4637
|
+
botId: speaker.botId, botName: speaker.name,
|
|
4638
|
+
threadId: event.threadId, turnId: completedTurnId,
|
|
4639
|
+
driverKind: selection && registry.get(selection.instanceId)?.driverKind,
|
|
4640
|
+
reply, from: speaker,
|
|
4641
|
+
isCurrent: () => store.groupByThread(event.threadId)?.id === group.id,
|
|
4642
|
+
usage: { input: tokens?.input ?? 0, output: tokens?.output ?? 0, costUsd: event.cost ?? null },
|
|
4643
|
+
});
|
|
4644
|
+
}
|
|
4645
|
+
liveTurnByThread.delete(event.threadId);
|
|
4263
4646
|
}
|
|
4264
4647
|
if (speaker && group?.busyBotId === speaker.botId) {
|
|
4265
4648
|
releaseTurnResources(turnResourceOwners.get(event.threadId));
|
|
@@ -4350,19 +4733,109 @@ function wakeDelegationSource(source, threadId, targetName, failureReason, routi
|
|
|
4350
4733
|
// delegated reply is already in the thread, so nothing is lost, and the
|
|
4351
4734
|
// source processes it the moment it is free rather than only on a later
|
|
4352
4735
|
// user nudge.
|
|
4353
|
-
if (threadBusy(source.id, threadId) || activeGroupTurnForBot(source.id)) {
|
|
4736
|
+
if (threadBusy(source.id, threadId) || botAtThreadCapacity(source.id) || activeGroupTurnForBot(source.id)) {
|
|
4354
4737
|
pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason, routineRunId });
|
|
4355
4738
|
return;
|
|
4356
4739
|
}
|
|
4357
4740
|
dispatchDelegationWake(source.id, threadId, targetName, failureReason, routineRunId);
|
|
4358
4741
|
}
|
|
4742
|
+
// ── incidents: a broken run reaches the Chief of Staff ──────────────────
|
|
4743
|
+
// A failed, stalled or unstartable run used to leave one chip in the thread
|
|
4744
|
+
// it died in and nothing anywhere else; the person found it hours later,
|
|
4745
|
+
// from a phone, by opening the desktop and reading every thread. The team
|
|
4746
|
+
// already has a role for this — the Chief coordinates the section — so the
|
|
4747
|
+
// incident becomes a turn of the Chief's, in its "Team incidents" thread,
|
|
4748
|
+
// with a link to the broken thread and retry_thread to act on it. The person
|
|
4749
|
+
// reads one place. Policy in server/incidents.ts.
|
|
4750
|
+
const incidentLedger = new IncidentLedger();
|
|
4751
|
+
/** What the broken thread was about: the last line the person (or the
|
|
4752
|
+
* requester) sent there, and the last thing the bot said. */
|
|
4753
|
+
function incidentContext(threadId) {
|
|
4754
|
+
const messages = [...store.messagesFor(threadId)].reverse();
|
|
4755
|
+
return {
|
|
4756
|
+
lastRequest: messages.find((message) => message.role === "user" && message.kind === "text" && message.text)?.text ?? null,
|
|
4757
|
+
lastReply: messages.find((message) => message.role === "bot" && message.kind === "text" && message.text)?.text ?? null,
|
|
4758
|
+
};
|
|
4759
|
+
}
|
|
4760
|
+
function reportIncident(input) {
|
|
4761
|
+
const { bot, threadId } = input;
|
|
4762
|
+
const task = store.taskByThread(bot.id, threadId);
|
|
4763
|
+
// A thread another bot opened and is watching is that bot's to handle:
|
|
4764
|
+
// the delegator is woken with the failure already (wakeDelegationSource).
|
|
4765
|
+
if (task?.openedBy?.delegationId || delegationWatch.has(threadId) || roomHandoffs.activeDirect(threadId))
|
|
4766
|
+
return;
|
|
4767
|
+
const group = store.groupByThread(threadId);
|
|
4768
|
+
const incident = {
|
|
4769
|
+
kind: input.kind,
|
|
4770
|
+
bot,
|
|
4771
|
+
threadId,
|
|
4772
|
+
title: task?.title ?? null,
|
|
4773
|
+
room: group?.name ?? null,
|
|
4774
|
+
detail: redactSecretsInText(input.detail),
|
|
4775
|
+
...incidentContext(threadId),
|
|
4776
|
+
};
|
|
4777
|
+
const count = incidentLedger.note(threadId);
|
|
4778
|
+
// a crash loop is one incident, not a storm
|
|
4779
|
+
if (count.muted)
|
|
4780
|
+
return;
|
|
4781
|
+
const chief = chiefForBot(store.bots, bot);
|
|
4782
|
+
// A run that could not start and a failed routine have already buzzed
|
|
4783
|
+
// the person (turn-failed, routine-failed) by the time they get here; a
|
|
4784
|
+
// failure or stall mid-run has not. One notification per failure, never two.
|
|
4785
|
+
const alreadyNotified = input.kind === "could-not-start" || input.kind === "routine-failed";
|
|
4786
|
+
const tellThePerson = () => {
|
|
4787
|
+
if (alreadyNotified)
|
|
4788
|
+
return;
|
|
4789
|
+
notify(buildNotification("incident", bot, threadId, incidentChip(incident), {
|
|
4790
|
+
avatarUrl: bot.avatarUrl,
|
|
4791
|
+
...(group ? { group: { id: group.id, name: group.name } } : {}),
|
|
4792
|
+
}));
|
|
4793
|
+
};
|
|
4794
|
+
// no Chief on duty, or the Chief itself broke: the person is next
|
|
4795
|
+
if (!chief) {
|
|
4796
|
+
tellThePerson();
|
|
4797
|
+
return;
|
|
4798
|
+
}
|
|
4799
|
+
const incidents = store.tasks(chief.id).find((candidate) => candidate.title === INCIDENTS_THREAD_TITLE && !candidate.archivedAt)
|
|
4800
|
+
?? store.createTask(chief.id, INCIDENTS_THREAD_TITLE, false, undefined, { botId: chief.id, name: chief.name, at: Date.now() });
|
|
4801
|
+
if (!incidents || incidents.threadId === threadId) {
|
|
4802
|
+
tellThePerson();
|
|
4803
|
+
return;
|
|
4804
|
+
}
|
|
4805
|
+
store.appendMessage(incidents.threadId, {
|
|
4806
|
+
role: "bot",
|
|
4807
|
+
kind: "activity",
|
|
4808
|
+
tool: { name: incidentChip(incident), ok: false },
|
|
4809
|
+
threadRef: { botId: bot.id, threadId, title: task?.title ?? (group ? group.name : `${bot.name}'s conversation`) },
|
|
4810
|
+
});
|
|
4811
|
+
const text = incidentText(incident, count);
|
|
4812
|
+
// the report carries the broken bot's name as its provenance: it is about
|
|
4813
|
+
// that bot's work and nobody was at the keyboard
|
|
4814
|
+
const peerAsk = { botId: bot.id, name: bot.name, unattended: true };
|
|
4815
|
+
if (botAtThreadCapacity(chief.id) || activeGroupTurnForBot(chief.id)) {
|
|
4816
|
+
queueSteeredMessage(chief.id, incidents.threadId, text, { reason: "capacity", unattended: true, peerAsk });
|
|
4817
|
+
return;
|
|
4818
|
+
}
|
|
4819
|
+
void startTurn(chief.id, text, { threadId: incidents.threadId, unattended: true, peerAsk }).catch((error) => {
|
|
4820
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
4821
|
+
store.appendMessage(incidents.threadId, {
|
|
4822
|
+
role: "bot",
|
|
4823
|
+
kind: "activity",
|
|
4824
|
+
tool: { name: `error: the incident could not reach ${chief.name} — ${why.slice(0, 120)}`, ok: false },
|
|
4825
|
+
});
|
|
4826
|
+
tellThePerson();
|
|
4827
|
+
});
|
|
4828
|
+
}
|
|
4359
4829
|
function drainDelegationWakes() {
|
|
4360
4830
|
for (const [threadId, entry] of pendingDelegationWakes) {
|
|
4361
4831
|
if (!store.taskByThread(entry.botId, threadId) || !routineDelegationCanResume(threadId, entry.routineRunId)) {
|
|
4362
4832
|
pendingDelegationWakes.delete(threadId);
|
|
4363
4833
|
continue;
|
|
4364
4834
|
}
|
|
4365
|
-
|
|
4835
|
+
// Completion can precede asynchronous digest/resource settlement. Keep
|
|
4836
|
+
// the wake parked until a slot is actually free; a rejected async start
|
|
4837
|
+
// could otherwise requeue after the last idle-release drain has run.
|
|
4838
|
+
if (threadBusy(entry.botId, threadId) || botAtThreadCapacity(entry.botId) || activeGroupTurnForBot(entry.botId))
|
|
4366
4839
|
continue;
|
|
4367
4840
|
pendingDelegationWakes.delete(threadId);
|
|
4368
4841
|
dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason, entry.routineRunId, entry.budgetAcquired);
|
|
@@ -4390,12 +4863,125 @@ function markTaskContextExternallyUpdated(bot, threadId) {
|
|
|
4390
4863
|
const task = store.taskByThread(bot.id, threadId);
|
|
4391
4864
|
if (!task)
|
|
4392
4865
|
return;
|
|
4866
|
+
// An engine that records which messages its current session was handed
|
|
4867
|
+
// keeps that session: its next turn is sent what it has not seen. Without a
|
|
4868
|
+
// record for that exact session (another engine, one switched in since, a
|
|
4869
|
+
// replaced session, or a task from before records existed) the next turn
|
|
4870
|
+
// replays once, as before.
|
|
4871
|
+
const owner = task.lastInstanceId;
|
|
4872
|
+
const record = owner ? task.handedMessages?.[owner] : undefined;
|
|
4873
|
+
if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
|
|
4874
|
+
turnInstance(botForThread(bot.id, threadId) ?? bot, undefined, threadId)?.instanceId === owner &&
|
|
4875
|
+
registry.get(owner)?.adapter.capabilities.strictResume) {
|
|
4876
|
+
store.patchTask(bot.id, threadId, { unread: true });
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4393
4879
|
store.patchTask(bot.id, threadId, {
|
|
4394
4880
|
resumeCursors: {},
|
|
4395
4881
|
lastInstanceId: `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`,
|
|
4396
4882
|
unread: true,
|
|
4397
4883
|
});
|
|
4398
4884
|
}
|
|
4885
|
+
/** Active-branch messages a provider reads as conversation context. */
|
|
4886
|
+
function isContextMessage(m) {
|
|
4887
|
+
return Boolean((m.kind === "text" && m.text) || (m.kind === "digest" && m.digest) || m.kind === "compaction" || m.roomRequest?.phase === "result");
|
|
4888
|
+
}
|
|
4889
|
+
function latestCompaction(messages) {
|
|
4890
|
+
const record = messages.findLast(message => message.kind === "compaction" && message.compaction);
|
|
4891
|
+
return record?.compaction ? { ...record.compaction, id: record.id } : undefined;
|
|
4892
|
+
}
|
|
4893
|
+
function directContext(bot, threadId, messages) {
|
|
4894
|
+
const messagesById = new Map(store.messagesFor(threadId).map(message => [message.id, message]));
|
|
4895
|
+
return messages.filter(isContextMessage).map(m => ({
|
|
4896
|
+
id: m.id, role: m.role === "user" ? "user" : "assistant",
|
|
4897
|
+
text: m.kind === "compaction" ? ""
|
|
4898
|
+
: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
|
|
4899
|
+
: m.kind === "digest" && m.digest ? digestPromptLine(m.digest, m.from?.name ?? bot.name)
|
|
4900
|
+
: m.peerAsk ? peerMessageText(m.peerAsk.name, m.text ?? "")
|
|
4901
|
+
: m.role !== "user" && m.from ? peerMessageText(m.from.name, transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"))
|
|
4902
|
+
: transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"),
|
|
4903
|
+
keep: m.roomRequest?.phase === "result" || Boolean(m.peerAsk) || (m.role !== "user" && Boolean(m.from)),
|
|
4904
|
+
...(m.steered ? { steered: true } : {}),
|
|
4905
|
+
}));
|
|
4906
|
+
}
|
|
4907
|
+
/** The existing direct-turn claim owns summarization, including Stop and
|
|
4908
|
+
* thread deletion. A durable record is committed only to its original branch. */
|
|
4909
|
+
async function compactConversation(input) {
|
|
4910
|
+
const { bot, threadId, generation, instance, model, excludedIds, manual } = input;
|
|
4911
|
+
const task = store.taskByThread(bot.id, threadId);
|
|
4912
|
+
if (!task || (!manual && cfg.context?.autoCompact === false))
|
|
4913
|
+
return;
|
|
4914
|
+
const messages = store.activePath(threadId);
|
|
4915
|
+
const context = directContext(bot, threadId, messages);
|
|
4916
|
+
const record = latestCompaction(messages);
|
|
4917
|
+
// A retry must apply the existing record before considering another fold.
|
|
4918
|
+
if (!manual && record && record.id !== task.appliedCompactionId)
|
|
4919
|
+
return;
|
|
4920
|
+
if (manual && record?.id === messages.at(-1)?.id && !record?.firstKeptId)
|
|
4921
|
+
return;
|
|
4922
|
+
const selected = selectReplay(context, {
|
|
4923
|
+
budgetBytes: Math.max(1_024, context.reduce((n, entry) => n + Buffer.byteLength(entry.text) + 16, 0) + 4 * MAX_SUMMARY_BYTES + 1_024),
|
|
4924
|
+
maxMessages: Number.MAX_SAFE_INTEGER, excludedIds, compaction: record,
|
|
4925
|
+
});
|
|
4926
|
+
const history = selected.transcript.filter(entry => entry.id);
|
|
4927
|
+
if (!history.length)
|
|
4928
|
+
return;
|
|
4929
|
+
const measurement = task.lastContextModel === `${instance.instanceId}:${model}` && !task.rewound ? task.usage?.context : undefined;
|
|
4930
|
+
const window = contextWindowFor(model, instance.models, measurement?.window);
|
|
4931
|
+
const bytes = history.reduce((n, entry) => n + Buffer.byteLength(entry.text), 0);
|
|
4932
|
+
let floor = task.contextFloor;
|
|
4933
|
+
if (record && record.id === task.appliedCompactionId && floor === undefined && measurement?.tokens) {
|
|
4934
|
+
floor = measurement.tokens;
|
|
4935
|
+
store.patchTask(bot.id, threadId, { contextFloor: floor });
|
|
4936
|
+
}
|
|
4937
|
+
// Use the selected account's launch setting. "auto" and "off" supply no
|
|
4938
|
+
// known numeric boundary; other providers do not inherit Claude's limit.
|
|
4939
|
+
const nativeCompactAt = instance.driverKind === "claudeAgent"
|
|
4940
|
+
? Number(autoCompactWindow({ ...process.env, ...cfg.instances?.[instance.instanceId]?.environment })) : undefined;
|
|
4941
|
+
if (!manual && !shouldCompact({ contextTokens: measurement?.tokens, estimatedBytes: bytes,
|
|
4942
|
+
budget: compactBudget(window, cfg.context?.compactAt, nativeCompactAt), floor, window, nativeCompactAt }))
|
|
4943
|
+
return;
|
|
4944
|
+
const fold = manual ? { folded: history, firstKeptId: "" } : foldPoint(history);
|
|
4945
|
+
if (!fold)
|
|
4946
|
+
return;
|
|
4947
|
+
const foldedThroughId = manual
|
|
4948
|
+
? context.findLast(entry => !excludedIds.has(entry.id))?.id
|
|
4949
|
+
: fold.folded.at(-1)?.id;
|
|
4950
|
+
if (!foldedThroughId)
|
|
4951
|
+
return;
|
|
4952
|
+
const prefix = JSON.stringify(messages);
|
|
4953
|
+
const controller = new AbortController();
|
|
4954
|
+
compactionControllers.set(threadId, { generation, controller });
|
|
4955
|
+
try {
|
|
4956
|
+
const summary = await draftSummary(fold.folded, { signal: controller.signal, generateText: instance.generateText?.bind(instance) });
|
|
4957
|
+
if (!directTurnClaimIsCurrent(bot.id, generation, threadId))
|
|
4958
|
+
throw new DirectTurnSetupCancelled("context summarization stopped");
|
|
4959
|
+
controller.signal.throwIfAborted();
|
|
4960
|
+
// Appended teammate results remain after the fold. A changed prefix is
|
|
4961
|
+
// never summarized using stale input, even if the branch tip still exists.
|
|
4962
|
+
if (prefix !== JSON.stringify(store.activePath(threadId).slice(0, messages.length))) {
|
|
4963
|
+
if (manual)
|
|
4964
|
+
throw new Error("the conversation changed during summarization — please retry");
|
|
4965
|
+
return;
|
|
4966
|
+
}
|
|
4967
|
+
store.appendMessage(threadId, {
|
|
4968
|
+
role: "bot", kind: "compaction",
|
|
4969
|
+
compaction: { summary, firstKeptId: fold.firstKeptId, foldedThroughId,
|
|
4970
|
+
tokensBefore: measurement?.tokens ?? Math.ceil(bytes / 4), by: manual ? "person" : "harness" },
|
|
4971
|
+
}, { kind: "compaction.append", key: `${threadId}:${messages.at(-1)?.id}:${fold.firstKeptId}` });
|
|
4972
|
+
}
|
|
4973
|
+
finally {
|
|
4974
|
+
if (compactionControllers.get(threadId)?.generation === generation)
|
|
4975
|
+
compactionControllers.delete(threadId);
|
|
4976
|
+
}
|
|
4977
|
+
}
|
|
4978
|
+
const handoffs = new Handoffs({
|
|
4979
|
+
order: (threadId) => store.activePath(threadId).filter(isContextMessage).map((m) => m.id),
|
|
4980
|
+
read: (botId, threadId, instanceId) => store.taskByThread(botId, threadId)?.handedMessages?.[instanceId],
|
|
4981
|
+
write: (botId, threadId, instanceId, state) => store.setHandedMessages(botId, threadId, instanceId, state),
|
|
4982
|
+
replies: (threadId, turnId) => store.activePath(threadId)
|
|
4983
|
+
.filter((m) => m.role === "bot" && m.kind === "text" && !m.from && m.turnId === turnId).map((m) => m.id),
|
|
4984
|
+
});
|
|
4399
4985
|
/** Consume one delegated-turn watch and mirror exactly one terminal state.
|
|
4400
4986
|
* Some harness paths settle a busy bot without a provider turn.completed
|
|
4401
4987
|
* event, so they call this same finalizer explicitly. */
|
|
@@ -4751,7 +5337,7 @@ function drainQueuedSends() {
|
|
|
4751
5337
|
}
|
|
4752
5338
|
/** Keep a person's words off the transcript until a direct-thread slot is
|
|
4753
5339
|
* available. Reuse the existing cancellable, idempotent composer queue. */
|
|
4754
|
-
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId) {
|
|
5340
|
+
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender) {
|
|
4755
5341
|
const capacity = botAtThreadCapacity(botId);
|
|
4756
5342
|
if (capacity || threadBusy(botId, threadId) || parksBehindCoordination(botId, threadId)) {
|
|
4757
5343
|
const reason = capacity ? "capacity" : undefined;
|
|
@@ -4763,7 +5349,7 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId)
|
|
|
4763
5349
|
});
|
|
4764
5350
|
return { ok: true, queued: true, queueId: queued.id, threadId, reason };
|
|
4765
5351
|
}
|
|
4766
|
-
const message = await startTurn(botId, text, { threadId, replyTo, sendId });
|
|
5352
|
+
const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender });
|
|
4767
5353
|
return { ok: true, threadId, message };
|
|
4768
5354
|
}
|
|
4769
5355
|
/** How many start_thread calls one turn may make. Same spirit as the
|
|
@@ -4924,6 +5510,41 @@ async function finalScreenFrame(_botId, threadId) {
|
|
|
4924
5510
|
settledScreenHashes.set(threadId, screenFrameHash(frame.png));
|
|
4925
5511
|
return frame;
|
|
4926
5512
|
}
|
|
5513
|
+
/** A short title for a fresh thread, from the provider's cheap one-shot
|
|
5514
|
+
* (generateText — Haiku on Claude, the chat completion endpoint's text
|
|
5515
|
+
* path on OpenAI-compatible engines). Null whenever that call cannot run,
|
|
5516
|
+
* runs long, or answers with something that is not a plain short title;
|
|
5517
|
+
* the caller keeps the snippet it already applied. */
|
|
5518
|
+
async function generateThreadTitle(provider, text) {
|
|
5519
|
+
const prompt = [
|
|
5520
|
+
"Name the conversation that begins with the message below.",
|
|
5521
|
+
"Reply with only a short title: 3 to 6 words, plain text, no quotes, no trailing period.",
|
|
5522
|
+
"Message:",
|
|
5523
|
+
text.trim().slice(0, 1_500),
|
|
5524
|
+
].join("\n");
|
|
5525
|
+
const expiry = new AbortController();
|
|
5526
|
+
let timer;
|
|
5527
|
+
try {
|
|
5528
|
+
// the race caps the wait; the signal aborts the provider call itself,
|
|
5529
|
+
// which every generateText driver that can honor it does
|
|
5530
|
+
const reply = await Promise.race([
|
|
5531
|
+
provider.generateText(prompt, { signal: expiry.signal }),
|
|
5532
|
+
new Promise((_, reject) => {
|
|
5533
|
+
timer = setTimeout(() => {
|
|
5534
|
+
expiry.abort();
|
|
5535
|
+
reject(new Error("thread title timed out"));
|
|
5536
|
+
}, 10_000);
|
|
5537
|
+
}),
|
|
5538
|
+
]);
|
|
5539
|
+
return titleFromLlm(reply);
|
|
5540
|
+
}
|
|
5541
|
+
catch {
|
|
5542
|
+
return null;
|
|
5543
|
+
}
|
|
5544
|
+
finally {
|
|
5545
|
+
clearTimeout(timer);
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
4927
5548
|
// ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
|
|
4928
5549
|
async function startTurn(botId, text, opts) {
|
|
4929
5550
|
workspaceMaintenance.assertAvailable();
|
|
@@ -5022,7 +5643,23 @@ async function startTurn(botId, text, opts) {
|
|
|
5022
5643
|
clearInternalTurn(threadId);
|
|
5023
5644
|
// a task takes its name from the first thing you asked it to do
|
|
5024
5645
|
if (resolvedImages.text.trim() && !opts?.cardContinuation) {
|
|
5025
|
-
store.titleTaskFromFirstMessage(bot.id, resolvedImages.text, threadId);
|
|
5646
|
+
const titled = store.titleTaskFromFirstMessage(bot.id, resolvedImages.text, threadId);
|
|
5647
|
+
// The snippet is only the fallback name. A cheap one-shot may trade it
|
|
5648
|
+
// for a title a person would have typed, but never on a peer-opened
|
|
5649
|
+
// row: adoption recognises those by the exact title their assignment
|
|
5650
|
+
// gave them (openingRequestTitle), and a generated one would break the
|
|
5651
|
+
// comparison it renames under. Everywhere else, the swap happens only
|
|
5652
|
+
// while the row still carries the snippet — a rename by the person or
|
|
5653
|
+
// by adoption has already broken that equality by then.
|
|
5654
|
+
const snippet = titled?.title;
|
|
5655
|
+
if (titled && snippet && !titled.openedBy?.botId && llmThreadTitlesEnabled(cfg) && instance.generateText) {
|
|
5656
|
+
void generateThreadTitle(instance, resolvedImages.text)
|
|
5657
|
+
.then((title) => {
|
|
5658
|
+
if (title)
|
|
5659
|
+
store.retitleTask(bot.id, threadId, snippet, title);
|
|
5660
|
+
})
|
|
5661
|
+
.catch(() => undefined);
|
|
5662
|
+
}
|
|
5026
5663
|
}
|
|
5027
5664
|
console.error(`[omb-turn] bot=${botId} text=${JSON.stringify(resolvedImages.text.slice(0, 70))} images=${turnImages.length} depth=${commsDepth} card=${Boolean(opts?.cardContinuation)}`);
|
|
5028
5665
|
const instanceId = instance.instanceId;
|
|
@@ -5060,6 +5697,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5060
5697
|
replyToId: opts?.replyTo?.id,
|
|
5061
5698
|
sendId: opts?.sendId,
|
|
5062
5699
|
peerAsk: opts?.peerAsk,
|
|
5700
|
+
sender: opts?.sender,
|
|
5063
5701
|
});
|
|
5064
5702
|
}
|
|
5065
5703
|
// A card continuation neither starts nor ends the person's ask: it
|
|
@@ -5076,96 +5714,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5076
5714
|
personAskAt.delete(threadId);
|
|
5077
5715
|
}
|
|
5078
5716
|
}
|
|
5079
|
-
// transcript for API-backed drivers: settled text turns on the ACTIVE
|
|
5080
|
-
// branch only — abandoned forks never reach the model
|
|
5081
|
-
const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]);
|
|
5082
|
-
const activeMessages = store.activePath(threadId);
|
|
5083
|
-
// This turn's own text carries the addressed request and, when resuming,
|
|
5084
|
-
// every child result as JSON: neither is repeated from the transcript.
|
|
5085
|
-
const coordinationChildren = opts?.coordination?.resumed
|
|
5086
|
-
? new Set(roomHandoffs.children(opts.coordination.id).map((child) => child.id)) : undefined;
|
|
5087
|
-
for (const m of activeMessages) {
|
|
5088
|
-
if (!opts?.coordination || !m.roomRequest)
|
|
5089
|
-
continue;
|
|
5090
|
-
if ((m.roomRequest.phase === "request" && m.roomRequest.id === opts.coordination.id) ||
|
|
5091
|
-
(m.roomRequest.phase === "result" && coordinationChildren?.has(m.roomRequest.id)))
|
|
5092
|
-
skipTranscript.add(m.id);
|
|
5093
|
-
}
|
|
5094
|
-
// A flat reply may deliberately point across a fork in the same thread.
|
|
5095
|
-
// Resolve its quote from full storage, while the replay itself remains
|
|
5096
|
-
// strictly limited to the selected branch below.
|
|
5097
|
-
const messagesById = new Map(store.messagesFor(threadId).map((message) => [message.id, message]));
|
|
5098
|
-
const transcript = activeMessages
|
|
5099
|
-
.filter((m) => ((m.kind === "text" && m.text) || m.roomRequest?.phase === "result") && !skipTranscript.has(m.id))
|
|
5100
|
-
.slice(-40)
|
|
5101
|
-
.map((m) => ({
|
|
5102
|
-
role: m.role === "user" ? "user" : "assistant",
|
|
5103
|
-
text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
|
|
5104
|
-
: m.role !== "user" && m.from ? peerMessageText(m.from.name, transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"))
|
|
5105
|
-
: transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"),
|
|
5106
|
-
}));
|
|
5107
|
-
// After a rewind (edit / branch switch) the provider's native session
|
|
5108
|
-
// still contains the abandoned branch: start a fresh session instead of
|
|
5109
|
-
// resuming, and for cursor-resuming drivers replay the surviving path
|
|
5110
|
-
// inline (transcript-replay drivers get it via transcript). The flag is
|
|
5111
|
-
// cleared only once the turn is actually dispatched — clearing it here
|
|
5112
|
-
// would cost the next attempt its history if this dispatch fails.
|
|
5113
|
-
const rewound = Boolean(task.rewound);
|
|
5114
|
-
// A fresh engine — the user switched this bot's model mid-thread — has no
|
|
5115
|
-
// current session here either, so it gets the same replay. Distinct from
|
|
5116
|
-
// rewound: the OTHER instances' cursors are left alone (a rewind wipes
|
|
5117
|
-
// them all), and "fresh" is decided by who ran the last turn, not by
|
|
5118
|
-
// whether we hold a cursor — see engineIsFresh.
|
|
5119
|
-
const externalContextMarker = isExternalContextMarker(task.lastInstanceId)
|
|
5120
|
-
? task.lastInstanceId
|
|
5121
|
-
: undefined;
|
|
5122
|
-
const fresh = !rewound &&
|
|
5123
|
-
!externalContextMarker &&
|
|
5124
|
-
engineIsFresh({ instanceId, lastInstanceId: task.lastInstanceId, resumeCursors: task.resumeCursors, transcript });
|
|
5125
|
-
// Agent-tool gate shared by skill authoring, the /setup turn-text rewrite,
|
|
5126
|
-
// the setup prompt block, and the peer-comms integration below: a driver
|
|
5127
|
-
// that never mounts agent tools (or a turn already at the comms-depth cap)
|
|
5128
|
-
// must not be steered into — or told about — tools it cannot call.
|
|
5129
5717
|
const agentsMounted = (commsDepth < MAX_COMMS_DEPTH || Boolean(opts?.coordination)) && instance.adapter.capabilities.agentsMcp === true;
|
|
5130
|
-
const skillAuthoring = skillAuthoringEnabled(cfg) && agentsMounted;
|
|
5131
|
-
// Setup mode's turn-text rewrite (parseSetupCommand/expandSetupTurnText)
|
|
5132
|
-
// must not run ahead of a system prompt that can't explain it: a driver
|
|
5133
|
-
// without agent tools sees the user's literal "/setup ..." message. The
|
|
5134
|
-
// gate on whether the coaching block itself is active — setupModeActive,
|
|
5135
|
-
// which also depends on the bot's soul/description — is decided below,
|
|
5136
|
-
// from the same bot snapshot the prompt's soul is built from.
|
|
5137
|
-
const setupText = agentsMounted ? expandSetupTurnText(providerText) : providerText;
|
|
5138
|
-
const { turnText, resume } = buildTurnContext({
|
|
5139
|
-
text: promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User"),
|
|
5140
|
-
transcript,
|
|
5141
|
-
rewound,
|
|
5142
|
-
fresh,
|
|
5143
|
-
externallyUpdated: Boolean(externalContextMarker),
|
|
5144
|
-
replaysNatively: instance.driverKind === "grok",
|
|
5145
|
-
});
|
|
5146
|
-
// Snapshot the cursor alongside the context decision. An external result
|
|
5147
|
-
// can arrive during async computer/setup work and clear the task cursor;
|
|
5148
|
-
// this already-built turn must either keep its old session or replay on the
|
|
5149
|
-
// following turn, never start a blank session with no transcript.
|
|
5150
|
-
const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
|
|
5151
|
-
// A cursor the provider no longer honours must not brick the thread: the
|
|
5152
|
-
// driver may fall back to ONE fresh session, and this is what it sends
|
|
5153
|
-
// there, so the new session is not blank (server/resume-recovery.ts).
|
|
5154
|
-
const recoveryText = resumeCursor !== undefined ? buildRecoveryText({ text: turnText, transcript }) : undefined;
|
|
5155
|
-
const persona = [
|
|
5156
|
-
`You are ${bot.name}, a personal bot in OpenMausBot.`,
|
|
5157
|
-
bot.title && `Role: ${bot.title}.`,
|
|
5158
|
-
bot.description && `About: ${bot.description}`,
|
|
5159
|
-
]
|
|
5160
|
-
.filter(Boolean)
|
|
5161
|
-
.join(" ");
|
|
5162
|
-
// The SOUL.md mirror is checked here, at dispatch, and only reported:
|
|
5163
|
-
// the prompt below reads bot.soul, never the file.
|
|
5164
|
-
{
|
|
5165
|
-
const drift = checkSoulDrift(bot.id, bot.soul ?? "", bot.soulHash ?? "");
|
|
5166
|
-
if (drift.drift !== Boolean(bot.soulDrift))
|
|
5167
|
-
store.patchBot(bot.id, { soulDrift: drift.drift });
|
|
5168
|
-
}
|
|
5169
5718
|
// busy flips immediately so the composer locks; the dispatch itself runs
|
|
5170
5719
|
// in the background — box provisioning can take ~90s and must never
|
|
5171
5720
|
// hang the HTTP request
|
|
@@ -5202,6 +5751,161 @@ async function startTurn(botId, text, opts) {
|
|
|
5202
5751
|
turnContext.delete(threadId);
|
|
5203
5752
|
void (async () => {
|
|
5204
5753
|
try {
|
|
5754
|
+
await compactConversation({ bot, threadId, generation: dispatchClaimId, instance, model,
|
|
5755
|
+
excludedIds: new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]), manual: opts?.compactOnly === true });
|
|
5756
|
+
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId))
|
|
5757
|
+
throw new DirectTurnSetupCancelled("turn stopped during context setup");
|
|
5758
|
+
if (opts?.compactOnly) {
|
|
5759
|
+
clearDirectTurnDispatch(threadId, dispatchClaimId);
|
|
5760
|
+
revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
|
|
5761
|
+
settleDirectFollowup(dispatchClaimId);
|
|
5762
|
+
releaseTurnResources(resourceOwner);
|
|
5763
|
+
store.setTaskActivity(bot.id, threadId, "idle");
|
|
5764
|
+
directTurnBots.delete(threadId);
|
|
5765
|
+
retryDelegationsWaitingOn(bot.id);
|
|
5766
|
+
drainQueuedSends();
|
|
5767
|
+
drainConnectorResumes();
|
|
5768
|
+
drainSecretResumes();
|
|
5769
|
+
drainTeamSetupResumes();
|
|
5770
|
+
drainDelegationWakes();
|
|
5771
|
+
return;
|
|
5772
|
+
}
|
|
5773
|
+
// Keep the full branch order for delivery bookkeeping, but select actual
|
|
5774
|
+
// replay entries by bytes. A compacted prefix is represented by its record.
|
|
5775
|
+
const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]);
|
|
5776
|
+
const activeMessages = store.activePath(threadId);
|
|
5777
|
+
const coordinationChildren = opts?.coordination?.resumed
|
|
5778
|
+
? new Set(roomHandoffs.children(opts.coordination.id).map((child) => child.id)) : undefined;
|
|
5779
|
+
for (const m of activeMessages) {
|
|
5780
|
+
if (!opts?.coordination || !m.roomRequest)
|
|
5781
|
+
continue;
|
|
5782
|
+
if ((m.roomRequest.phase === "request" && m.roomRequest.id === opts.coordination.id) ||
|
|
5783
|
+
(m.roomRequest.phase === "result" && coordinationChildren?.has(m.roomRequest.id)))
|
|
5784
|
+
skipTranscript.add(m.id);
|
|
5785
|
+
}
|
|
5786
|
+
const context = directContext(bot, threadId, activeMessages);
|
|
5787
|
+
const contextOrder = context.map(m => m.id);
|
|
5788
|
+
const replayable = context.filter(m => !skipTranscript.has(m.id));
|
|
5789
|
+
const record = latestCompaction(activeMessages);
|
|
5790
|
+
const selection = selectReplay(context, { budgetBytes: cfg.context?.rebuildBytes ?? DEFAULT_REBUILD_BYTES, excludedIds: skipTranscript, compaction: record });
|
|
5791
|
+
const transcript = selection.transcript.map(({ role, text }) => ({ role, text }));
|
|
5792
|
+
const contextReset = selection.compacted > 0 && record?.id !== task.appliedCompactionId;
|
|
5793
|
+
// The record is durable even if a crash occurs before this bookkeeping.
|
|
5794
|
+
// A retry then sees the unapplied record again and rebuilds safely.
|
|
5795
|
+
if (contextReset)
|
|
5796
|
+
store.patchTask(bot.id, threadId, { resumeCursors: {} });
|
|
5797
|
+
// After a rewind (edit / branch switch) the provider's native session
|
|
5798
|
+
// still contains the abandoned branch: start a fresh session instead of
|
|
5799
|
+
// resuming, and for cursor-resuming drivers replay the surviving path
|
|
5800
|
+
// inline (transcript-replay drivers get it via transcript). The flag is
|
|
5801
|
+
// cleared only once the turn is actually dispatched — clearing it here
|
|
5802
|
+
// would cost the next attempt its history if this dispatch fails.
|
|
5803
|
+
const rewound = Boolean(task.rewound);
|
|
5804
|
+
// A fresh engine — the user switched this bot's model mid-thread — has no
|
|
5805
|
+
// current session here either, so it gets the same replay. Distinct from
|
|
5806
|
+
// rewound: the OTHER instances' cursors are left alone (a rewind wipes
|
|
5807
|
+
// them all), and "fresh" is decided by who ran the last turn, not by
|
|
5808
|
+
// whether we hold a cursor — see engineIsFresh.
|
|
5809
|
+
const externalContextMarker = isExternalContextMarker(task.lastInstanceId)
|
|
5810
|
+
? task.lastInstanceId
|
|
5811
|
+
: undefined;
|
|
5812
|
+
const fresh = !rewound &&
|
|
5813
|
+
!externalContextMarker &&
|
|
5814
|
+
engineIsFresh({ instanceId, lastInstanceId: task.lastInstanceId, resumeCursors: task.resumeCursors, transcript: replayable });
|
|
5815
|
+
// An engine that records what its session was handed resumes it with only
|
|
5816
|
+
// the context messages outside that record. A record of another session
|
|
5817
|
+
// (the one it replaced) or one that no longer lines up with the branch is
|
|
5818
|
+
// not trusted: the session is rebuilt by the same replay as any other.
|
|
5819
|
+
const strictResume = instance.adapter.capabilities.strictResume === true;
|
|
5820
|
+
const cursor = task.resumeCursors[instanceId];
|
|
5821
|
+
const handed = strictResume && !rewound && !contextReset && !fresh && !externalContextMarker && cursor !== undefined
|
|
5822
|
+
? task.handedMessages?.[instanceId] : undefined;
|
|
5823
|
+
const unseen = handed && handedStateUsable(handed, cursor, contextOrder) ? unseenMessages(replayable, contextOrder, handed) : undefined;
|
|
5824
|
+
// A teammate's result or reply: without records this turn would replay.
|
|
5825
|
+
const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
|
|
5826
|
+
// What a resumed session keeps from its launch: the standing instructions
|
|
5827
|
+
// (tools, servers and — for Claude — the model are passed on every launch),
|
|
5828
|
+
// plus whatever this engine can only set when a session starts. Codex's
|
|
5829
|
+
// thread/resume sends no model selection, and an effort it is not sent stays
|
|
5830
|
+
// at the thread's last value, so both belong to the session there. An
|
|
5831
|
+
// external update that finds any of it changed since the session started
|
|
5832
|
+
// gets the fresh session and replay it always got, rather than a resume.
|
|
5833
|
+
const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
|
|
5834
|
+
...(instance.driverKind === "codex" ? [model, effort ?? null] : [])];
|
|
5835
|
+
const sessionConfig = (soul) => createHash("sha256").update(JSON.stringify([...persistentConfig, soul])).digest("hex").slice(0, 16);
|
|
5836
|
+
const plannedConfig = sessionConfig(bot.soul);
|
|
5837
|
+
// Agent-tool gate shared by skill authoring, the /setup turn-text rewrite,
|
|
5838
|
+
// the setup prompt block, and the peer-comms integration below: a driver
|
|
5839
|
+
// that never mounts agent tools (or a turn already at the comms-depth cap)
|
|
5840
|
+
// must not be steered into — or told about — tools it cannot call.
|
|
5841
|
+
const skillAuthoring = skillAuthoringEnabled(cfg) && agentsMounted;
|
|
5842
|
+
// Setup mode's turn-text rewrite (parseSetupCommand/expandSetupTurnText)
|
|
5843
|
+
// must not run ahead of a system prompt that can't explain it: a driver
|
|
5844
|
+
// without agent tools sees the user's literal "/setup ..." message. The
|
|
5845
|
+
// gate on whether the coaching block itself is active — setupModeActive,
|
|
5846
|
+
// which also depends on the bot's soul/description — is decided below,
|
|
5847
|
+
// from the same bot snapshot the prompt's soul is built from.
|
|
5848
|
+
const setupText = agentsMounted ? expandSetupTurnText(providerText) : providerText;
|
|
5849
|
+
const userTurnText = promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User");
|
|
5850
|
+
// Decided again at dispatch when setup outlasted a soul edit (config).
|
|
5851
|
+
const decideContext = (config) => {
|
|
5852
|
+
const handedStale = Boolean(handed && (!unseen || (externalUpdate && handed.config !== config)));
|
|
5853
|
+
const { block: unseenBlock, placed } = unseen && !handedStale ? renderUnseen(unseen) : { block: "", placed: [] };
|
|
5854
|
+
const { turnText: contextTurnText, resume } = buildTurnContext({
|
|
5855
|
+
text: userTurnText,
|
|
5856
|
+
transcript,
|
|
5857
|
+
rewound,
|
|
5858
|
+
fresh: fresh || contextReset,
|
|
5859
|
+
externallyUpdated: Boolean(externalContextMarker) || handedStale,
|
|
5860
|
+
replaysNatively: instance.driverKind === "grok",
|
|
5861
|
+
});
|
|
5862
|
+
// Snapshot the cursor alongside the context decision. An external result
|
|
5863
|
+
// can arrive during async computer/setup work and clear the task cursor;
|
|
5864
|
+
// this already-built turn must either keep its old session or replay on the
|
|
5865
|
+
// following turn, never start a blank session with no transcript.
|
|
5866
|
+
const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
|
|
5867
|
+
// A cursor the provider no longer honours must not brick the thread: the
|
|
5868
|
+
// driver may fall back to ONE fresh session, and this is what it sends
|
|
5869
|
+
// there, so the new session is not blank (server/resume-recovery.ts). A
|
|
5870
|
+
// turn carrying an external update gets the replay it would have had.
|
|
5871
|
+
const recoveryIsReplay = resumeCursor !== undefined && externalUpdate && transcript.length > 0;
|
|
5872
|
+
const recoveryText = resumeCursor === undefined ? undefined : recoveryIsReplay
|
|
5873
|
+
? buildTurnContext({ text: userTurnText, transcript, rewound: false, fresh: false, externallyUpdated: true, replaysNatively: false }).turnText
|
|
5874
|
+
: buildRecoveryText({ text: userTurnText, transcript });
|
|
5875
|
+
// What this turn puts in front of the provider, for each session it can end
|
|
5876
|
+
// up in (server/delta-context.ts). buildTurnContext prepends a replay only
|
|
5877
|
+
// when it replays, so a changed text means the transcript was sent.
|
|
5878
|
+
const carried = [...skipTranscript];
|
|
5879
|
+
const windowIds = selection.representedIds;
|
|
5880
|
+
return {
|
|
5881
|
+
turnText: withUnseenMessages(unseenBlock, contextTurnText),
|
|
5882
|
+
resumeCursor, sessionReset: !resume, recoveryText, recoveryIsReplay,
|
|
5883
|
+
handoff: strictResume ? {
|
|
5884
|
+
botId: bot.id, instanceId, config, resumeCursor: typeof resumeCursor === "string" ? resumeCursor : undefined,
|
|
5885
|
+
started: sessionStart(contextOrder, contextTurnText !== userTurnText ? windowIds : [], carried),
|
|
5886
|
+
recovery: sessionStart(contextOrder, recoveryText !== undefined ? windowIds : [], carried),
|
|
5887
|
+
resumed: sessionStart(contextOrder, [], [...placed, ...carried]),
|
|
5888
|
+
placed, carried, own: [userMessage.id, ...(opts?.excludeMessageIds ?? [])],
|
|
5889
|
+
} : undefined,
|
|
5890
|
+
};
|
|
5891
|
+
};
|
|
5892
|
+
let dispatchContext = decideContext(plannedConfig);
|
|
5893
|
+
const persona = [
|
|
5894
|
+
`You are ${bot.name}, a personal bot in OpenMausBot.`,
|
|
5895
|
+
bot.title && `Role: ${bot.title}.`,
|
|
5896
|
+
bot.description && `About: ${bot.description}`,
|
|
5897
|
+
]
|
|
5898
|
+
.filter(Boolean)
|
|
5899
|
+
.join(" ");
|
|
5900
|
+
// The SOUL.md mirror is checked here, at dispatch, and only reported:
|
|
5901
|
+
// the prompt below reads bot.soul, never the file.
|
|
5902
|
+
{
|
|
5903
|
+
const drift = checkSoulDrift(bot.id, bot.soul ?? "", bot.soulHash ?? "");
|
|
5904
|
+
if (drift.drift !== Boolean(bot.soulDrift))
|
|
5905
|
+
store.patchBot(bot.id, { soulDrift: drift.drift });
|
|
5906
|
+
}
|
|
5907
|
+
if (dispatchContext.handoff)
|
|
5908
|
+
handoffs.begin(threadId, dispatchClaimId, dispatchContext.handoff);
|
|
5205
5909
|
const integrations = {};
|
|
5206
5910
|
let browser = null;
|
|
5207
5911
|
const selectedSkills = selectBundledSkills(providerText, [
|
|
@@ -5365,6 +6069,15 @@ async function startTurn(botId, text, opts) {
|
|
|
5365
6069
|
dropLease();
|
|
5366
6070
|
throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
|
|
5367
6071
|
}
|
|
6072
|
+
// The readiness walk can wait minutes for the desktop, and the group
|
|
6073
|
+
// path re-validates its lease afterwards; the direct path needs the
|
|
6074
|
+
// same guard so a turn never attaches MCP to a desktop another turn
|
|
6075
|
+
// now owns.
|
|
6076
|
+
const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
|
|
6077
|
+
if (owner?.threadId !== claimThreadId || owner.botId !== bot.id) {
|
|
6078
|
+
dropLease();
|
|
6079
|
+
throw new Error("the Local VM lease expired while preparing the turn");
|
|
6080
|
+
}
|
|
5368
6081
|
// Same contract as the Box and VPS branches below: without this the
|
|
5369
6082
|
// poller never starts, so the Local VM publishes no `screen` events
|
|
5370
6083
|
// and every client that only has the stream (the phone) waits
|
|
@@ -5414,6 +6127,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5414
6127
|
autoVmClaims.set(threadId, {
|
|
5415
6128
|
owner: resourceOwner,
|
|
5416
6129
|
lazy: true,
|
|
6130
|
+
label: "the Local VM",
|
|
5417
6131
|
claim: async () => {
|
|
5418
6132
|
await claimAutoLocalVm(threadId, localVmTarget);
|
|
5419
6133
|
// The dispatch-site poller start saw a null previewCapture
|
|
@@ -5440,6 +6154,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5440
6154
|
// re-assert the same owner — a no-op (issue #1361).
|
|
5441
6155
|
autoVmClaims.set(threadId, {
|
|
5442
6156
|
owner: resourceOwner,
|
|
6157
|
+
label: "the Local VM",
|
|
5443
6158
|
claim: async () => { await claimAutoLocalVm(threadId); },
|
|
5444
6159
|
});
|
|
5445
6160
|
return true;
|
|
@@ -5456,22 +6171,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5456
6171
|
computerKind = "vm";
|
|
5457
6172
|
}
|
|
5458
6173
|
else if (wants === "local") {
|
|
5459
|
-
|
|
5460
|
-
requested: "local",
|
|
5461
|
-
hostPlatform: process.platform,
|
|
5462
|
-
providerSupportsLocal: mountsLocalComputer,
|
|
5463
|
-
})) {
|
|
5464
|
-
// Name the condition that actually failed: a person told "choose an
|
|
5465
|
-
// ACP engine" while already on one has nowhere to go.
|
|
5466
|
-
throw new Error(mountsLocalComputer
|
|
5467
|
-
? `local computer control is not available on ${process.platform} — select another destination`
|
|
5468
|
-
: "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
|
|
5469
|
-
}
|
|
5470
|
-
const cua = readCuaConnection();
|
|
5471
|
-
if (!cua)
|
|
5472
|
-
throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
|
|
5473
|
-
await bindTurnComputer(resourceOwner, "computer:host");
|
|
5474
|
-
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
6174
|
+
integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
|
|
5475
6175
|
computerKind = "local";
|
|
5476
6176
|
}
|
|
5477
6177
|
// A VPS is a local-agent computer mount, never a remote agent runner.
|
|
@@ -5484,31 +6184,39 @@ async function startTurn(botId, text, opts) {
|
|
|
5484
6184
|
if (unsupported && wants === undefined)
|
|
5485
6185
|
autoVpsProblem = unsupported;
|
|
5486
6186
|
if (!unsupported) {
|
|
5487
|
-
// The
|
|
5488
|
-
//
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
6187
|
+
// The VPS "computer" is the desktop inside this bot's managed
|
|
6188
|
+
// container, and only screen work needs that desktop to itself.
|
|
6189
|
+
// So the lease is claimed on the first computer call, through the
|
|
6190
|
+
// computer-control gate (the Local VM's seam, #1361), never at
|
|
6191
|
+
// mount: a bot's turns that never touch the computer tools run
|
|
6192
|
+
// side by side, and its 3-hourly routine no longer queues behind
|
|
6193
|
+
// — or fails after 30 minutes behind — its own long-running task.
|
|
6194
|
+
// Container lifecycle (provision, start) is serialized by the
|
|
6195
|
+
// runner's per-container lock, not by this turn.
|
|
6196
|
+
vpsThreadStarted(bot.id, threadId);
|
|
6197
|
+
const mounted = await mountBotVps(bot, resourceOwner, {
|
|
6198
|
+
start: vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource }),
|
|
6199
|
+
// The claim restarts the poller with the capture, the way the
|
|
6200
|
+
// Local VM's lazy claim does.
|
|
6201
|
+
onClaimed: (vpsCapture) => {
|
|
6202
|
+
previewCapture = vpsCapture;
|
|
6203
|
+
if (threadBusy(bot.id, threadId)) {
|
|
6204
|
+
const touched = screenPollers.get(threadId)?.touched ?? false;
|
|
6205
|
+
stopScreenPoller(bot.id, threadId);
|
|
6206
|
+
startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
|
|
6207
|
+
}
|
|
6208
|
+
},
|
|
6209
|
+
});
|
|
6210
|
+
if ("integration" in mounted) {
|
|
6211
|
+
integrations.localComputer = mounted.integration;
|
|
5503
6212
|
computerKind = "vps";
|
|
5504
|
-
previewCapture = () => vps.vpsComputerScreenshot(targetCfg, bot.id);
|
|
5505
6213
|
}
|
|
5506
6214
|
else {
|
|
5507
|
-
|
|
6215
|
+
vpsThreadEnded(bot.id, threadId);
|
|
5508
6216
|
if (wants === "cloud") {
|
|
5509
|
-
throw new Error(
|
|
6217
|
+
throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
|
|
5510
6218
|
}
|
|
5511
|
-
autoVpsProblem =
|
|
6219
|
+
autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
|
|
5512
6220
|
}
|
|
5513
6221
|
}
|
|
5514
6222
|
}
|
|
@@ -5521,62 +6229,15 @@ async function startTurn(botId, text, opts) {
|
|
|
5521
6229
|
computerKind = "box";
|
|
5522
6230
|
}
|
|
5523
6231
|
if (!teamComputer && mountsCloudComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
|
|
5524
|
-
|
|
5525
|
-
// before any network await so setup itself cannot race another turn.
|
|
5526
|
-
if (wants === "cloud")
|
|
5527
|
-
await bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
|
|
5528
|
-
if (!mountsCloudComputer && wants === "cloud") {
|
|
5529
|
-
throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
|
|
5530
|
-
}
|
|
5531
|
-
let b;
|
|
5532
|
-
try {
|
|
5533
|
-
b = await box.findBox(cfg, bot.id);
|
|
5534
|
-
}
|
|
5535
|
-
catch (error) {
|
|
5536
|
-
// Auto may fall through when an optional provider is offline, but a
|
|
5537
|
-
// durable deletion fence must never be mistaken for "no computer".
|
|
5538
|
-
if (wants === "cloud" || error?.status === 409)
|
|
5539
|
-
throw error;
|
|
5540
|
-
b = null;
|
|
5541
|
-
}
|
|
5542
|
-
let lifecycle = box.boxTurnLifecycleAction({
|
|
6232
|
+
const attached = await attachBotBox(bot, resourceOwner, {
|
|
5543
6233
|
explicitCloud: wants === "cloud",
|
|
5544
6234
|
canMount: mountsCloudComputer,
|
|
5545
|
-
|
|
6235
|
+
remoteAgent: instance.driverKind === "boxAgent",
|
|
5546
6236
|
});
|
|
5547
|
-
if (
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
lifecycle = box.boxTurnLifecycleAction({
|
|
5552
|
-
explicitCloud: true,
|
|
5553
|
-
canMount: mountsCloudComputer,
|
|
5554
|
-
state: typeof b?.state === "string" ? b.state : null,
|
|
5555
|
-
});
|
|
5556
|
-
}
|
|
5557
|
-
// an archived box answers every action with an error until it
|
|
5558
|
-
// resumes — wake it here, once, instead of letting the agent
|
|
5559
|
-
// discover it one failed tool call at a time. Explicit Cloud is the
|
|
5560
|
-
// consent boundary for the resume (~8s, and it un-pauses billing).
|
|
5561
|
-
if (lifecycle === "wake") {
|
|
5562
|
-
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
5563
|
-
b = (await box.readyBox(cfg, bot.id)) ?? b;
|
|
5564
|
-
lifecycle = box.boxTurnLifecycleAction({
|
|
5565
|
-
explicitCloud: true,
|
|
5566
|
-
canMount: mountsCloudComputer,
|
|
5567
|
-
state: typeof b?.state === "string" ? b.state : null,
|
|
5568
|
-
});
|
|
5569
|
-
}
|
|
5570
|
-
if (b && lifecycle === "attach") {
|
|
5571
|
-
await bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
|
|
5572
|
-
previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
|
|
5573
|
-
if (mountsCloudComputer) {
|
|
5574
|
-
integrations.computer = {
|
|
5575
|
-
kind: "box",
|
|
5576
|
-
boxId: b.id,
|
|
5577
|
-
token: cfg.box.token,
|
|
5578
|
-
control: controlIntegration(bot.id, threadId, dispatchClaimId),
|
|
5579
|
-
};
|
|
6237
|
+
if (attached) {
|
|
6238
|
+
previewCapture = attached.capture;
|
|
6239
|
+
if (attached.integration) {
|
|
6240
|
+
integrations.computer = attached.integration;
|
|
5580
6241
|
computerKind = "box";
|
|
5581
6242
|
}
|
|
5582
6243
|
}
|
|
@@ -5648,6 +6309,9 @@ async function startTurn(botId, text, opts) {
|
|
|
5648
6309
|
const ownThreadCreation = boundedCoordination && !opts?.coordination && Boolean(origin && !origin.peerAsk);
|
|
5649
6310
|
integrations.agents = agentsIntegration(bot.id, threadId, commsDepth, skillAuthoring, dispatchClaimId, opts?.coordination?.id, boundedCoordination, ownThreadCreation);
|
|
5650
6311
|
}
|
|
6312
|
+
if (instance.adapter.capabilities.hooks === true && hooksEnabled()) {
|
|
6313
|
+
integrations.hooks = hooksIntegration(bot.id, threadId, dispatchClaimId);
|
|
6314
|
+
}
|
|
5651
6315
|
// @mentions in the user's message (the composer's tagging UI) become
|
|
5652
6316
|
// an explicit coordination nudge. The agent still chooses the matching
|
|
5653
6317
|
// peer tool, so the harness stays the single owner of turns/permissions.
|
|
@@ -5674,8 +6338,16 @@ async function startTurn(botId, text, opts) {
|
|
|
5674
6338
|
// the engine cannot edit the project until the snapshot has settled.
|
|
5675
6339
|
// snapshot() absorbs failures, so checkpointing may delay but never fail
|
|
5676
6340
|
// a turn.
|
|
5677
|
-
if (checkpointCwd)
|
|
5678
|
-
await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`);
|
|
6341
|
+
if (checkpointCwd) {
|
|
6342
|
+
const before = await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`);
|
|
6343
|
+
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
|
|
6344
|
+
throw new DirectTurnSetupCancelled("turn stopped during checkpoint");
|
|
6345
|
+
}
|
|
6346
|
+
if (before)
|
|
6347
|
+
turnCheckpoints.set(threadId, { cwd: checkpointCwd, hash: before });
|
|
6348
|
+
else
|
|
6349
|
+
turnCheckpoints.delete(threadId);
|
|
6350
|
+
}
|
|
5679
6351
|
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
|
|
5680
6352
|
throw new DirectTurnSetupCancelled("turn stopped before dispatch");
|
|
5681
6353
|
}
|
|
@@ -5715,7 +6387,15 @@ async function startTurn(botId, text, opts) {
|
|
|
5715
6387
|
if (browser) {
|
|
5716
6388
|
const frame = { binaryPath: browser.spec.command, env: browser.spec.env };
|
|
5717
6389
|
const session = browser.session;
|
|
5718
|
-
|
|
6390
|
+
// The preview shares the profile with tool calls: claim the same
|
|
6391
|
+
// exclusive browser:<session> resource the tools/call path claims,
|
|
6392
|
+
// and skip the frame while another thread holds it.
|
|
6393
|
+
browserCapture = async () => {
|
|
6394
|
+
const owner = turnResourceOwners.get(threadId);
|
|
6395
|
+
if (!owner || !claimTurnResource(owner, `browser:${session}`))
|
|
6396
|
+
throw new Error("another thread is using this browser");
|
|
6397
|
+
return browserRuntime.withAgentAction(session, () => agentBrowserFrame(frame));
|
|
6398
|
+
};
|
|
5719
6399
|
}
|
|
5720
6400
|
}
|
|
5721
6401
|
// An Auto conversation remembers where its first turn landed, so later
|
|
@@ -5786,10 +6466,29 @@ async function startTurn(botId, text, opts) {
|
|
|
5786
6466
|
{ id: "mentions", label: "Mentions", text: boundedCoordination && tagged.length ? `The user named these existing teammates: ${tagged.map(b => `${peerName(b.name)} (${b.id})`).join(", ")}. Use coordinate_bots when their contribution is needed; do not substitute native helper agents for these bots.` : mentionPrompt(tagged) },
|
|
5787
6467
|
]);
|
|
5788
6468
|
runningTurnEngines.set(threadId, instance);
|
|
6469
|
+
// The prompt carries the soul as saved now. If it changed during setup,
|
|
6470
|
+
// decide again from what is actually sent — except on a continuation
|
|
6471
|
+
// wake inside an automation run, where a delegated result is reviving
|
|
6472
|
+
// its source unattended. That turn runs
|
|
6473
|
+
// as the resume it planned (the new soul reaches the session through
|
|
6474
|
+
// system-prompt refresh), so its record keeps the config of the session
|
|
6475
|
+
// it resumed. The next dispatch then sees that stored config differ
|
|
6476
|
+
// from the soul saved now and gives the source the fresh session and
|
|
6477
|
+
// replay a soul change owes it. Re-deciding here instead would start a
|
|
6478
|
+
// new session whose record already carries the new soul, and the
|
|
6479
|
+
// following wake would resume it — a session started under the old soul
|
|
6480
|
+
// must never survive a soul change by laundering its record. A
|
|
6481
|
+
// person-facing delegated return still re-decides here: that turn is
|
|
6482
|
+
// the one the person watches.
|
|
6483
|
+
const dispatchedConfig = sessionConfig(liveBot?.soul ?? bot.soul);
|
|
6484
|
+
if (strictResume && !(opts?.cardContinuation && continuingRoutine) && dispatchedConfig !== plannedConfig)
|
|
6485
|
+
dispatchContext = decideContext(dispatchedConfig);
|
|
6486
|
+
// Before sendTurn: an adapter may emit the whole turn before it resolves.
|
|
6487
|
+
handoffs.dispatching(threadId, dispatchClaimId, dispatchContext.handoff);
|
|
5789
6488
|
const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
|
|
5790
6489
|
threadId,
|
|
5791
6490
|
botId: bot.id,
|
|
5792
|
-
text: turnText,
|
|
6491
|
+
text: dispatchContext.turnText,
|
|
5793
6492
|
refreshSystemPrompt: true,
|
|
5794
6493
|
images: turnImages,
|
|
5795
6494
|
approvalMode: approvalModeForTurn(bot, commsDepth > 0),
|
|
@@ -5799,8 +6498,10 @@ async function startTurn(botId, text, opts) {
|
|
|
5799
6498
|
// a rewound thread never resumes the abandoned branch's session
|
|
5800
6499
|
// the active task's own session — another task's cursor would
|
|
5801
6500
|
// resume the wrong conversation and defeat the context bubble
|
|
5802
|
-
resumeCursor,
|
|
5803
|
-
|
|
6501
|
+
resumeCursor: dispatchContext.resumeCursor,
|
|
6502
|
+
sessionReset: dispatchContext.sessionReset,
|
|
6503
|
+
...(dispatchContext.recoveryText !== undefined ? { recoveryText: dispatchContext.recoveryText } : {}),
|
|
6504
|
+
...(dispatchContext.recoveryIsReplay ? { recoveryIsReplay: true } : {}),
|
|
5804
6505
|
transcript,
|
|
5805
6506
|
system: prompt.text,
|
|
5806
6507
|
systemStable: prompt.stable,
|
|
@@ -5816,6 +6517,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5816
6517
|
throw new DirectTurnSetupCancelled("turn stopped during provider setup");
|
|
5817
6518
|
}
|
|
5818
6519
|
bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
|
|
6520
|
+
handoffs.bindTurn(threadId, dispatchClaimId, dispatch.value.turnId);
|
|
5819
6521
|
if (directFollowupSettlers.has(dispatchClaimId) && dispatch.value.turnId &&
|
|
5820
6522
|
!directFollowupTurns.bind(threadId, dispatchClaimId, dispatch.value.turnId)) {
|
|
5821
6523
|
// This exact queued turn completed before its dispatch ACK arrived.
|
|
@@ -5828,6 +6530,8 @@ async function startTurn(botId, text, opts) {
|
|
|
5828
6530
|
// dispatched: the rewind is spent, and the old cursors are dead
|
|
5829
6531
|
if (rewound)
|
|
5830
6532
|
store.patchTask(bot.id, threadId, { rewound: false, resumeCursors: {} });
|
|
6533
|
+
if (contextReset && record)
|
|
6534
|
+
store.patchTask(bot.id, threadId, { appliedCompactionId: record.id, contextFloor: undefined });
|
|
5831
6535
|
// and this engine now owns the thread's most recent turn
|
|
5832
6536
|
// Consume exactly the external-update generation this turn replayed.
|
|
5833
6537
|
// If a newer delegated result landed during setup, its unique marker
|
|
@@ -5858,6 +6562,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5858
6562
|
}
|
|
5859
6563
|
}
|
|
5860
6564
|
catch (e) {
|
|
6565
|
+
handoffs.abandon(threadId, dispatchClaimId);
|
|
5861
6566
|
if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
|
|
5862
6567
|
computerSelectionTurns.delete(threadId);
|
|
5863
6568
|
settleDirectFollowup(dispatchClaimId);
|
|
@@ -5867,9 +6572,9 @@ async function startTurn(botId, text, opts) {
|
|
|
5867
6572
|
const ownsLatestGeneration = directTurnGenerationByThread.get(threadId) === dispatchClaimId;
|
|
5868
6573
|
releaseTurnResources(resourceOwner);
|
|
5869
6574
|
if (ownsLatestGeneration) {
|
|
6575
|
+
clearTurnDigestState(threadId);
|
|
5870
6576
|
releaseLocalVmThread(threadId);
|
|
5871
|
-
|
|
5872
|
-
activeVpsThreads.delete(bot.id);
|
|
6577
|
+
vpsThreadEnded(bot.id, threadId);
|
|
5873
6578
|
watchdog.settle(threadId);
|
|
5874
6579
|
turnUsage.delete(threadId);
|
|
5875
6580
|
turnContext.delete(threadId);
|
|
@@ -5910,6 +6615,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5910
6615
|
// for it, in its own thread, so it does not need a second channel.
|
|
5911
6616
|
if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
|
|
5912
6617
|
notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
|
|
6618
|
+
reportIncident({ kind: "could-not-start", bot, threadId, detail: message });
|
|
5913
6619
|
}
|
|
5914
6620
|
store.setTaskActivity(bot.id, threadId, "idle");
|
|
5915
6621
|
directTurnBots.delete(threadId);
|
|
@@ -6167,6 +6873,7 @@ routines = new RoutineManager({
|
|
|
6167
6873
|
const task = store.taskByThread(botId, threadId);
|
|
6168
6874
|
if (task && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
|
|
6169
6875
|
store.deleteTask(botId, threadId);
|
|
6876
|
+
handoffs.forget(threadId);
|
|
6170
6877
|
}
|
|
6171
6878
|
},
|
|
6172
6879
|
startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
|
|
@@ -6207,6 +6914,7 @@ routines = new RoutineManager({
|
|
|
6207
6914
|
const detail = run.error ? `${run.routineName}: ${run.error}` : run.routineName;
|
|
6208
6915
|
const notificationBot = routineSourceOwner(run)?.bot ?? bot;
|
|
6209
6916
|
notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
|
|
6917
|
+
reportIncident({ kind: "routine-failed", bot, threadId: run.threadId ?? bot.threadId, detail });
|
|
6210
6918
|
},
|
|
6211
6919
|
onRunDeferred: (run) => {
|
|
6212
6920
|
const bot = store.bot(run.botId);
|
|
@@ -6266,7 +6974,7 @@ async function cloudRoutineReadiness() {
|
|
|
6266
6974
|
if (!box.boxConfigured(cfg)) {
|
|
6267
6975
|
return {
|
|
6268
6976
|
ready: false,
|
|
6269
|
-
reason:
|
|
6977
|
+
reason: 'The Box-hosted agent needs a working Box API key. For the bot’s existing model and configured computer, including a self-hosted VPS, set run_on="maus" instead. Do not request a Box key unless the user actually wants the Box-hosted agent.',
|
|
6270
6978
|
};
|
|
6271
6979
|
}
|
|
6272
6980
|
const instance = registry.instances().find((candidate) => candidate.driverKind === "boxAgent");
|
|
@@ -6461,6 +7169,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
6461
7169
|
// staged provider images into a message, so dispose them here.
|
|
6462
7170
|
for (const task of store.tasks(bot.id)) {
|
|
6463
7171
|
purgeGeneratedImagesForThread(task.threadId);
|
|
7172
|
+
clearTurnDigestState(task.threadId);
|
|
6464
7173
|
settleDirectFollowup(directTurnGenerationByThread.get(task.threadId));
|
|
6465
7174
|
directTurnGenerationByThread.delete(task.threadId);
|
|
6466
7175
|
directTurnBots.delete(task.threadId);
|
|
@@ -6689,6 +7398,10 @@ const agentRoutine = (routine, latestRun) => {
|
|
|
6689
7398
|
instructions: safeInstructions.slice(0, 2_000),
|
|
6690
7399
|
instructionsTruncated: safeInstructions.length > 2_000,
|
|
6691
7400
|
continuity: routine.continuity === true,
|
|
7401
|
+
overlap: routine.overlap ?? "skip",
|
|
7402
|
+
skippedRuns: routine.skippedRuns ?? 0,
|
|
7403
|
+
lastSkippedAt: routineTimestamp(routine.lastSkippedAt),
|
|
7404
|
+
failureStreak: routine.failureStreak ?? 0,
|
|
6692
7405
|
enabled: routine.enabled,
|
|
6693
7406
|
runOn: routine.runOn,
|
|
6694
7407
|
durationMinutes: routine.durationMinutes,
|
|
@@ -6822,6 +7535,13 @@ const webhooks = new WebhookManager({
|
|
|
6822
7535
|
findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
|
|
6823
7536
|
cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
|
|
6824
7537
|
pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
|
|
7538
|
+
// delivery:"post" webhooks land in the bot's main chat as the bot's own message.
|
|
7539
|
+
post: (botId, text) => {
|
|
7540
|
+
const bot = store.bot(botId);
|
|
7541
|
+
if (!bot)
|
|
7542
|
+
return;
|
|
7543
|
+
store.appendMessage(bot.threadId, { role: "bot", kind: "text", text });
|
|
7544
|
+
},
|
|
6825
7545
|
});
|
|
6826
7546
|
let webhookIngress = null;
|
|
6827
7547
|
let webhookIngressError = null;
|
|
@@ -6872,9 +7592,12 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
|
|
|
6872
7592
|
const messages = store.messagesFor(threadId);
|
|
6873
7593
|
const messagesById = new Map(messages.map((message) => [message.id, message]));
|
|
6874
7594
|
return messages
|
|
6875
|
-
.filter((m) => (m.kind === "text" && m.text) || m.roomRequest?.phase === "result")
|
|
7595
|
+
.filter((m) => (m.kind === "text" && m.text) || (m.kind === "digest" && m.digest) || m.roomRequest?.phase === "result")
|
|
6876
7596
|
.slice(-GROUP_CONTEXT_MESSAGES)
|
|
6877
7597
|
.map((m) => {
|
|
7598
|
+
if (m.kind === "digest" && m.digest) {
|
|
7599
|
+
return digestPromptLine(m.digest, m.from ? peerName(m.from.name) : "a bot");
|
|
7600
|
+
}
|
|
6878
7601
|
if (m.roomRequest?.phase === "result") {
|
|
6879
7602
|
// Keep the chat receipt small without erasing the report from later
|
|
6880
7603
|
// turns. Resolve from the existing bounded store and recheck access.
|
|
@@ -6884,8 +7607,9 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
|
|
|
6884
7607
|
// a bot's name is quoted on the speaker line, so it gets one line; a
|
|
6885
7608
|
// user line that came through the API says so, since the reader would
|
|
6886
7609
|
// otherwise take it for the person typing
|
|
7610
|
+
const person = m.sender?.name ?? userName;
|
|
6887
7611
|
const speaker = m.role === "user"
|
|
6888
|
-
? m.via === "api" ? `${
|
|
7612
|
+
? m.via === "api" ? `${person} (sent through the local API, not typed)` : person
|
|
6889
7613
|
: m.from ? peerName(m.from.name) : "Bot";
|
|
6890
7614
|
const line = `${speaker}: ${transcriptText(rendered, messagesById, userName)}`;
|
|
6891
7615
|
// A room reply is the room talking. A post_to_room message is another
|
|
@@ -7005,10 +7729,25 @@ setupRetry = 0) {
|
|
|
7005
7729
|
let retainRoomVmLease = false;
|
|
7006
7730
|
let roomSpeaker;
|
|
7007
7731
|
let providerDispatched = false;
|
|
7732
|
+
// The speaker's own This computer / Cloud mount, and what it must give back.
|
|
7733
|
+
let roomComputerKind = null;
|
|
7734
|
+
let roomVpsBotId = null;
|
|
7735
|
+
let roomScreenBotId = null;
|
|
7008
7736
|
const releaseRoomVmLease = () => {
|
|
7009
7737
|
if (roomVmTarget && localVmThreadTargets.get(threadId) === roomVmTarget)
|
|
7010
7738
|
releaseLocalVmThread(threadId);
|
|
7011
7739
|
roomVmTarget = null;
|
|
7740
|
+
// Only while this turn still owns the thread: a replacement speaker's
|
|
7741
|
+
// poller and VPS record are its own to end.
|
|
7742
|
+
const currentOwner = turnResourceOwners.get(threadId);
|
|
7743
|
+
if (!currentOwner || currentOwner.generation === resourceOwner.generation) {
|
|
7744
|
+
if (roomScreenBotId)
|
|
7745
|
+
stopScreenPoller(roomScreenBotId, threadId);
|
|
7746
|
+
if (roomVpsBotId)
|
|
7747
|
+
vpsThreadEnded(roomVpsBotId, threadId);
|
|
7748
|
+
}
|
|
7749
|
+
roomScreenBotId = null;
|
|
7750
|
+
roomVpsBotId = null;
|
|
7012
7751
|
releaseTurnResources(resourceOwner);
|
|
7013
7752
|
};
|
|
7014
7753
|
let roomHandoffSourceSucceeded = false;
|
|
@@ -7026,6 +7765,9 @@ setupRetry = 0) {
|
|
|
7026
7765
|
if ((hop < MAX_COMMS_DEPTH || orchestration?.roomHandoffId) && instance.adapter.capabilities.agentsMcp === true) {
|
|
7027
7766
|
integrations.agents = agentsIntegration(bot.id, threadId, hop, skillAuthoring, internalGeneration, orchestration?.roomHandoffId, !orchestration || Boolean(orchestration.roomHandoffId));
|
|
7028
7767
|
}
|
|
7768
|
+
if (instance.adapter.capabilities.hooks === true && hooksEnabled()) {
|
|
7769
|
+
integrations.hooks = hooksIntegration(bot.id, threadId, internalGeneration);
|
|
7770
|
+
}
|
|
7029
7771
|
const latestUser = [...store.activePath(threadId)].reverse().find((message) => message.role === "user" && message.kind === "text" && message.text);
|
|
7030
7772
|
const resolvedLatestImages = latestUser?.text && !cardContinuation
|
|
7031
7773
|
? extractTurnImages(latestUser.text)
|
|
@@ -7179,11 +7921,10 @@ setupRetry = 0) {
|
|
|
7179
7921
|
readyBot.browser !== false &&
|
|
7180
7922
|
instance.adapter.capabilities.browserMcp === true,
|
|
7181
7923
|
});
|
|
7182
|
-
//
|
|
7183
|
-
//
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
throw new Error("This computer destination is not available in channels yet — open a bot thread to work on it, or use a Local VM, team computer, or Browser here");
|
|
7924
|
+
// The Computer engine runs on the Box; this computer has no tools it can
|
|
7925
|
+
// reach, exactly as a bot thread refuses it.
|
|
7926
|
+
if (roomPlan.computer === "local" && instance.driverKind === "boxAgent") {
|
|
7927
|
+
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
7187
7928
|
}
|
|
7188
7929
|
// One place per room turn as well: a team computer reached on Auto means
|
|
7189
7930
|
// no separate built-in browser.
|
|
@@ -7210,6 +7951,56 @@ setupRetry = 0) {
|
|
|
7210
7951
|
integrations.computer = attached.integration;
|
|
7211
7952
|
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.driverKind === "boxAgent" });
|
|
7212
7953
|
}
|
|
7954
|
+
// The speaker's own explicit place, mounted exactly as its bot thread
|
|
7955
|
+
// mounts it. A channel has no conversation pin and no Auto fallback here:
|
|
7956
|
+
// only a destination the person chose reaches a desktop.
|
|
7957
|
+
const roomSetupIsCurrent = () => !isCancelled?.() &&
|
|
7958
|
+
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
7959
|
+
activeInternalGenerationByThread.get(threadId) === internalGeneration;
|
|
7960
|
+
if (!roomTeamComputer && roomPlan.computer === "local") {
|
|
7961
|
+
integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
|
|
7962
|
+
if (!roomSetupIsCurrent())
|
|
7963
|
+
return false;
|
|
7964
|
+
roomComputerKind = "local";
|
|
7965
|
+
}
|
|
7966
|
+
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
7967
|
+
if (turnProvider(readyBot) === "vps") {
|
|
7968
|
+
const unsupported = vps.vpsDriverError(instance.driverKind, instance.adapter.capabilities.computerMcp === true);
|
|
7969
|
+
if (unsupported)
|
|
7970
|
+
throw new Error(unsupported);
|
|
7971
|
+
vpsThreadStarted(readyBot.id, threadId);
|
|
7972
|
+
roomVpsBotId = readyBot.id;
|
|
7973
|
+
const mounted = await mountBotVps(readyBot, resourceOwner, {
|
|
7974
|
+
start: true,
|
|
7975
|
+
onClaimed: (capture) => {
|
|
7976
|
+
if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
|
|
7977
|
+
roomScreenBotId = readyBot.id;
|
|
7978
|
+
startScreenPoller(readyBot.id, threadId, { computer: capture });
|
|
7979
|
+
}
|
|
7980
|
+
},
|
|
7981
|
+
});
|
|
7982
|
+
if (!roomSetupIsCurrent())
|
|
7983
|
+
return false;
|
|
7984
|
+
if (!("integration" in mounted))
|
|
7985
|
+
throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
|
|
7986
|
+
integrations.localComputer = mounted.integration;
|
|
7987
|
+
roomComputerKind = "vps";
|
|
7988
|
+
}
|
|
7989
|
+
else {
|
|
7990
|
+
if (!box.boxConfigured(cfg))
|
|
7991
|
+
throw new Error("Cloud box is not configured — add a Box API key or choose Local VM");
|
|
7992
|
+
const remoteAgent = instance.driverKind === "boxAgent";
|
|
7993
|
+
const attached = await attachBotBox(readyBot, resourceOwner, { explicitCloud: true, canMount: remoteAgent, remoteAgent });
|
|
7994
|
+
if (!roomSetupIsCurrent())
|
|
7995
|
+
return false;
|
|
7996
|
+
if (!attached?.integration)
|
|
7997
|
+
throw new Error("the cloud computer could not be created or reached");
|
|
7998
|
+
integrations.computer = attached.integration;
|
|
7999
|
+
roomScreenBotId = readyBot.id;
|
|
8000
|
+
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
|
|
8001
|
+
roomComputerKind = "box";
|
|
8002
|
+
}
|
|
8003
|
+
}
|
|
7213
8004
|
// Room and Goal turns use the speaker's desktop, never the coordinator's.
|
|
7214
8005
|
// Claim the same lease as direct turns before asynchronous VM setup.
|
|
7215
8006
|
if (readyBot.computer === "vm") {
|
|
@@ -7336,9 +8127,9 @@ setupRetry = 0) {
|
|
|
7336
8127
|
const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
|
|
7337
8128
|
{ id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
|
|
7338
8129
|
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
7339
|
-
{ id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer ? instance.driverKind === "boxAgent" ? "box-agent" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" :
|
|
8130
|
+
{ id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer || roomComputerKind === "box" ? instance.driverKind === "boxAgent" ? "box-agent" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : roomComputerKind) },
|
|
7340
8131
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
|
|
7341
|
-
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" :
|
|
8132
|
+
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
|
|
7342
8133
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
7343
8134
|
{ id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
7344
8135
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
@@ -7985,9 +8776,10 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
7985
8776
|
channelMode,
|
|
7986
8777
|
queueId,
|
|
7987
8778
|
via: options.via,
|
|
8779
|
+
sender: options.sender,
|
|
7988
8780
|
});
|
|
7989
|
-
|
|
7990
|
-
|
|
8781
|
+
const titled = group.dm ? null : store.titleGroupTaskFromFirstMessage(group.id, text, threadId);
|
|
8782
|
+
const snippet = titled?.title;
|
|
7991
8783
|
const archived = members.filter((member) => member.hidden);
|
|
7992
8784
|
const mentionedArchived = mentionedBots(text, archived.map(({ name }) => ({ name })))[0];
|
|
7993
8785
|
if (mentionedArchived) {
|
|
@@ -8032,6 +8824,23 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
8032
8824
|
}
|
|
8033
8825
|
return message;
|
|
8034
8826
|
}
|
|
8827
|
+
// The snippet is only the fallback name here too. The member about to
|
|
8828
|
+
// answer supplies the same cheap one-shot the bot path uses, and the
|
|
8829
|
+
// swap lands only while the row still carries the snippet — a rename by
|
|
8830
|
+
// the person always wins. Channel tasks carry no peer provenance (no
|
|
8831
|
+
// assignment opens them), and a member whose engine offers no text
|
|
8832
|
+
// one-shot simply keeps the snippet.
|
|
8833
|
+
const titleBot = goalCoordinator ?? responders[0];
|
|
8834
|
+
const titleInstance = registry.get(titleBot.modelSelection.instanceId);
|
|
8835
|
+
const titleText = extractTurnImages(text).text;
|
|
8836
|
+
if (titled && snippet && titleText.trim() && llmThreadTitlesEnabled(cfg) && titleInstance?.generateText) {
|
|
8837
|
+
void generateThreadTitle(titleInstance, titleText)
|
|
8838
|
+
.then((title) => {
|
|
8839
|
+
if (title)
|
|
8840
|
+
store.retitleGroupTask(group.id, threadId, snippet, title);
|
|
8841
|
+
})
|
|
8842
|
+
.catch(() => undefined);
|
|
8843
|
+
}
|
|
8035
8844
|
const operation = beginGroupTurnOperation(groupId, threadId, goalCoordinator ? [] : responders.map((responder) => responder.id));
|
|
8036
8845
|
if (goalCoordinator) {
|
|
8037
8846
|
const runId = options.goalRunId?.trim() || `goal-${Date.now().toString(36)}-${randomUUID()}`;
|
|
@@ -8962,30 +9771,51 @@ async function localVmPayload(target) {
|
|
|
8962
9771
|
* refuses.
|
|
8963
9772
|
*/
|
|
8964
9773
|
async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
|
|
8965
|
-
let status = await containerComputerStatus(undefined, undefined, target);
|
|
8966
|
-
noteLocalVmSeen(target, status);
|
|
8967
|
-
if (!isCurrent())
|
|
8968
|
-
return status;
|
|
8969
|
-
if (status.ready || !localVmRecreatableOnDemand(status))
|
|
8970
|
-
return status;
|
|
8971
|
-
if (target.key !== SHARED_LOCAL_VM_TARGET.key) {
|
|
8972
|
-
const count = await existingPerBotLocalVmCount(status.runtime);
|
|
8973
|
-
if (!isCurrent() || count >= localVmMaxInstances(cfg))
|
|
8974
|
-
return status;
|
|
8975
|
-
}
|
|
8976
|
-
broadcast({ kind: "computer", botId, state: "provisioning" });
|
|
8977
9774
|
localVmLifecycleBusy.add(target.key);
|
|
8978
|
-
|
|
9775
|
+
// Fence this target, and the cross-target capacity decision for creates,
|
|
9776
|
+
// before the first await — the same synchronous-fence-then-count shape
|
|
9777
|
+
// the panel route uses — so two concurrent turns cannot both pass the
|
|
9778
|
+
// per-bot limit between count and create.
|
|
9779
|
+
const ownsProvision = !localVmProvisionBusy;
|
|
9780
|
+
if (ownsProvision)
|
|
9781
|
+
localVmProvisionBusy = true;
|
|
9782
|
+
let status;
|
|
8979
9783
|
try {
|
|
8980
|
-
status = await
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
9784
|
+
status = await containerComputerStatus(undefined, undefined, target);
|
|
9785
|
+
noteLocalVmSeen(target, status);
|
|
9786
|
+
if (!isCurrent())
|
|
9787
|
+
return status;
|
|
9788
|
+
if (status.ready || !localVmRecreatableOnDemand(status))
|
|
9789
|
+
return status;
|
|
9790
|
+
// Another creation is already mid-flight and its container is not yet
|
|
9791
|
+
// visible to a count, so the safe answer is the inspected status —
|
|
9792
|
+
// exactly what the over-cap path below returns.
|
|
9793
|
+
if (!ownsProvision)
|
|
9794
|
+
return status;
|
|
9795
|
+
if (target.key !== SHARED_LOCAL_VM_TARGET.key) {
|
|
9796
|
+
const count = await existingPerBotLocalVmCount(status.runtime);
|
|
9797
|
+
if (!isCurrent() || count >= localVmMaxInstances(cfg))
|
|
9798
|
+
return status;
|
|
9799
|
+
}
|
|
9800
|
+
broadcast({ kind: "computer", botId, state: "provisioning" });
|
|
9801
|
+
try {
|
|
9802
|
+
status = await containerComputerAction("run", undefined, undefined, target);
|
|
9803
|
+
}
|
|
9804
|
+
catch {
|
|
9805
|
+
// Keep the inspected status: its `problem` names the real obstacle,
|
|
9806
|
+
// which is more use to the person than "podman run exited non-zero".
|
|
9807
|
+
// `run` can throw after the container exists, so arm the idle
|
|
9808
|
+
// backstop anyway — expiry defers while the target is busy and its
|
|
9809
|
+
// remove step no-ops unless a fresh probe sees a running container.
|
|
9810
|
+
// The problem text stays as inspected: cheaply telling a half-created
|
|
9811
|
+
// container from none here would need another container probe.
|
|
9812
|
+
localVmIdleFor(target).touch();
|
|
9813
|
+
return status;
|
|
9814
|
+
}
|
|
8986
9815
|
}
|
|
8987
9816
|
finally {
|
|
8988
|
-
|
|
9817
|
+
if (ownsProvision)
|
|
9818
|
+
localVmProvisionBusy = false;
|
|
8989
9819
|
localVmLifecycleBusy.delete(target.key);
|
|
8990
9820
|
}
|
|
8991
9821
|
localVmIdleFor(target).touch();
|
|
@@ -9127,22 +9957,23 @@ function persistMcpServers(next) {
|
|
|
9127
9957
|
async function describeInstances() {
|
|
9128
9958
|
const configs = instanceConfigs(cfg);
|
|
9129
9959
|
return (await registry.describe()).map((instance) => {
|
|
9960
|
+
const entry = configs[instance.instanceId];
|
|
9961
|
+
const described = entry?.icon ? { ...instance, icon: entry.icon } : instance;
|
|
9130
9962
|
if (managedDesktop.owns(instance.instanceId))
|
|
9131
9963
|
return {
|
|
9132
|
-
...
|
|
9964
|
+
...described, readOnly: true, managed: managedDesktop.info(instance.instanceId),
|
|
9133
9965
|
install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
|
|
9134
9966
|
};
|
|
9135
|
-
const entry = configs[instance.instanceId];
|
|
9136
9967
|
if (entry?.driver !== "claudeAgent")
|
|
9137
|
-
return
|
|
9968
|
+
return described;
|
|
9138
9969
|
try {
|
|
9139
9970
|
const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
|
|
9140
|
-
return { ...
|
|
9971
|
+
return { ...described, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
|
|
9141
9972
|
}
|
|
9142
9973
|
catch {
|
|
9143
9974
|
// A malformed saved config remains a repairable shadow, never takes
|
|
9144
9975
|
// the model picker down or offers a login for the wrong directory.
|
|
9145
|
-
return { ...
|
|
9976
|
+
return { ...described, install: { ...instance.install, signInCommand: undefined } };
|
|
9146
9977
|
}
|
|
9147
9978
|
});
|
|
9148
9979
|
}
|
|
@@ -9273,8 +10104,7 @@ async function reloadProviders() {
|
|
|
9273
10104
|
stopScreenPoller(botId, threadId);
|
|
9274
10105
|
releaseLocalVmThread(threadId);
|
|
9275
10106
|
releaseTurnResources(owner);
|
|
9276
|
-
|
|
9277
|
-
activeVpsThreads.delete(botId);
|
|
10107
|
+
vpsThreadEnded(botId, threadId);
|
|
9278
10108
|
watchdog.settle(threadId);
|
|
9279
10109
|
closeOpenApprovals(threadId);
|
|
9280
10110
|
directTurnBots.delete(threadId);
|
|
@@ -9463,6 +10293,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
9463
10293
|
},
|
|
9464
10294
|
}, keepLocked),
|
|
9465
10295
|
});
|
|
10296
|
+
const toolResults = new ToolResults();
|
|
9466
10297
|
const handleRequest = async (req, res) => {
|
|
9467
10298
|
let url;
|
|
9468
10299
|
try {
|
|
@@ -9783,10 +10614,12 @@ const handleRequest = async (req, res) => {
|
|
|
9783
10614
|
const body = await readBody(req, 1024);
|
|
9784
10615
|
if (!sharedComputersEnabled(cfg))
|
|
9785
10616
|
return json(res, 404, { error: `no route: ${method} ${path}` });
|
|
9786
|
-
if (!z.string().uuid().safeParse(body?.id).success || !["acquire", "release"].includes(body?.action))
|
|
10617
|
+
if (!z.string().uuid().safeParse(body?.id).success || !["acquire", "release", "renew"].includes(body?.action))
|
|
9787
10618
|
return json(res, 400, { error: "Invalid computer lease" });
|
|
9788
10619
|
if (body.action === "release")
|
|
9789
10620
|
sharedComputerControl.release(body.id);
|
|
10621
|
+
else if (body.action === "renew")
|
|
10622
|
+
sharedComputerControl.renew(body.id);
|
|
9790
10623
|
else
|
|
9791
10624
|
sharedComputerControl.acquire(body.id);
|
|
9792
10625
|
return json(res, 200, { ok: true });
|
|
@@ -9888,13 +10721,15 @@ const handleRequest = async (req, res) => {
|
|
|
9888
10721
|
if (!internalSender) {
|
|
9889
10722
|
return json(res, 401, { error: "unauthorized" });
|
|
9890
10723
|
}
|
|
9891
|
-
const requiredCapabilityKind = path === "/api/internal/
|
|
9892
|
-
? "
|
|
9893
|
-
: path
|
|
9894
|
-
? "
|
|
9895
|
-
: path
|
|
9896
|
-
? "
|
|
9897
|
-
: "
|
|
10724
|
+
const requiredCapabilityKind = path === "/api/internal/hook"
|
|
10725
|
+
? "hooks"
|
|
10726
|
+
: path === "/api/internal/browser/mcp"
|
|
10727
|
+
? "browser"
|
|
10728
|
+
: path.startsWith("/api/internal/connectors/")
|
|
10729
|
+
? "connectors"
|
|
10730
|
+
: path === "/api/internal/computer-control"
|
|
10731
|
+
? "computer"
|
|
10732
|
+
: "agents";
|
|
9898
10733
|
if (internalCapability.kind !== requiredCapabilityKind) {
|
|
9899
10734
|
return json(res, 403, { error: "this internal capability cannot access that service" });
|
|
9900
10735
|
}
|
|
@@ -9986,6 +10821,27 @@ const handleRequest = async (req, res) => {
|
|
|
9986
10821
|
return json(res, 200, { status: "pending", surface: option.surface,
|
|
9987
10822
|
message: `End this turn now without using the previous computer tools. OpenMausBot will continue the original request on ${option.label} with a fresh tool connection.` });
|
|
9988
10823
|
}
|
|
10824
|
+
if (method === "POST" && path === "/api/internal/hook") {
|
|
10825
|
+
const body = await readInternalBody();
|
|
10826
|
+
return json(res, 200, ingestEngineHook(internalCapability, body));
|
|
10827
|
+
}
|
|
10828
|
+
if (method === "POST" && path === "/api/internal/tool-result") {
|
|
10829
|
+
const body = await readInternalBody();
|
|
10830
|
+
if (!body || typeof body.text !== "string" || !body.text || body.text.length > TOOL_RESULT_MAX_CHARS ||
|
|
10831
|
+
(body.truncated !== undefined && typeof body.truncated !== "boolean")) {
|
|
10832
|
+
return json(res, 400, { error: "Expected bounded text and an optional truncated boolean." });
|
|
10833
|
+
}
|
|
10834
|
+
return json(res, 201, toolResults.save(internalCapability, body.text, body.truncated));
|
|
10835
|
+
}
|
|
10836
|
+
if (method === "GET" && path === "/api/internal/tool-result") {
|
|
10837
|
+
const id = url.searchParams.get("id") ?? "";
|
|
10838
|
+
const offset = Number(url.searchParams.get("offset") ?? "0");
|
|
10839
|
+
if (!/^r-[0-9a-f-]{36}$/.test(id) || !Number.isSafeInteger(offset) || offset < 0) {
|
|
10840
|
+
return json(res, 400, { error: "Expected a saved result id and a non-negative integer offset." });
|
|
10841
|
+
}
|
|
10842
|
+
const result = toolResults.read(internalCapability, id, offset);
|
|
10843
|
+
return result ? json(res, 200, result) : json(res, 404, { error: "Saved result unavailable in this bot's conversation, expired, or offset out of range. Do not repeat an action to retrieve its output." });
|
|
10844
|
+
}
|
|
9989
10845
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
9990
10846
|
const body = await readInternalBody();
|
|
9991
10847
|
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
|
|
@@ -10229,7 +11085,7 @@ const handleRequest = async (req, res) => {
|
|
|
10229
11085
|
return json(res, 404, { error: "no bot with that id — call list_bots and copy the exact id from the result" });
|
|
10230
11086
|
}
|
|
10231
11087
|
if (!canReachPeer(from, target)) {
|
|
10232
|
-
return json(res, 403, { error:
|
|
11088
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
10233
11089
|
}
|
|
10234
11090
|
forBot = { botId: target.id, name: target.name };
|
|
10235
11091
|
}
|
|
@@ -10600,13 +11456,13 @@ const handleRequest = async (req, res) => {
|
|
|
10600
11456
|
// hard refusal — every peer turn has an accountable sender.
|
|
10601
11457
|
const from = internalSender;
|
|
10602
11458
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
10603
|
-
return json(res, 403, { error:
|
|
11459
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
10604
11460
|
}
|
|
10605
11461
|
// The sender's allow-list, when it has one. Checked here rather than
|
|
10606
11462
|
// trusted from the roster: the tool call carries a bot id, and an id
|
|
10607
11463
|
// the model held from an earlier turn must not outlive the grant.
|
|
10608
11464
|
if (!peerAllowed(from, target.id)) {
|
|
10609
|
-
return json(res, 403, { error:
|
|
11465
|
+
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
10610
11466
|
}
|
|
10611
11467
|
const fromThreadId = internalCapability.threadId;
|
|
10612
11468
|
// Rooms are conversations too. The task-only lookup here refused every
|
|
@@ -10791,6 +11647,52 @@ const handleRequest = async (req, res) => {
|
|
|
10791
11647
|
await new Promise((wake) => setTimeout(wake, 500));
|
|
10792
11648
|
}
|
|
10793
11649
|
}
|
|
11650
|
+
// A Chief resumes a teammate's broken thread (server/incidents.ts): the
|
|
11651
|
+
// same thread, its conversation and files, one more turn, with a line
|
|
11652
|
+
// saying who asked and why. Chief-only, for a teammate it can reach,
|
|
11653
|
+
// never a room (coordinate there) and never a thread still running.
|
|
11654
|
+
if (method === "POST" && path === "/api/internal/retry-thread") {
|
|
11655
|
+
const body = await readInternalBody();
|
|
11656
|
+
const from = internalSender;
|
|
11657
|
+
const fromThreadId = internalCapability.threadId;
|
|
11658
|
+
if (!from.chiefOfStaff || from.hidden)
|
|
11659
|
+
return json(res, 403, { error: "only a Chief of Staff can retry a teammate's thread" });
|
|
11660
|
+
// `toBotId`/`toThreadId`: the guard above reads bare botId/threadId as
|
|
11661
|
+
// the caller's own identity, the way every internal route does.
|
|
11662
|
+
const botId = typeof body.toBotId === "string" ? body.toBotId : "";
|
|
11663
|
+
const threadId = typeof body.toThreadId === "string" ? body.toThreadId : "";
|
|
11664
|
+
const note = typeof body.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
11665
|
+
const target = store.bot(botId);
|
|
11666
|
+
if (!target || target.id === from.id)
|
|
11667
|
+
return json(res, 404, { error: "no such teammate" });
|
|
11668
|
+
if (target.hidden || !canAccessTeam(from, target.section) || !peerAllowed(from, target.id)) {
|
|
11669
|
+
return json(res, 403, { error: "that bot is not on this Chief's team — call list_bots for the ones you can reach" });
|
|
11670
|
+
}
|
|
11671
|
+
if (store.groupByThread(threadId))
|
|
11672
|
+
return json(res, 400, { error: "that is a room thread — use coordinate_bots in the room instead" });
|
|
11673
|
+
const task = store.taskByThread(target.id, threadId);
|
|
11674
|
+
if (!task)
|
|
11675
|
+
return json(res, 404, { error: "no such thread on that bot" });
|
|
11676
|
+
if (threadBusy(target.id, threadId) || queuedThreadPosition(target.id, threadId) !== null) {
|
|
11677
|
+
return json(res, 409, { error: "that thread is still running — wait for it to settle before retrying" });
|
|
11678
|
+
}
|
|
11679
|
+
requireActiveInternalCapability();
|
|
11680
|
+
const unattended = isUnattended(from.id, fromThreadId);
|
|
11681
|
+
const text = `[Retry requested by ${from.name}, your Chief of Staff, after this thread's last run stopped.${note ? ` Note from ${from.name}: ${note}` : ""} Continue the request above from where it stopped and finish it. If the same problem comes back, say exactly what is blocking and stop.]`;
|
|
11682
|
+
try {
|
|
11683
|
+
await startTurn(target.id, text, { threadId, unattended, peerAsk: { botId: from.id, name: from.name, ...(unattended ? { unattended: true } : {}) } });
|
|
11684
|
+
}
|
|
11685
|
+
catch (error) {
|
|
11686
|
+
return json(res, 409, { error: error instanceof Error ? error.message : String(error) });
|
|
11687
|
+
}
|
|
11688
|
+
store.appendMessage(fromThreadId, {
|
|
11689
|
+
role: "bot",
|
|
11690
|
+
kind: "activity",
|
|
11691
|
+
tool: { name: `Retried ${target.name}'s thread #${task.title}`, ok: true },
|
|
11692
|
+
threadRef: { botId: target.id, threadId, title: task.title },
|
|
11693
|
+
});
|
|
11694
|
+
return json(res, 200, { started: true, message: `${target.name}'s thread #${task.title} is running again. Its result stays in that thread; you are not woken for it — check later with list_threads or session_search if you need to.` });
|
|
11695
|
+
}
|
|
10794
11696
|
if (method === "POST" && path === "/api/internal/delegate-bot") {
|
|
10795
11697
|
const body = await readInternalBody();
|
|
10796
11698
|
const toBotRef = String(body.toBotId ?? "");
|
|
@@ -10812,10 +11714,10 @@ const handleRequest = async (req, res) => {
|
|
|
10812
11714
|
if (!target)
|
|
10813
11715
|
return json(res, 404, { error: "no such bot" });
|
|
10814
11716
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
10815
|
-
return json(res, 403, { error:
|
|
11717
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
10816
11718
|
}
|
|
10817
11719
|
if (!peerAllowed(from, target.id)) {
|
|
10818
|
-
return json(res, 403, { error:
|
|
11720
|
+
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
10819
11721
|
}
|
|
10820
11722
|
const fromThreadId = internalCapability.threadId;
|
|
10821
11723
|
if (!connectorThread(from.id, fromThreadId)) {
|
|
@@ -11205,10 +12107,10 @@ const handleRequest = async (req, res) => {
|
|
|
11205
12107
|
return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
|
|
11206
12108
|
}
|
|
11207
12109
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
11208
|
-
return json(res, 403, { error:
|
|
12110
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
11209
12111
|
}
|
|
11210
12112
|
if (!peerAllowed(from, target.id)) {
|
|
11211
|
-
return json(res, 403, { error:
|
|
12113
|
+
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
11212
12114
|
}
|
|
11213
12115
|
const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
11214
12116
|
if (!task)
|
|
@@ -11492,7 +12394,7 @@ const handleRequest = async (req, res) => {
|
|
|
11492
12394
|
// model into a screenshot loop against a claim that cannot land.
|
|
11493
12395
|
return json(res, 200, {
|
|
11494
12396
|
held: true, helpOpen: false,
|
|
11495
|
-
blockedReason: `This turn could not claim
|
|
12397
|
+
blockedReason: `This turn could not claim ${lazyClaim.label ?? "this computer"}${lazyClaim.failure ? ` (${lazyClaim.failure})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`,
|
|
11496
12398
|
});
|
|
11497
12399
|
}
|
|
11498
12400
|
if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
|
|
@@ -11971,8 +12873,8 @@ const handleRequest = async (req, res) => {
|
|
|
11971
12873
|
return res.end(bytes);
|
|
11972
12874
|
}
|
|
11973
12875
|
// Download one local file only when this exact stored message grants it:
|
|
11974
|
-
// a bot must render a Markdown link
|
|
11975
|
-
//
|
|
12876
|
+
// a bot must render a Markdown link or carry a generated-image attachment,
|
|
12877
|
+
// while a user message must carry the standalone composer tag. The bot
|
|
11976
12878
|
// branch derives conversation/workspace roots; the user branch is limited
|
|
11977
12879
|
// to OpenMausBot's private attachment directory. This is deliberately not
|
|
11978
12880
|
// a general path reader.
|
|
@@ -11990,7 +12892,7 @@ const handleRequest = async (req, res) => {
|
|
|
11990
12892
|
const message = store.messagesFor(threadId).find((candidate) => candidate.id === m[2]);
|
|
11991
12893
|
if (!message)
|
|
11992
12894
|
return json(res, 404, { error: "no such message" });
|
|
11993
|
-
if (message.kind !== "text"
|
|
12895
|
+
if (message.kind !== "text") {
|
|
11994
12896
|
return json(res, 403, { error: "that message does not share this file" });
|
|
11995
12897
|
}
|
|
11996
12898
|
const body = method === "POST" ? await readBody(req) : null;
|
|
@@ -12000,20 +12902,27 @@ const handleRequest = async (req, res) => {
|
|
|
12000
12902
|
}
|
|
12001
12903
|
const href = method === "POST"
|
|
12002
12904
|
? (typeof body.path === "string" ? body.path : "")
|
|
12003
|
-
: messageImageTargetAt(message.text, Number(rawReference));
|
|
12905
|
+
: messageImageTargetAt(message.text ?? "", Number(rawReference));
|
|
12004
12906
|
if (!href)
|
|
12005
12907
|
return json(res, 400, { error: "path is required" });
|
|
12908
|
+
// Generated image paths are durable capabilities on this exact message.
|
|
12909
|
+
// They do not grant access to arbitrary workspace files or Markdown refs.
|
|
12910
|
+
const generatedImage = method === "POST" && message.role === "bot" &&
|
|
12911
|
+
message.attachments?.some((attachment) => attachment.kind === "image" && attachment.path === href) === true;
|
|
12006
12912
|
let roots;
|
|
12007
12913
|
let downloadName;
|
|
12008
|
-
if (
|
|
12009
|
-
|
|
12914
|
+
if (generatedImage) {
|
|
12915
|
+
roots = [ATTACHMENTS_DIR];
|
|
12916
|
+
}
|
|
12917
|
+
else if (message.role === "user") {
|
|
12918
|
+
downloadName = messageAttachmentName(message.text ?? "", href) ?? undefined;
|
|
12010
12919
|
if (!downloadName) {
|
|
12011
12920
|
return json(res, 403, { error: "that message does not share this file" });
|
|
12012
12921
|
}
|
|
12013
12922
|
roots = [ATTACHMENTS_DIR];
|
|
12014
12923
|
}
|
|
12015
12924
|
else {
|
|
12016
|
-
if (!messageReferencesFile(message.text, href)) {
|
|
12925
|
+
if (!messageReferencesFile(message.text ?? "", href)) {
|
|
12017
12926
|
return json(res, 403, { error: "that bot message does not link to this file" });
|
|
12018
12927
|
}
|
|
12019
12928
|
const senderId = directBot?.id ?? message.from?.botId;
|
|
@@ -12042,7 +12951,7 @@ const handleRequest = async (req, res) => {
|
|
|
12042
12951
|
});
|
|
12043
12952
|
}
|
|
12044
12953
|
const file = await openMessageFile(href, roots);
|
|
12045
|
-
if (streamsMessageImage && !file.mime.startsWith("image/")) {
|
|
12954
|
+
if ((streamsMessageImage || generatedImage) && !file.mime.startsWith("image/")) {
|
|
12046
12955
|
await file.handle.close();
|
|
12047
12956
|
return json(res, 415, { error: "only images can be previewed here" });
|
|
12048
12957
|
}
|
|
@@ -12277,8 +13186,9 @@ const handleRequest = async (req, res) => {
|
|
|
12277
13186
|
const userName = cfg.profile?.name?.trim() || "User";
|
|
12278
13187
|
const lines = [`# ${title}`, ""];
|
|
12279
13188
|
for (const msg of messages) {
|
|
13189
|
+
const sentBy = msg.sender?.name ?? userName;
|
|
12280
13190
|
const who = msg.role === "user"
|
|
12281
|
-
? msg.via === "api" ? `${
|
|
13191
|
+
? msg.via === "api" ? `${sentBy} (via the local API)` : sentBy
|
|
12282
13192
|
: (msg.from?.name ?? bot?.name ?? "Bot");
|
|
12283
13193
|
if (msg.kind === "text" && msg.text)
|
|
12284
13194
|
lines.push(`**${who}:**`, "", msg.text, "");
|
|
@@ -12566,6 +13476,7 @@ const handleRequest = async (req, res) => {
|
|
|
12566
13476
|
schedule: routine.schedule,
|
|
12567
13477
|
durationMinutes: routine.durationMinutes,
|
|
12568
13478
|
...(routine.timeoutMinutes === undefined ? {} : { timeoutMinutes: routine.timeoutMinutes }),
|
|
13479
|
+
...(routine.overlap ? { overlap: routine.overlap } : {}),
|
|
12569
13480
|
});
|
|
12570
13481
|
createdRoutineIds.push(created.id);
|
|
12571
13482
|
}
|
|
@@ -12720,14 +13631,35 @@ const handleRequest = async (req, res) => {
|
|
|
12720
13631
|
if (channelTaskSwitchBlocked(group, m[2])) {
|
|
12721
13632
|
return json(res, 409, { error: "this channel is working or waiting on you in another task" });
|
|
12722
13633
|
}
|
|
13634
|
+
// Parsed before the switch: a rejected parameter must not leave the
|
|
13635
|
+
// channel pointing at another thread.
|
|
13636
|
+
const requestedMessages = url.searchParams.get("messages");
|
|
13637
|
+
const switchLimit = pageSize(requestedMessages);
|
|
13638
|
+
if (switchLimit === null)
|
|
13639
|
+
return json(res, 400, { error: "messages must be a non-negative whole number" });
|
|
12723
13640
|
const switched = store.switchGroupTask(group.id, m[2]);
|
|
12724
13641
|
if (!switched)
|
|
12725
13642
|
return json(res, 404, { error: "no such channel task" });
|
|
12726
|
-
const
|
|
12727
|
-
|
|
12728
|
-
|
|
12729
|
-
|
|
12730
|
-
|
|
13643
|
+
const switchedSettings = { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) };
|
|
13644
|
+
// The frame says the channel moved; it is not a transcript delivery.
|
|
13645
|
+
// A long room's whole history over SSE is the payload `?messages=`
|
|
13646
|
+
// exists to avoid, and it would also overwrite a bounded snapshot on
|
|
13647
|
+
// every other client. One default page is enough to render the switch;
|
|
13648
|
+
// anything earlier pages back through /api/threads/:id/messages.
|
|
13649
|
+
broadcast({
|
|
13650
|
+
kind: "group",
|
|
13651
|
+
group: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
|
|
13652
|
+
});
|
|
13653
|
+
// "0" predates paging and means settings only — no `messages` key at
|
|
13654
|
+
// all, which clients tell apart from an empty page. A positive page is
|
|
13655
|
+
// the transcript a client can actually hold; omitting the parameter
|
|
13656
|
+
// keeps the whole transcript, as it always did — and only that branch
|
|
13657
|
+
// materialises it.
|
|
13658
|
+
const responseGroup = requestedMessages === "0"
|
|
13659
|
+
? switchedSettings
|
|
13660
|
+
: switchLimit === undefined
|
|
13661
|
+
? groupWithThread(switched)
|
|
13662
|
+
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
12731
13663
|
return json(res, 200, { group: responseGroup });
|
|
12732
13664
|
}
|
|
12733
13665
|
if (m && method === "PATCH") {
|
|
@@ -12766,6 +13698,8 @@ const handleRequest = async (req, res) => {
|
|
|
12766
13698
|
lastReply.delete(m[2]);
|
|
12767
13699
|
cancelTeamSetupResumesForThread(m[2]);
|
|
12768
13700
|
const updated = store.deleteGroupTask(group.id, m[2]);
|
|
13701
|
+
if (updated)
|
|
13702
|
+
clearTurnDigestState(m[2]);
|
|
12769
13703
|
if (!updated)
|
|
12770
13704
|
return json(res, 400, { error: "a channel keeps at least one task" });
|
|
12771
13705
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
@@ -12808,6 +13742,7 @@ const handleRequest = async (req, res) => {
|
|
|
12808
13742
|
for (const threadId of threadIds) {
|
|
12809
13743
|
cancelTeamSetupResumesForThread(threadId);
|
|
12810
13744
|
lastReply.delete(threadId);
|
|
13745
|
+
clearTurnDigestState(threadId);
|
|
12811
13746
|
}
|
|
12812
13747
|
routines.disableForGroup(group.id);
|
|
12813
13748
|
store.deleteGroup(group.id);
|
|
@@ -12904,7 +13839,7 @@ const handleRequest = async (req, res) => {
|
|
|
12904
13839
|
});
|
|
12905
13840
|
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
12906
13841
|
}
|
|
12907
|
-
const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode, undefined, { via });
|
|
13842
|
+
const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode, undefined, { via, sender: messageSender(auth) });
|
|
12908
13843
|
return { ok: true, threadId, message };
|
|
12909
13844
|
});
|
|
12910
13845
|
return json(res, 202, receipt);
|
|
@@ -14326,12 +15261,24 @@ const handleRequest = async (req, res) => {
|
|
|
14326
15261
|
});
|
|
14327
15262
|
return json(res, 200, { message: patched });
|
|
14328
15263
|
}
|
|
14329
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/messages
|
|
15264
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/messages(?:\/(guarded))?$/);
|
|
14330
15265
|
if (m && method === "POST") {
|
|
15266
|
+
const guarded = m[2] === "guarded";
|
|
14331
15267
|
const body = await readBody(req);
|
|
14332
15268
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
14333
15269
|
return json(res, 400, { error: "body must be a JSON object" });
|
|
14334
15270
|
}
|
|
15271
|
+
// External interfaces must opt into a distinct route: an older server
|
|
15272
|
+
// returns 404 rather than silently ignoring safety preconditions.
|
|
15273
|
+
if (guarded && !z.object({
|
|
15274
|
+
threadId: z.string().regex(/^[\w-]+$/),
|
|
15275
|
+
sendId: z.string().regex(/^[A-Za-z0-9_-]{16,80}$/),
|
|
15276
|
+
text: z.string().min(1),
|
|
15277
|
+
expectedActiveLeafId: z.string().regex(/^[\w-]+$/).nullable(),
|
|
15278
|
+
replyToId: z.string().optional(),
|
|
15279
|
+
}).strict().safeParse(body).success) {
|
|
15280
|
+
return json(res, 400, { error: "guarded sends require threadId, sendId, text and expectedActiveLeafId" });
|
|
15281
|
+
}
|
|
14335
15282
|
const text = String(body.text ?? "").trim();
|
|
14336
15283
|
if (!text)
|
|
14337
15284
|
return json(res, 400, { error: "text required" });
|
|
@@ -14346,7 +15293,8 @@ const handleRequest = async (req, res) => {
|
|
|
14346
15293
|
// receipt after a task switch, while a genuinely new send still has to
|
|
14347
15294
|
// target the task that is active now.
|
|
14348
15295
|
const threadId = body.threadId ?? bot.threadId;
|
|
14349
|
-
|
|
15296
|
+
if (!guarded)
|
|
15297
|
+
noteTurnTrigger(threadId, auth);
|
|
14350
15298
|
// The send is acknowledged before the turn starts, so a workspace at its
|
|
14351
15299
|
// spend limit is refused here, where the person can see it.
|
|
14352
15300
|
try {
|
|
@@ -14393,6 +15341,23 @@ const handleRequest = async (req, res) => {
|
|
|
14393
15341
|
if (!store.taskByThread(currentAtStart.id, threadId)) {
|
|
14394
15342
|
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
14395
15343
|
}
|
|
15344
|
+
if (guarded) {
|
|
15345
|
+
// There is no await between these checks and startTurn's
|
|
15346
|
+
// synchronous transcript append / runtime reservation. In
|
|
15347
|
+
// particular, never steer or enqueue under stale permissions.
|
|
15348
|
+
if (approvalModeFor(currentAtStart) !== "ask" || currentAtStart.autoApprove === true || currentAtStart.alwaysAllow?.length) {
|
|
15349
|
+
throw Object.assign(new Error("guarded sends require Ask mode without remembered permissions"), { status: 409, code: "guarded_permissions" });
|
|
15350
|
+
}
|
|
15351
|
+
if (store.activeLeaf(threadId) !== body.expectedActiveLeafId) {
|
|
15352
|
+
throw Object.assign(new Error("the conversation changed before this message could start"), { status: 409, code: "guarded_branch" });
|
|
15353
|
+
}
|
|
15354
|
+
if (currentAtStart.busy || threadBusy(bot.id, threadId) || botAtThreadCapacity(bot.id) || parksBehindCoordination(bot.id, threadId) || activeGroupTurnForBot(bot.id)) {
|
|
15355
|
+
throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
|
|
15356
|
+
}
|
|
15357
|
+
noteTurnTrigger(threadId, auth);
|
|
15358
|
+
const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth) });
|
|
15359
|
+
return { ok: true, threadId, message };
|
|
15360
|
+
}
|
|
14396
15361
|
// Claude can accept the message inside its live turn. If the write
|
|
14397
15362
|
// loses a race with turn settlement, or the engine cannot steer, the
|
|
14398
15363
|
// existing server-side queue records it atomically for the next turn.
|
|
@@ -14403,6 +15368,7 @@ const handleRequest = async (req, res) => {
|
|
|
14403
15368
|
// message intact for the next ordinary turn, where central image
|
|
14404
15369
|
// admission can hand it to the provider natively.
|
|
14405
15370
|
const carriesImages = extractTurnImages(text).images.length > 0;
|
|
15371
|
+
const steerTarget = handoffs.current(threadId);
|
|
14406
15372
|
if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14407
15373
|
steered = await instance.adapter
|
|
14408
15374
|
.steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
|
|
@@ -14446,11 +15412,14 @@ const handleRequest = async (req, res) => {
|
|
|
14446
15412
|
replyToId: replyTo?.id,
|
|
14447
15413
|
sendId,
|
|
14448
15414
|
steered: true,
|
|
15415
|
+
sender: messageSender(auth),
|
|
14449
15416
|
});
|
|
15417
|
+
// Offered to the next turn again unless the person stops this one.
|
|
15418
|
+
handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
|
|
14450
15419
|
return { ok: true, steered: true, threadId, message };
|
|
14451
15420
|
}
|
|
14452
15421
|
if (!current.busy) {
|
|
14453
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
|
|
15422
|
+
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
|
|
14454
15423
|
}
|
|
14455
15424
|
const queued = queueSteeredMessage(current.id, threadId, text, {
|
|
14456
15425
|
replyToId: replyTo?.id,
|
|
@@ -14459,7 +15428,7 @@ const handleRequest = async (req, res) => {
|
|
|
14459
15428
|
});
|
|
14460
15429
|
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
14461
15430
|
}
|
|
14462
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
|
|
15431
|
+
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
|
|
14463
15432
|
});
|
|
14464
15433
|
return json(res, 202, receipt);
|
|
14465
15434
|
}
|
|
@@ -14497,6 +15466,7 @@ const handleRequest = async (req, res) => {
|
|
|
14497
15466
|
const currentAtStart = store.projectBotForTask(bot.id, bot.threadId);
|
|
14498
15467
|
const instance = currentAtStart?.busy ? runningTurnInstance(currentAtStart, bot.threadId) : undefined;
|
|
14499
15468
|
const prompt = held.items.map((item) => item.prompt).join("\n\n");
|
|
15469
|
+
const steerTarget = handoffs.current(bot.threadId);
|
|
14500
15470
|
let steered = "refused";
|
|
14501
15471
|
if (currentAtStart?.busy && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14502
15472
|
steered = await instance.adapter
|
|
@@ -14525,6 +15495,9 @@ const handleRequest = async (req, res) => {
|
|
|
14525
15495
|
peerAsk: item.peerAsk,
|
|
14526
15496
|
steered: true,
|
|
14527
15497
|
}));
|
|
15498
|
+
// Offered to the next turn again unless the person stops this one.
|
|
15499
|
+
for (const message of messages)
|
|
15500
|
+
handoffs.steered(bot.threadId, steerTarget, instance?.instanceId, message.id);
|
|
14528
15501
|
const queueIds = held.items.map((item) => item.messageId);
|
|
14529
15502
|
settleHeldSteeredQueue(held);
|
|
14530
15503
|
return json(res, 200, { ok: true, steered: true, threadId: bot.threadId, messages, queueIds });
|
|
@@ -14545,6 +15518,19 @@ const handleRequest = async (req, res) => {
|
|
|
14545
15518
|
drainQueuedSends();
|
|
14546
15519
|
return json(res, 200, { ok: true, queued: true, threadId: bot.threadId });
|
|
14547
15520
|
}
|
|
15521
|
+
// Compaction shares normal turn admission and Stop, but never executes
|
|
15522
|
+
// an agent turn or provisions a computer. It preserves the full history.
|
|
15523
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/compact$/);
|
|
15524
|
+
if (m && method === "POST") {
|
|
15525
|
+
const body = await readBody(req);
|
|
15526
|
+
requirePinnedClientThread(m[1], body?.threadId);
|
|
15527
|
+
const bot = requestedTaskBot(m[1], body?.threadId);
|
|
15528
|
+
noteTurnTrigger(bot.threadId, auth);
|
|
15529
|
+
await startTurn(bot.id, "Summarize conversation context", {
|
|
15530
|
+
threadId: bot.threadId, compactOnly: true, cardContinuation: true,
|
|
15531
|
+
});
|
|
15532
|
+
return json(res, 202, { ok: true, threadId: bot.threadId });
|
|
15533
|
+
}
|
|
14548
15534
|
// edit a user message → fork the conversation there and rerun the turn.
|
|
14549
15535
|
// Rewinding a live thread is refused, exactly like switching versions
|
|
14550
15536
|
// below: interrupting mid-flight and branching under the dying turn is
|
|
@@ -14761,8 +15747,10 @@ const handleRequest = async (req, res) => {
|
|
|
14761
15747
|
const routine = routines.activeBotRunForBot(bot.id);
|
|
14762
15748
|
if (routine?.threadId === expectedThreadId)
|
|
14763
15749
|
await routines.cancelRun(routine.id);
|
|
14764
|
-
else
|
|
15750
|
+
else {
|
|
15751
|
+
handoffs.stoppedByPerson(expectedThreadId);
|
|
14765
15752
|
await interruptDirectThread(bot.id, expectedThreadId);
|
|
15753
|
+
}
|
|
14766
15754
|
return json(res, 200, { ok: true });
|
|
14767
15755
|
}
|
|
14768
15756
|
const directClaim = directTurnDispatchClaims.get(bot.threadId);
|
|
@@ -14798,6 +15786,7 @@ const handleRequest = async (req, res) => {
|
|
|
14798
15786
|
directClaim?.threadId !== expectedThreadId) {
|
|
14799
15787
|
return json(res, 409, { error: "the bot switched tasks before it could be interrupted" });
|
|
14800
15788
|
}
|
|
15789
|
+
handoffs.stoppedByPerson(expectedThreadId ?? bot.threadId);
|
|
14801
15790
|
await interruptDirectThread(bot.id, expectedThreadId ?? bot.threadId);
|
|
14802
15791
|
return json(res, 200, { ok: true });
|
|
14803
15792
|
}
|
|
@@ -14891,14 +15880,26 @@ const handleRequest = async (req, res) => {
|
|
|
14891
15880
|
return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
|
|
14892
15881
|
}
|
|
14893
15882
|
// Navigation only: execution owns its thread, never this selected id.
|
|
15883
|
+
const requestedMessages = url.searchParams.get("messages");
|
|
15884
|
+
const switchLimit = pageSize(requestedMessages);
|
|
15885
|
+
if (switchLimit === null)
|
|
15886
|
+
return json(res, 400, { error: "messages must be a non-negative whole number" });
|
|
14894
15887
|
const switched = store.switchTask(bot.id, m[2]);
|
|
14895
15888
|
if (!switched)
|
|
14896
15889
|
return json(res, 404, { error: "no such task" });
|
|
14897
|
-
const
|
|
14898
|
-
|
|
14899
|
-
|
|
14900
|
-
|
|
14901
|
-
:
|
|
15890
|
+
const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) };
|
|
15891
|
+
// Bounded for the same reason as the channel switch above.
|
|
15892
|
+
broadcast({
|
|
15893
|
+
kind: "bot",
|
|
15894
|
+
bot: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
|
|
15895
|
+
});
|
|
15896
|
+
// "0" is settings only, a positive page is a bounded transcript, and no
|
|
15897
|
+
// parameter is the whole thread — the one branch that materialises it.
|
|
15898
|
+
const responseBot = requestedMessages === "0"
|
|
15899
|
+
? switchedSettings
|
|
15900
|
+
: switchLimit === undefined
|
|
15901
|
+
? botWithThread(switched)
|
|
15902
|
+
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
14902
15903
|
return json(res, 200, { bot: responseBot });
|
|
14903
15904
|
}
|
|
14904
15905
|
if (m && method === "PATCH") {
|
|
@@ -15030,6 +16031,8 @@ const handleRequest = async (req, res) => {
|
|
|
15030
16031
|
const updated = store.deleteTask(m[1], m[2]);
|
|
15031
16032
|
if (!updated)
|
|
15032
16033
|
return json(res, 404, { error: "no such task" });
|
|
16034
|
+
clearTurnDigestState(m[2]);
|
|
16035
|
+
handoffs.forget(m[2]);
|
|
15033
16036
|
settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
|
|
15034
16037
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
15035
16038
|
const fresh = botWithThread(updated);
|
|
@@ -15052,6 +16055,11 @@ const handleRequest = async (req, res) => {
|
|
|
15052
16055
|
if (method === "GET" && action === "control" && found) {
|
|
15053
16056
|
return json(res, 200, computerControl.snapshot(teamComputerOwner(found.id)));
|
|
15054
16057
|
}
|
|
16058
|
+
// A GET carries no body, so the content-type gate below can only
|
|
16059
|
+
// mislead: the control snapshot is the sole GET on this subtree.
|
|
16060
|
+
if (method === "GET" && action !== "control") {
|
|
16061
|
+
return json(res, 404, { error: "unknown team computer action" });
|
|
16062
|
+
}
|
|
15055
16063
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json"))
|
|
15056
16064
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
15057
16065
|
const body = await readBody(req);
|
|
@@ -15249,6 +16257,12 @@ const handleRequest = async (req, res) => {
|
|
|
15249
16257
|
if (vmOwner && (action === "stop" || action === "remove" || action === "run")) {
|
|
15250
16258
|
return json(res, 409, { error: "the Local VM is being used by a bot — stop that turn first" });
|
|
15251
16259
|
}
|
|
16260
|
+
// A lease can lapse under a long, quiet turn whose thread still holds
|
|
16261
|
+
// the desktop, so stop/remove must also refuse while the thread
|
|
16262
|
+
// registry or a setup action pins it — the same guard bot deletion uses.
|
|
16263
|
+
if ((action === "stop" || action === "remove") && (localVmActiveThreads.has(SHARED_LOCAL_VM_TARGET.key) || localVmLifecycleBusy.has(SHARED_LOCAL_VM_TARGET.key))) {
|
|
16264
|
+
return json(res, 409, { error: localVmActiveThreads.has(SHARED_LOCAL_VM_TARGET.key) ? "the Local VM is being used by a bot — stop that turn first" : "another Local VM setup action is still running" });
|
|
16265
|
+
}
|
|
15252
16266
|
if (action === "pull")
|
|
15253
16267
|
localVmImageBusy = true;
|
|
15254
16268
|
else
|
|
@@ -15276,6 +16290,7 @@ const handleRequest = async (req, res) => {
|
|
|
15276
16290
|
}
|
|
15277
16291
|
if (method === "POST" && path === "/api/local-computer/screenshot") {
|
|
15278
16292
|
localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
|
|
16293
|
+
res.setHeader("cache-control", "private, no-store");
|
|
15279
16294
|
return json(res, 200, {
|
|
15280
16295
|
image: await containerComputerScreenshot(undefined, undefined, SHARED_LOCAL_VM_TARGET),
|
|
15281
16296
|
});
|
|
@@ -15312,6 +16327,11 @@ const handleRequest = async (req, res) => {
|
|
|
15312
16327
|
const vmOwner = localVmLeaseFor(target).current(localVmOwnerBusy);
|
|
15313
16328
|
if (vmOwner)
|
|
15314
16329
|
return json(res, 409, { error: "this bot is using its Local VM — stop the turn first" });
|
|
16330
|
+
// Mirrors the shared-target guard above: the lease alone can miss a
|
|
16331
|
+
// long, quiet turn, and a setup action must not be torn down mid-call.
|
|
16332
|
+
if ((action === "stop" || action === "remove") && (localVmActiveThreads.has(target.key) || localVmLifecycleBusy.has(target.key))) {
|
|
16333
|
+
return json(res, 409, { error: localVmActiveThreads.has(target.key) ? "this bot is using its Local VM — stop the turn first" : "this bot's Local VM setup action is still running" });
|
|
16334
|
+
}
|
|
15315
16335
|
// Fence this target, and the cross-target capacity decision for creates,
|
|
15316
16336
|
// before the first await so two requests cannot both pass the limit.
|
|
15317
16337
|
localVmLifecycleBusy.add(target.key);
|
|
@@ -15360,6 +16380,7 @@ const handleRequest = async (req, res) => {
|
|
|
15360
16380
|
}
|
|
15361
16381
|
const target = localVmTargetForBot(bot.id);
|
|
15362
16382
|
localVmIdleFor(target).touch();
|
|
16383
|
+
res.setHeader("cache-control", "private, no-store");
|
|
15363
16384
|
return json(res, 200, {
|
|
15364
16385
|
image: await containerComputerScreenshot(undefined, undefined, target),
|
|
15365
16386
|
});
|
|
@@ -15368,7 +16389,7 @@ const handleRequest = async (req, res) => {
|
|
|
15368
16389
|
// child proves it is OURS by echoing its pid (a stray dev server has
|
|
15369
16390
|
// the same API shape but a different pid)
|
|
15370
16391
|
if (method === "GET" && path === "/api/health") {
|
|
15371
|
-
return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR) });
|
|
16392
|
+
return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR), capabilities: { guardedMessages: 1 } });
|
|
15372
16393
|
}
|
|
15373
16394
|
// The bots' browser engine: install it on this machine (agent-browser +
|
|
15374
16395
|
// a Chrome for Testing, a one-time download), or ask how that is going.
|
|
@@ -15531,6 +16552,37 @@ const handleRequest = async (req, res) => {
|
|
|
15531
16552
|
const companyMutation = /^\/api\/instances\/(company\.[\w.-]+)(?:\/|$)/.exec(path);
|
|
15532
16553
|
if (companyMutation && method !== "GET")
|
|
15533
16554
|
return json(res, 403, { error: "Company accounts are read-only here. Manage this connection in desktop Settings." });
|
|
16555
|
+
const instanceIconPatch = /^\/api\/instances\/([\w.-]+)\/icon$/.exec(path);
|
|
16556
|
+
if (method === "PATCH" && instanceIconPatch) {
|
|
16557
|
+
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
16558
|
+
return json(res, 415, { error: "content-type must be application/json" });
|
|
16559
|
+
}
|
|
16560
|
+
const parsed = providerIconPatchSchema.safeParse(await readBody(req, 192 * 1024));
|
|
16561
|
+
if (!parsed.success) {
|
|
16562
|
+
const detail = parsed.error.issues[0]?.message;
|
|
16563
|
+
return json(res, 400, { error: detail && detail !== "Invalid input" ? detail : "Choose a built-in icon or upload a PNG, JPEG, or WebP image up to 128 KB." });
|
|
16564
|
+
}
|
|
16565
|
+
if (parsed.data.icon) {
|
|
16566
|
+
const invalid = providerIconError(parsed.data.icon);
|
|
16567
|
+
if (invalid)
|
|
16568
|
+
return json(res, 400, { error: invalid });
|
|
16569
|
+
}
|
|
16570
|
+
if (providerConfigBusy)
|
|
16571
|
+
return json(res, 409, { error: "provider settings are already being updated" });
|
|
16572
|
+
providerConfigBusy = true;
|
|
16573
|
+
try {
|
|
16574
|
+
const changed = withInstanceIcon(cfg, instanceIconPatch[1], parsed.data.icon);
|
|
16575
|
+
if (!changed.ok)
|
|
16576
|
+
return json(res, 404, { error: `unknown instance "${instanceIconPatch[1]}"` });
|
|
16577
|
+
saveConfig({ instances: changed.instances }, { replaceInstances: true });
|
|
16578
|
+
cfg.instances = changed.instances;
|
|
16579
|
+
broadcast({ kind: "config", ...configStatus() });
|
|
16580
|
+
return json(res, 200, { instances: await describeInstances() });
|
|
16581
|
+
}
|
|
16582
|
+
finally {
|
|
16583
|
+
providerConfigBusy = false;
|
|
16584
|
+
}
|
|
16585
|
+
}
|
|
15534
16586
|
if (method === "POST" && path === "/api/instances/claude-accounts") {
|
|
15535
16587
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
15536
16588
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
@@ -16757,8 +17809,10 @@ const handleRequest = async (req, res) => {
|
|
|
16757
17809
|
try {
|
|
16758
17810
|
if (m[2] === "join")
|
|
16759
17811
|
return json(res, 200, await box.joinReadyBox(cfg, key));
|
|
16760
|
-
if (m[2] === "screenshot")
|
|
17812
|
+
if (m[2] === "screenshot") {
|
|
17813
|
+
res.setHeader("cache-control", "private, no-store");
|
|
16761
17814
|
return json(res, 200, await box.screenshotBox(cfg, key));
|
|
17815
|
+
}
|
|
16762
17816
|
return json(res, 200, await box.sleepBox(cfg, key));
|
|
16763
17817
|
}
|
|
16764
17818
|
finally {
|
|
@@ -16774,6 +17828,7 @@ const handleRequest = async (req, res) => {
|
|
|
16774
17828
|
});
|
|
16775
17829
|
vpsPreviewRequests.set(botId, preview);
|
|
16776
17830
|
}
|
|
17831
|
+
res.setHeader("cache-control", "private, no-store");
|
|
16777
17832
|
return json(res, 200, await preview);
|
|
16778
17833
|
}
|
|
16779
17834
|
// Opening the existing SSH viewer can coexist with a capture. Start,
|
|
@@ -16839,6 +17894,7 @@ const handleRequest = async (req, res) => {
|
|
|
16839
17894
|
case "exec":
|
|
16840
17895
|
return json(res, 200, await box.execOnBox(cfg, botId, boxCommand ?? ""));
|
|
16841
17896
|
case "screenshot":
|
|
17897
|
+
res.setHeader("cache-control", "private, no-store");
|
|
16842
17898
|
return json(res, 200, await box.screenshotBox(cfg, botId));
|
|
16843
17899
|
}
|
|
16844
17900
|
}
|
|
@@ -16850,7 +17906,10 @@ const handleRequest = async (req, res) => {
|
|
|
16850
17906
|
}
|
|
16851
17907
|
catch (e) {
|
|
16852
17908
|
const status = e?.status ?? 500;
|
|
16853
|
-
|
|
17909
|
+
const candidateCode = e?.code;
|
|
17910
|
+
const code = typeof candidateCode === "string" && ["guarded_busy", "guarded_branch", "guarded_permissions"].includes(candidateCode)
|
|
17911
|
+
? candidateCode : undefined;
|
|
17912
|
+
return json(res, status, { error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
|
|
16854
17913
|
}
|
|
16855
17914
|
finally {
|
|
16856
17915
|
releaseWorkspaceRequest?.();
|
|
@@ -16929,12 +17988,17 @@ for (const row of chatFollowups()) {
|
|
|
16929
17988
|
}
|
|
16930
17989
|
settleChatFollowups([row.id], "interrupted");
|
|
16931
17990
|
const messages = store.messagesFor(row.threadId);
|
|
16932
|
-
|
|
16933
|
-
|
|
16934
|
-
|
|
16935
|
-
|
|
16936
|
-
|
|
16937
|
-
|
|
17991
|
+
const recovered = messages.find((message) => message.queueId === row.id && message.role === "user") ?? store.appendMessage(row.threadId, {
|
|
17992
|
+
role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
|
|
17993
|
+
sendId: row.payload.sendId, queueId: row.id,
|
|
17994
|
+
...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
|
|
17995
|
+
});
|
|
17996
|
+
// Nor as a message a resumed session has not seen: count it as handed.
|
|
17997
|
+
const recoveredTask = row.kind === "bot" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
|
|
17998
|
+
const order = store.activePath(row.threadId).filter(isContextMessage).map((m) => m.id);
|
|
17999
|
+
for (const [instanceId, state] of Object.entries(recoveredTask?.handedMessages ?? {})) {
|
|
18000
|
+
if (state.session !== undefined)
|
|
18001
|
+
store.setHandedMessages(row.ownerId, row.threadId, instanceId, recordHanded(state, order, [recovered.id]));
|
|
16938
18002
|
}
|
|
16939
18003
|
if (!messages.some((message) => message.queueId === row.id && message.kind === "activity")) {
|
|
16940
18004
|
store.appendMessage(row.threadId, {
|