openmausbot 0.1.82 → 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 (47) hide show
  1. package/dist/assets/{index-BLLsDK2F.js → index-DSvqfBOf.js} +71 -71
  2. package/dist/assets/{index-5HVwp5m2.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 +1063 -493
  7. package/dist-server/mcp-gate.js +2 -1
  8. package/dist-server/openmausbot.js +187 -55
  9. package/dist-server/pair-cli.js +187 -55
  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/gemini.js +11 -1
  19. package/dist-server/server/drivers/acp/qwen.js +16 -1
  20. package/dist-server/server/drivers/agents-proxy.js +4 -4
  21. package/dist-server/server/drivers/claude.js +4 -3
  22. package/dist-server/server/drivers/codex.js +66 -1
  23. package/dist-server/server/drivers/pi.js +2 -1
  24. package/dist-server/server/env-path.js +6 -1
  25. package/dist-server/server/index.js +534 -84
  26. package/dist-server/server/local-routing.js +10 -4
  27. package/dist-server/server/peer-roster.js +37 -1
  28. package/dist-server/server/recent-work.js +4 -1
  29. package/dist-server/server/redact.js +5 -48
  30. package/dist-server/server/room-handoffs.js +79 -16
  31. package/dist-server/server/skill-fetch.js +1 -1
  32. package/dist-server/server/steer-queue.js +29 -0
  33. package/dist-server/server/store.js +26 -3
  34. package/dist-server/server/thread-retention.js +50 -0
  35. package/dist-server/server/turn-context.js +7 -0
  36. package/dist-server/server/turn-resources.js +6 -0
  37. package/dist-server/shared/approval-mode.js +8 -5
  38. package/dist-server/shared/inspector.js +1 -0
  39. package/dist-server/shared/json.js +1 -0
  40. package/dist-server/shared/notification.js +1 -0
  41. package/dist-server/shared/redact.js +61 -0
  42. package/dist-server/shared/routines.js +1 -0
  43. package/dist-server/shared/runtime-events.js +1 -0
  44. package/dist-server/shared/webhooks.js +1 -0
  45. package/dist-server/shared/wire.js +8 -0
  46. package/dist-server/vps-container-mcp.js +20 -4
  47. 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);
@@ -676,6 +681,14 @@ function botAtThreadCapacity(botId) {
676
681
  function hasDirectDispatch(botId) {
677
682
  return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
678
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
+ }
679
692
  function requestedTaskBot(botId, rawThreadId) {
680
693
  const profile = store.bot(botId);
681
694
  if (!profile)
@@ -1265,7 +1278,9 @@ if (browserCleanupReferencesReconciled)
1265
1278
  * than the desktop window did. Stripped here rather than at each call site
1266
1279
  * so a new broadcast cannot forget. */
1267
1280
  let activeCoordinationForThread = (_threadId) => false;
1268
- 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);
1269
1284
  const wireBot = (bot) => {
1270
1285
  const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
1271
1286
  // An elevated selection is inert until the desktop confirms its exact
@@ -1433,7 +1448,10 @@ async function botOverview(bot) {
1433
1448
  * approval semantics require an implemented provider mapping. The trusted transition enforces
1434
1449
  * this too, but no provider dispatch or later permission callback relies on
1435
1450
  * persistence having been produced exclusively by that route. Delegation
1436
- * 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. */
1437
1455
  const approvalModeForTurn = (bot, peerInitiated = false) => {
1438
1456
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
1439
1457
  if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
@@ -1454,6 +1472,48 @@ function fullAccessForSource(botId, threadId) {
1454
1472
  function peerReviewRequired(bot, threadId) {
1455
1473
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
1456
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
+ }
1457
1517
  /** Privileged approval-mode transitions are deliberately absent from the
1458
1518
  * loopback HTTP authority model: a bot with shell access can curl that
1459
1519
  * surface itself. Only Electron's private utility-process channel can deliver
@@ -2260,7 +2320,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
2260
2320
  await tracked;
2261
2321
  return { ok: result.outcome === "settled", text: result.stopReason || result.replyText || result.outcome || "The addressed agent could not run" };
2262
2322
  },
2263
- });
2323
+ }, Date.now, roomHandoffLimits(cfg));
2264
2324
  activeCoordinationForThread = threadId => roomHandoffs.activeDirect(threadId);
2265
2325
  function publicGroupState(group) {
2266
2326
  return { ...group, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
@@ -3152,6 +3212,15 @@ const localVmLeases = new LocalVmLeasePool(30 * 60_000);
3152
3212
  const localVmLifecycleBusy = new Set();
3153
3213
  const localVmThreadTargets = new Map();
3154
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;
3155
3224
  /** Local VM targets this process has seen ready or recreatable — at boot, in
3156
3225
  * the inventory, or in a turn. Auto probes the container runtime for a VM
3157
3226
  * only when one of these exists, so an ordinary Auto turn on a machine with
@@ -3577,6 +3646,10 @@ function localVmIdleFor(target) {
3577
3646
  return idle;
3578
3647
  }
3579
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);
3580
3653
  const target = localVmThreadTargets.get(threadId);
3581
3654
  if (!target)
3582
3655
  return;
@@ -5007,6 +5080,17 @@ async function startTurn(botId, text, opts) {
5007
5080
  // branch only — abandoned forks never reach the model
5008
5081
  const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]);
5009
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
+ }
5010
5094
  // A flat reply may deliberately point across a fork in the same thread.
5011
5095
  // Resolve its quote from full storage, while the replay itself remains
5012
5096
  // strictly limited to the selected branch below.
@@ -5017,7 +5101,8 @@ async function startTurn(botId, text, opts) {
5017
5101
  .map((m) => ({
5018
5102
  role: m.role === "user" ? "user" : "assistant",
5019
5103
  text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
5020
- : 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"),
5021
5106
  }));
5022
5107
  // After a rewind (edit / branch switch) the provider's native session
5023
5108
  // still contains the abandoned branch: start a fresh session instead of
@@ -5215,6 +5300,79 @@ async function startTurn(botId, text, opts) {
5215
5300
  let browserCapture = null;
5216
5301
  let computerKind = null;
5217
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
+ };
5218
5376
  // Explicit destinations are strict. In particular, Local VM must never
5219
5377
  // fall through to host CUA and accidentally click on the user's Mac.
5220
5378
  // The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
@@ -5229,6 +5387,7 @@ async function startTurn(botId, text, opts) {
5229
5387
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
5230
5388
  }
5231
5389
  const localVmTarget = localVmTargetForBot(bot.id);
5390
+ let lazyReadyVm = null;
5232
5391
  if (!strict) {
5233
5392
  // Nothing this process has ever seen for this target, and nobody is
5234
5393
  // relying on an unattended run: do not pay for a runtime probe.
@@ -5239,43 +5398,50 @@ async function startTurn(botId, text, opts) {
5239
5398
  return false;
5240
5399
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key))
5241
5400
  return false;
5401
+ if (seen.ready && seen.runtime)
5402
+ lazyReadyVm = { runtime: seen.runtime };
5242
5403
  }
5243
5404
  try {
5244
- await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
5245
- if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
5246
- throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
5247
- }
5248
- // Claim before the first await. The lifecycle route performs its
5249
- // matching check synchronously, so neither side can enter while the
5250
- // other is between inspection and mutation.
5251
- if (!localVmLeaseFor(localVmTarget).claim(threadId, bot.id, localVmOwnerBusy)) {
5252
- throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
5253
- }
5254
- localVmThreadTargets.set(threadId, localVmTarget);
5255
- localVmActiveThreads.set(localVmTarget.key, threadId);
5256
- localVmIdleFor(localVmTarget).touch();
5257
- const localVm = await readyLocalVmForTurn(bot.id, localVmTarget);
5258
- if (!localVm.ready || !localVm.runtime) {
5259
- 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;
5260
5435
  }
5261
- integrations.localComputer = containerComputerMcp(localVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
5262
- // Same contract as the Box and VPS branches below: without this the
5263
- // poller never starts, so the Local VM publishes no `screen` events
5264
- // and every client that only has the stream (the phone) waits
5265
- // forever. The web panel hid the gap by polling the screenshot
5266
- // route itself.
5267
- previewCapture = () => {
5268
- // The shared desktop outlives the turn. Once another thread owns
5269
- // it, a capture still in flight would picture ITS work under this
5270
- // bot's name — live and in the settled transcript frame, which is
5271
- // taken after the lease is already released. No owner means the
5272
- // desktop is simply idle: that final frame is ours to keep.
5273
- const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
5274
- if (owner && owner.threadId !== threadId) {
5275
- throw new Error("the Local VM moved on to another turn");
5276
- }
5277
- return containerComputerFrame(undefined, undefined, localVmTarget);
5278
- };
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
+ });
5279
5445
  return true;
5280
5446
  }
5281
5447
  catch (error) {
@@ -5295,7 +5461,11 @@ async function startTurn(botId, text, opts) {
5295
5461
  hostPlatform: process.platform,
5296
5462
  providerSupportsLocal: mountsLocalComputer,
5297
5463
  })) {
5298
- 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");
5299
5469
  }
5300
5470
  const cua = readCuaConnection();
5301
5471
  if (!cua)
@@ -5958,10 +6128,7 @@ routines = new RoutineManager({
5958
6128
  hasPendingDelegations: (threadId) => pendingThreads().includes(threadId) ||
5959
6129
  [...delegationWatch.values()].some((watch) => watch.sourceThreadId === threadId) ||
5960
6130
  pendingDelegationWakes.has(threadId),
5961
- botState: (botId) => {
5962
- const bot = store.bot(botId);
5963
- return !bot ? "missing" : bot.busy ? "busy" : "ready";
5964
- },
6131
+ botState: unattendedDispatchState,
5965
6132
  goalState: (groupId, coordinatorBotId) => {
5966
6133
  const group = store.group(groupId);
5967
6134
  const coordinator = store.bot(coordinatorBotId);
@@ -6342,12 +6509,6 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
6342
6509
  const acknowledged = await browserCleanup.ensure(committedCleanup);
6343
6510
  requireBrowserCleanupAcknowledged(acknowledged, `Browser data for ${bot.name}`);
6344
6511
  }
6345
- for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
6346
- try {
6347
- unlinkSync(join(dir, `${bot.threadId}.ndjson`));
6348
- }
6349
- catch { }
6350
- }
6351
6512
  return deletionResponse(200, { ok: true });
6352
6513
  }
6353
6514
  finally {
@@ -6656,10 +6817,7 @@ function resolveAndSendProfile(res, args) {
6656
6817
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
6657
6818
  const webhooks = new WebhookManager({
6658
6819
  emit: broadcast,
6659
- botState: (botId) => {
6660
- const bot = store.bot(botId);
6661
- return !bot ? "missing" : bot.busy ? "busy" : "ready";
6662
- },
6820
+ botState: unattendedDispatchState,
6663
6821
  enqueue: (input) => routines.enqueueWebhook(input),
6664
6822
  findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
6665
6823
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
@@ -6794,7 +6952,11 @@ setupRetry = 0) {
6794
6952
  }
6795
6953
  revokeInternalCapabilitiesForThread(threadId);
6796
6954
  spoken.add(botId);
6797
- 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);
6798
6960
  const preparedSelection = { ...bot.modelSelection };
6799
6961
  const preparedComposio = bot.composio;
6800
6962
  const instance = turnInstance(bot);
@@ -6922,7 +7084,7 @@ setupRetry = 0) {
6922
7084
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
6923
7085
  return false;
6924
7086
  const setupChanged = turnInstance(readyBot) !== instance ||
6925
- approvalModeForTurn(readyBot, Boolean(orchestration?.roomHandoffId)) !== preparedApprovalMode ||
7087
+ roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
6926
7088
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
6927
7089
  readyBot.modelSelection.model !== preparedSelection.model ||
6928
7090
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -7295,7 +7457,7 @@ setupRetry = 0) {
7295
7457
  text,
7296
7458
  refreshSystemPrompt: true,
7297
7459
  images: turnImages,
7298
- approvalMode: approvalModeForTurn(readyBot, Boolean(orchestration?.roomHandoffId)),
7460
+ approvalMode: roomTurnApprovalMode(readyBot, orchestration),
7299
7461
  system: roomSystem.text,
7300
7462
  systemStable: roomSystem.stable,
7301
7463
  systemVolatile: roomSystem.volatile,
@@ -10408,19 +10570,27 @@ const handleRequest = async (req, res) => {
10408
10570
  if (method === "POST" && path === "/api/internal/ask-bot") {
10409
10571
  const body = await readInternalBody();
10410
10572
  const fromBotId = internalSender.id;
10411
- const toBotId = String(body.toBotId ?? "");
10573
+ const toBotRef = String(body.toBotId ?? "");
10412
10574
  const message = String(body.message ?? "").trim();
10413
10575
  if (body.depth !== undefined &&
10414
10576
  (!Number.isInteger(body.depth) || body.depth < 0 || body.depth !== internalCapability.depth)) {
10415
10577
  return json(res, 403, { error: "the recursion depth does not match this turn" });
10416
10578
  }
10417
10579
  const depth = internalCapability.depth;
10418
- if (!toBotId || !message)
10580
+ if (!toBotRef || !message)
10419
10581
  return json(res, 400, { error: "toBotId and message required" });
10420
- if (toBotId === fromBotId)
10582
+ if (toBotRef === fromBotId)
10421
10583
  return json(res, 400, { error: "a bot cannot message itself" });
10422
10584
  if (depth >= MAX_COMMS_DEPTH)
10423
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;
10424
10594
  const target = store.bot(toBotId);
10425
10595
  if (!target)
10426
10596
  return json(res, 404, { error: "no such bot" });
@@ -10623,7 +10793,7 @@ const handleRequest = async (req, res) => {
10623
10793
  }
10624
10794
  if (method === "POST" && path === "/api/internal/delegate-bot") {
10625
10795
  const body = await readInternalBody();
10626
- const toBotId = String(body.toBotId ?? "");
10796
+ const toBotRef = String(body.toBotId ?? "");
10627
10797
  const message = String(body.message ?? "").trim();
10628
10798
  const reason = typeof body.reason === "string" && body.reason.trim() ? body.reason.trim() : undefined;
10629
10799
  if (body.depth !== undefined &&
@@ -10631,9 +10801,13 @@ const handleRequest = async (req, res) => {
10631
10801
  return json(res, 403, { error: "the recursion depth does not match this turn" });
10632
10802
  }
10633
10803
  const depth = internalCapability.depth;
10634
- if (!toBotId || !message)
10804
+ if (!toBotRef || !message)
10635
10805
  return json(res, 400, { error: "toBotId and message required" });
10636
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;
10637
10811
  const target = store.bot(toBotId);
10638
10812
  if (!target)
10639
10813
  return json(res, 404, { error: "no such bot" });
@@ -10704,11 +10878,35 @@ const handleRequest = async (req, res) => {
10704
10878
  const destination = groupId ? store.group(groupId) : undefined;
10705
10879
  if (groupId && !destination)
10706
10880
  return json(res, 404, { error: "No such room; use list_room_targets." });
10707
- 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,
10708
10896
  threadId: destination ? destination.id === source?.id ? address.threadId : destination.threadId : store.bot(botId)?.threadId ?? "", botId,
10709
10897
  }));
10710
10898
  for (const target of targets) {
10711
- 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);
10712
10910
  if (eligibility)
10713
10911
  return json(res, 403, { error: eligibility });
10714
10912
  }
@@ -10744,6 +10942,9 @@ const handleRequest = async (req, res) => {
10744
10942
  target.threadId = resolved.task.threadId;
10745
10943
  if (resolved.created)
10746
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
+ }
10747
10948
  }
10748
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 ?? "");
10749
10950
  // A re-dispatched request_key is answered by the request it
@@ -11012,6 +11213,8 @@ const handleRequest = async (req, res) => {
11012
11213
  const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
11013
11214
  if (!task)
11014
11215
  return json(res, 500, { error: "couldn't create that thread" });
11216
+ if (delegatedFullAccess(from, fromThreadId, target))
11217
+ grantDelegatedFullAccess(from, target, task.threadId);
11015
11218
  const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
11016
11219
  if (queued.result !== "ok" || !queued.id) {
11017
11220
  // nothing will ever run there: take the row back before the person
@@ -11258,6 +11461,48 @@ const handleRequest = async (req, res) => {
11258
11461
  return json(res, 404, { error: "no such bot" });
11259
11462
  if (method === "GET") {
11260
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
+ }
11261
11506
  const computer = turnComputerResources.get(internalCapability.threadId);
11262
11507
  if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
11263
11508
  !claimTurnResource(computer.owner, computer.resource)) {
@@ -12567,14 +12812,6 @@ const handleRequest = async (req, res) => {
12567
12812
  routines.disableForGroup(group.id);
12568
12813
  store.deleteGroup(group.id);
12569
12814
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
12570
- for (const threadId of threadIds) {
12571
- for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
12572
- try {
12573
- unlinkSync(join(dir, `${threadId}.ndjson`));
12574
- }
12575
- catch { }
12576
- }
12577
- }
12578
12815
  return json(res, 200, { ok: true });
12579
12816
  }
12580
12817
  m = path.match(/^\/api\/groups\/([\w-]+)\/messages$/);
@@ -12682,6 +12919,112 @@ const handleRequest = async (req, res) => {
12682
12919
  }
12683
12920
  return json(res, 200, { ok: true });
12684
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
+ }
12685
13028
  m = path.match(/^\/api\/groups\/([\w-]+)\/interrupt$/);
12686
13029
  if (m && method === "POST") {
12687
13030
  const group = store.group(m[1]);
@@ -14055,7 +14398,7 @@ const handleRequest = async (req, res) => {
14055
14398
  // existing server-side queue records it atomically for the next turn.
14056
14399
  if (currentAtStart.busy) {
14057
14400
  const instance = runningTurnInstance(currentAtStart, threadId);
14058
- let steered = false;
14401
+ let steered = "refused";
14059
14402
  // A live text steer has no image side channel. Keep an attachment
14060
14403
  // message intact for the next ordinary turn, where central image
14061
14404
  // admission can hand it to the provider natively.
@@ -14063,7 +14406,7 @@ const handleRequest = async (req, res) => {
14063
14406
  if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
14064
14407
  steered = await instance.adapter
14065
14408
  .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
14066
- .catch(() => false);
14409
+ .catch(() => "indeterminate");
14067
14410
  }
14068
14411
  // steer() is awaited adapter work. The turn can settle, the task can
14069
14412
  // switch, or the whole bot can be deleted before its acknowledgement
@@ -14077,10 +14420,15 @@ const handleRequest = async (req, res) => {
14077
14420
  if (!store.taskByThread(bot.id, threadId)) {
14078
14421
  throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
14079
14422
  }
14080
- if (steered) {
14081
- if (!current.busy) {
14423
+ const delivered = steered !== "refused";
14424
+ if (delivered) {
14425
+ if (steered === "steered" && !current.busy) {
14082
14426
  throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
14083
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.
14084
14432
  // A person steering a webhook turn is present, and auto mode may
14085
14433
  // follow them again. But this route is also reachable from the
14086
14434
  // bot's own shell on a headless server (loopback is the owner
@@ -14126,6 +14474,77 @@ const handleRequest = async (req, res) => {
14126
14474
  }
14127
14475
  return json(res, 200, { ok: true });
14128
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
+ }
14129
14548
  // edit a user message → fork the conversation there and rerun the turn.
14130
14549
  // Rewinding a live thread is refused, exactly like switching versions
14131
14550
  // below: interrupting mid-flight and branching under the dying turn is
@@ -16464,6 +16883,37 @@ try {
16464
16883
  catch (error) {
16465
16884
  console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
16466
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();
16467
16917
  // A dispatch claim is deliberately committed before transcript/provider work.
16468
16918
  // If we died after that point, its outcome is unknown: recover the user's words
16469
16919
  // and a review notice, never hand them to a model for a second execution.