openmausbot 0.1.84 → 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-DNa2umw-.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 +135 -12
- package/dist-server/enterprise/server/index.js +1 -0
- package/dist-server/hooks/omb-hook.js +74 -0
- package/dist-server/index.js +8051 -6252
- package/dist-server/local-computer-proxy.js +83 -1
- package/dist-server/local-computer.js +14 -2
- package/dist-server/openmausbot.js +759 -301
- package/dist-server/pair-cli.js +759 -301
- 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 +62 -18
- 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/config.js +59 -2
- package/dist-server/server/context-budget.js +29 -0
- package/dist-server/server/context-rebuild.js +72 -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 +51 -12
- package/dist-server/server/drivers/agents-result.js +23 -0
- package/dist-server/server/drivers/claude.js +62 -10
- package/dist-server/server/drivers/codex.js +6 -0
- 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/index.js +1127 -365
- 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/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 +94 -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-BbU5REzd.js +0 -310
- package/dist/assets/index-CLGfYlx_.css +0 -1
|
@@ -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";
|
|
@@ -70,7 +80,7 @@ import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, inci
|
|
|
70
80
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
71
81
|
import { promptWithReply, transcriptText } from "./replies.js";
|
|
72
82
|
import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
|
|
73
|
-
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";
|
|
74
84
|
import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
|
|
75
85
|
import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
|
|
76
86
|
import { EventBus } from "./harness/bus.js";
|
|
@@ -80,7 +90,7 @@ import { selectDefaultModelSelection } from "./default-model-selection.js";
|
|
|
80
90
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
81
91
|
import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
|
|
82
92
|
import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
|
|
83
|
-
import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, toWireTask, } from "./store.js";
|
|
93
|
+
import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
|
|
84
94
|
import * as tts from "./tts/index.js";
|
|
85
95
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
86
96
|
import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
|
|
@@ -272,6 +282,17 @@ registerEnginesBinDir();
|
|
|
272
282
|
// still belongs to the person who wrote, and routine or peer turns are
|
|
273
283
|
// told apart before this is consulted.
|
|
274
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
|
+
}
|
|
275
296
|
function noteTurnTrigger(threadId, auth) {
|
|
276
297
|
turnTriggers.set(threadId, auth.kind === "session"
|
|
277
298
|
? { kind: "user", ...(auth.session.email ? { email: auth.session.email } : {}), label: auth.session.label }
|
|
@@ -317,7 +338,13 @@ function applyDesktopMutationTokenMessage(raw) {
|
|
|
317
338
|
const browserCleanup = new BrowserCleanupCoordinator({
|
|
318
339
|
file: join(DATA_DIR, "browser-cleanups.json"),
|
|
319
340
|
send: (request) => {
|
|
320
|
-
|
|
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 });
|
|
321
348
|
// Guest sessions are throwaway and never saved, so only the bot's own
|
|
322
349
|
// session and shared profile sessions have state to clear.
|
|
323
350
|
const sessions = request.type === "openmausbot:browser-bot-deleted" && request.botId
|
|
@@ -561,6 +588,22 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
|
|
|
561
588
|
},
|
|
562
589
|
};
|
|
563
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
|
+
}
|
|
564
607
|
class DirectTurnSetupCancelled extends Error {
|
|
565
608
|
}
|
|
566
609
|
const directTurnDispatchClaims = new Map();
|
|
@@ -821,6 +864,7 @@ function clearDirectTurnDispatch(threadId, claimId) {
|
|
|
821
864
|
if (directTurnDispatchClaims.get(threadId)?.id === claimId)
|
|
822
865
|
directTurnDispatchClaims.delete(threadId);
|
|
823
866
|
}
|
|
867
|
+
const compactionControllers = new Map();
|
|
824
868
|
function cancelDirectTurnDispatch(botId, expectedThreadId) {
|
|
825
869
|
const threadId = expectedThreadId ?? store.bot(botId)?.threadId;
|
|
826
870
|
if (!threadId)
|
|
@@ -829,7 +873,12 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
|
|
|
829
873
|
const claim = directTurnDispatchClaims.get(threadId);
|
|
830
874
|
if (!claim || claim.botId !== botId)
|
|
831
875
|
return null;
|
|
876
|
+
const compacting = compactionControllers.get(threadId);
|
|
877
|
+
if (compacting?.generation === claim.id)
|
|
878
|
+
compacting.controller.abort(new DirectTurnSetupCancelled("context summarization stopped"));
|
|
832
879
|
directTurnDispatchClaims.delete(threadId);
|
|
880
|
+
if (directTurnGenerationByThread.get(threadId) === claim.id)
|
|
881
|
+
clearTurnDigestState(threadId);
|
|
833
882
|
// Setup has not called the adapter yet, so there is no provider handshake
|
|
834
883
|
// (and no unknown turn id) to quarantine. Dispatching is the only phase in
|
|
835
884
|
// which a late provider event can exist.
|
|
@@ -897,6 +946,7 @@ async function browserIntegration(botId, profile, turn) {
|
|
|
897
946
|
encryptionKey: browserEngineEncryptionKey(),
|
|
898
947
|
persistent: profile !== "guest",
|
|
899
948
|
env: { ...process.env, PATH: augmentedPath() },
|
|
949
|
+
attachCdpUrl: browserEngineAttachCdpUrl(cfg) ?? undefined,
|
|
900
950
|
});
|
|
901
951
|
await prepareBrowserSessionState(status.binaryPath, session, { env: spec.env, persistent: profile !== "guest", isCurrent: () => {
|
|
902
952
|
const current = store.bot(botId);
|
|
@@ -2183,15 +2233,11 @@ function outstandingAssignmentsPrompt(threadId) {
|
|
|
2183
2233
|
const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
2184
2234
|
validate: (node, parent) => roomHandoffProblem(node, parent) ??
|
|
2185
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),
|
|
2186
|
-
//
|
|
2187
|
-
//
|
|
2188
|
-
//
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
// busy teammate's whole bot (#1238); an owed resume only needs its own
|
|
2192
|
-
// thread free and a thread slot to admit it.
|
|
2193
|
-
busy: n => !n.groupId && n.status === "resume"
|
|
2194
|
-
? 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))
|
|
2195
2241
|
: Boolean(store.bot(n.botId)?.busy || (n.groupId && store.group(n.groupId) && groupIsWorking(store.group(n.groupId)))),
|
|
2196
2242
|
changed: (groupIds, directThreadIds) => {
|
|
2197
2243
|
for (const id of groupIds) {
|
|
@@ -2961,7 +3007,7 @@ const turnContext = new Map();
|
|
|
2961
3007
|
// unique arguments would let one pathological turn grow the server forever.
|
|
2962
3008
|
const repeats = new RepeatDetector({ thresholds: [5, 10, 20], maxKeysPerThread: 256 });
|
|
2963
3009
|
// ── stall watchdog ─────────────────────────────────────────────────────
|
|
2964
|
-
// ask_bot has a
|
|
3010
|
+
// ask_bot has a short inline wait budget, while room turns have a separately
|
|
2965
3011
|
// configurable absolute ceiling. The main 1:1 path had none, so a wedged CLI
|
|
2966
3012
|
// left its bot busy forever. The watchdog stops a turn whose thread has emitted NOTHING for stallMs —
|
|
2967
3013
|
// activity-based, so an hour-long turn that keeps streaming is never
|
|
@@ -2969,7 +3015,7 @@ const repeats = new RepeatDetector({ thresholds: [5, 10, 20], maxKeysPerThread:
|
|
|
2969
3015
|
const TURN_STALL_MS = Math.max(60_000, Number(process.env.OMB_TURN_STALL_MS) || 20 * 60_000);
|
|
2970
3016
|
/** How long ask_bot waits synchronously before the ask is converted into a
|
|
2971
3017
|
* delegation claim ticket (the peer's turn keeps running either way). */
|
|
2972
|
-
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);
|
|
2973
3019
|
// A room waits for a busy teammate instead of dropping them, but never
|
|
2974
3020
|
// forever: a bot parked on a permission card in another chat is "busy" until
|
|
2975
3021
|
// a human returns. Past this cap a goal's lead is told the teammate could not
|
|
@@ -3101,12 +3147,20 @@ bus.subscribe((event) => {
|
|
|
3101
3147
|
// settles — session.exited too, because a turn that died may still have
|
|
3102
3148
|
// written. Fire-and-forget by construction: endMemoryTurn swallows its own
|
|
3103
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();
|
|
3104
3153
|
bus.subscribe((event) => {
|
|
3105
3154
|
if (shouldIgnoreProviderEvent(event))
|
|
3106
3155
|
return;
|
|
3107
3156
|
if (event.type === "turn.completed" || event.type === "session.exited") {
|
|
3108
3157
|
runningTurnEngines.delete(event.threadId);
|
|
3109
|
-
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);
|
|
3110
3164
|
}
|
|
3111
3165
|
});
|
|
3112
3166
|
// One line per finished turn in the bot's daily log (server/recent-work.ts):
|
|
@@ -3144,6 +3198,160 @@ bus.subscribe((event) => {
|
|
|
3144
3198
|
// a missing note never fails a turn
|
|
3145
3199
|
}
|
|
3146
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
|
+
}
|
|
3147
3355
|
// Bots currently working with nobody at the keyboard — a webhook turn, or a
|
|
3148
3356
|
// turn a webhook-driven bot handed to a teammate. Auto mode is a decision
|
|
3149
3357
|
// someone made for turns they were present for, so these don't inherit it:
|
|
@@ -3373,6 +3581,107 @@ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
|
|
|
3373
3581
|
capture: () => box.screenshotBox(cfg, ownerId, machine.id),
|
|
3374
3582
|
};
|
|
3375
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
|
+
}
|
|
3376
3685
|
function managedBoxOwners() {
|
|
3377
3686
|
return [...store.bots.map((bot) => ({
|
|
3378
3687
|
botId: bot.id,
|
|
@@ -3553,9 +3862,9 @@ function continueComputerSelection(threadId, generation, succeeded) {
|
|
|
3553
3862
|
const selection = computerSelectionTurns.get(threadId);
|
|
3554
3863
|
if (!selection || selection.generation !== generation)
|
|
3555
3864
|
return false;
|
|
3556
|
-
if (!succeeded || !selection.selected) {
|
|
3865
|
+
if (!succeeded || !selection.selected || hasQueuedSteeredMessages(selection.botId, threadId)) {
|
|
3557
3866
|
computerSelectionTurns.delete(threadId);
|
|
3558
|
-
return false;
|
|
3867
|
+
return false; // Let ordinary settlement drain the person's newer request.
|
|
3559
3868
|
}
|
|
3560
3869
|
const surface = selection.selected;
|
|
3561
3870
|
setImmediate(() => {
|
|
@@ -3568,8 +3877,10 @@ function continueComputerSelection(threadId, generation, succeeded) {
|
|
|
3568
3877
|
return;
|
|
3569
3878
|
if (store.taskByThread(bot.id, threadId)?.surface !== selection.previousSurface)
|
|
3570
3879
|
return;
|
|
3571
|
-
if (
|
|
3880
|
+
if (hasQueuedSteeredMessages(bot.id, threadId)) {
|
|
3881
|
+
drainQueuedSends();
|
|
3572
3882
|
return;
|
|
3883
|
+
}
|
|
3573
3884
|
if (store.activePath(threadId).findLast(message => message.role === "user" && message.kind === "text")?.id !== selection.source.id)
|
|
3574
3885
|
return;
|
|
3575
3886
|
store.patchTask(bot.id, threadId, { surface });
|
|
@@ -3816,7 +4127,13 @@ bus.subscribe((event) => {
|
|
|
3816
4127
|
}
|
|
3817
4128
|
if (bot)
|
|
3818
4129
|
handoffs.onEvent(event);
|
|
4130
|
+
if (event.turnId)
|
|
4131
|
+
liveTurnByThread.set(event.threadId, event.turnId);
|
|
4132
|
+
const liveTurnId = event.turnId ?? liveTurnByThread.get(event.threadId);
|
|
3819
4133
|
switch (event.type) {
|
|
4134
|
+
case "turn.started":
|
|
4135
|
+
turnStartedAt.set(event.threadId, Date.now());
|
|
4136
|
+
break;
|
|
3820
4137
|
case "session.started":
|
|
3821
4138
|
if (bot && event.sessionId && event.providerInstanceId) {
|
|
3822
4139
|
store.setResumeCursor(bot.id, event.providerInstanceId, event.sessionId, event.threadId);
|
|
@@ -3894,7 +4211,9 @@ bus.subscribe((event) => {
|
|
|
3894
4211
|
const message = pushMessage({
|
|
3895
4212
|
role: "bot",
|
|
3896
4213
|
kind: "activity",
|
|
3897
|
-
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,
|
|
3898
4217
|
});
|
|
3899
4218
|
if (event.itemId)
|
|
3900
4219
|
toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
|
|
@@ -4149,6 +4468,7 @@ bus.subscribe((event) => {
|
|
|
4149
4468
|
}
|
|
4150
4469
|
const lastReported = turnUsage.get(event.threadId);
|
|
4151
4470
|
turnUsage.delete(event.threadId);
|
|
4471
|
+
const lastContext = turnContext.get(event.threadId);
|
|
4152
4472
|
turnContext.delete(event.threadId);
|
|
4153
4473
|
// group turns run on the room's thread — the speaking bot's task
|
|
4154
4474
|
// tally is not the right home for a shared room's spend, so only
|
|
@@ -4194,8 +4514,6 @@ bus.subscribe((event) => {
|
|
|
4194
4514
|
const tokens = event.usage ?? lastReported;
|
|
4195
4515
|
// the context figure: what the last model call's prompt held, with
|
|
4196
4516
|
// the window from the driver or, failing that, the model's family
|
|
4197
|
-
const lastContext = turnContext.get(event.threadId);
|
|
4198
|
-
turnContext.delete(event.threadId);
|
|
4199
4517
|
const contextModel = store.taskByThread(bot.id, event.threadId)?.modelSelection?.model ?? bot.modelSelection.model;
|
|
4200
4518
|
store.addTaskUsage(bot.id, event.threadId, {
|
|
4201
4519
|
input: tokens?.input,
|
|
@@ -4208,6 +4526,11 @@ bus.subscribe((event) => {
|
|
|
4208
4526
|
// the task and answers "what did we spend, by whom" for a period
|
|
4209
4527
|
const settledTask = store.taskByThread(bot.id, event.threadId);
|
|
4210
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 });
|
|
4211
4534
|
appendUsage(DATA_DIR, {
|
|
4212
4535
|
botId: bot.id,
|
|
4213
4536
|
botName: bot.name,
|
|
@@ -4226,6 +4549,20 @@ bus.subscribe((event) => {
|
|
|
4226
4549
|
: turnTriggers.get(event.threadId) ?? { kind: "owner" },
|
|
4227
4550
|
});
|
|
4228
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);
|
|
4229
4566
|
const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
|
|
4230
4567
|
// A routine's result belongs to its reporting thread's unread state.
|
|
4231
4568
|
// Its internal execution should not light up the sidebar as well.
|
|
@@ -4247,8 +4584,6 @@ bus.subscribe((event) => {
|
|
|
4247
4584
|
notify(buildNotification("done", notificationBot, routineReportThread ?? event.threadId, completionDetail, { avatarUrl: notificationBot.avatarUrl }));
|
|
4248
4585
|
}
|
|
4249
4586
|
if (screenPollers.has(event.threadId)) {
|
|
4250
|
-
if (resourceOwner)
|
|
4251
|
-
settlingResourceOwners.set(resourceOwner.threadId, resourceOwner.generation);
|
|
4252
4587
|
// the last live frame becomes a settled inline screen message —
|
|
4253
4588
|
// the screenshot-in-chat moment. One fresh capture first, so the
|
|
4254
4589
|
// frame shows the turn's END state (the final tool's poke may
|
|
@@ -4259,7 +4594,7 @@ bus.subscribe((event) => {
|
|
|
4259
4594
|
// these claims and fails as though another thread owned its folder.
|
|
4260
4595
|
const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
|
|
4261
4596
|
let timeout;
|
|
4262
|
-
|
|
4597
|
+
const screenSettled = Promise.race([
|
|
4263
4598
|
finalScreenFrame(bot.id, event.threadId),
|
|
4264
4599
|
new Promise((resolve) => { timeout = setTimeout(() => resolve(null), SCREEN_SETTLE_TIMEOUT_MS); }),
|
|
4265
4600
|
]).then((frame) => {
|
|
@@ -4268,11 +4603,11 @@ bus.subscribe((event) => {
|
|
|
4268
4603
|
}
|
|
4269
4604
|
}).catch(() => { }).finally(() => {
|
|
4270
4605
|
clearTimeout(timeout);
|
|
4271
|
-
settleDirectTurn(true);
|
|
4272
4606
|
});
|
|
4607
|
+
void Promise.all([screenSettled, digestSettled]).finally(() => settleDirectTurn(true));
|
|
4273
4608
|
}
|
|
4274
4609
|
else {
|
|
4275
|
-
settleDirectTurn();
|
|
4610
|
+
void digestSettled.finally(() => settleDirectTurn(true));
|
|
4276
4611
|
}
|
|
4277
4612
|
}
|
|
4278
4613
|
else if (group && speaker) {
|
|
@@ -4297,6 +4632,17 @@ bus.subscribe((event) => {
|
|
|
4297
4632
|
: turnTriggers.get(event.threadId) ?? { kind: "owner" },
|
|
4298
4633
|
});
|
|
4299
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);
|
|
4300
4646
|
}
|
|
4301
4647
|
if (speaker && group?.busyBotId === speaker.botId) {
|
|
4302
4648
|
releaseTurnResources(turnResourceOwners.get(event.threadId));
|
|
@@ -4387,7 +4733,7 @@ function wakeDelegationSource(source, threadId, targetName, failureReason, routi
|
|
|
4387
4733
|
// delegated reply is already in the thread, so nothing is lost, and the
|
|
4388
4734
|
// source processes it the moment it is free rather than only on a later
|
|
4389
4735
|
// user nudge.
|
|
4390
|
-
if (threadBusy(source.id, threadId) || activeGroupTurnForBot(source.id)) {
|
|
4736
|
+
if (threadBusy(source.id, threadId) || botAtThreadCapacity(source.id) || activeGroupTurnForBot(source.id)) {
|
|
4391
4737
|
pendingDelegationWakes.set(threadId, { botId: source.id, targetName, failureReason, routineRunId });
|
|
4392
4738
|
return;
|
|
4393
4739
|
}
|
|
@@ -4486,7 +4832,10 @@ function drainDelegationWakes() {
|
|
|
4486
4832
|
pendingDelegationWakes.delete(threadId);
|
|
4487
4833
|
continue;
|
|
4488
4834
|
}
|
|
4489
|
-
|
|
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))
|
|
4490
4839
|
continue;
|
|
4491
4840
|
pendingDelegationWakes.delete(threadId);
|
|
4492
4841
|
dispatchDelegationWake(entry.botId, threadId, entry.targetName, entry.failureReason, entry.routineRunId, entry.budgetAcquired);
|
|
@@ -4535,7 +4884,96 @@ function markTaskContextExternallyUpdated(bot, threadId) {
|
|
|
4535
4884
|
}
|
|
4536
4885
|
/** Active-branch messages a provider reads as conversation context. */
|
|
4537
4886
|
function isContextMessage(m) {
|
|
4538
|
-
return Boolean((m.kind === "text" && m.text) || m.roomRequest?.phase === "result");
|
|
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
|
+
}
|
|
4539
4977
|
}
|
|
4540
4978
|
const handoffs = new Handoffs({
|
|
4541
4979
|
order: (threadId) => store.activePath(threadId).filter(isContextMessage).map((m) => m.id),
|
|
@@ -4899,7 +5337,7 @@ function drainQueuedSends() {
|
|
|
4899
5337
|
}
|
|
4900
5338
|
/** Keep a person's words off the transcript until a direct-thread slot is
|
|
4901
5339
|
* available. Reuse the existing cancellable, idempotent composer queue. */
|
|
4902
|
-
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId) {
|
|
5340
|
+
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender) {
|
|
4903
5341
|
const capacity = botAtThreadCapacity(botId);
|
|
4904
5342
|
if (capacity || threadBusy(botId, threadId) || parksBehindCoordination(botId, threadId)) {
|
|
4905
5343
|
const reason = capacity ? "capacity" : undefined;
|
|
@@ -4911,7 +5349,7 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId)
|
|
|
4911
5349
|
});
|
|
4912
5350
|
return { ok: true, queued: true, queueId: queued.id, threadId, reason };
|
|
4913
5351
|
}
|
|
4914
|
-
const message = await startTurn(botId, text, { threadId, replyTo, sendId });
|
|
5352
|
+
const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender });
|
|
4915
5353
|
return { ok: true, threadId, message };
|
|
4916
5354
|
}
|
|
4917
5355
|
/** How many start_thread calls one turn may make. Same spirit as the
|
|
@@ -5072,6 +5510,41 @@ async function finalScreenFrame(_botId, threadId) {
|
|
|
5072
5510
|
settledScreenHashes.set(threadId, screenFrameHash(frame.png));
|
|
5073
5511
|
return frame;
|
|
5074
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
|
+
}
|
|
5075
5548
|
// ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
|
|
5076
5549
|
async function startTurn(botId, text, opts) {
|
|
5077
5550
|
workspaceMaintenance.assertAvailable();
|
|
@@ -5170,7 +5643,23 @@ async function startTurn(botId, text, opts) {
|
|
|
5170
5643
|
clearInternalTurn(threadId);
|
|
5171
5644
|
// a task takes its name from the first thing you asked it to do
|
|
5172
5645
|
if (resolvedImages.text.trim() && !opts?.cardContinuation) {
|
|
5173
|
-
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
|
+
}
|
|
5174
5663
|
}
|
|
5175
5664
|
console.error(`[omb-turn] bot=${botId} text=${JSON.stringify(resolvedImages.text.slice(0, 70))} images=${turnImages.length} depth=${commsDepth} card=${Boolean(opts?.cardContinuation)}`);
|
|
5176
5665
|
const instanceId = instance.instanceId;
|
|
@@ -5208,6 +5697,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5208
5697
|
replyToId: opts?.replyTo?.id,
|
|
5209
5698
|
sendId: opts?.sendId,
|
|
5210
5699
|
peerAsk: opts?.peerAsk,
|
|
5700
|
+
sender: opts?.sender,
|
|
5211
5701
|
});
|
|
5212
5702
|
}
|
|
5213
5703
|
// A card continuation neither starts nor ends the person's ask: it
|
|
@@ -5224,148 +5714,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5224
5714
|
personAskAt.delete(threadId);
|
|
5225
5715
|
}
|
|
5226
5716
|
}
|
|
5227
|
-
// transcript for API-backed drivers: settled text turns on the ACTIVE
|
|
5228
|
-
// branch only — abandoned forks never reach the model
|
|
5229
|
-
const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]);
|
|
5230
|
-
const activeMessages = store.activePath(threadId);
|
|
5231
|
-
// This turn's own text carries the addressed request and, when resuming,
|
|
5232
|
-
// every child result as JSON: neither is repeated from the transcript.
|
|
5233
|
-
const coordinationChildren = opts?.coordination?.resumed
|
|
5234
|
-
? new Set(roomHandoffs.children(opts.coordination.id).map((child) => child.id)) : undefined;
|
|
5235
|
-
for (const m of activeMessages) {
|
|
5236
|
-
if (!opts?.coordination || !m.roomRequest)
|
|
5237
|
-
continue;
|
|
5238
|
-
if ((m.roomRequest.phase === "request" && m.roomRequest.id === opts.coordination.id) ||
|
|
5239
|
-
(m.roomRequest.phase === "result" && coordinationChildren?.has(m.roomRequest.id)))
|
|
5240
|
-
skipTranscript.add(m.id);
|
|
5241
|
-
}
|
|
5242
|
-
// A flat reply may deliberately point across a fork in the same thread.
|
|
5243
|
-
// Resolve its quote from full storage, while the replay itself remains
|
|
5244
|
-
// strictly limited to the selected branch below.
|
|
5245
|
-
const messagesById = new Map(store.messagesFor(threadId).map((message) => [message.id, message]));
|
|
5246
|
-
const context = activeMessages.filter(isContextMessage).map((m) => ({
|
|
5247
|
-
id: m.id,
|
|
5248
|
-
role: m.role === "user" ? "user" : "assistant",
|
|
5249
|
-
text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
|
|
5250
|
-
: m.role !== "user" && m.from ? peerMessageText(m.from.name, transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"))
|
|
5251
|
-
: transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"),
|
|
5252
|
-
keep: m.roomRequest?.phase === "result" || (m.role !== "user" && Boolean(m.from)),
|
|
5253
|
-
...(m.steered ? { steered: true } : {}),
|
|
5254
|
-
}));
|
|
5255
|
-
const contextOrder = context.map((m) => m.id);
|
|
5256
|
-
const replayable = context.filter((m) => !skipTranscript.has(m.id));
|
|
5257
|
-
const transcript = replayable.slice(-40).map((m) => ({ role: m.role, text: m.text }));
|
|
5258
|
-
// After a rewind (edit / branch switch) the provider's native session
|
|
5259
|
-
// still contains the abandoned branch: start a fresh session instead of
|
|
5260
|
-
// resuming, and for cursor-resuming drivers replay the surviving path
|
|
5261
|
-
// inline (transcript-replay drivers get it via transcript). The flag is
|
|
5262
|
-
// cleared only once the turn is actually dispatched — clearing it here
|
|
5263
|
-
// would cost the next attempt its history if this dispatch fails.
|
|
5264
|
-
const rewound = Boolean(task.rewound);
|
|
5265
|
-
// A fresh engine — the user switched this bot's model mid-thread — has no
|
|
5266
|
-
// current session here either, so it gets the same replay. Distinct from
|
|
5267
|
-
// rewound: the OTHER instances' cursors are left alone (a rewind wipes
|
|
5268
|
-
// them all), and "fresh" is decided by who ran the last turn, not by
|
|
5269
|
-
// whether we hold a cursor — see engineIsFresh.
|
|
5270
|
-
const externalContextMarker = isExternalContextMarker(task.lastInstanceId)
|
|
5271
|
-
? task.lastInstanceId
|
|
5272
|
-
: undefined;
|
|
5273
|
-
const fresh = !rewound &&
|
|
5274
|
-
!externalContextMarker &&
|
|
5275
|
-
engineIsFresh({ instanceId, lastInstanceId: task.lastInstanceId, resumeCursors: task.resumeCursors, transcript });
|
|
5276
|
-
// An engine that records what its session was handed resumes it with only
|
|
5277
|
-
// the context messages outside that record. A record of another session
|
|
5278
|
-
// (the one it replaced) or one that no longer lines up with the branch is
|
|
5279
|
-
// not trusted: the session is rebuilt by the same replay as any other.
|
|
5280
|
-
const strictResume = instance.adapter.capabilities.strictResume === true;
|
|
5281
|
-
const cursor = task.resumeCursors[instanceId];
|
|
5282
|
-
const handed = strictResume && !rewound && !fresh && !externalContextMarker && cursor !== undefined
|
|
5283
|
-
? task.handedMessages?.[instanceId] : undefined;
|
|
5284
|
-
const unseen = handed && handedStateUsable(handed, cursor, contextOrder) ? unseenMessages(replayable, contextOrder, handed) : undefined;
|
|
5285
|
-
// A teammate's result or reply: without records this turn would replay.
|
|
5286
|
-
const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
|
|
5287
|
-
// What a resumed session keeps from its launch: the standing instructions
|
|
5288
|
-
// (tools, servers and — for Claude — the model are passed on every launch),
|
|
5289
|
-
// plus whatever this engine can only set when a session starts. Codex's
|
|
5290
|
-
// thread/resume sends no model selection, and an effort it is not sent stays
|
|
5291
|
-
// at the thread's last value, so both belong to the session there. An
|
|
5292
|
-
// external update that finds any of it changed since the session started
|
|
5293
|
-
// gets the fresh session and replay it always got, rather than a resume.
|
|
5294
|
-
const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
|
|
5295
|
-
...(instance.driverKind === "codex" ? [model, effort ?? null] : [])];
|
|
5296
|
-
const sessionConfig = (soul) => createHash("sha256").update(JSON.stringify([...persistentConfig, soul])).digest("hex").slice(0, 16);
|
|
5297
|
-
const plannedConfig = sessionConfig(bot.soul);
|
|
5298
|
-
// Agent-tool gate shared by skill authoring, the /setup turn-text rewrite,
|
|
5299
|
-
// the setup prompt block, and the peer-comms integration below: a driver
|
|
5300
|
-
// that never mounts agent tools (or a turn already at the comms-depth cap)
|
|
5301
|
-
// must not be steered into — or told about — tools it cannot call.
|
|
5302
5717
|
const agentsMounted = (commsDepth < MAX_COMMS_DEPTH || Boolean(opts?.coordination)) && instance.adapter.capabilities.agentsMcp === true;
|
|
5303
|
-
const skillAuthoring = skillAuthoringEnabled(cfg) && agentsMounted;
|
|
5304
|
-
// Setup mode's turn-text rewrite (parseSetupCommand/expandSetupTurnText)
|
|
5305
|
-
// must not run ahead of a system prompt that can't explain it: a driver
|
|
5306
|
-
// without agent tools sees the user's literal "/setup ..." message. The
|
|
5307
|
-
// gate on whether the coaching block itself is active — setupModeActive,
|
|
5308
|
-
// which also depends on the bot's soul/description — is decided below,
|
|
5309
|
-
// from the same bot snapshot the prompt's soul is built from.
|
|
5310
|
-
const setupText = agentsMounted ? expandSetupTurnText(providerText) : providerText;
|
|
5311
|
-
const userTurnText = promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User");
|
|
5312
|
-
// Decided again at dispatch when setup outlasted a soul edit (config).
|
|
5313
|
-
const decideContext = (config) => {
|
|
5314
|
-
const handedStale = Boolean(handed && (!unseen || (externalUpdate && handed.config !== config)));
|
|
5315
|
-
const { block: unseenBlock, placed } = unseen && !handedStale ? renderUnseen(unseen) : { block: "", placed: [] };
|
|
5316
|
-
const { turnText: contextTurnText, resume } = buildTurnContext({
|
|
5317
|
-
text: userTurnText,
|
|
5318
|
-
transcript,
|
|
5319
|
-
rewound,
|
|
5320
|
-
fresh,
|
|
5321
|
-
externallyUpdated: Boolean(externalContextMarker) || handedStale,
|
|
5322
|
-
replaysNatively: instance.driverKind === "grok",
|
|
5323
|
-
});
|
|
5324
|
-
// Snapshot the cursor alongside the context decision. An external result
|
|
5325
|
-
// can arrive during async computer/setup work and clear the task cursor;
|
|
5326
|
-
// this already-built turn must either keep its old session or replay on the
|
|
5327
|
-
// following turn, never start a blank session with no transcript.
|
|
5328
|
-
const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
|
|
5329
|
-
// A cursor the provider no longer honours must not brick the thread: the
|
|
5330
|
-
// driver may fall back to ONE fresh session, and this is what it sends
|
|
5331
|
-
// there, so the new session is not blank (server/resume-recovery.ts). A
|
|
5332
|
-
// turn carrying an external update gets the replay it would have had.
|
|
5333
|
-
const recoveryIsReplay = resumeCursor !== undefined && externalUpdate && transcript.length > 0;
|
|
5334
|
-
const recoveryText = resumeCursor === undefined ? undefined : recoveryIsReplay
|
|
5335
|
-
? buildTurnContext({ text: userTurnText, transcript, rewound: false, fresh: false, externallyUpdated: true, replaysNatively: false }).turnText
|
|
5336
|
-
: buildRecoveryText({ text: userTurnText, transcript });
|
|
5337
|
-
// What this turn puts in front of the provider, for each session it can end
|
|
5338
|
-
// up in (server/delta-context.ts). buildTurnContext prepends a replay only
|
|
5339
|
-
// when it replays, so a changed text means the transcript was sent.
|
|
5340
|
-
const carried = [...skipTranscript];
|
|
5341
|
-
const windowIds = replayable.slice(-40).map((m) => m.id);
|
|
5342
|
-
return {
|
|
5343
|
-
turnText: withUnseenMessages(unseenBlock, contextTurnText),
|
|
5344
|
-
resumeCursor, recoveryText, recoveryIsReplay,
|
|
5345
|
-
handoff: strictResume ? {
|
|
5346
|
-
botId: bot.id, instanceId, config, resumeCursor: typeof resumeCursor === "string" ? resumeCursor : undefined,
|
|
5347
|
-
started: sessionStart(contextOrder, contextTurnText !== userTurnText ? windowIds : [], carried),
|
|
5348
|
-
recovery: sessionStart(contextOrder, recoveryText !== undefined ? windowIds : [], carried),
|
|
5349
|
-
resumed: sessionStart(contextOrder, [], [...placed, ...carried]),
|
|
5350
|
-
placed, carried, own: [userMessage.id, ...(opts?.excludeMessageIds ?? [])],
|
|
5351
|
-
} : undefined,
|
|
5352
|
-
};
|
|
5353
|
-
};
|
|
5354
|
-
let dispatchContext = decideContext(plannedConfig);
|
|
5355
|
-
const persona = [
|
|
5356
|
-
`You are ${bot.name}, a personal bot in OpenMausBot.`,
|
|
5357
|
-
bot.title && `Role: ${bot.title}.`,
|
|
5358
|
-
bot.description && `About: ${bot.description}`,
|
|
5359
|
-
]
|
|
5360
|
-
.filter(Boolean)
|
|
5361
|
-
.join(" ");
|
|
5362
|
-
// The SOUL.md mirror is checked here, at dispatch, and only reported:
|
|
5363
|
-
// the prompt below reads bot.soul, never the file.
|
|
5364
|
-
{
|
|
5365
|
-
const drift = checkSoulDrift(bot.id, bot.soul ?? "", bot.soulHash ?? "");
|
|
5366
|
-
if (drift.drift !== Boolean(bot.soulDrift))
|
|
5367
|
-
store.patchBot(bot.id, { soulDrift: drift.drift });
|
|
5368
|
-
}
|
|
5369
5718
|
// busy flips immediately so the composer locks; the dispatch itself runs
|
|
5370
5719
|
// in the background — box provisioning can take ~90s and must never
|
|
5371
5720
|
// hang the HTTP request
|
|
@@ -5379,8 +5728,6 @@ async function startTurn(botId, text, opts) {
|
|
|
5379
5728
|
// follow-ups: any normal turn may ask teammates to coordinate work.
|
|
5380
5729
|
directFollowupSettlers.set(dispatchClaimId, { threadId, settle: opts?.onTurnSettled });
|
|
5381
5730
|
directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
|
|
5382
|
-
if (dispatchContext.handoff)
|
|
5383
|
-
handoffs.begin(threadId, dispatchClaimId, dispatchContext.handoff);
|
|
5384
5731
|
directTurnBots.set(threadId, bot);
|
|
5385
5732
|
beginInternalCapabilityGeneration(threadId, dispatchClaimId);
|
|
5386
5733
|
if (!opts?.computerSelectionContinuation && !opts?.cardContinuation && !opts?.automationSource && !opts?.unattended &&
|
|
@@ -5404,6 +5751,161 @@ async function startTurn(botId, text, opts) {
|
|
|
5404
5751
|
turnContext.delete(threadId);
|
|
5405
5752
|
void (async () => {
|
|
5406
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);
|
|
5407
5909
|
const integrations = {};
|
|
5408
5910
|
let browser = null;
|
|
5409
5911
|
const selectedSkills = selectBundledSkills(providerText, [
|
|
@@ -5567,6 +6069,15 @@ async function startTurn(botId, text, opts) {
|
|
|
5567
6069
|
dropLease();
|
|
5568
6070
|
throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
|
|
5569
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
|
+
}
|
|
5570
6081
|
// Same contract as the Box and VPS branches below: without this the
|
|
5571
6082
|
// poller never starts, so the Local VM publishes no `screen` events
|
|
5572
6083
|
// and every client that only has the stream (the phone) waits
|
|
@@ -5660,22 +6171,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5660
6171
|
computerKind = "vm";
|
|
5661
6172
|
}
|
|
5662
6173
|
else if (wants === "local") {
|
|
5663
|
-
|
|
5664
|
-
requested: "local",
|
|
5665
|
-
hostPlatform: process.platform,
|
|
5666
|
-
providerSupportsLocal: mountsLocalComputer,
|
|
5667
|
-
})) {
|
|
5668
|
-
// Name the condition that actually failed: a person told "choose an
|
|
5669
|
-
// ACP engine" while already on one has nowhere to go.
|
|
5670
|
-
throw new Error(mountsLocalComputer
|
|
5671
|
-
? `local computer control is not available on ${process.platform} — select another destination`
|
|
5672
|
-
: "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
|
|
5673
|
-
}
|
|
5674
|
-
const cua = readCuaConnection();
|
|
5675
|
-
if (!cua)
|
|
5676
|
-
throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
|
|
5677
|
-
await bindTurnComputer(resourceOwner, "computer:host");
|
|
5678
|
-
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
6174
|
+
integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
|
|
5679
6175
|
computerKind = "local";
|
|
5680
6176
|
}
|
|
5681
6177
|
// A VPS is a local-agent computer mount, never a remote agent runner.
|
|
@@ -5697,47 +6193,30 @@ async function startTurn(botId, text, opts) {
|
|
|
5697
6193
|
// — or fails after 30 minutes behind — its own long-running task.
|
|
5698
6194
|
// Container lifecycle (provision, start) is serialized by the
|
|
5699
6195
|
// runner's per-container lock, not by this turn.
|
|
5700
|
-
const vpsResource = `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`;
|
|
5701
6196
|
vpsThreadStarted(bot.id, threadId);
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
}
|
|
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;
|
|
5714
6212
|
computerKind = "vps";
|
|
5715
|
-
// Live frames only once this turn holds the desktop: a poller on
|
|
5716
|
-
// a desktop another turn is driving would publish that turn's
|
|
5717
|
-
// screen as this one's. The claim restarts the poller with the
|
|
5718
|
-
// capture, the way the Local VM's lazy claim does.
|
|
5719
|
-
const vpsCapture = () => vps.vpsComputerScreenshot(targetCfg, bot.id);
|
|
5720
|
-
autoVmClaims.set(threadId, {
|
|
5721
|
-
owner: resourceOwner,
|
|
5722
|
-
lazy: true,
|
|
5723
|
-
label: "the VPS computer",
|
|
5724
|
-
claim: async () => {
|
|
5725
|
-
await bindTurnComputer(resourceOwner, vpsResource, true);
|
|
5726
|
-
previewCapture = vpsCapture;
|
|
5727
|
-
if (threadBusy(bot.id, threadId)) {
|
|
5728
|
-
const touched = screenPollers.get(threadId)?.touched ?? false;
|
|
5729
|
-
stopScreenPoller(bot.id, threadId);
|
|
5730
|
-
startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
|
|
5731
|
-
}
|
|
5732
|
-
},
|
|
5733
|
-
});
|
|
5734
6213
|
}
|
|
5735
6214
|
else {
|
|
5736
6215
|
vpsThreadEnded(bot.id, threadId);
|
|
5737
6216
|
if (wants === "cloud") {
|
|
5738
|
-
throw new Error(
|
|
6217
|
+
throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
|
|
5739
6218
|
}
|
|
5740
|
-
autoVpsProblem =
|
|
6219
|
+
autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
|
|
5741
6220
|
}
|
|
5742
6221
|
}
|
|
5743
6222
|
}
|
|
@@ -5750,62 +6229,15 @@ async function startTurn(botId, text, opts) {
|
|
|
5750
6229
|
computerKind = "box";
|
|
5751
6230
|
}
|
|
5752
6231
|
if (!teamComputer && mountsCloudComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
|
|
5753
|
-
|
|
5754
|
-
// before any network await so setup itself cannot race another turn.
|
|
5755
|
-
if (wants === "cloud")
|
|
5756
|
-
await bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
|
|
5757
|
-
if (!mountsCloudComputer && wants === "cloud") {
|
|
5758
|
-
throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
|
|
5759
|
-
}
|
|
5760
|
-
let b;
|
|
5761
|
-
try {
|
|
5762
|
-
b = await box.findBox(cfg, bot.id);
|
|
5763
|
-
}
|
|
5764
|
-
catch (error) {
|
|
5765
|
-
// Auto may fall through when an optional provider is offline, but a
|
|
5766
|
-
// durable deletion fence must never be mistaken for "no computer".
|
|
5767
|
-
if (wants === "cloud" || error?.status === 409)
|
|
5768
|
-
throw error;
|
|
5769
|
-
b = null;
|
|
5770
|
-
}
|
|
5771
|
-
let lifecycle = box.boxTurnLifecycleAction({
|
|
6232
|
+
const attached = await attachBotBox(bot, resourceOwner, {
|
|
5772
6233
|
explicitCloud: wants === "cloud",
|
|
5773
6234
|
canMount: mountsCloudComputer,
|
|
5774
|
-
|
|
6235
|
+
remoteAgent: instance.driverKind === "boxAgent",
|
|
5775
6236
|
});
|
|
5776
|
-
if (
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
lifecycle = box.boxTurnLifecycleAction({
|
|
5781
|
-
explicitCloud: true,
|
|
5782
|
-
canMount: mountsCloudComputer,
|
|
5783
|
-
state: typeof b?.state === "string" ? b.state : null,
|
|
5784
|
-
});
|
|
5785
|
-
}
|
|
5786
|
-
// an archived box answers every action with an error until it
|
|
5787
|
-
// resumes — wake it here, once, instead of letting the agent
|
|
5788
|
-
// discover it one failed tool call at a time. Explicit Cloud is the
|
|
5789
|
-
// consent boundary for the resume (~8s, and it un-pauses billing).
|
|
5790
|
-
if (lifecycle === "wake") {
|
|
5791
|
-
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
5792
|
-
b = (await box.readyBox(cfg, bot.id)) ?? b;
|
|
5793
|
-
lifecycle = box.boxTurnLifecycleAction({
|
|
5794
|
-
explicitCloud: true,
|
|
5795
|
-
canMount: mountsCloudComputer,
|
|
5796
|
-
state: typeof b?.state === "string" ? b.state : null,
|
|
5797
|
-
});
|
|
5798
|
-
}
|
|
5799
|
-
if (b && lifecycle === "attach") {
|
|
5800
|
-
await bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
|
|
5801
|
-
previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
|
|
5802
|
-
if (mountsCloudComputer) {
|
|
5803
|
-
integrations.computer = {
|
|
5804
|
-
kind: "box",
|
|
5805
|
-
boxId: b.id,
|
|
5806
|
-
token: cfg.box.token,
|
|
5807
|
-
control: controlIntegration(bot.id, threadId, dispatchClaimId),
|
|
5808
|
-
};
|
|
6237
|
+
if (attached) {
|
|
6238
|
+
previewCapture = attached.capture;
|
|
6239
|
+
if (attached.integration) {
|
|
6240
|
+
integrations.computer = attached.integration;
|
|
5809
6241
|
computerKind = "box";
|
|
5810
6242
|
}
|
|
5811
6243
|
}
|
|
@@ -5877,6 +6309,9 @@ async function startTurn(botId, text, opts) {
|
|
|
5877
6309
|
const ownThreadCreation = boundedCoordination && !opts?.coordination && Boolean(origin && !origin.peerAsk);
|
|
5878
6310
|
integrations.agents = agentsIntegration(bot.id, threadId, commsDepth, skillAuthoring, dispatchClaimId, opts?.coordination?.id, boundedCoordination, ownThreadCreation);
|
|
5879
6311
|
}
|
|
6312
|
+
if (instance.adapter.capabilities.hooks === true && hooksEnabled()) {
|
|
6313
|
+
integrations.hooks = hooksIntegration(bot.id, threadId, dispatchClaimId);
|
|
6314
|
+
}
|
|
5880
6315
|
// @mentions in the user's message (the composer's tagging UI) become
|
|
5881
6316
|
// an explicit coordination nudge. The agent still chooses the matching
|
|
5882
6317
|
// peer tool, so the harness stays the single owner of turns/permissions.
|
|
@@ -5903,8 +6338,16 @@ async function startTurn(botId, text, opts) {
|
|
|
5903
6338
|
// the engine cannot edit the project until the snapshot has settled.
|
|
5904
6339
|
// snapshot() absorbs failures, so checkpointing may delay but never fail
|
|
5905
6340
|
// a turn.
|
|
5906
|
-
if (checkpointCwd)
|
|
5907
|
-
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
|
+
}
|
|
5908
6351
|
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
|
|
5909
6352
|
throw new DirectTurnSetupCancelled("turn stopped before dispatch");
|
|
5910
6353
|
}
|
|
@@ -5944,7 +6387,15 @@ async function startTurn(botId, text, opts) {
|
|
|
5944
6387
|
if (browser) {
|
|
5945
6388
|
const frame = { binaryPath: browser.spec.command, env: browser.spec.env };
|
|
5946
6389
|
const session = browser.session;
|
|
5947
|
-
|
|
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
|
+
};
|
|
5948
6399
|
}
|
|
5949
6400
|
}
|
|
5950
6401
|
// An Auto conversation remembers where its first turn landed, so later
|
|
@@ -6016,9 +6467,21 @@ async function startTurn(botId, text, opts) {
|
|
|
6016
6467
|
]);
|
|
6017
6468
|
runningTurnEngines.set(threadId, instance);
|
|
6018
6469
|
// The prompt carries the soul as saved now. If it changed during setup,
|
|
6019
|
-
// decide again from what is actually sent
|
|
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.
|
|
6020
6483
|
const dispatchedConfig = sessionConfig(liveBot?.soul ?? bot.soul);
|
|
6021
|
-
if (strictResume && dispatchedConfig !== plannedConfig)
|
|
6484
|
+
if (strictResume && !(opts?.cardContinuation && continuingRoutine) && dispatchedConfig !== plannedConfig)
|
|
6022
6485
|
dispatchContext = decideContext(dispatchedConfig);
|
|
6023
6486
|
// Before sendTurn: an adapter may emit the whole turn before it resolves.
|
|
6024
6487
|
handoffs.dispatching(threadId, dispatchClaimId, dispatchContext.handoff);
|
|
@@ -6036,6 +6499,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6036
6499
|
// the active task's own session — another task's cursor would
|
|
6037
6500
|
// resume the wrong conversation and defeat the context bubble
|
|
6038
6501
|
resumeCursor: dispatchContext.resumeCursor,
|
|
6502
|
+
sessionReset: dispatchContext.sessionReset,
|
|
6039
6503
|
...(dispatchContext.recoveryText !== undefined ? { recoveryText: dispatchContext.recoveryText } : {}),
|
|
6040
6504
|
...(dispatchContext.recoveryIsReplay ? { recoveryIsReplay: true } : {}),
|
|
6041
6505
|
transcript,
|
|
@@ -6066,6 +6530,8 @@ async function startTurn(botId, text, opts) {
|
|
|
6066
6530
|
// dispatched: the rewind is spent, and the old cursors are dead
|
|
6067
6531
|
if (rewound)
|
|
6068
6532
|
store.patchTask(bot.id, threadId, { rewound: false, resumeCursors: {} });
|
|
6533
|
+
if (contextReset && record)
|
|
6534
|
+
store.patchTask(bot.id, threadId, { appliedCompactionId: record.id, contextFloor: undefined });
|
|
6069
6535
|
// and this engine now owns the thread's most recent turn
|
|
6070
6536
|
// Consume exactly the external-update generation this turn replayed.
|
|
6071
6537
|
// If a newer delegated result landed during setup, its unique marker
|
|
@@ -6106,6 +6572,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6106
6572
|
const ownsLatestGeneration = directTurnGenerationByThread.get(threadId) === dispatchClaimId;
|
|
6107
6573
|
releaseTurnResources(resourceOwner);
|
|
6108
6574
|
if (ownsLatestGeneration) {
|
|
6575
|
+
clearTurnDigestState(threadId);
|
|
6109
6576
|
releaseLocalVmThread(threadId);
|
|
6110
6577
|
vpsThreadEnded(bot.id, threadId);
|
|
6111
6578
|
watchdog.settle(threadId);
|
|
@@ -6507,7 +6974,7 @@ async function cloudRoutineReadiness() {
|
|
|
6507
6974
|
if (!box.boxConfigured(cfg)) {
|
|
6508
6975
|
return {
|
|
6509
6976
|
ready: false,
|
|
6510
|
-
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.',
|
|
6511
6978
|
};
|
|
6512
6979
|
}
|
|
6513
6980
|
const instance = registry.instances().find((candidate) => candidate.driverKind === "boxAgent");
|
|
@@ -6702,6 +7169,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
6702
7169
|
// staged provider images into a message, so dispose them here.
|
|
6703
7170
|
for (const task of store.tasks(bot.id)) {
|
|
6704
7171
|
purgeGeneratedImagesForThread(task.threadId);
|
|
7172
|
+
clearTurnDigestState(task.threadId);
|
|
6705
7173
|
settleDirectFollowup(directTurnGenerationByThread.get(task.threadId));
|
|
6706
7174
|
directTurnGenerationByThread.delete(task.threadId);
|
|
6707
7175
|
directTurnBots.delete(task.threadId);
|
|
@@ -6930,6 +7398,10 @@ const agentRoutine = (routine, latestRun) => {
|
|
|
6930
7398
|
instructions: safeInstructions.slice(0, 2_000),
|
|
6931
7399
|
instructionsTruncated: safeInstructions.length > 2_000,
|
|
6932
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,
|
|
6933
7405
|
enabled: routine.enabled,
|
|
6934
7406
|
runOn: routine.runOn,
|
|
6935
7407
|
durationMinutes: routine.durationMinutes,
|
|
@@ -7063,6 +7535,13 @@ const webhooks = new WebhookManager({
|
|
|
7063
7535
|
findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
|
|
7064
7536
|
cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
|
|
7065
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
|
+
},
|
|
7066
7545
|
});
|
|
7067
7546
|
let webhookIngress = null;
|
|
7068
7547
|
let webhookIngressError = null;
|
|
@@ -7113,9 +7592,12 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
|
|
|
7113
7592
|
const messages = store.messagesFor(threadId);
|
|
7114
7593
|
const messagesById = new Map(messages.map((message) => [message.id, message]));
|
|
7115
7594
|
return messages
|
|
7116
|
-
.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")
|
|
7117
7596
|
.slice(-GROUP_CONTEXT_MESSAGES)
|
|
7118
7597
|
.map((m) => {
|
|
7598
|
+
if (m.kind === "digest" && m.digest) {
|
|
7599
|
+
return digestPromptLine(m.digest, m.from ? peerName(m.from.name) : "a bot");
|
|
7600
|
+
}
|
|
7119
7601
|
if (m.roomRequest?.phase === "result") {
|
|
7120
7602
|
// Keep the chat receipt small without erasing the report from later
|
|
7121
7603
|
// turns. Resolve from the existing bounded store and recheck access.
|
|
@@ -7125,8 +7607,9 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
|
|
|
7125
7607
|
// a bot's name is quoted on the speaker line, so it gets one line; a
|
|
7126
7608
|
// user line that came through the API says so, since the reader would
|
|
7127
7609
|
// otherwise take it for the person typing
|
|
7610
|
+
const person = m.sender?.name ?? userName;
|
|
7128
7611
|
const speaker = m.role === "user"
|
|
7129
|
-
? m.via === "api" ? `${
|
|
7612
|
+
? m.via === "api" ? `${person} (sent through the local API, not typed)` : person
|
|
7130
7613
|
: m.from ? peerName(m.from.name) : "Bot";
|
|
7131
7614
|
const line = `${speaker}: ${transcriptText(rendered, messagesById, userName)}`;
|
|
7132
7615
|
// A room reply is the room talking. A post_to_room message is another
|
|
@@ -7246,10 +7729,25 @@ setupRetry = 0) {
|
|
|
7246
7729
|
let retainRoomVmLease = false;
|
|
7247
7730
|
let roomSpeaker;
|
|
7248
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;
|
|
7249
7736
|
const releaseRoomVmLease = () => {
|
|
7250
7737
|
if (roomVmTarget && localVmThreadTargets.get(threadId) === roomVmTarget)
|
|
7251
7738
|
releaseLocalVmThread(threadId);
|
|
7252
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;
|
|
7253
7751
|
releaseTurnResources(resourceOwner);
|
|
7254
7752
|
};
|
|
7255
7753
|
let roomHandoffSourceSucceeded = false;
|
|
@@ -7267,6 +7765,9 @@ setupRetry = 0) {
|
|
|
7267
7765
|
if ((hop < MAX_COMMS_DEPTH || orchestration?.roomHandoffId) && instance.adapter.capabilities.agentsMcp === true) {
|
|
7268
7766
|
integrations.agents = agentsIntegration(bot.id, threadId, hop, skillAuthoring, internalGeneration, orchestration?.roomHandoffId, !orchestration || Boolean(orchestration.roomHandoffId));
|
|
7269
7767
|
}
|
|
7768
|
+
if (instance.adapter.capabilities.hooks === true && hooksEnabled()) {
|
|
7769
|
+
integrations.hooks = hooksIntegration(bot.id, threadId, internalGeneration);
|
|
7770
|
+
}
|
|
7270
7771
|
const latestUser = [...store.activePath(threadId)].reverse().find((message) => message.role === "user" && message.kind === "text" && message.text);
|
|
7271
7772
|
const resolvedLatestImages = latestUser?.text && !cardContinuation
|
|
7272
7773
|
? extractTurnImages(latestUser.text)
|
|
@@ -7420,11 +7921,10 @@ setupRetry = 0) {
|
|
|
7420
7921
|
readyBot.browser !== false &&
|
|
7421
7922
|
instance.adapter.capabilities.browserMcp === true,
|
|
7422
7923
|
});
|
|
7423
|
-
//
|
|
7424
|
-
//
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
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");
|
|
7428
7928
|
}
|
|
7429
7929
|
// One place per room turn as well: a team computer reached on Auto means
|
|
7430
7930
|
// no separate built-in browser.
|
|
@@ -7451,6 +7951,56 @@ setupRetry = 0) {
|
|
|
7451
7951
|
integrations.computer = attached.integration;
|
|
7452
7952
|
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.driverKind === "boxAgent" });
|
|
7453
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
|
+
}
|
|
7454
8004
|
// Room and Goal turns use the speaker's desktop, never the coordinator's.
|
|
7455
8005
|
// Claim the same lease as direct turns before asynchronous VM setup.
|
|
7456
8006
|
if (readyBot.computer === "vm") {
|
|
@@ -7577,9 +8127,9 @@ setupRetry = 0) {
|
|
|
7577
8127
|
const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
|
|
7578
8128
|
{ id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
|
|
7579
8129
|
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
7580
|
-
{ 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) },
|
|
7581
8131
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
|
|
7582
|
-
{ 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 }) },
|
|
7583
8133
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
7584
8134
|
{ id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
7585
8135
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
@@ -8226,9 +8776,10 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
8226
8776
|
channelMode,
|
|
8227
8777
|
queueId,
|
|
8228
8778
|
via: options.via,
|
|
8779
|
+
sender: options.sender,
|
|
8229
8780
|
});
|
|
8230
|
-
|
|
8231
|
-
|
|
8781
|
+
const titled = group.dm ? null : store.titleGroupTaskFromFirstMessage(group.id, text, threadId);
|
|
8782
|
+
const snippet = titled?.title;
|
|
8232
8783
|
const archived = members.filter((member) => member.hidden);
|
|
8233
8784
|
const mentionedArchived = mentionedBots(text, archived.map(({ name }) => ({ name })))[0];
|
|
8234
8785
|
if (mentionedArchived) {
|
|
@@ -8273,6 +8824,23 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
8273
8824
|
}
|
|
8274
8825
|
return message;
|
|
8275
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
|
+
}
|
|
8276
8844
|
const operation = beginGroupTurnOperation(groupId, threadId, goalCoordinator ? [] : responders.map((responder) => responder.id));
|
|
8277
8845
|
if (goalCoordinator) {
|
|
8278
8846
|
const runId = options.goalRunId?.trim() || `goal-${Date.now().toString(36)}-${randomUUID()}`;
|
|
@@ -9203,30 +9771,51 @@ async function localVmPayload(target) {
|
|
|
9203
9771
|
* refuses.
|
|
9204
9772
|
*/
|
|
9205
9773
|
async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
|
|
9206
|
-
let status = await containerComputerStatus(undefined, undefined, target);
|
|
9207
|
-
noteLocalVmSeen(target, status);
|
|
9208
|
-
if (!isCurrent())
|
|
9209
|
-
return status;
|
|
9210
|
-
if (status.ready || !localVmRecreatableOnDemand(status))
|
|
9211
|
-
return status;
|
|
9212
|
-
if (target.key !== SHARED_LOCAL_VM_TARGET.key) {
|
|
9213
|
-
const count = await existingPerBotLocalVmCount(status.runtime);
|
|
9214
|
-
if (!isCurrent() || count >= localVmMaxInstances(cfg))
|
|
9215
|
-
return status;
|
|
9216
|
-
}
|
|
9217
|
-
broadcast({ kind: "computer", botId, state: "provisioning" });
|
|
9218
9774
|
localVmLifecycleBusy.add(target.key);
|
|
9219
|
-
|
|
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;
|
|
9220
9783
|
try {
|
|
9221
|
-
status = await
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
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
|
+
}
|
|
9227
9815
|
}
|
|
9228
9816
|
finally {
|
|
9229
|
-
|
|
9817
|
+
if (ownsProvision)
|
|
9818
|
+
localVmProvisionBusy = false;
|
|
9230
9819
|
localVmLifecycleBusy.delete(target.key);
|
|
9231
9820
|
}
|
|
9232
9821
|
localVmIdleFor(target).touch();
|
|
@@ -9368,22 +9957,23 @@ function persistMcpServers(next) {
|
|
|
9368
9957
|
async function describeInstances() {
|
|
9369
9958
|
const configs = instanceConfigs(cfg);
|
|
9370
9959
|
return (await registry.describe()).map((instance) => {
|
|
9960
|
+
const entry = configs[instance.instanceId];
|
|
9961
|
+
const described = entry?.icon ? { ...instance, icon: entry.icon } : instance;
|
|
9371
9962
|
if (managedDesktop.owns(instance.instanceId))
|
|
9372
9963
|
return {
|
|
9373
|
-
...
|
|
9964
|
+
...described, readOnly: true, managed: managedDesktop.info(instance.instanceId),
|
|
9374
9965
|
install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
|
|
9375
9966
|
};
|
|
9376
|
-
const entry = configs[instance.instanceId];
|
|
9377
9967
|
if (entry?.driver !== "claudeAgent")
|
|
9378
|
-
return
|
|
9968
|
+
return described;
|
|
9379
9969
|
try {
|
|
9380
9970
|
const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
|
|
9381
|
-
return { ...
|
|
9971
|
+
return { ...described, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
|
|
9382
9972
|
}
|
|
9383
9973
|
catch {
|
|
9384
9974
|
// A malformed saved config remains a repairable shadow, never takes
|
|
9385
9975
|
// the model picker down or offers a login for the wrong directory.
|
|
9386
|
-
return { ...
|
|
9976
|
+
return { ...described, install: { ...instance.install, signInCommand: undefined } };
|
|
9387
9977
|
}
|
|
9388
9978
|
});
|
|
9389
9979
|
}
|
|
@@ -9703,6 +10293,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
9703
10293
|
},
|
|
9704
10294
|
}, keepLocked),
|
|
9705
10295
|
});
|
|
10296
|
+
const toolResults = new ToolResults();
|
|
9706
10297
|
const handleRequest = async (req, res) => {
|
|
9707
10298
|
let url;
|
|
9708
10299
|
try {
|
|
@@ -10023,10 +10614,12 @@ const handleRequest = async (req, res) => {
|
|
|
10023
10614
|
const body = await readBody(req, 1024);
|
|
10024
10615
|
if (!sharedComputersEnabled(cfg))
|
|
10025
10616
|
return json(res, 404, { error: `no route: ${method} ${path}` });
|
|
10026
|
-
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))
|
|
10027
10618
|
return json(res, 400, { error: "Invalid computer lease" });
|
|
10028
10619
|
if (body.action === "release")
|
|
10029
10620
|
sharedComputerControl.release(body.id);
|
|
10621
|
+
else if (body.action === "renew")
|
|
10622
|
+
sharedComputerControl.renew(body.id);
|
|
10030
10623
|
else
|
|
10031
10624
|
sharedComputerControl.acquire(body.id);
|
|
10032
10625
|
return json(res, 200, { ok: true });
|
|
@@ -10128,13 +10721,15 @@ const handleRequest = async (req, res) => {
|
|
|
10128
10721
|
if (!internalSender) {
|
|
10129
10722
|
return json(res, 401, { error: "unauthorized" });
|
|
10130
10723
|
}
|
|
10131
|
-
const requiredCapabilityKind = path === "/api/internal/
|
|
10132
|
-
? "
|
|
10133
|
-
: path
|
|
10134
|
-
? "
|
|
10135
|
-
: path
|
|
10136
|
-
? "
|
|
10137
|
-
: "
|
|
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";
|
|
10138
10733
|
if (internalCapability.kind !== requiredCapabilityKind) {
|
|
10139
10734
|
return json(res, 403, { error: "this internal capability cannot access that service" });
|
|
10140
10735
|
}
|
|
@@ -10226,6 +10821,27 @@ const handleRequest = async (req, res) => {
|
|
|
10226
10821
|
return json(res, 200, { status: "pending", surface: option.surface,
|
|
10227
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.` });
|
|
10228
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
|
+
}
|
|
10229
10845
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
10230
10846
|
const body = await readInternalBody();
|
|
10231
10847
|
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
|
|
@@ -10469,7 +11085,7 @@ const handleRequest = async (req, res) => {
|
|
|
10469
11085
|
return json(res, 404, { error: "no bot with that id — call list_bots and copy the exact id from the result" });
|
|
10470
11086
|
}
|
|
10471
11087
|
if (!canReachPeer(from, target)) {
|
|
10472
|
-
return json(res, 403, { error:
|
|
11088
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
10473
11089
|
}
|
|
10474
11090
|
forBot = { botId: target.id, name: target.name };
|
|
10475
11091
|
}
|
|
@@ -10840,13 +11456,13 @@ const handleRequest = async (req, res) => {
|
|
|
10840
11456
|
// hard refusal — every peer turn has an accountable sender.
|
|
10841
11457
|
const from = internalSender;
|
|
10842
11458
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
10843
|
-
return json(res, 403, { error:
|
|
11459
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
10844
11460
|
}
|
|
10845
11461
|
// The sender's allow-list, when it has one. Checked here rather than
|
|
10846
11462
|
// trusted from the roster: the tool call carries a bot id, and an id
|
|
10847
11463
|
// the model held from an earlier turn must not outlive the grant.
|
|
10848
11464
|
if (!peerAllowed(from, target.id)) {
|
|
10849
|
-
return json(res, 403, { error:
|
|
11465
|
+
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
10850
11466
|
}
|
|
10851
11467
|
const fromThreadId = internalCapability.threadId;
|
|
10852
11468
|
// Rooms are conversations too. The task-only lookup here refused every
|
|
@@ -11098,10 +11714,10 @@ const handleRequest = async (req, res) => {
|
|
|
11098
11714
|
if (!target)
|
|
11099
11715
|
return json(res, 404, { error: "no such bot" });
|
|
11100
11716
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
11101
|
-
return json(res, 403, { error:
|
|
11717
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
11102
11718
|
}
|
|
11103
11719
|
if (!peerAllowed(from, target.id)) {
|
|
11104
|
-
return json(res, 403, { error:
|
|
11720
|
+
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
11105
11721
|
}
|
|
11106
11722
|
const fromThreadId = internalCapability.threadId;
|
|
11107
11723
|
if (!connectorThread(from.id, fromThreadId)) {
|
|
@@ -11491,10 +12107,10 @@ const handleRequest = async (req, res) => {
|
|
|
11491
12107
|
return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
|
|
11492
12108
|
}
|
|
11493
12109
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
11494
|
-
return json(res, 403, { error:
|
|
12110
|
+
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
11495
12111
|
}
|
|
11496
12112
|
if (!peerAllowed(from, target.id)) {
|
|
11497
|
-
return json(res, 403, { error:
|
|
12113
|
+
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
11498
12114
|
}
|
|
11499
12115
|
const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
11500
12116
|
if (!task)
|
|
@@ -12257,8 +12873,8 @@ const handleRequest = async (req, res) => {
|
|
|
12257
12873
|
return res.end(bytes);
|
|
12258
12874
|
}
|
|
12259
12875
|
// Download one local file only when this exact stored message grants it:
|
|
12260
|
-
// a bot must render a Markdown link
|
|
12261
|
-
//
|
|
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
|
|
12262
12878
|
// branch derives conversation/workspace roots; the user branch is limited
|
|
12263
12879
|
// to OpenMausBot's private attachment directory. This is deliberately not
|
|
12264
12880
|
// a general path reader.
|
|
@@ -12276,7 +12892,7 @@ const handleRequest = async (req, res) => {
|
|
|
12276
12892
|
const message = store.messagesFor(threadId).find((candidate) => candidate.id === m[2]);
|
|
12277
12893
|
if (!message)
|
|
12278
12894
|
return json(res, 404, { error: "no such message" });
|
|
12279
|
-
if (message.kind !== "text"
|
|
12895
|
+
if (message.kind !== "text") {
|
|
12280
12896
|
return json(res, 403, { error: "that message does not share this file" });
|
|
12281
12897
|
}
|
|
12282
12898
|
const body = method === "POST" ? await readBody(req) : null;
|
|
@@ -12286,20 +12902,27 @@ const handleRequest = async (req, res) => {
|
|
|
12286
12902
|
}
|
|
12287
12903
|
const href = method === "POST"
|
|
12288
12904
|
? (typeof body.path === "string" ? body.path : "")
|
|
12289
|
-
: messageImageTargetAt(message.text, Number(rawReference));
|
|
12905
|
+
: messageImageTargetAt(message.text ?? "", Number(rawReference));
|
|
12290
12906
|
if (!href)
|
|
12291
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;
|
|
12292
12912
|
let roots;
|
|
12293
12913
|
let downloadName;
|
|
12294
|
-
if (
|
|
12295
|
-
|
|
12914
|
+
if (generatedImage) {
|
|
12915
|
+
roots = [ATTACHMENTS_DIR];
|
|
12916
|
+
}
|
|
12917
|
+
else if (message.role === "user") {
|
|
12918
|
+
downloadName = messageAttachmentName(message.text ?? "", href) ?? undefined;
|
|
12296
12919
|
if (!downloadName) {
|
|
12297
12920
|
return json(res, 403, { error: "that message does not share this file" });
|
|
12298
12921
|
}
|
|
12299
12922
|
roots = [ATTACHMENTS_DIR];
|
|
12300
12923
|
}
|
|
12301
12924
|
else {
|
|
12302
|
-
if (!messageReferencesFile(message.text, href)) {
|
|
12925
|
+
if (!messageReferencesFile(message.text ?? "", href)) {
|
|
12303
12926
|
return json(res, 403, { error: "that bot message does not link to this file" });
|
|
12304
12927
|
}
|
|
12305
12928
|
const senderId = directBot?.id ?? message.from?.botId;
|
|
@@ -12328,7 +12951,7 @@ const handleRequest = async (req, res) => {
|
|
|
12328
12951
|
});
|
|
12329
12952
|
}
|
|
12330
12953
|
const file = await openMessageFile(href, roots);
|
|
12331
|
-
if (streamsMessageImage && !file.mime.startsWith("image/")) {
|
|
12954
|
+
if ((streamsMessageImage || generatedImage) && !file.mime.startsWith("image/")) {
|
|
12332
12955
|
await file.handle.close();
|
|
12333
12956
|
return json(res, 415, { error: "only images can be previewed here" });
|
|
12334
12957
|
}
|
|
@@ -12563,8 +13186,9 @@ const handleRequest = async (req, res) => {
|
|
|
12563
13186
|
const userName = cfg.profile?.name?.trim() || "User";
|
|
12564
13187
|
const lines = [`# ${title}`, ""];
|
|
12565
13188
|
for (const msg of messages) {
|
|
13189
|
+
const sentBy = msg.sender?.name ?? userName;
|
|
12566
13190
|
const who = msg.role === "user"
|
|
12567
|
-
? msg.via === "api" ? `${
|
|
13191
|
+
? msg.via === "api" ? `${sentBy} (via the local API)` : sentBy
|
|
12568
13192
|
: (msg.from?.name ?? bot?.name ?? "Bot");
|
|
12569
13193
|
if (msg.kind === "text" && msg.text)
|
|
12570
13194
|
lines.push(`**${who}:**`, "", msg.text, "");
|
|
@@ -12852,6 +13476,7 @@ const handleRequest = async (req, res) => {
|
|
|
12852
13476
|
schedule: routine.schedule,
|
|
12853
13477
|
durationMinutes: routine.durationMinutes,
|
|
12854
13478
|
...(routine.timeoutMinutes === undefined ? {} : { timeoutMinutes: routine.timeoutMinutes }),
|
|
13479
|
+
...(routine.overlap ? { overlap: routine.overlap } : {}),
|
|
12855
13480
|
});
|
|
12856
13481
|
createdRoutineIds.push(created.id);
|
|
12857
13482
|
}
|
|
@@ -13006,14 +13631,35 @@ const handleRequest = async (req, res) => {
|
|
|
13006
13631
|
if (channelTaskSwitchBlocked(group, m[2])) {
|
|
13007
13632
|
return json(res, 409, { error: "this channel is working or waiting on you in another task" });
|
|
13008
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" });
|
|
13009
13640
|
const switched = store.switchGroupTask(group.id, m[2]);
|
|
13010
13641
|
if (!switched)
|
|
13011
13642
|
return json(res, 404, { error: "no such channel task" });
|
|
13012
|
-
const
|
|
13013
|
-
|
|
13014
|
-
|
|
13015
|
-
|
|
13016
|
-
|
|
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) };
|
|
13017
13663
|
return json(res, 200, { group: responseGroup });
|
|
13018
13664
|
}
|
|
13019
13665
|
if (m && method === "PATCH") {
|
|
@@ -13052,6 +13698,8 @@ const handleRequest = async (req, res) => {
|
|
|
13052
13698
|
lastReply.delete(m[2]);
|
|
13053
13699
|
cancelTeamSetupResumesForThread(m[2]);
|
|
13054
13700
|
const updated = store.deleteGroupTask(group.id, m[2]);
|
|
13701
|
+
if (updated)
|
|
13702
|
+
clearTurnDigestState(m[2]);
|
|
13055
13703
|
if (!updated)
|
|
13056
13704
|
return json(res, 400, { error: "a channel keeps at least one task" });
|
|
13057
13705
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
@@ -13094,6 +13742,7 @@ const handleRequest = async (req, res) => {
|
|
|
13094
13742
|
for (const threadId of threadIds) {
|
|
13095
13743
|
cancelTeamSetupResumesForThread(threadId);
|
|
13096
13744
|
lastReply.delete(threadId);
|
|
13745
|
+
clearTurnDigestState(threadId);
|
|
13097
13746
|
}
|
|
13098
13747
|
routines.disableForGroup(group.id);
|
|
13099
13748
|
store.deleteGroup(group.id);
|
|
@@ -13190,7 +13839,7 @@ const handleRequest = async (req, res) => {
|
|
|
13190
13839
|
});
|
|
13191
13840
|
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
13192
13841
|
}
|
|
13193
|
-
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) });
|
|
13194
13843
|
return { ok: true, threadId, message };
|
|
13195
13844
|
});
|
|
13196
13845
|
return json(res, 202, receipt);
|
|
@@ -14612,12 +15261,24 @@ const handleRequest = async (req, res) => {
|
|
|
14612
15261
|
});
|
|
14613
15262
|
return json(res, 200, { message: patched });
|
|
14614
15263
|
}
|
|
14615
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/messages
|
|
15264
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/messages(?:\/(guarded))?$/);
|
|
14616
15265
|
if (m && method === "POST") {
|
|
15266
|
+
const guarded = m[2] === "guarded";
|
|
14617
15267
|
const body = await readBody(req);
|
|
14618
15268
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
14619
15269
|
return json(res, 400, { error: "body must be a JSON object" });
|
|
14620
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
|
+
}
|
|
14621
15282
|
const text = String(body.text ?? "").trim();
|
|
14622
15283
|
if (!text)
|
|
14623
15284
|
return json(res, 400, { error: "text required" });
|
|
@@ -14632,7 +15293,8 @@ const handleRequest = async (req, res) => {
|
|
|
14632
15293
|
// receipt after a task switch, while a genuinely new send still has to
|
|
14633
15294
|
// target the task that is active now.
|
|
14634
15295
|
const threadId = body.threadId ?? bot.threadId;
|
|
14635
|
-
|
|
15296
|
+
if (!guarded)
|
|
15297
|
+
noteTurnTrigger(threadId, auth);
|
|
14636
15298
|
// The send is acknowledged before the turn starts, so a workspace at its
|
|
14637
15299
|
// spend limit is refused here, where the person can see it.
|
|
14638
15300
|
try {
|
|
@@ -14679,6 +15341,23 @@ const handleRequest = async (req, res) => {
|
|
|
14679
15341
|
if (!store.taskByThread(currentAtStart.id, threadId)) {
|
|
14680
15342
|
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
14681
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
|
+
}
|
|
14682
15361
|
// Claude can accept the message inside its live turn. If the write
|
|
14683
15362
|
// loses a race with turn settlement, or the engine cannot steer, the
|
|
14684
15363
|
// existing server-side queue records it atomically for the next turn.
|
|
@@ -14733,13 +15412,14 @@ const handleRequest = async (req, res) => {
|
|
|
14733
15412
|
replyToId: replyTo?.id,
|
|
14734
15413
|
sendId,
|
|
14735
15414
|
steered: true,
|
|
15415
|
+
sender: messageSender(auth),
|
|
14736
15416
|
});
|
|
14737
15417
|
// Offered to the next turn again unless the person stops this one.
|
|
14738
15418
|
handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
|
|
14739
15419
|
return { ok: true, steered: true, threadId, message };
|
|
14740
15420
|
}
|
|
14741
15421
|
if (!current.busy) {
|
|
14742
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
|
|
15422
|
+
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
|
|
14743
15423
|
}
|
|
14744
15424
|
const queued = queueSteeredMessage(current.id, threadId, text, {
|
|
14745
15425
|
replyToId: replyTo?.id,
|
|
@@ -14748,7 +15428,7 @@ const handleRequest = async (req, res) => {
|
|
|
14748
15428
|
});
|
|
14749
15429
|
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
14750
15430
|
}
|
|
14751
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId);
|
|
15431
|
+
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
|
|
14752
15432
|
});
|
|
14753
15433
|
return json(res, 202, receipt);
|
|
14754
15434
|
}
|
|
@@ -14838,6 +15518,19 @@ const handleRequest = async (req, res) => {
|
|
|
14838
15518
|
drainQueuedSends();
|
|
14839
15519
|
return json(res, 200, { ok: true, queued: true, threadId: bot.threadId });
|
|
14840
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
|
+
}
|
|
14841
15534
|
// edit a user message → fork the conversation there and rerun the turn.
|
|
14842
15535
|
// Rewinding a live thread is refused, exactly like switching versions
|
|
14843
15536
|
// below: interrupting mid-flight and branching under the dying turn is
|
|
@@ -15187,14 +15880,26 @@ const handleRequest = async (req, res) => {
|
|
|
15187
15880
|
return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
|
|
15188
15881
|
}
|
|
15189
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" });
|
|
15190
15887
|
const switched = store.switchTask(bot.id, m[2]);
|
|
15191
15888
|
if (!switched)
|
|
15192
15889
|
return json(res, 404, { error: "no such task" });
|
|
15193
|
-
const
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
|
|
15197
|
-
:
|
|
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) };
|
|
15198
15903
|
return json(res, 200, { bot: responseBot });
|
|
15199
15904
|
}
|
|
15200
15905
|
if (m && method === "PATCH") {
|
|
@@ -15326,6 +16031,7 @@ const handleRequest = async (req, res) => {
|
|
|
15326
16031
|
const updated = store.deleteTask(m[1], m[2]);
|
|
15327
16032
|
if (!updated)
|
|
15328
16033
|
return json(res, 404, { error: "no such task" });
|
|
16034
|
+
clearTurnDigestState(m[2]);
|
|
15329
16035
|
handoffs.forget(m[2]);
|
|
15330
16036
|
settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
|
|
15331
16037
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
@@ -15349,6 +16055,11 @@ const handleRequest = async (req, res) => {
|
|
|
15349
16055
|
if (method === "GET" && action === "control" && found) {
|
|
15350
16056
|
return json(res, 200, computerControl.snapshot(teamComputerOwner(found.id)));
|
|
15351
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
|
+
}
|
|
15352
16063
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json"))
|
|
15353
16064
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
15354
16065
|
const body = await readBody(req);
|
|
@@ -15546,6 +16257,12 @@ const handleRequest = async (req, res) => {
|
|
|
15546
16257
|
if (vmOwner && (action === "stop" || action === "remove" || action === "run")) {
|
|
15547
16258
|
return json(res, 409, { error: "the Local VM is being used by a bot — stop that turn first" });
|
|
15548
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
|
+
}
|
|
15549
16266
|
if (action === "pull")
|
|
15550
16267
|
localVmImageBusy = true;
|
|
15551
16268
|
else
|
|
@@ -15573,6 +16290,7 @@ const handleRequest = async (req, res) => {
|
|
|
15573
16290
|
}
|
|
15574
16291
|
if (method === "POST" && path === "/api/local-computer/screenshot") {
|
|
15575
16292
|
localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
|
|
16293
|
+
res.setHeader("cache-control", "private, no-store");
|
|
15576
16294
|
return json(res, 200, {
|
|
15577
16295
|
image: await containerComputerScreenshot(undefined, undefined, SHARED_LOCAL_VM_TARGET),
|
|
15578
16296
|
});
|
|
@@ -15609,6 +16327,11 @@ const handleRequest = async (req, res) => {
|
|
|
15609
16327
|
const vmOwner = localVmLeaseFor(target).current(localVmOwnerBusy);
|
|
15610
16328
|
if (vmOwner)
|
|
15611
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
|
+
}
|
|
15612
16335
|
// Fence this target, and the cross-target capacity decision for creates,
|
|
15613
16336
|
// before the first await so two requests cannot both pass the limit.
|
|
15614
16337
|
localVmLifecycleBusy.add(target.key);
|
|
@@ -15657,6 +16380,7 @@ const handleRequest = async (req, res) => {
|
|
|
15657
16380
|
}
|
|
15658
16381
|
const target = localVmTargetForBot(bot.id);
|
|
15659
16382
|
localVmIdleFor(target).touch();
|
|
16383
|
+
res.setHeader("cache-control", "private, no-store");
|
|
15660
16384
|
return json(res, 200, {
|
|
15661
16385
|
image: await containerComputerScreenshot(undefined, undefined, target),
|
|
15662
16386
|
});
|
|
@@ -15665,7 +16389,7 @@ const handleRequest = async (req, res) => {
|
|
|
15665
16389
|
// child proves it is OURS by echoing its pid (a stray dev server has
|
|
15666
16390
|
// the same API shape but a different pid)
|
|
15667
16391
|
if (method === "GET" && path === "/api/health") {
|
|
15668
|
-
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 } });
|
|
15669
16393
|
}
|
|
15670
16394
|
// The bots' browser engine: install it on this machine (agent-browser +
|
|
15671
16395
|
// a Chrome for Testing, a one-time download), or ask how that is going.
|
|
@@ -15828,6 +16552,37 @@ const handleRequest = async (req, res) => {
|
|
|
15828
16552
|
const companyMutation = /^\/api\/instances\/(company\.[\w.-]+)(?:\/|$)/.exec(path);
|
|
15829
16553
|
if (companyMutation && method !== "GET")
|
|
15830
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
|
+
}
|
|
15831
16586
|
if (method === "POST" && path === "/api/instances/claude-accounts") {
|
|
15832
16587
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
15833
16588
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
@@ -17054,8 +17809,10 @@ const handleRequest = async (req, res) => {
|
|
|
17054
17809
|
try {
|
|
17055
17810
|
if (m[2] === "join")
|
|
17056
17811
|
return json(res, 200, await box.joinReadyBox(cfg, key));
|
|
17057
|
-
if (m[2] === "screenshot")
|
|
17812
|
+
if (m[2] === "screenshot") {
|
|
17813
|
+
res.setHeader("cache-control", "private, no-store");
|
|
17058
17814
|
return json(res, 200, await box.screenshotBox(cfg, key));
|
|
17815
|
+
}
|
|
17059
17816
|
return json(res, 200, await box.sleepBox(cfg, key));
|
|
17060
17817
|
}
|
|
17061
17818
|
finally {
|
|
@@ -17071,6 +17828,7 @@ const handleRequest = async (req, res) => {
|
|
|
17071
17828
|
});
|
|
17072
17829
|
vpsPreviewRequests.set(botId, preview);
|
|
17073
17830
|
}
|
|
17831
|
+
res.setHeader("cache-control", "private, no-store");
|
|
17074
17832
|
return json(res, 200, await preview);
|
|
17075
17833
|
}
|
|
17076
17834
|
// Opening the existing SSH viewer can coexist with a capture. Start,
|
|
@@ -17136,6 +17894,7 @@ const handleRequest = async (req, res) => {
|
|
|
17136
17894
|
case "exec":
|
|
17137
17895
|
return json(res, 200, await box.execOnBox(cfg, botId, boxCommand ?? ""));
|
|
17138
17896
|
case "screenshot":
|
|
17897
|
+
res.setHeader("cache-control", "private, no-store");
|
|
17139
17898
|
return json(res, 200, await box.screenshotBox(cfg, botId));
|
|
17140
17899
|
}
|
|
17141
17900
|
}
|
|
@@ -17147,7 +17906,10 @@ const handleRequest = async (req, res) => {
|
|
|
17147
17906
|
}
|
|
17148
17907
|
catch (e) {
|
|
17149
17908
|
const status = e?.status ?? 500;
|
|
17150
|
-
|
|
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 } : {}) });
|
|
17151
17913
|
}
|
|
17152
17914
|
finally {
|
|
17153
17915
|
releaseWorkspaceRequest?.();
|