openmausbot 0.1.83 → 0.1.85

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