openmausbot 0.1.81 → 0.1.83

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 (51) hide show
  1. package/dist/assets/{index-peLFiSH8.js → index-DSvqfBOf.js} +151 -149
  2. package/dist/assets/{index-oLMljotU.js → index-nqTFZTxg.js} +1 -1
  3. package/dist/index.html +1 -1
  4. package/dist-server/container-mcp.js +20 -4
  5. package/dist-server/drivers/agents-proxy.js +4 -4
  6. package/dist-server/index.js +1155 -511
  7. package/dist-server/mcp-gate.js +2 -1
  8. package/dist-server/openmausbot.js +210 -56
  9. package/dist-server/pair-cli.js +210 -56
  10. package/dist-server/server/auto-approve.js +15 -1
  11. package/dist-server/server/auto-vm-claims.js +22 -0
  12. package/dist-server/server/browser-bundle-release.js +8 -7
  13. package/dist-server/server/browser-engine.js +1 -1
  14. package/dist-server/server/channel-queue.js +58 -0
  15. package/dist-server/server/chief-of-staff.js +2 -2
  16. package/dist-server/server/config.js +33 -2
  17. package/dist-server/server/contracts.js +0 -7
  18. package/dist-server/server/drivers/acp/core.js +1 -1
  19. package/dist-server/server/drivers/acp/gemini.js +11 -1
  20. package/dist-server/server/drivers/acp/opencode-go.js +13 -0
  21. package/dist-server/server/drivers/acp/qwen.js +16 -1
  22. package/dist-server/server/drivers/agents-proxy.js +4 -4
  23. package/dist-server/server/drivers/claude.js +4 -3
  24. package/dist-server/server/drivers/codex.js +66 -1
  25. package/dist-server/server/drivers/pi.js +2 -1
  26. package/dist-server/server/env-path.js +6 -1
  27. package/dist-server/server/index.js +596 -97
  28. package/dist-server/server/local-routing.js +10 -4
  29. package/dist-server/server/notify.js +8 -3
  30. package/dist-server/server/peer-roster.js +37 -1
  31. package/dist-server/server/recent-work.js +4 -1
  32. package/dist-server/server/redact.js +5 -48
  33. package/dist-server/server/room-handoffs.js +79 -16
  34. package/dist-server/server/routines.js +19 -1
  35. package/dist-server/server/skill-fetch.js +1 -1
  36. package/dist-server/server/steer-queue.js +29 -0
  37. package/dist-server/server/store.js +26 -3
  38. package/dist-server/server/thread-retention.js +50 -0
  39. package/dist-server/server/turn-context.js +7 -0
  40. package/dist-server/server/turn-resources.js +13 -2
  41. package/dist-server/shared/approval-mode.js +8 -5
  42. package/dist-server/shared/inspector.js +1 -0
  43. package/dist-server/shared/json.js +1 -0
  44. package/dist-server/shared/notification.js +1 -0
  45. package/dist-server/shared/redact.js +61 -0
  46. package/dist-server/shared/routines.js +1 -0
  47. package/dist-server/shared/runtime-events.js +1 -0
  48. package/dist-server/shared/webhooks.js +1 -0
  49. package/dist-server/shared/wire.js +8 -0
  50. package/dist-server/vps-container-mcp.js +20 -4
  51. package/package.json +1 -1
@@ -2,7 +2,7 @@
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, unlinkSync } from "node:fs";
5
+ import { existsSync, readFileSync, rmSync } from "node:fs";
6
6
  import { rm as removeDirectory } from "node:fs/promises";
7
7
  import { createServer } from "node:http";
8
8
  import { extname, join } from "node:path";
@@ -16,7 +16,7 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
16
16
  import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
17
17
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
18
18
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
19
- import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval } from "./auto-approve.js";
19
+ import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
20
20
  import { updateClaudeCli } from "./claude-update.js";
21
21
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
22
22
  import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
@@ -31,15 +31,17 @@ import { groupTurnCwd } from "./room-cwd.js";
31
31
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
32
32
  import * as box from "./box.js";
33
33
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
34
+ import { isEffortLevel } from "../shared/wire.js";
34
35
  import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-idempotency.js";
35
36
  import { boxDeletionSnapshot } from "./box-delete-journal.js";
36
37
  import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
37
38
  import * as composio from "./composio.js";
38
39
  import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
39
- import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
40
+ import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
40
41
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
41
42
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
42
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, threadEventLogMaxBytes, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.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";
44
+ import { sweepThreadEventLogs } from "./thread-retention.js";
43
45
  import { ComputerControl } from "./computer-control.js";
44
46
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
45
47
  import { registerEnginesBinDir } from "./engine-install.js";
@@ -51,7 +53,7 @@ import { entitled } from "./enterprise.js";
51
53
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
52
54
  import { describeSpawnFailure, execCli } from "./procs.js";
53
55
  import { blockedTarget, buildNotification } from "./notify.js";
54
- import { isEffortLevel, isModelVariant, newId, } from "./contracts.js";
56
+ import { isModelVariant, newId, } from "./contracts.js";
55
57
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
56
58
  import { decodeGeneratedImage } from "./generated-image.js";
57
59
  import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
@@ -67,8 +69,8 @@ import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./rec
67
69
  const SESSION_READ_MAX_CHARS = 8_000;
68
70
  import { promptWithReply, transcriptText } from "./replies.js";
69
71
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
70
- import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreSteeredMessages, } from "./steer-queue.js";
71
- import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, } from "./channel-queue.js";
72
+ import { cancelSteeredMessage, drainSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
73
+ import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
72
74
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
73
75
  import { EventBus } from "./harness/bus.js";
74
76
  import { ProviderRegistry } from "./harness/registry.js";
@@ -77,10 +79,10 @@ import { selectDefaultModelSelection } from "./default-model-selection.js";
77
79
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
78
80
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
79
81
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
80
- import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
82
+ import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, toWireTask, } from "./store.js";
81
83
  import * as tts from "./tts/index.js";
82
84
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
83
- import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-context.js";
85
+ import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
84
86
  import { extractTurnImages } from "./turn-images.js";
85
87
  import { TurnWatchdog } from "./turn-watchdog.js";
86
88
  import { TurnResources, workspaceResource } from "./turn-resources.js";
@@ -135,6 +137,7 @@ import { createTeamBackup, importTeamBackup } from "./team-backup.js";
135
137
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
136
138
  import { shouldMountLocalComputer } from "./local-routing.js";
137
139
  import { autoLocalVmAttachable } from "./container-computer.js";
140
+ import { startAutoVmClaim } from "./auto-vm-claims.js";
138
141
  import { modelContextWindow } from "./model-context-window.js";
139
142
  import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
140
143
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
@@ -603,6 +606,8 @@ function claimTurnResource(owner, resource) {
603
606
  function releaseTurnResources(owner) {
604
607
  if (!owner)
605
608
  return;
609
+ if (autoVmClaims.get(owner.threadId)?.owner.generation === owner.generation)
610
+ autoVmClaims.delete(owner.threadId);
606
611
  if (settlingResourceOwners.get(owner.threadId) === owner.generation)
607
612
  settlingResourceOwners.delete(owner.threadId);
608
613
  turnResources.release(owner);
@@ -616,9 +621,39 @@ function releaseTurnResources(owner) {
616
621
  teamComputerTurns.delete(owner.threadId);
617
622
  }
618
623
  }
619
- function bindTurnComputer(owner, resource, exclusive = false) {
620
- if (exclusive && !claimTurnResource(owner, resource)) {
621
- throw Object.assign(new Error("another thread is using this computer — wait for it to finish"), { status: 409, code: "computer_busy" });
624
+ async function bindTurnComputer(owner, resource, exclusive = false) {
625
+ const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
626
+ turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
627
+ let waitingMessage;
628
+ const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
629
+ try {
630
+ while (true) {
631
+ if (!active())
632
+ throw new DirectTurnSetupCancelled("Computer wait cancelled");
633
+ if (!exclusive || claimTurnResource(owner, resource))
634
+ break;
635
+ if (!waitingMessage) {
636
+ const blocker = turnResources.blocker(resource, owner);
637
+ const holderBot = blocker && store.botByThread(blocker.threadId);
638
+ 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;
641
+ waitingMessage = store.appendMessage(owner.threadId, {
642
+ role: "bot", kind: "activity",
643
+ tool: { name: `Waiting for computer${holder ? ` — ${holder} is using it` : ""}; will continue automatically` },
644
+ ...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
645
+ });
646
+ }
647
+ if (Date.now() >= deadline)
648
+ throw new Error("Computer is still busy. Stop the turn using it, then retry.");
649
+ await new Promise(resolve => setTimeout(resolve, 100));
650
+ }
651
+ }
652
+ finally {
653
+ if (waitingMessage)
654
+ store.patchMessage(owner.threadId, waitingMessage.id, {
655
+ tool: { name: active() && turnResources.owns(resource, owner) ? "Computer available — continuing" : "Computer wait ended", ok: true },
656
+ });
622
657
  }
623
658
  turnResourceOwners.set(owner.threadId, owner);
624
659
  turnComputerResources.set(owner.threadId, { owner, resource });
@@ -646,6 +681,14 @@ function botAtThreadCapacity(botId) {
646
681
  function hasDirectDispatch(botId) {
647
682
  return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
648
683
  }
684
+ /** Routine and webhook dispatch shares startTurn's admission preconditions
685
+ * instead of waiting for whole-bot idleness: a free thread slot and no
686
+ * active group turn. A group turn blocks scheduled starts the same way it
687
+ * blocks every other turn kind; it does not consume a capacity slot. */
688
+ function unattendedDispatchState(botId) {
689
+ const bot = store.bot(botId);
690
+ return !bot ? "missing" : botAtThreadCapacity(botId) || activeGroupTurnForBot(botId) ? "busy" : "ready";
691
+ }
649
692
  function requestedTaskBot(botId, rawThreadId) {
650
693
  const profile = store.bot(botId);
651
694
  if (!profile)
@@ -1235,7 +1278,9 @@ if (browserCleanupReferencesReconciled)
1235
1278
  * than the desktop window did. Stripped here rather than at each call site
1236
1279
  * so a new broadcast cannot forget. */
1237
1280
  let activeCoordinationForThread = (_threadId) => false;
1238
- const wireTask = ({ resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, ...task }) => activeCoordinationForThread(task.threadId) && !task.busy ? { ...task, busy: true, activity: "working" } : task;
1281
+ const wireTask = (task) => activeCoordinationForThread(task.threadId) && !task.busy
1282
+ ? { ...toWireTask(task), busy: true, activity: "working" }
1283
+ : toWireTask(task);
1239
1284
  const wireBot = (bot) => {
1240
1285
  const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
1241
1286
  // An elevated selection is inert until the desktop confirms its exact
@@ -1403,7 +1448,10 @@ async function botOverview(bot) {
1403
1448
  * approval semantics require an implemented provider mapping. The trusted transition enforces
1404
1449
  * this too, but no provider dispatch or later permission callback relies on
1405
1450
  * persistence having been produced exclusively by that route. Delegation
1406
- * uses the receiving bot's grant, never the sender's — see approvalModeForOrigin. */
1451
+ * uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
1452
+ * with one deliberate exception: a Chief of Staff with Full access makes the
1453
+ * threads it delegates Full too (delegatedFullAccess), so the grant the
1454
+ * person gave the Chief covers the work the Chief hands out. */
1407
1455
  const approvalModeForTurn = (bot, peerInitiated = false) => {
1408
1456
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
1409
1457
  if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
@@ -1424,6 +1472,48 @@ function fullAccessForSource(botId, threadId) {
1424
1472
  function peerReviewRequired(bot, threadId) {
1425
1473
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
1426
1474
  }
1475
+ /** Full access flows down a Chief of Staff's delegation. The person gave the
1476
+ * Chief Full access so its work runs without prompts; a teammate stopping
1477
+ * that same work to ask defeats the grant — and in practice the person was
1478
+ * answering every one of those cards, all day, for the whole team. So a
1479
+ * teammate a Full-access Chief delegates to runs Full for that work: the
1480
+ * recipient switches, whatever its own level says. The recipient's engine
1481
+ * has to implement Full (supportsApprovalMode); otherwise the work keeps the
1482
+ * recipient's own level, as before. Only a Chief passes access on — an
1483
+ * ordinary bot's delegation still uses the recipient's setting. */
1484
+ function delegatedFullAccess(from, fromThreadId, target) {
1485
+ return delegationInheritsFullAccess({
1486
+ senderIsChief: Boolean(from.chiefOfStaff),
1487
+ senderHasFullAccess: fullAccessForSource(from.id, fromThreadId),
1488
+ sameBot: from.id === target.id,
1489
+ recipientDriverKind: registry.cliTarget(target.modelSelection.instanceId)?.driverKind,
1490
+ });
1491
+ }
1492
+ /** Make a delegated thread Full and say so in it once, so the level the
1493
+ * chip shows and the level the turns run at agree, and the person can see
1494
+ * where the access came from. Idempotent: a pair conversation is reused
1495
+ * across delegations and must not collect a chip per request. */
1496
+ function grantDelegatedFullAccess(from, target, threadId) {
1497
+ if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
1498
+ return;
1499
+ store.patchTask(target.id, threadId, { approvalMode: "full", autoApprove: false, alwaysAllow: [] });
1500
+ store.appendMessage(threadId, {
1501
+ role: "bot",
1502
+ kind: "activity",
1503
+ tool: { name: `Full access — delegated by ${from.name}, a Chief of Staff with Full access`, ok: true },
1504
+ });
1505
+ }
1506
+ /** A room member's level for one turn. Work a Full-access Chief hands out
1507
+ * in a room runs Full for that turn: the room thread is shared, so the
1508
+ * level is not stored on it — it rides the handoff. */
1509
+ function roomTurnApprovalMode(bot, orchestration) {
1510
+ const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
1511
+ const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
1512
+ const from = source ? store.bot(source.botId) : undefined;
1513
+ if (from && source && delegatedFullAccess(from, source.threadId, bot))
1514
+ return "full";
1515
+ return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId));
1516
+ }
1427
1517
  /** Privileged approval-mode transitions are deliberately absent from the
1428
1518
  * loopback HTTP authority model: a bot with shell access can curl that
1429
1519
  * surface itself. Only Electron's private utility-process channel can deliver
@@ -2230,7 +2320,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
2230
2320
  await tracked;
2231
2321
  return { ok: result.outcome === "settled", text: result.stopReason || result.replyText || result.outcome || "The addressed agent could not run" };
2232
2322
  },
2233
- });
2323
+ }, Date.now, roomHandoffLimits(cfg));
2234
2324
  activeCoordinationForThread = threadId => roomHandoffs.activeDirect(threadId);
2235
2325
  function publicGroupState(group) {
2236
2326
  return { ...group, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
@@ -2943,10 +3033,12 @@ const watchdog = new TurnWatchdog({
2943
3033
  groupSpeakers.delete(turn.threadId);
2944
3034
  store.patchGroup(group.id, { busyBotId: null, unread: true });
2945
3035
  }
3036
+ // A cleared UI busy flag must not strand this finished generation's
3037
+ // computer claim. The generation checks above protect replacements.
3038
+ releaseTurnResources(stalledResourceOwner);
2946
3039
  const currentBot = store.bot(turn.botId);
2947
3040
  if (currentBot?.busy) {
2948
3041
  stopScreenPoller(currentBot.id, turn.threadId);
2949
- releaseTurnResources(stalledResourceOwner);
2950
3042
  if (activeVpsThreads.get(currentBot.id) === turn.threadId)
2951
3043
  activeVpsThreads.delete(currentBot.id);
2952
3044
  if (store.taskByThread(currentBot.id, turn.threadId))
@@ -3120,6 +3212,15 @@ const localVmLeases = new LocalVmLeasePool(30 * 60_000);
3120
3212
  const localVmLifecycleBusy = new Set();
3121
3213
  const localVmThreadTargets = new Map();
3122
3214
  const localVmActiveThreads = new Map();
3215
+ // Lazy Auto-VM claims (issue #1361): a thread whose auto-resolved Local VM
3216
+ // attach deferred the exclusive claim registers here so the first screen
3217
+ // tools/call gate can fire it. Dispatch claims eagerly today, so entries
3218
+ // exist only as a no-op handoff to the gate.
3219
+ const autoVmClaims = new Map();
3220
+ /** How long the computer-control gate lets a lazy claim land before it
3221
+ * answers held. A free, ready VM claims in the time of one container
3222
+ * inspect; only a claim queued behind another holder outlives this. */
3223
+ const LAZY_VM_CLAIM_GRACE_MS = 5_000;
3123
3224
  /** Local VM targets this process has seen ready or recreatable — at boot, in
3124
3225
  * the inventory, or in a turn. Auto probes the container runtime for a VM
3125
3226
  * only when one of these exists, so an ordinary Auto turn on a machine with
@@ -3223,12 +3324,12 @@ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
3223
3324
  throw new Error("The team computer is being changed; wait for it to finish");
3224
3325
  if (computerControl.snapshot(ownerId).held)
3225
3326
  throw new Error("Release human control of the team computer before starting another turn");
3226
- bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
3327
+ await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
3227
3328
  teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
3228
3329
  let machine = await box.findBox(cfg, ownerId);
3229
3330
  if (!machine)
3230
3331
  throw new Error("The team's Box computer is missing; explicitly create or retry it from the Team map");
3231
- bindTurnComputer(owner, `computer:box:${machine.id}`, true);
3332
+ await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
3232
3333
  const action = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
3233
3334
  if (action === "wake")
3234
3335
  machine = await box.readyBox(cfg, ownerId);
@@ -3545,6 +3646,10 @@ function localVmIdleFor(target) {
3545
3646
  return idle;
3546
3647
  }
3547
3648
  function releaseLocalVmThread(threadId) {
3649
+ // Covers lazy claims too (issue #1361): every settle path funnels through
3650
+ // here or through releaseTurnResources, so a turn that ends before its
3651
+ // first screen call leaves no claim slot behind.
3652
+ autoVmClaims.delete(threadId);
3548
3653
  const target = localVmThreadTargets.get(threadId);
3549
3654
  if (!target)
3550
3655
  return;
@@ -4975,6 +5080,17 @@ async function startTurn(botId, text, opts) {
4975
5080
  // branch only — abandoned forks never reach the model
4976
5081
  const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]);
4977
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
+ }
4978
5094
  // A flat reply may deliberately point across a fork in the same thread.
4979
5095
  // Resolve its quote from full storage, while the replay itself remains
4980
5096
  // strictly limited to the selected branch below.
@@ -4985,7 +5101,8 @@ async function startTurn(botId, text, opts) {
4985
5101
  .map((m) => ({
4986
5102
  role: m.role === "user" ? "user" : "assistant",
4987
5103
  text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
4988
- : transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"),
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"),
4989
5106
  }));
4990
5107
  // After a rewind (edit / branch switch) the provider's native session
4991
5108
  // still contains the abandoned branch: start a fresh session instead of
@@ -5183,6 +5300,79 @@ async function startTurn(botId, text, opts) {
5183
5300
  let browserCapture = null;
5184
5301
  let computerKind = null;
5185
5302
  let autoVpsProblem = null;
5303
+ /** The Local VM frame capture for the poller and the settled transcript
5304
+ * screenshot. The shared desktop outlives the turn: once another thread
5305
+ * owns it, a capture still in flight would picture ITS work under this
5306
+ * bot's name — live and in the settled frame, which is taken after the
5307
+ * lease is already released. No owner means the desktop is simply
5308
+ * idle: that final frame is ours to keep. */
5309
+ const localVmPreviewFor = (localVmTarget, claimThreadId) => () => {
5310
+ const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
5311
+ if (owner && owner.threadId !== claimThreadId) {
5312
+ throw new Error("the Local VM moved on to another turn");
5313
+ }
5314
+ return containerComputerFrame(undefined, undefined, localVmTarget);
5315
+ };
5316
+ /** The exclusive Local VM claim sequence, verbatim from the old inline
5317
+ * attach path, shared by dispatch (eager) and the first-screen-call
5318
+ * gate (issue #1361). Idempotent per turn: the resource claim and the
5319
+ * lease both re-assert the same owner, so a re-entrant call from the
5320
+ * gate no-ops once dispatch has already claimed. A lazy attach pins
5321
+ * the target it mounted the tools against: the claim must lease that
5322
+ * desktop, not whatever localVmTargetForBot resolves to by the time
5323
+ * the first screen call arrives. */
5324
+ const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
5325
+ const localVmTarget = pinnedTarget ?? localVmTargetForBot(bot.id);
5326
+ await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
5327
+ if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
5328
+ throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
5329
+ }
5330
+ // Claim before the first await. The lifecycle route performs its
5331
+ // matching check synchronously, so neither side can enter while the
5332
+ // other is between inspection and mutation.
5333
+ if (!localVmLeaseFor(localVmTarget).claim(claimThreadId, bot.id, localVmOwnerBusy)) {
5334
+ throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
5335
+ }
5336
+ localVmThreadTargets.set(claimThreadId, localVmTarget);
5337
+ localVmActiveThreads.set(localVmTarget.key, claimThreadId);
5338
+ localVmIdleFor(localVmTarget).touch();
5339
+ // The lease is held from here. An eager attach that fails below
5340
+ // fails the turn and settle releases it; a lazy claim's rejection is
5341
+ // swallowed into the slot's failed flag and the turn carries on, so
5342
+ // without this the exclusive lease would sit held for the rest of a
5343
+ // turn that never got the VM — the very serialisation #1361 removes.
5344
+ const dropLease = () => {
5345
+ localVmLeaseFor(localVmTarget).release(claimThreadId);
5346
+ if (localVmActiveThreads.get(localVmTarget.key) === claimThreadId)
5347
+ localVmActiveThreads.delete(localVmTarget.key);
5348
+ localVmThreadTargets.delete(claimThreadId);
5349
+ // bindTurnComputer above also took the turn-level resource; a
5350
+ // later turn's exclusive bind queues behind it just the same.
5351
+ const resource = `computer:vm:${localVmTarget.key}`;
5352
+ turnResources.releaseOne(resource, resourceOwner);
5353
+ if (turnComputerResources.get(resourceOwner.threadId)?.resource === resource)
5354
+ turnComputerResources.delete(resourceOwner.threadId);
5355
+ };
5356
+ let localVm;
5357
+ try {
5358
+ localVm = await readyLocalVmForTurn(bot.id, localVmTarget);
5359
+ }
5360
+ catch (error) {
5361
+ dropLease();
5362
+ throw error;
5363
+ }
5364
+ if (!localVm.ready || !localVm.runtime) {
5365
+ dropLease();
5366
+ throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
5367
+ }
5368
+ // Same contract as the Box and VPS branches below: without this the
5369
+ // poller never starts, so the Local VM publishes no `screen` events
5370
+ // and every client that only has the stream (the phone) waits
5371
+ // forever. The web panel hid the gap by polling the screenshot
5372
+ // route itself.
5373
+ previewCapture = localVmPreviewFor(localVmTarget, claimThreadId);
5374
+ return { target: localVmTarget, runtime: localVm.runtime };
5375
+ };
5186
5376
  // Explicit destinations are strict. In particular, Local VM must never
5187
5377
  // fall through to host CUA and accidentally click on the user's Mac.
5188
5378
  // The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
@@ -5197,6 +5387,7 @@ async function startTurn(botId, text, opts) {
5197
5387
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
5198
5388
  }
5199
5389
  const localVmTarget = localVmTargetForBot(bot.id);
5390
+ let lazyReadyVm = null;
5200
5391
  if (!strict) {
5201
5392
  // Nothing this process has ever seen for this target, and nobody is
5202
5393
  // relying on an unattended run: do not pay for a runtime probe.
@@ -5207,43 +5398,50 @@ async function startTurn(botId, text, opts) {
5207
5398
  return false;
5208
5399
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key))
5209
5400
  return false;
5401
+ if (seen.ready && seen.runtime)
5402
+ lazyReadyVm = { runtime: seen.runtime };
5210
5403
  }
5211
5404
  try {
5212
- bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
5213
- if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
5214
- throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
5215
- }
5216
- // Claim before the first await. The lifecycle route performs its
5217
- // matching check synchronously, so neither side can enter while the
5218
- // other is between inspection and mutation.
5219
- if (!localVmLeaseFor(localVmTarget).claim(threadId, bot.id, localVmOwnerBusy)) {
5220
- throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
5221
- }
5222
- localVmThreadTargets.set(threadId, localVmTarget);
5223
- localVmActiveThreads.set(localVmTarget.key, threadId);
5224
- localVmIdleFor(localVmTarget).touch();
5225
- const localVm = await readyLocalVmForTurn(bot.id, localVmTarget);
5226
- if (!localVm.ready || !localVm.runtime) {
5227
- throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
5405
+ if (lazyReadyVm) {
5406
+ // Lazy exclusivity (issue #1361): a VM that is ready right now
5407
+ // mounts without claiming — screen-less Auto turns never touch
5408
+ // the lease, and the first screen tools/call fires the claim
5409
+ // through the computer-control gate. A VM that must be created
5410
+ // or recreated first keeps the eager claim below: the bridge
5411
+ // child needs the container to exist, and readyLocalVmForTurn
5412
+ // is what boots it.
5413
+ integrations.localComputer = containerComputerMcp(lazyReadyVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
5414
+ autoVmClaims.set(threadId, {
5415
+ owner: resourceOwner,
5416
+ lazy: true,
5417
+ claim: async () => {
5418
+ await claimAutoLocalVm(threadId, localVmTarget);
5419
+ // The dispatch-site poller start saw a null previewCapture
5420
+ // (this lazy mount runs before any claim exists), so this
5421
+ // turn would publish no live `screen` events and settle no
5422
+ // final computer frame. Restart the poller with the now-live
5423
+ // computer capture, keeping any browser capture and whether
5424
+ // this turn already touched its screen. Same still-running
5425
+ // guard as dispatch: a poller started after its own
5426
+ // turn.completed would never be torn down.
5427
+ if (previewCapture && threadBusy(bot.id, threadId)) {
5428
+ const touched = screenPollers.get(threadId)?.touched ?? instance.driverKind === "boxAgent";
5429
+ stopScreenPoller(bot.id, threadId);
5430
+ startScreenPoller(bot.id, threadId, { computer: previewCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
5431
+ }
5432
+ },
5433
+ });
5434
+ return true;
5228
5435
  }
5229
- integrations.localComputer = containerComputerMcp(localVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
5230
- // Same contract as the Box and VPS branches below: without this the
5231
- // poller never starts, so the Local VM publishes no `screen` events
5232
- // and every client that only has the stream (the phone) waits
5233
- // forever. The web panel hid the gap by polling the screenshot
5234
- // route itself.
5235
- previewCapture = () => {
5236
- // The shared desktop outlives the turn. Once another thread owns
5237
- // it, a capture still in flight would picture ITS work under this
5238
- // bot's name — live and in the settled transcript frame, which is
5239
- // taken after the lease is already released. No owner means the
5240
- // desktop is simply idle: that final frame is ours to keep.
5241
- const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
5242
- if (owner && owner.threadId !== threadId) {
5243
- throw new Error("the Local VM moved on to another turn");
5244
- }
5245
- return containerComputerFrame(undefined, undefined, localVmTarget);
5246
- };
5436
+ const claimed = await claimAutoLocalVm(threadId);
5437
+ integrations.localComputer = containerComputerMcp(claimed.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), claimed.target);
5438
+ // Hand the same claim to the first-screen-call gate. This eager
5439
+ // path has already claimed, so the gate's fire-once call can only
5440
+ // re-assert the same owner — a no-op (issue #1361).
5441
+ autoVmClaims.set(threadId, {
5442
+ owner: resourceOwner,
5443
+ claim: async () => { await claimAutoLocalVm(threadId); },
5444
+ });
5247
5445
  return true;
5248
5446
  }
5249
5447
  catch (error) {
@@ -5263,12 +5461,16 @@ async function startTurn(botId, text, opts) {
5263
5461
  hostPlatform: process.platform,
5264
5462
  providerSupportsLocal: mountsLocalComputer,
5265
5463
  })) {
5266
- throw new Error("this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
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");
5267
5469
  }
5268
5470
  const cua = readCuaConnection();
5269
5471
  if (!cua)
5270
5472
  throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
5271
- bindTurnComputer(resourceOwner, "computer:host");
5473
+ await bindTurnComputer(resourceOwner, "computer:host");
5272
5474
  integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
5273
5475
  computerKind = "local";
5274
5476
  }
@@ -5284,7 +5486,7 @@ async function startTurn(botId, text, opts) {
5284
5486
  if (!unsupported) {
5285
5487
  // The remote lifecycle and container are shared by this bot. Keep
5286
5488
  // its explicit computer turns serialized; ordinary threads still run.
5287
- bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
5489
+ await bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
5288
5490
  activeVpsThreads.set(bot.id, threadId);
5289
5491
  let remote;
5290
5492
  remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
@@ -5322,7 +5524,7 @@ async function startTurn(botId, text, opts) {
5322
5524
  // Explicit cloud turns can provision/wake the same bot's Box. Claim
5323
5525
  // before any network await so setup itself cannot race another turn.
5324
5526
  if (wants === "cloud")
5325
- bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
5527
+ await bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
5326
5528
  if (!mountsCloudComputer && wants === "cloud") {
5327
5529
  throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
5328
5530
  }
@@ -5366,7 +5568,7 @@ async function startTurn(botId, text, opts) {
5366
5568
  });
5367
5569
  }
5368
5570
  if (b && lifecycle === "attach") {
5369
- bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
5571
+ await bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
5370
5572
  previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
5371
5573
  if (mountsCloudComputer) {
5372
5574
  integrations.computer = {
@@ -5407,7 +5609,7 @@ async function startTurn(botId, text, opts) {
5407
5609
  })) {
5408
5610
  const cua = readCuaConnection();
5409
5611
  if (cua) {
5410
- bindTurnComputer(resourceOwner, "computer:host");
5612
+ await bindTurnComputer(resourceOwner, "computer:host");
5411
5613
  integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
5412
5614
  computerKind = "local";
5413
5615
  }
@@ -5771,6 +5973,8 @@ function routineRunCard(run) {
5771
5973
  };
5772
5974
  if (run.goalStatus)
5773
5975
  card.goalStatus = run.goalStatus;
5976
+ if (run.deferredAt != null && run.status === "queued")
5977
+ card.deferredAt = run.deferredAt;
5774
5978
  if (run.threadId)
5775
5979
  card.executionThreadId = run.threadId;
5776
5980
  if (summary)
@@ -5801,7 +6005,9 @@ function routineRunFallbackText(card) {
5801
6005
  ? "was cancelled"
5802
6006
  : card.status === "missed"
5803
6007
  ? "was missed"
5804
- : card.status);
6008
+ : card.status === "queued" && card.deferredAt != null
6009
+ ? "deferred: target busy"
6010
+ : card.status);
5805
6011
  return `Routine “${card.routineName}” ${state}`;
5806
6012
  }
5807
6013
  /** Upsert one durable lifecycle card per run. Replaying the same transition,
@@ -5922,10 +6128,7 @@ routines = new RoutineManager({
5922
6128
  hasPendingDelegations: (threadId) => pendingThreads().includes(threadId) ||
5923
6129
  [...delegationWatch.values()].some((watch) => watch.sourceThreadId === threadId) ||
5924
6130
  pendingDelegationWakes.has(threadId),
5925
- botState: (botId) => {
5926
- const bot = store.bot(botId);
5927
- return !bot ? "missing" : bot.busy ? "busy" : "ready";
5928
- },
6131
+ botState: unattendedDispatchState,
5929
6132
  goalState: (groupId, coordinatorBotId) => {
5930
6133
  const group = store.group(groupId);
5931
6134
  const coordinator = store.bot(coordinatorBotId);
@@ -6005,6 +6208,17 @@ routines = new RoutineManager({
6005
6208
  const notificationBot = routineSourceOwner(run)?.bot ?? bot;
6006
6209
  notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
6007
6210
  },
6211
+ onRunDeferred: (run) => {
6212
+ const bot = store.bot(run.botId);
6213
+ if (!bot)
6214
+ return;
6215
+ const minutes = run.deferredAt != null && run.deferredNoticeAt != null
6216
+ ? Math.max(1, Math.round((run.deferredNoticeAt - run.deferredAt) / 60_000))
6217
+ : null;
6218
+ const detail = `${redactSecretsInText(run.routineName)}: target busy${minutes != null ? ` for ${minutes} minutes` : ""}`;
6219
+ const notificationBot = routineSourceOwner(run)?.bot ?? bot;
6220
+ notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
6221
+ },
6008
6222
  });
6009
6223
  // The scheduler receipt and room transcript live in separate durable stores.
6010
6224
  // If the process exited between those two writes, prefer the correlated
@@ -6295,12 +6509,6 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
6295
6509
  const acknowledged = await browserCleanup.ensure(committedCleanup);
6296
6510
  requireBrowserCleanupAcknowledged(acknowledged, `Browser data for ${bot.name}`);
6297
6511
  }
6298
- for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
6299
- try {
6300
- unlinkSync(join(dir, `${bot.threadId}.ndjson`));
6301
- }
6302
- catch { }
6303
- }
6304
6512
  return deletionResponse(200, { ok: true });
6305
6513
  }
6306
6514
  finally {
@@ -6609,10 +6817,7 @@ function resolveAndSendProfile(res, args) {
6609
6817
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
6610
6818
  const webhooks = new WebhookManager({
6611
6819
  emit: broadcast,
6612
- botState: (botId) => {
6613
- const bot = store.bot(botId);
6614
- return !bot ? "missing" : bot.busy ? "busy" : "ready";
6615
- },
6820
+ botState: unattendedDispatchState,
6616
6821
  enqueue: (input) => routines.enqueueWebhook(input),
6617
6822
  findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
6618
6823
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
@@ -6747,7 +6952,11 @@ setupRetry = 0) {
6747
6952
  }
6748
6953
  revokeInternalCapabilitiesForThread(threadId);
6749
6954
  spoken.add(botId);
6750
- const preparedApprovalMode = approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId));
6955
+ // Must be the SAME resolver the readiness re-check uses below, or a Chief's
6956
+ // delegated Full elevation makes the two disagree by construction: every
6957
+ // such room turn then reads as "settings changed", retries once, and
6958
+ // settles as busy without ever dispatching.
6959
+ const preparedApprovalMode = roomTurnApprovalMode(bot, orchestration);
6751
6960
  const preparedSelection = { ...bot.modelSelection };
6752
6961
  const preparedComposio = bot.composio;
6753
6962
  const instance = turnInstance(bot);
@@ -6875,7 +7084,7 @@ setupRetry = 0) {
6875
7084
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
6876
7085
  return false;
6877
7086
  const setupChanged = turnInstance(readyBot) !== instance ||
6878
- approvalModeForTurn(readyBot, Boolean(orchestration?.roomHandoffId)) !== preparedApprovalMode ||
7087
+ roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
6879
7088
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
6880
7089
  readyBot.modelSelection.model !== preparedSelection.model ||
6881
7090
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -7009,7 +7218,7 @@ setupRetry = 0) {
7009
7218
  }
7010
7219
  // A distinct identity fences cleanup even in shared mode on the same room thread.
7011
7220
  const target = { ...localVmTargetForBot(readyBot.id) };
7012
- bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
7221
+ await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
7013
7222
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
7014
7223
  throw new Error("this Local VM is being started, stopped, or replaced");
7015
7224
  }
@@ -7248,7 +7457,7 @@ setupRetry = 0) {
7248
7457
  text,
7249
7458
  refreshSystemPrompt: true,
7250
7459
  images: turnImages,
7251
- approvalMode: approvalModeForTurn(readyBot, Boolean(orchestration?.roomHandoffId)),
7460
+ approvalMode: roomTurnApprovalMode(readyBot, orchestration),
7252
7461
  system: roomSystem.text,
7253
7462
  systemStable: roomSystem.stable,
7254
7463
  systemVolatile: roomSystem.volatile,
@@ -7449,6 +7658,8 @@ setupRetry = 0) {
7449
7658
  catch (error) {
7450
7659
  if (providerDispatched)
7451
7660
  throw error;
7661
+ if (error instanceof DirectTurnSetupCancelled)
7662
+ return false;
7452
7663
  const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
7453
7664
  const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
7454
7665
  if (!roomSpeaker && !isSpendCap && !isVariantError)
@@ -10359,19 +10570,27 @@ const handleRequest = async (req, res) => {
10359
10570
  if (method === "POST" && path === "/api/internal/ask-bot") {
10360
10571
  const body = await readInternalBody();
10361
10572
  const fromBotId = internalSender.id;
10362
- const toBotId = String(body.toBotId ?? "");
10573
+ const toBotRef = String(body.toBotId ?? "");
10363
10574
  const message = String(body.message ?? "").trim();
10364
10575
  if (body.depth !== undefined &&
10365
10576
  (!Number.isInteger(body.depth) || body.depth < 0 || body.depth !== internalCapability.depth)) {
10366
10577
  return json(res, 403, { error: "the recursion depth does not match this turn" });
10367
10578
  }
10368
10579
  const depth = internalCapability.depth;
10369
- if (!toBotId || !message)
10580
+ if (!toBotRef || !message)
10370
10581
  return json(res, 400, { error: "toBotId and message required" });
10371
- if (toBotId === fromBotId)
10582
+ if (toBotRef === fromBotId)
10372
10583
  return json(res, 400, { error: "a bot cannot message itself" });
10373
10584
  if (depth >= MAX_COMMS_DEPTH)
10374
10585
  return json(res, 200, { error: "message chains are limited to one hop" });
10586
+ // A unique reachable teammate name is accepted where an id is
10587
+ // expected; see resolveTeammate for why.
10588
+ const resolvedTo = resolveTeammate(store.bots, internalSender, toBotRef);
10589
+ if ("error" in resolvedTo)
10590
+ return json(res, 404, { error: `no such bot: ${resolvedTo.error}` });
10591
+ if (resolvedTo.id === fromBotId)
10592
+ return json(res, 400, { error: "a bot cannot message itself" });
10593
+ const toBotId = resolvedTo.id;
10375
10594
  const target = store.bot(toBotId);
10376
10595
  if (!target)
10377
10596
  return json(res, 404, { error: "no such bot" });
@@ -10574,7 +10793,7 @@ const handleRequest = async (req, res) => {
10574
10793
  }
10575
10794
  if (method === "POST" && path === "/api/internal/delegate-bot") {
10576
10795
  const body = await readInternalBody();
10577
- const toBotId = String(body.toBotId ?? "");
10796
+ const toBotRef = String(body.toBotId ?? "");
10578
10797
  const message = String(body.message ?? "").trim();
10579
10798
  const reason = typeof body.reason === "string" && body.reason.trim() ? body.reason.trim() : undefined;
10580
10799
  if (body.depth !== undefined &&
@@ -10582,9 +10801,13 @@ const handleRequest = async (req, res) => {
10582
10801
  return json(res, 403, { error: "the recursion depth does not match this turn" });
10583
10802
  }
10584
10803
  const depth = internalCapability.depth;
10585
- if (!toBotId || !message)
10804
+ if (!toBotRef || !message)
10586
10805
  return json(res, 400, { error: "toBotId and message required" });
10587
10806
  const from = internalSender;
10807
+ const resolvedTo = resolveTeammate(store.bots, from, toBotRef);
10808
+ if ("error" in resolvedTo)
10809
+ return json(res, 404, { error: `no such bot: ${resolvedTo.error}` });
10810
+ const toBotId = resolvedTo.id;
10588
10811
  const target = store.bot(toBotId);
10589
10812
  if (!target)
10590
10813
  return json(res, 404, { error: "no such bot" });
@@ -10655,11 +10878,35 @@ const handleRequest = async (req, res) => {
10655
10878
  const destination = groupId ? store.group(groupId) : undefined;
10656
10879
  if (groupId && !destination)
10657
10880
  return json(res, 404, { error: "No such room; use list_room_targets." });
10658
- const targets = parsed.data.botIds.map(botId => ({ groupId: destination?.id,
10881
+ // A slot may carry a teammate's name instead of its id — the
10882
+ // roster shows both, list_bots shows both, and a Chief reading its
10883
+ // prompt reaches for the name. A unique reachable name resolves;
10884
+ // anything else is refused with the id or name the caller sent
10885
+ // and the way to the real ids (peer-roster.ts).
10886
+ const botIds = [];
10887
+ for (const raw of parsed.data.botIds) {
10888
+ const resolved = resolveTeammate(store.bots, internalSender, raw);
10889
+ if ("error" in resolved)
10890
+ return json(res, 403, { error: resolved.error });
10891
+ botIds.push(resolved.id);
10892
+ }
10893
+ if (new Set(botIds).size !== botIds.length)
10894
+ return json(res, 400, { error: "bot_ids name the same teammate twice — send each teammate once" });
10895
+ const targets = botIds.map(botId => ({ groupId: destination?.id,
10659
10896
  threadId: destination ? destination.id === source?.id ? address.threadId : destination.threadId : store.bot(botId)?.threadId ?? "", botId,
10660
10897
  }));
10661
10898
  for (const target of targets) {
10662
- const eligibility = target.botId === internalSender.id ? "Choose a teammate, not yourself" : roomHandoffProblem(target, address);
10899
+ // roomHandoffProblem's "no longer exists" is written for a route
10900
+ // that was valid and went away. Here the id is the model's own
10901
+ // argument — usually a display name dropped into a bot_ids slot —
10902
+ // so say which id failed and where the real ones are, instead of
10903
+ // telling the model a teammate it can still reach is gone. Only
10904
+ // the id the caller sent is echoed back, never a bot's name.
10905
+ const addressed = store.bot(target.botId);
10906
+ const eligibility = target.botId === internalSender.id ? "Choose a teammate, not yourself"
10907
+ : !addressed ? `No bot with id "${target.botId}" — call list_bots and copy the exact id from the result`
10908
+ : addressed.hidden ? `The bot with id "${target.botId}" is no longer available — call list_bots for the ones you can reach`
10909
+ : roomHandoffProblem(target, address);
10663
10910
  if (eligibility)
10664
10911
  return json(res, 403, { error: eligibility });
10665
10912
  }
@@ -10695,6 +10942,9 @@ const handleRequest = async (req, res) => {
10695
10942
  target.threadId = resolved.task.threadId;
10696
10943
  if (resolved.created)
10697
10944
  createdThread = resolved.task.threadId;
10945
+ if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
10946
+ grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
10947
+ }
10698
10948
  }
10699
10949
  const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "");
10700
10950
  // A re-dispatched request_key is answered by the request it
@@ -10963,6 +11213,8 @@ const handleRequest = async (req, res) => {
10963
11213
  const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
10964
11214
  if (!task)
10965
11215
  return json(res, 500, { error: "couldn't create that thread" });
11216
+ if (delegatedFullAccess(from, fromThreadId, target))
11217
+ grantDelegatedFullAccess(from, target, task.threadId);
10966
11218
  const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
10967
11219
  if (queued.result !== "ok" || !queued.id) {
10968
11220
  // nothing will ever run there: take the row back before the person
@@ -11209,6 +11461,48 @@ const handleRequest = async (req, res) => {
11209
11461
  return json(res, 404, { error: "no such bot" });
11210
11462
  if (method === "GET") {
11211
11463
  const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
11464
+ const slot = autoVmClaims.get(internalCapability.threadId);
11465
+ const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
11466
+ if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
11467
+ // First screen tools/call on a lazily-attached Auto VM (issue
11468
+ // #1361): fire the exclusive claim — once — and give it a moment
11469
+ // to land. A free, ready VM claims in the time of one container
11470
+ // inspect, so this call then proceeds with an honest answer;
11471
+ // only a claim still queued behind another holder answers held
11472
+ // below, and then the contention text is true. Keyed on the
11473
+ // slot, never on the thread's turn-computer entry: a bind this
11474
+ // turn abandoned earlier (a VPS that turned out to be asleep)
11475
+ // must not hide the unclaimed VM and let the call through.
11476
+ startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
11477
+ await Promise.race([
11478
+ lazyClaim.begin ?? Promise.resolve(),
11479
+ new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
11480
+ ]);
11481
+ }
11482
+ if (!snapshot.held && lazyClaim?.failed === true) {
11483
+ // A rejected lazy claim (gate finding F1, issue #1361): the
11484
+ // computer MCP mounted at dispatch is still live, and the claim
11485
+ // may even have left a turn-computer entry behind (it can reject
11486
+ // after bindTurnComputer succeeded — lease lost to a person,
11487
+ // lifecycle busy, boot failure). Either way this turn owns no
11488
+ // usable VM, so keep refusing every screen call for the rest of
11489
+ // the generation; the bridge must never forward one onto a VM
11490
+ // this turn never claimed. Turn settle GC clears the slot. Say
11491
+ // why, and say not to retry: the contention text would send the
11492
+ // model into a screenshot loop against a claim that cannot land.
11493
+ return json(res, 200, {
11494
+ 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.`,
11496
+ });
11497
+ }
11498
+ if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
11499
+ // The claim fired and is still waiting on the exclusive bind:
11500
+ // another turn genuinely holds this desktop right now.
11501
+ return json(res, 200, {
11502
+ held: true, helpOpen: false,
11503
+ blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
11504
+ });
11505
+ }
11212
11506
  const computer = turnComputerResources.get(internalCapability.threadId);
11213
11507
  if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
11214
11508
  !claimTurnResource(computer.owner, computer.resource)) {
@@ -12518,14 +12812,6 @@ const handleRequest = async (req, res) => {
12518
12812
  routines.disableForGroup(group.id);
12519
12813
  store.deleteGroup(group.id);
12520
12814
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
12521
- for (const threadId of threadIds) {
12522
- for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
12523
- try {
12524
- unlinkSync(join(dir, `${threadId}.ndjson`));
12525
- }
12526
- catch { }
12527
- }
12528
- }
12529
12815
  return json(res, 200, { ok: true });
12530
12816
  }
12531
12817
  m = path.match(/^\/api\/groups\/([\w-]+)\/messages$/);
@@ -12633,6 +12919,112 @@ const handleRequest = async (req, res) => {
12633
12919
  }
12634
12920
  return json(res, 200, { ok: true });
12635
12921
  }
12922
+ // Steer a queued room message into the RUNNING room turn (no interrupt).
12923
+ // Only the head steers — room queues drain one item at a time — and the
12924
+ // engine that receives it is the thread's live speaker. A room whose
12925
+ // running driver cannot steer keeps its queue, exactly like an incapable
12926
+ // 1:1 engine; this never ends the running turn.
12927
+ m = path.match(/^\/api\/groups\/([\w-]+)\/queue\/([\w-]+)\/steer$/);
12928
+ if (m && method === "POST") {
12929
+ const body = await readBody(req);
12930
+ if (body !== null && (typeof body !== "object" || Array.isArray(body))) {
12931
+ return json(res, 400, { error: "body must be a JSON object" });
12932
+ }
12933
+ const threadId = typeof body?.threadId === "string" ? body.threadId : undefined;
12934
+ if (threadId !== undefined && !/^[\w-]+$/.test(threadId)) {
12935
+ return json(res, 400, { error: "threadId must be a task id" });
12936
+ }
12937
+ const group = store.group(m[1]);
12938
+ if (!group)
12939
+ return json(res, 404, { error: "no such room" });
12940
+ const targetThreadId = threadId ?? group.threadId;
12941
+ const ownsThread = group.dm
12942
+ ? group.threadId === targetThreadId
12943
+ : Boolean(store.groupTaskByThread(group.id, targetThreadId));
12944
+ if (!ownsThread) {
12945
+ return json(res, 409, { error: "the channel switched tasks before it could receive the message" });
12946
+ }
12947
+ noteTurnTrigger(targetThreadId, auth);
12948
+ const current = store.group(group.id);
12949
+ if (!current)
12950
+ return json(res, 404, { error: "no such room" });
12951
+ // Which engine owns the running room turn on this thread? The same
12952
+ // resolution the room's own Stop uses: the live speaker, else the busy
12953
+ // bot on the channel's main thread.
12954
+ const speakerBotId = groupSpeakers.get(targetThreadId)?.botId ??
12955
+ (targetThreadId === current.threadId ? current.busyBotId : undefined);
12956
+ const speaker = speakerBotId ? store.bot(speakerBotId) : undefined;
12957
+ const instance = speaker ? runningTurnInstance(speaker, targetThreadId) : undefined;
12958
+ // Lift the queue atomically: the room settling can drain it as the
12959
+ // next follow-up, or this request can steer its head into the live
12960
+ // turn — never both for the same words.
12961
+ const held = holdChannelQueue(current.id, targetThreadId, m[2]);
12962
+ if (!held)
12963
+ return json(res, 404, { error: "no such queued message" });
12964
+ if (!speaker || !instance?.adapter.capabilities.queueing || !instance.adapter.steer) {
12965
+ restoreHeldChannelQueue(held);
12966
+ return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
12967
+ }
12968
+ const [head] = held.items;
12969
+ if (!head || head.id !== m[2]) {
12970
+ restoreHeldChannelQueue(held);
12971
+ return json(res, 409, { error: "only the first queued message can steer" });
12972
+ }
12973
+ // A reply target that cannot be resolved restores the held queue
12974
+ // before the request fails — the room's normal drain keeps the head.
12975
+ const replyTo = resolveHeldReplyTarget(held, resolveReplyTarget);
12976
+ const steered = await instance.adapter
12977
+ .steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
12978
+ .catch(() => "indeterminate");
12979
+ // The steer was awaited adapter work: re-read every ownership
12980
+ // invariant before writing anything, exactly like the 1:1 path. A
12981
+ // speaker change, a channel switch, or a settled room restores the
12982
+ // queue instead of recording words the new turn never saw.
12983
+ const after = store.group(current.id);
12984
+ const afterSpeakerBotId = after
12985
+ ? groupSpeakers.get(targetThreadId)?.botId ??
12986
+ (targetThreadId === after.threadId ? after.busyBotId : undefined)
12987
+ : undefined;
12988
+ // "indeterminate" (timeout after delivery, lost transport, a settle
12989
+ // race) never restores: the words may already be folded into the turn
12990
+ // that was live when they were sent, and replaying them into a new
12991
+ // turn would run them twice. Record them once — even under a new
12992
+ // speaker — and settle the head.
12993
+ const delivered = steered !== "refused";
12994
+ if (after && delivered && (steered === "indeterminate" || afterSpeakerBotId === speakerBotId)) {
12995
+ const message = store.appendMessage(targetThreadId, {
12996
+ role: "user",
12997
+ kind: "text",
12998
+ text: head.text,
12999
+ replyToId: head.replyToId,
13000
+ sendId: head.sendId,
13001
+ channelMode: head.mode,
13002
+ queueId: head.id,
13003
+ via: head.via,
13004
+ steered: true,
13005
+ });
13006
+ settleHeldChannelQueueHead(held);
13007
+ return json(res, 200, {
13008
+ ok: true,
13009
+ steered: true,
13010
+ threadId: targetThreadId,
13011
+ messages: [message],
13012
+ queueIds: [head.id],
13013
+ });
13014
+ }
13015
+ if (steered === "indeterminate" && !after) {
13016
+ // The room vanished while the answer was lost: settle the head so a
13017
+ // restart cannot replay words the dead turn may already have run.
13018
+ settleHeldChannelQueueHead(held);
13019
+ return json(res, 404, { error: "no such room" });
13020
+ }
13021
+ restoreHeldChannelQueue(held);
13022
+ // The room may have settled while the steer was refused; a queue that
13023
+ // is now drainable must not strand behind a missed settle.
13024
+ if (after && !groupIsWorking(after))
13025
+ drainQueuedChannelSends();
13026
+ return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
13027
+ }
12636
13028
  m = path.match(/^\/api\/groups\/([\w-]+)\/interrupt$/);
12637
13029
  if (m && method === "POST") {
12638
13030
  const group = store.group(m[1]);
@@ -14006,7 +14398,7 @@ const handleRequest = async (req, res) => {
14006
14398
  // existing server-side queue records it atomically for the next turn.
14007
14399
  if (currentAtStart.busy) {
14008
14400
  const instance = runningTurnInstance(currentAtStart, threadId);
14009
- let steered = false;
14401
+ let steered = "refused";
14010
14402
  // A live text steer has no image side channel. Keep an attachment
14011
14403
  // message intact for the next ordinary turn, where central image
14012
14404
  // admission can hand it to the provider natively.
@@ -14014,7 +14406,7 @@ const handleRequest = async (req, res) => {
14014
14406
  if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
14015
14407
  steered = await instance.adapter
14016
14408
  .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
14017
- .catch(() => false);
14409
+ .catch(() => "indeterminate");
14018
14410
  }
14019
14411
  // steer() is awaited adapter work. The turn can settle, the task can
14020
14412
  // switch, or the whole bot can be deleted before its acknowledgement
@@ -14028,10 +14420,15 @@ const handleRequest = async (req, res) => {
14028
14420
  if (!store.taskByThread(bot.id, threadId)) {
14029
14421
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
14030
14422
  }
14031
- if (steered) {
14032
- if (!current.busy) {
14423
+ const delivered = steered !== "refused";
14424
+ if (delivered) {
14425
+ if (steered === "steered" && !current.busy) {
14033
14426
  throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
14034
14427
  }
14428
+ // "indeterminate" falls through to the same record: the words
14429
+ // may already be folded into a turn whose acknowledgement was
14430
+ // lost, and handing them back for a resend could run them
14431
+ // twice. Recording them once is the honest outcome.
14035
14432
  // A person steering a webhook turn is present, and auto mode may
14036
14433
  // follow them again. But this route is also reachable from the
14037
14434
  // bot's own shell on a headless server (loopback is the owner
@@ -14077,6 +14474,77 @@ const handleRequest = async (req, res) => {
14077
14474
  }
14078
14475
  return json(res, 200, { ok: true });
14079
14476
  }
14477
+ // Steer a queued message into the RUNNING turn (no interrupt). Engines
14478
+ // without a live steer keep the queue; this never ends the current turn.
14479
+ m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)\/steer$/);
14480
+ if (m && method === "POST") {
14481
+ const body = await readBody(req);
14482
+ requirePinnedClientThread(m[1], body?.threadId);
14483
+ const bot = requestedTaskBot(m[1], body?.threadId);
14484
+ noteTurnTrigger(bot.threadId, auth);
14485
+ // Lift the whole queue atomically: a settle racing this request can
14486
+ // drain it as a follow-up, or this request can steer it into the live
14487
+ // turn — never both for the same words.
14488
+ const held = holdSteeredQueue(bot.id, bot.threadId, m[2]);
14489
+ if (!held)
14490
+ return json(res, 404, { error: "no such queued message" });
14491
+ // A live steer has no image side channel. Attachment words wait for a
14492
+ // real turn where central admission can hand the images to the engine.
14493
+ if (held.items.some((item) => extractTurnImages(item.text).images.length > 0)) {
14494
+ restoreHeldSteeredQueue(held);
14495
+ return json(res, 200, { ok: true, queued: true, threadId: bot.threadId });
14496
+ }
14497
+ const currentAtStart = store.projectBotForTask(bot.id, bot.threadId);
14498
+ const instance = currentAtStart?.busy ? runningTurnInstance(currentAtStart, bot.threadId) : undefined;
14499
+ const prompt = held.items.map((item) => item.prompt).join("\n\n");
14500
+ let steered = "refused";
14501
+ if (currentAtStart?.busy && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
14502
+ steered = await instance.adapter
14503
+ .steer(bot.threadId, prompt)
14504
+ .catch(() => "indeterminate");
14505
+ }
14506
+ // The steer was awaited adapter work: re-read every ownership
14507
+ // invariant before writing anything, exactly like the live-send path.
14508
+ const current = store.projectBotForTask(bot.id, bot.threadId);
14509
+ // "indeterminate" never restores: the words may already be folded into
14510
+ // the turn that was live when they were sent, and replaying them into
14511
+ // a fresh follow-up turn would run them twice. Record them whenever
14512
+ // the destination still exists, busy or not.
14513
+ if ((steered === "steered" && current?.busy ||
14514
+ steered === "indeterminate" && current) &&
14515
+ store.taskByThread(bot.id, bot.threadId)) {
14516
+ if (auth.kind === "session" || DESKTOP_MANAGED)
14517
+ clearUnattended(bot.threadId);
14518
+ const messages = held.items.map((item) => store.appendMessage(bot.threadId, {
14519
+ role: "user",
14520
+ kind: "text",
14521
+ text: item.text,
14522
+ replyToId: item.replyToId,
14523
+ sendId: item.sendId,
14524
+ queueId: item.messageId,
14525
+ peerAsk: item.peerAsk,
14526
+ steered: true,
14527
+ }));
14528
+ const queueIds = held.items.map((item) => item.messageId);
14529
+ settleHeldSteeredQueue(held);
14530
+ return json(res, 200, { ok: true, steered: true, threadId: bot.threadId, messages, queueIds });
14531
+ }
14532
+ if (steered === "indeterminate") {
14533
+ // No destination is left: settle so a restart cannot replay words a
14534
+ // dead turn may already have run.
14535
+ settleHeldSteeredQueue(held);
14536
+ if (!store.taskByThread(bot.id, bot.threadId)) {
14537
+ throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
14538
+ }
14539
+ throw Object.assign(new Error("no such bot"), { status: 404 });
14540
+ }
14541
+ restoreHeldSteeredQueue(held);
14542
+ // The turn may have settled while the steer was refused; a queue that
14543
+ // is now drainable must not strand behind a missed settle.
14544
+ if (current && !current.busy)
14545
+ drainQueuedSends();
14546
+ return json(res, 200, { ok: true, queued: true, threadId: bot.threadId });
14547
+ }
14080
14548
  // edit a user message → fork the conversation there and rerun the turn.
14081
14549
  // Rewinding a live thread is refused, exactly like switching versions
14082
14550
  // below: interrupting mid-flight and branching under the dying turn is
@@ -16415,6 +16883,37 @@ try {
16415
16883
  catch (error) {
16416
16884
  console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
16417
16885
  }
16886
+ // #1280: retention for per-thread event logs. Off unless configured, and
16887
+ // even then it only removes log files — transcripts, thread records, and
16888
+ // workspace state stay untouched. A thread qualifies only when its newest
16889
+ // close or archive stamp is older than the window and it is not busy,
16890
+ // unread, or carrying an open direct handoff.
16891
+ const THREAD_LOG_RETENTION_SWEEP_MS = 24 * 60 * 60 * 1000;
16892
+ function sweepThreadEventLogsNow() {
16893
+ const retentionDays = threadEventLogRetentionDays(cfg);
16894
+ if (retentionDays === null)
16895
+ return;
16896
+ const candidates = store.bots.flatMap((bot) => (bot.tasks ?? []).map((task) => ({
16897
+ threadId: task.threadId,
16898
+ closedAt: task.closedBy?.at ?? null,
16899
+ archivedAt: task.archivedAt ?? null,
16900
+ unread: task.unread === true,
16901
+ busy: threadBusy(bot.id, task.threadId),
16902
+ openDirectHandoff: roomHandoffs.activeDirect(task.threadId),
16903
+ })));
16904
+ const swept = sweepThreadEventLogs(candidates, retentionDays);
16905
+ if (swept > 0)
16906
+ console.log(`[retention] removed event logs for ${swept} idle thread(s) past ${retentionDays} day(s)`);
16907
+ }
16908
+ try {
16909
+ sweepThreadEventLogsNow();
16910
+ }
16911
+ catch (error) {
16912
+ console.warn(`thread event log retention sweep failed: ${error instanceof Error ? error.message : String(error)}`);
16913
+ }
16914
+ // A days-scale window needs no tighter cadence; unref so the timer never
16915
+ // holds the process open.
16916
+ setInterval(sweepThreadEventLogsNow, THREAD_LOG_RETENTION_SWEEP_MS).unref();
16418
16917
  // A dispatch claim is deliberately committed before transcript/provider work.
16419
16918
  // If we died after that point, its outcome is unknown: recover the user's words
16420
16919
  // and a review notice, never hand them to a model for a second execution.