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.
Files changed (71) hide show
  1. package/dist/assets/index-Ba9G44HI.js +310 -0
  2. package/dist/assets/{index-DNa2umw-.js → index-CTNNoqSJ.js} +1 -1
  3. package/dist/assets/index-CkRNp7wX.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +1 -0
  6. package/dist-server/container-mcp.js +212 -7
  7. package/dist-server/drivers/agents-proxy.js +135 -12
  8. package/dist-server/enterprise/server/index.js +1 -0
  9. package/dist-server/hooks/omb-hook.js +74 -0
  10. package/dist-server/index.js +8051 -6252
  11. package/dist-server/local-computer-proxy.js +83 -1
  12. package/dist-server/local-computer.js +14 -2
  13. package/dist-server/openmausbot.js +759 -301
  14. package/dist-server/pair-cli.js +759 -301
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/agent-tool-policy.js +1 -0
  17. package/dist-server/server/bot-package.js +2 -0
  18. package/dist-server/server/box.js +23 -4
  19. package/dist-server/server/browser-engine.js +87 -10
  20. package/dist-server/server/browser-live.js +7 -5
  21. package/dist-server/server/browser-runtime.js +65 -10
  22. package/dist-server/server/checkpoints.js +62 -18
  23. package/dist-server/server/cli-prompts.js +3 -1
  24. package/dist-server/server/commands.js +27 -0
  25. package/dist-server/server/compaction-summary.js +78 -0
  26. package/dist-server/server/config.js +59 -2
  27. package/dist-server/server/context-budget.js +29 -0
  28. package/dist-server/server/context-rebuild.js +72 -0
  29. package/dist-server/server/digest.js +130 -0
  30. package/dist-server/server/drivers/acp/core.js +540 -229
  31. package/dist-server/server/drivers/agents-proxy.js +51 -12
  32. package/dist-server/server/drivers/agents-result.js +23 -0
  33. package/dist-server/server/drivers/claude.js +62 -10
  34. package/dist-server/server/drivers/codex.js +6 -0
  35. package/dist-server/server/drivers/openai-chat.js +66 -45
  36. package/dist-server/server/drivers/openai-compat.js +1 -0
  37. package/dist-server/server/hooks/omb-hook.js +103 -0
  38. package/dist-server/server/index.js +1127 -365
  39. package/dist-server/server/local-computer.js +21 -2
  40. package/dist-server/server/mcp-bridge.js +10 -2
  41. package/dist-server/server/mcp-tool-images.js +37 -0
  42. package/dist-server/server/mcp-tool-schema.js +108 -0
  43. package/dist-server/server/message-db.js +80 -21
  44. package/dist-server/server/message-file.js +3 -2
  45. package/dist-server/server/package-export.js +1 -0
  46. package/dist-server/server/peer-roster.js +4 -2
  47. package/dist-server/server/provider-icon.js +20 -0
  48. package/dist-server/server/proxy-paths.js +1 -0
  49. package/dist-server/server/request-auth.js +1 -0
  50. package/dist-server/server/room-handoffs.js +16 -2
  51. package/dist-server/server/routine-requests.js +39 -1
  52. package/dist-server/server/routines.js +43 -9
  53. package/dist-server/server/shared-computer-control.js +31 -4
  54. package/dist-server/server/steer-queue.js +6 -0
  55. package/dist-server/server/store.js +94 -14
  56. package/dist-server/server/system-prompt.js +4 -4
  57. package/dist-server/server/team-backup.js +9 -1
  58. package/dist-server/server/tool-results.js +72 -0
  59. package/dist-server/server/tts/grok.js +74 -0
  60. package/dist-server/server/tts/index.js +19 -1
  61. package/dist-server/server/webhooks.js +28 -0
  62. package/dist-server/shared/approval-mode.js +7 -1
  63. package/dist-server/shared/digest.js +1 -0
  64. package/dist-server/shared/markdown-windows-paths.js +36 -0
  65. package/dist-server/shared/provider-icon.js +110 -0
  66. package/dist-server/shared/team-backup.js +3 -0
  67. package/dist-server/vps-container-mcp.js +217 -12
  68. package/enterprise/server/index.js +1 -0
  69. package/package.json +1 -1
  70. package/dist/assets/index-BbU5REzd.js +0 -310
  71. 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
- const status = browserEngineStatus();
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
- // A direct follow-up is owed to one conversation, so it waits for that
2187
- // conversation, not for the whole bot. Bot-level busy aggregates every
2188
- // thread — including cards still waiting on the person — so one busy
2189
- // sibling thread would otherwise starve the owed resume forever while the
2190
- // UI keeps showing this thread working. Fresh work still queues behind a
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 4-minute ceiling, while room turns have a separately
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) || 4 * 60_000);
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 (queuedThreadPosition(bot.id, threadId) !== null)
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
- void Promise.race([
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
- if (threadBusy(entry.botId, threadId) || activeGroupTurnForBot(entry.botId))
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
- if (!shouldMountLocalComputer({
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
- let remote;
5703
- remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
5704
- ? await vps.vpsComputerAction("provision", cfg, bot.id)
5705
- : await vps.inspectVpsForAuto(cfg, bot.id);
5706
- if (remote?.ready && remote.sshAlias) {
5707
- const targetCfg = { ...cfg, vps: { sshAlias: remote.sshAlias } };
5708
- const vpsMcp = vps.vpsComputerMcp(targetCfg, bot.id, remote.container_id ?? undefined);
5709
- const vpsControl = controlIntegration(bot.id, threadId, dispatchClaimId);
5710
- integrations.localComputer = {
5711
- ...vpsMcp,
5712
- env: { ...vpsMcp.env, OMB_CONTROL_URL: vpsControl.url, OMB_CONTROL_TOKEN: vpsControl.token },
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(remote?.problem ?? "the VPS computer could not be created or reached");
6217
+ throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
5739
6218
  }
5740
- autoVpsProblem = remote?.problem ?? "the VPS computer could not be reached";
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
- // Explicit cloud turns can provision/wake the same bot's Box. Claim
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
- state: typeof b?.state === "string" ? b.state : null,
6235
+ remoteAgent: instance.driverKind === "boxAgent",
5775
6236
  });
5776
- if (lifecycle === "provision") {
5777
- broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
5778
- await box.provisionBox(cfg, bot.id, bot.name);
5779
- b = await box.findBox(cfg, bot.id);
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
- browserCapture = () => browserRuntime.withAgentAction(session, () => agentBrowserFrame(frame));
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: "Cloud VM needs a working Box API key in App Settings before this routine can run.",
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" ? `${userName} (sent through the local API, not typed)` : userName
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
- // Channels currently mount a team Box or a Local VM. Do not let an
7424
- // explicitly selected, unsupported destination become a tool-free turn
7425
- // that can claim to have acted on that screen.
7426
- if (!roomTeamComputer && (roomPlan.computer === "cloud" || roomPlan.computer === "local")) {
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" : null) },
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" : null, browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
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
- if (!group.dm)
8231
- store.titleGroupTaskFromFirstMessage(group.id, text, threadId);
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
- localVmProvisionBusy = true;
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 containerComputerAction("run", undefined, undefined, target);
9222
- }
9223
- catch {
9224
- // Keep the inspected status: its `problem` names the real obstacle, which
9225
- // is more use to the person than "podman run exited non-zero".
9226
- return status;
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
- localVmProvisionBusy = false;
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
- ...instance, readOnly: true, managed: managedDesktop.info(instance.instanceId),
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 instance;
9968
+ return described;
9379
9969
  try {
9380
9970
  const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
9381
- return { ...instance, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
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 { ...instance, install: { ...instance.install, signInCommand: undefined } };
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/browser/mcp"
10132
- ? "browser"
10133
- : path.startsWith("/api/internal/connectors/")
10134
- ? "connectors"
10135
- : path === "/api/internal/computer-control"
10136
- ? "computer"
10137
- : "agents";
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: "that bot belongs to a different section" });
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: "that bot belongs to a different section" });
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: "that bot is not on this bot's allowed peers — call list_bots for the ones you can reach" });
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: "that bot belongs to a different section" });
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: "that bot is not on this bot's allowed peers — call list_bots for the ones you can reach" });
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: "that bot belongs to a different section" });
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: "that bot is not on this bot's allowed peers — call list_bots for the ones you can reach" });
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 to it, while a user message must carry
12261
- // the exact standalone attachment tag written by the composer. The bot
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" || !message.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 (message.role === "user") {
12295
- downloadName = messageAttachmentName(message.text, href) ?? undefined;
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" ? `${userName} (via the local API)` : userName
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 fresh = groupWithThread(switched);
13013
- broadcast({ kind: "group", group: fresh });
13014
- const responseGroup = url.searchParams.get("messages") === "0"
13015
- ? { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) }
13016
- : fresh;
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
- noteTurnTrigger(threadId, auth);
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 fresh = botWithThread(switched);
15194
- broadcast({ kind: "bot", bot: fresh });
15195
- const responseBot = url.searchParams.get("messages") === "0"
15196
- ? { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) }
15197
- : fresh;
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
- return json(res, status, { error: e instanceof Error ? e.message : String(e) });
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?.();