openmausbot 0.1.83 → 0.1.84

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.
@@ -64,6 +64,7 @@ import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from
64
64
  import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
65
65
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
66
66
  import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
67
+ import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
67
68
  /** A session_read answer competes with the transcript for the context
68
69
  * window; a computer-use turn's output can run to hundreds of KB. */
69
70
  const SESSION_READ_MAX_CHARS = 8_000;
@@ -83,6 +84,7 @@ import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, toWir
83
84
  import * as tts from "./tts/index.js";
84
85
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
85
86
  import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
87
+ import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
86
88
  import { extractTurnImages } from "./turn-images.js";
87
89
  import { TurnWatchdog } from "./turn-watchdog.js";
88
90
  import { TurnResources, workspaceResource } from "./turn-resources.js";
@@ -138,6 +140,7 @@ import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
138
140
  import { shouldMountLocalComputer } from "./local-routing.js";
139
141
  import { autoLocalVmAttachable } from "./container-computer.js";
140
142
  import { startAutoVmClaim } from "./auto-vm-claims.js";
143
+ import { computerFreeText, computerStillBusyText, computerWaitEndedText, computerWaitingText } from "./computer-wait.js";
141
144
  import { modelContextWindow } from "./model-context-window.js";
142
145
  import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
143
146
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
@@ -625,6 +628,9 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
625
628
  const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
626
629
  turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
627
630
  let waitingMessage;
631
+ // Who holds the desktop, as the chip and the give-up error name them: a
632
+ // bot running a titled thread, or a room. Read once, when the wait begins.
633
+ let holder;
628
634
  const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
629
635
  try {
630
636
  while (true) {
@@ -636,23 +642,25 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
636
642
  const blocker = turnResources.blocker(resource, owner);
637
643
  const holderBot = blocker && store.botByThread(blocker.threadId);
638
644
  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;
645
+ const holderRoom = !holderBot && blocker ? store.groupByThread(blocker.threadId) : null;
646
+ holder = holderBot
647
+ ? { name: holderBot.name, ...(holderTask?.title ? { task: holderTask.title } : {}) }
648
+ : holderRoom ? { name: holderRoom.name } : undefined;
641
649
  waitingMessage = store.appendMessage(owner.threadId, {
642
650
  role: "bot", kind: "activity",
643
- tool: { name: `Waiting for computer${holder ? ` — ${holder} is using it` : ""}; will continue automatically` },
651
+ tool: { name: computerWaitingText(holder) },
644
652
  ...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
645
653
  });
646
654
  }
647
655
  if (Date.now() >= deadline)
648
- throw new Error("Computer is still busy. Stop the turn using it, then retry.");
656
+ throw new Error(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
649
657
  await new Promise(resolve => setTimeout(resolve, 100));
650
658
  }
651
659
  }
652
660
  finally {
653
661
  if (waitingMessage)
654
662
  store.patchMessage(owner.threadId, waitingMessage.id, {
655
- tool: { name: active() && turnResources.owns(resource, owner) ? "Computer available — continuing" : "Computer wait ended", ok: true },
663
+ tool: { name: active() && turnResources.owns(resource, owner) ? computerFreeText() : computerWaitEndedText(), ok: true },
656
664
  });
657
665
  }
658
666
  turnResourceOwners.set(owner.threadId, owner);
@@ -3001,6 +3009,10 @@ const watchdog = new TurnWatchdog({
3001
3009
  kind: "activity",
3002
3010
  tool: { name: `error: no activity for ${minutes} minutes — the turn was stopped`, ok: false },
3003
3011
  });
3012
+ // a routine's stall reports through its own failure path
3013
+ if (bot && routineRun?.target !== "bot") {
3014
+ reportIncident({ kind: "stalled", bot, threadId: turn.threadId, detail: `no activity for ${minutes} minutes — the turn was stopped` });
3015
+ }
3004
3016
  settleDirectFollowup(stalledGeneration);
3005
3017
  finalizeDelegationWatch(turn.threadId, false, "", "Delegated turn stalled and was stopped");
3006
3018
  turnUsage.delete(turn.threadId);
@@ -3039,8 +3051,7 @@ const watchdog = new TurnWatchdog({
3039
3051
  const currentBot = store.bot(turn.botId);
3040
3052
  if (currentBot?.busy) {
3041
3053
  stopScreenPoller(currentBot.id, turn.threadId);
3042
- if (activeVpsThreads.get(currentBot.id) === turn.threadId)
3043
- activeVpsThreads.delete(currentBot.id);
3054
+ vpsThreadEnded(currentBot.id, turn.threadId);
3044
3055
  if (store.taskByThread(currentBot.id, turn.threadId))
3045
3056
  store.setTaskActivity(currentBot.id, turn.threadId, "idle");
3046
3057
  else
@@ -3233,7 +3244,25 @@ function noteLocalVmSeen(target, status) {
3233
3244
  let localVmImageBusy = false;
3234
3245
  let localVmProvisionBusy = false;
3235
3246
  let localVmModeChangeBusy = false;
3247
+ /** Threads running with a bot's VPS computer mounted, per bot. Several run
3248
+ * at once — only the desktop lease is exclusive, and it is claimed on the
3249
+ * first screen call (see the VPS mount in dispatch) — so the alias and
3250
+ * backend guards ask "any thread?", and a settling thread removes only
3251
+ * itself, never a sibling still running. */
3236
3252
  const activeVpsThreads = new Map();
3253
+ function vpsThreadStarted(botId, threadId) {
3254
+ const threads = activeVpsThreads.get(botId) ?? new Set();
3255
+ threads.add(threadId);
3256
+ activeVpsThreads.set(botId, threads);
3257
+ }
3258
+ function vpsThreadEnded(botId, threadId) {
3259
+ const threads = activeVpsThreads.get(botId);
3260
+ if (!threads)
3261
+ return;
3262
+ threads.delete(threadId);
3263
+ if (!threads.size)
3264
+ activeVpsThreads.delete(botId);
3265
+ }
3237
3266
  const boxLifecycleBusyBots = new Set();
3238
3267
  // A refresh is a reader, not a lifecycle change. Keep its reservation until
3239
3268
  // the provider settles even if the HTTP client leaves, and share it on retry.
@@ -3785,6 +3814,8 @@ bus.subscribe((event) => {
3785
3814
  pushMessage({ role: "bot", kind: "text", text: coordinatorVisibleText, turnId: completedTurnId });
3786
3815
  lastReply.set(event.threadId, coordinatorVisibleText);
3787
3816
  }
3817
+ if (bot)
3818
+ handoffs.onEvent(event);
3788
3819
  switch (event.type) {
3789
3820
  case "session.started":
3790
3821
  if (bot && event.sessionId && event.providerInstanceId) {
@@ -4109,6 +4140,13 @@ bus.subscribe((event) => {
4109
4140
  store.markTerminalAssistantMessage(event.threadId, completedTurnId);
4110
4141
  const reply = lastReply.get(event.threadId) ?? "";
4111
4142
  lastReply.delete(event.threadId);
4143
+ // A run that broke — not one the person stopped, and not a routine's,
4144
+ // which reports through its own failure path — is the Chief's to see.
4145
+ if (!event.ok && event.stopReason !== "interrupted" && !routines?.runForThread(event.threadId)) {
4146
+ const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
4147
+ if (broken)
4148
+ reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
4149
+ }
4112
4150
  const lastReported = turnUsage.get(event.threadId);
4113
4151
  turnUsage.delete(event.threadId);
4114
4152
  turnContext.delete(event.threadId);
@@ -4126,8 +4164,7 @@ bus.subscribe((event) => {
4126
4164
  const ownsResources = resourceOwner &&
4127
4165
  turnResourceOwners.get(event.threadId)?.generation === resourceOwner.generation;
4128
4166
  if (ownsResources) {
4129
- if (activeVpsThreads.get(bot.id) === event.threadId)
4130
- activeVpsThreads.delete(bot.id);
4167
+ vpsThreadEnded(bot.id, event.threadId);
4131
4168
  releaseLocalVmThread(event.threadId);
4132
4169
  }
4133
4170
  releaseTurnResources(resourceOwner);
@@ -4356,6 +4393,93 @@ function wakeDelegationSource(source, threadId, targetName, failureReason, routi
4356
4393
  }
4357
4394
  dispatchDelegationWake(source.id, threadId, targetName, failureReason, routineRunId);
4358
4395
  }
4396
+ // ── incidents: a broken run reaches the Chief of Staff ──────────────────
4397
+ // A failed, stalled or unstartable run used to leave one chip in the thread
4398
+ // it died in and nothing anywhere else; the person found it hours later,
4399
+ // from a phone, by opening the desktop and reading every thread. The team
4400
+ // already has a role for this — the Chief coordinates the section — so the
4401
+ // incident becomes a turn of the Chief's, in its "Team incidents" thread,
4402
+ // with a link to the broken thread and retry_thread to act on it. The person
4403
+ // reads one place. Policy in server/incidents.ts.
4404
+ const incidentLedger = new IncidentLedger();
4405
+ /** What the broken thread was about: the last line the person (or the
4406
+ * requester) sent there, and the last thing the bot said. */
4407
+ function incidentContext(threadId) {
4408
+ const messages = [...store.messagesFor(threadId)].reverse();
4409
+ return {
4410
+ lastRequest: messages.find((message) => message.role === "user" && message.kind === "text" && message.text)?.text ?? null,
4411
+ lastReply: messages.find((message) => message.role === "bot" && message.kind === "text" && message.text)?.text ?? null,
4412
+ };
4413
+ }
4414
+ function reportIncident(input) {
4415
+ const { bot, threadId } = input;
4416
+ const task = store.taskByThread(bot.id, threadId);
4417
+ // A thread another bot opened and is watching is that bot's to handle:
4418
+ // the delegator is woken with the failure already (wakeDelegationSource).
4419
+ if (task?.openedBy?.delegationId || delegationWatch.has(threadId) || roomHandoffs.activeDirect(threadId))
4420
+ return;
4421
+ const group = store.groupByThread(threadId);
4422
+ const incident = {
4423
+ kind: input.kind,
4424
+ bot,
4425
+ threadId,
4426
+ title: task?.title ?? null,
4427
+ room: group?.name ?? null,
4428
+ detail: redactSecretsInText(input.detail),
4429
+ ...incidentContext(threadId),
4430
+ };
4431
+ const count = incidentLedger.note(threadId);
4432
+ // a crash loop is one incident, not a storm
4433
+ if (count.muted)
4434
+ return;
4435
+ const chief = chiefForBot(store.bots, bot);
4436
+ // A run that could not start and a failed routine have already buzzed
4437
+ // the person (turn-failed, routine-failed) by the time they get here; a
4438
+ // failure or stall mid-run has not. One notification per failure, never two.
4439
+ const alreadyNotified = input.kind === "could-not-start" || input.kind === "routine-failed";
4440
+ const tellThePerson = () => {
4441
+ if (alreadyNotified)
4442
+ return;
4443
+ notify(buildNotification("incident", bot, threadId, incidentChip(incident), {
4444
+ avatarUrl: bot.avatarUrl,
4445
+ ...(group ? { group: { id: group.id, name: group.name } } : {}),
4446
+ }));
4447
+ };
4448
+ // no Chief on duty, or the Chief itself broke: the person is next
4449
+ if (!chief) {
4450
+ tellThePerson();
4451
+ return;
4452
+ }
4453
+ const incidents = store.tasks(chief.id).find((candidate) => candidate.title === INCIDENTS_THREAD_TITLE && !candidate.archivedAt)
4454
+ ?? store.createTask(chief.id, INCIDENTS_THREAD_TITLE, false, undefined, { botId: chief.id, name: chief.name, at: Date.now() });
4455
+ if (!incidents || incidents.threadId === threadId) {
4456
+ tellThePerson();
4457
+ return;
4458
+ }
4459
+ store.appendMessage(incidents.threadId, {
4460
+ role: "bot",
4461
+ kind: "activity",
4462
+ tool: { name: incidentChip(incident), ok: false },
4463
+ threadRef: { botId: bot.id, threadId, title: task?.title ?? (group ? group.name : `${bot.name}'s conversation`) },
4464
+ });
4465
+ const text = incidentText(incident, count);
4466
+ // the report carries the broken bot's name as its provenance: it is about
4467
+ // that bot's work and nobody was at the keyboard
4468
+ const peerAsk = { botId: bot.id, name: bot.name, unattended: true };
4469
+ if (botAtThreadCapacity(chief.id) || activeGroupTurnForBot(chief.id)) {
4470
+ queueSteeredMessage(chief.id, incidents.threadId, text, { reason: "capacity", unattended: true, peerAsk });
4471
+ return;
4472
+ }
4473
+ void startTurn(chief.id, text, { threadId: incidents.threadId, unattended: true, peerAsk }).catch((error) => {
4474
+ const why = error instanceof Error ? error.message : String(error);
4475
+ store.appendMessage(incidents.threadId, {
4476
+ role: "bot",
4477
+ kind: "activity",
4478
+ tool: { name: `error: the incident could not reach ${chief.name} — ${why.slice(0, 120)}`, ok: false },
4479
+ });
4480
+ tellThePerson();
4481
+ });
4482
+ }
4359
4483
  function drainDelegationWakes() {
4360
4484
  for (const [threadId, entry] of pendingDelegationWakes) {
4361
4485
  if (!store.taskByThread(entry.botId, threadId) || !routineDelegationCanResume(threadId, entry.routineRunId)) {
@@ -4390,12 +4514,36 @@ function markTaskContextExternallyUpdated(bot, threadId) {
4390
4514
  const task = store.taskByThread(bot.id, threadId);
4391
4515
  if (!task)
4392
4516
  return;
4517
+ // An engine that records which messages its current session was handed
4518
+ // keeps that session: its next turn is sent what it has not seen. Without a
4519
+ // record for that exact session (another engine, one switched in since, a
4520
+ // replaced session, or a task from before records existed) the next turn
4521
+ // replays once, as before.
4522
+ const owner = task.lastInstanceId;
4523
+ const record = owner ? task.handedMessages?.[owner] : undefined;
4524
+ if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
4525
+ turnInstance(botForThread(bot.id, threadId) ?? bot, undefined, threadId)?.instanceId === owner &&
4526
+ registry.get(owner)?.adapter.capabilities.strictResume) {
4527
+ store.patchTask(bot.id, threadId, { unread: true });
4528
+ return;
4529
+ }
4393
4530
  store.patchTask(bot.id, threadId, {
4394
4531
  resumeCursors: {},
4395
4532
  lastInstanceId: `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`,
4396
4533
  unread: true,
4397
4534
  });
4398
4535
  }
4536
+ /** Active-branch messages a provider reads as conversation context. */
4537
+ function isContextMessage(m) {
4538
+ return Boolean((m.kind === "text" && m.text) || m.roomRequest?.phase === "result");
4539
+ }
4540
+ const handoffs = new Handoffs({
4541
+ order: (threadId) => store.activePath(threadId).filter(isContextMessage).map((m) => m.id),
4542
+ read: (botId, threadId, instanceId) => store.taskByThread(botId, threadId)?.handedMessages?.[instanceId],
4543
+ write: (botId, threadId, instanceId, state) => store.setHandedMessages(botId, threadId, instanceId, state),
4544
+ replies: (threadId, turnId) => store.activePath(threadId)
4545
+ .filter((m) => m.role === "bot" && m.kind === "text" && !m.from && m.turnId === turnId).map((m) => m.id),
4546
+ });
4399
4547
  /** Consume one delegated-turn watch and mirror exactly one terminal state.
4400
4548
  * Some harness paths settle a busy bot without a provider turn.completed
4401
4549
  * event, so they call this same finalizer explicitly. */
@@ -5095,15 +5243,18 @@ async function startTurn(botId, text, opts) {
5095
5243
  // Resolve its quote from full storage, while the replay itself remains
5096
5244
  // strictly limited to the selected branch below.
5097
5245
  const messagesById = new Map(store.messagesFor(threadId).map((message) => [message.id, message]));
5098
- const transcript = activeMessages
5099
- .filter((m) => ((m.kind === "text" && m.text) || m.roomRequest?.phase === "result") && !skipTranscript.has(m.id))
5100
- .slice(-40)
5101
- .map((m) => ({
5246
+ const context = activeMessages.filter(isContextMessage).map((m) => ({
5247
+ id: m.id,
5102
5248
  role: m.role === "user" ? "user" : "assistant",
5103
5249
  text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
5104
5250
  : m.role !== "user" && m.from ? peerMessageText(m.from.name, transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"))
5105
5251
  : transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"),
5252
+ keep: m.roomRequest?.phase === "result" || (m.role !== "user" && Boolean(m.from)),
5253
+ ...(m.steered ? { steered: true } : {}),
5106
5254
  }));
5255
+ const contextOrder = context.map((m) => m.id);
5256
+ const replayable = context.filter((m) => !skipTranscript.has(m.id));
5257
+ const transcript = replayable.slice(-40).map((m) => ({ role: m.role, text: m.text }));
5107
5258
  // After a rewind (edit / branch switch) the provider's native session
5108
5259
  // still contains the abandoned branch: start a fresh session instead of
5109
5260
  // resuming, and for cursor-resuming drivers replay the surviving path
@@ -5122,6 +5273,28 @@ async function startTurn(botId, text, opts) {
5122
5273
  const fresh = !rewound &&
5123
5274
  !externalContextMarker &&
5124
5275
  engineIsFresh({ instanceId, lastInstanceId: task.lastInstanceId, resumeCursors: task.resumeCursors, transcript });
5276
+ // An engine that records what its session was handed resumes it with only
5277
+ // the context messages outside that record. A record of another session
5278
+ // (the one it replaced) or one that no longer lines up with the branch is
5279
+ // not trusted: the session is rebuilt by the same replay as any other.
5280
+ const strictResume = instance.adapter.capabilities.strictResume === true;
5281
+ const cursor = task.resumeCursors[instanceId];
5282
+ const handed = strictResume && !rewound && !fresh && !externalContextMarker && cursor !== undefined
5283
+ ? task.handedMessages?.[instanceId] : undefined;
5284
+ const unseen = handed && handedStateUsable(handed, cursor, contextOrder) ? unseenMessages(replayable, contextOrder, handed) : undefined;
5285
+ // A teammate's result or reply: without records this turn would replay.
5286
+ const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
5287
+ // What a resumed session keeps from its launch: the standing instructions
5288
+ // (tools, servers and — for Claude — the model are passed on every launch),
5289
+ // plus whatever this engine can only set when a session starts. Codex's
5290
+ // thread/resume sends no model selection, and an effort it is not sent stays
5291
+ // at the thread's last value, so both belong to the session there. An
5292
+ // external update that finds any of it changed since the session started
5293
+ // gets the fresh session and replay it always got, rather than a resume.
5294
+ const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
5295
+ ...(instance.driverKind === "codex" ? [model, effort ?? null] : [])];
5296
+ const sessionConfig = (soul) => createHash("sha256").update(JSON.stringify([...persistentConfig, soul])).digest("hex").slice(0, 16);
5297
+ const plannedConfig = sessionConfig(bot.soul);
5125
5298
  // Agent-tool gate shared by skill authoring, the /setup turn-text rewrite,
5126
5299
  // the setup prompt block, and the peer-comms integration below: a driver
5127
5300
  // that never mounts agent tools (or a turn already at the comms-depth cap)
@@ -5135,23 +5308,50 @@ async function startTurn(botId, text, opts) {
5135
5308
  // which also depends on the bot's soul/description — is decided below,
5136
5309
  // from the same bot snapshot the prompt's soul is built from.
5137
5310
  const setupText = agentsMounted ? expandSetupTurnText(providerText) : providerText;
5138
- const { turnText, resume } = buildTurnContext({
5139
- text: promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User"),
5140
- transcript,
5141
- rewound,
5142
- fresh,
5143
- externallyUpdated: Boolean(externalContextMarker),
5144
- replaysNatively: instance.driverKind === "grok",
5145
- });
5146
- // Snapshot the cursor alongside the context decision. An external result
5147
- // can arrive during async computer/setup work and clear the task cursor;
5148
- // this already-built turn must either keep its old session or replay on the
5149
- // following turn, never start a blank session with no transcript.
5150
- const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
5151
- // A cursor the provider no longer honours must not brick the thread: the
5152
- // driver may fall back to ONE fresh session, and this is what it sends
5153
- // there, so the new session is not blank (server/resume-recovery.ts).
5154
- const recoveryText = resumeCursor !== undefined ? buildRecoveryText({ text: turnText, transcript }) : undefined;
5311
+ const userTurnText = promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User");
5312
+ // Decided again at dispatch when setup outlasted a soul edit (config).
5313
+ const decideContext = (config) => {
5314
+ const handedStale = Boolean(handed && (!unseen || (externalUpdate && handed.config !== config)));
5315
+ const { block: unseenBlock, placed } = unseen && !handedStale ? renderUnseen(unseen) : { block: "", placed: [] };
5316
+ const { turnText: contextTurnText, resume } = buildTurnContext({
5317
+ text: userTurnText,
5318
+ transcript,
5319
+ rewound,
5320
+ fresh,
5321
+ externallyUpdated: Boolean(externalContextMarker) || handedStale,
5322
+ replaysNatively: instance.driverKind === "grok",
5323
+ });
5324
+ // Snapshot the cursor alongside the context decision. An external result
5325
+ // can arrive during async computer/setup work and clear the task cursor;
5326
+ // this already-built turn must either keep its old session or replay on the
5327
+ // following turn, never start a blank session with no transcript.
5328
+ const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
5329
+ // A cursor the provider no longer honours must not brick the thread: the
5330
+ // driver may fall back to ONE fresh session, and this is what it sends
5331
+ // there, so the new session is not blank (server/resume-recovery.ts). A
5332
+ // turn carrying an external update gets the replay it would have had.
5333
+ const recoveryIsReplay = resumeCursor !== undefined && externalUpdate && transcript.length > 0;
5334
+ const recoveryText = resumeCursor === undefined ? undefined : recoveryIsReplay
5335
+ ? buildTurnContext({ text: userTurnText, transcript, rewound: false, fresh: false, externallyUpdated: true, replaysNatively: false }).turnText
5336
+ : buildRecoveryText({ text: userTurnText, transcript });
5337
+ // What this turn puts in front of the provider, for each session it can end
5338
+ // up in (server/delta-context.ts). buildTurnContext prepends a replay only
5339
+ // when it replays, so a changed text means the transcript was sent.
5340
+ const carried = [...skipTranscript];
5341
+ const windowIds = replayable.slice(-40).map((m) => m.id);
5342
+ return {
5343
+ turnText: withUnseenMessages(unseenBlock, contextTurnText),
5344
+ resumeCursor, recoveryText, recoveryIsReplay,
5345
+ handoff: strictResume ? {
5346
+ botId: bot.id, instanceId, config, resumeCursor: typeof resumeCursor === "string" ? resumeCursor : undefined,
5347
+ started: sessionStart(contextOrder, contextTurnText !== userTurnText ? windowIds : [], carried),
5348
+ recovery: sessionStart(contextOrder, recoveryText !== undefined ? windowIds : [], carried),
5349
+ resumed: sessionStart(contextOrder, [], [...placed, ...carried]),
5350
+ placed, carried, own: [userMessage.id, ...(opts?.excludeMessageIds ?? [])],
5351
+ } : undefined,
5352
+ };
5353
+ };
5354
+ let dispatchContext = decideContext(plannedConfig);
5155
5355
  const persona = [
5156
5356
  `You are ${bot.name}, a personal bot in OpenMausBot.`,
5157
5357
  bot.title && `Role: ${bot.title}.`,
@@ -5179,6 +5379,8 @@ async function startTurn(botId, text, opts) {
5179
5379
  // follow-ups: any normal turn may ask teammates to coordinate work.
5180
5380
  directFollowupSettlers.set(dispatchClaimId, { threadId, settle: opts?.onTurnSettled });
5181
5381
  directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
5382
+ if (dispatchContext.handoff)
5383
+ handoffs.begin(threadId, dispatchClaimId, dispatchContext.handoff);
5182
5384
  directTurnBots.set(threadId, bot);
5183
5385
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
5184
5386
  if (!opts?.computerSelectionContinuation && !opts?.cardContinuation && !opts?.automationSource && !opts?.unattended &&
@@ -5414,6 +5616,7 @@ async function startTurn(botId, text, opts) {
5414
5616
  autoVmClaims.set(threadId, {
5415
5617
  owner: resourceOwner,
5416
5618
  lazy: true,
5619
+ label: "the Local VM",
5417
5620
  claim: async () => {
5418
5621
  await claimAutoLocalVm(threadId, localVmTarget);
5419
5622
  // The dispatch-site poller start saw a null previewCapture
@@ -5440,6 +5643,7 @@ async function startTurn(botId, text, opts) {
5440
5643
  // re-assert the same owner — a no-op (issue #1361).
5441
5644
  autoVmClaims.set(threadId, {
5442
5645
  owner: resourceOwner,
5646
+ label: "the Local VM",
5443
5647
  claim: async () => { await claimAutoLocalVm(threadId); },
5444
5648
  });
5445
5649
  return true;
@@ -5484,10 +5688,17 @@ async function startTurn(botId, text, opts) {
5484
5688
  if (unsupported && wants === undefined)
5485
5689
  autoVpsProblem = unsupported;
5486
5690
  if (!unsupported) {
5487
- // The remote lifecycle and container are shared by this bot. Keep
5488
- // its explicit computer turns serialized; ordinary threads still run.
5489
- await bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
5490
- activeVpsThreads.set(bot.id, threadId);
5691
+ // The VPS "computer" is the desktop inside this bot's managed
5692
+ // container, and only screen work needs that desktop to itself.
5693
+ // So the lease is claimed on the first computer call, through the
5694
+ // computer-control gate (the Local VM's seam, #1361), never at
5695
+ // mount: a bot's turns that never touch the computer tools run
5696
+ // side by side, and its 3-hourly routine no longer queues behind
5697
+ // — or fails after 30 minutes behind — its own long-running task.
5698
+ // Container lifecycle (provision, start) is serialized by the
5699
+ // runner's per-container lock, not by this turn.
5700
+ const vpsResource = `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`;
5701
+ vpsThreadStarted(bot.id, threadId);
5491
5702
  let remote;
5492
5703
  remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
5493
5704
  ? await vps.vpsComputerAction("provision", cfg, bot.id)
@@ -5501,10 +5712,28 @@ async function startTurn(botId, text, opts) {
5501
5712
  env: { ...vpsMcp.env, OMB_CONTROL_URL: vpsControl.url, OMB_CONTROL_TOKEN: vpsControl.token },
5502
5713
  };
5503
5714
  computerKind = "vps";
5504
- previewCapture = () => vps.vpsComputerScreenshot(targetCfg, bot.id);
5715
+ // Live frames only once this turn holds the desktop: a poller on
5716
+ // a desktop another turn is driving would publish that turn's
5717
+ // screen as this one's. The claim restarts the poller with the
5718
+ // capture, the way the Local VM's lazy claim does.
5719
+ const vpsCapture = () => vps.vpsComputerScreenshot(targetCfg, bot.id);
5720
+ autoVmClaims.set(threadId, {
5721
+ owner: resourceOwner,
5722
+ lazy: true,
5723
+ label: "the VPS computer",
5724
+ claim: async () => {
5725
+ await bindTurnComputer(resourceOwner, vpsResource, true);
5726
+ previewCapture = vpsCapture;
5727
+ if (threadBusy(bot.id, threadId)) {
5728
+ const touched = screenPollers.get(threadId)?.touched ?? false;
5729
+ stopScreenPoller(bot.id, threadId);
5730
+ startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
5731
+ }
5732
+ },
5733
+ });
5505
5734
  }
5506
5735
  else {
5507
- activeVpsThreads.delete(bot.id);
5736
+ vpsThreadEnded(bot.id, threadId);
5508
5737
  if (wants === "cloud") {
5509
5738
  throw new Error(remote?.problem ?? "the VPS computer could not be created or reached");
5510
5739
  }
@@ -5786,10 +6015,17 @@ async function startTurn(botId, text, opts) {
5786
6015
  { id: "mentions", label: "Mentions", text: boundedCoordination && tagged.length ? `The user named these existing teammates: ${tagged.map(b => `${peerName(b.name)} (${b.id})`).join(", ")}. Use coordinate_bots when their contribution is needed; do not substitute native helper agents for these bots.` : mentionPrompt(tagged) },
5787
6016
  ]);
5788
6017
  runningTurnEngines.set(threadId, instance);
6018
+ // The prompt carries the soul as saved now. If it changed during setup,
6019
+ // decide again from what is actually sent.
6020
+ const dispatchedConfig = sessionConfig(liveBot?.soul ?? bot.soul);
6021
+ if (strictResume && dispatchedConfig !== plannedConfig)
6022
+ dispatchContext = decideContext(dispatchedConfig);
6023
+ // Before sendTurn: an adapter may emit the whole turn before it resolves.
6024
+ handoffs.dispatching(threadId, dispatchClaimId, dispatchContext.handoff);
5789
6025
  const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
5790
6026
  threadId,
5791
6027
  botId: bot.id,
5792
- text: turnText,
6028
+ text: dispatchContext.turnText,
5793
6029
  refreshSystemPrompt: true,
5794
6030
  images: turnImages,
5795
6031
  approvalMode: approvalModeForTurn(bot, commsDepth > 0),
@@ -5799,8 +6035,9 @@ async function startTurn(botId, text, opts) {
5799
6035
  // a rewound thread never resumes the abandoned branch's session
5800
6036
  // the active task's own session — another task's cursor would
5801
6037
  // resume the wrong conversation and defeat the context bubble
5802
- resumeCursor,
5803
- ...(recoveryText !== undefined ? { recoveryText } : {}),
6038
+ resumeCursor: dispatchContext.resumeCursor,
6039
+ ...(dispatchContext.recoveryText !== undefined ? { recoveryText: dispatchContext.recoveryText } : {}),
6040
+ ...(dispatchContext.recoveryIsReplay ? { recoveryIsReplay: true } : {}),
5804
6041
  transcript,
5805
6042
  system: prompt.text,
5806
6043
  systemStable: prompt.stable,
@@ -5816,6 +6053,7 @@ async function startTurn(botId, text, opts) {
5816
6053
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
5817
6054
  }
5818
6055
  bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
6056
+ handoffs.bindTurn(threadId, dispatchClaimId, dispatch.value.turnId);
5819
6057
  if (directFollowupSettlers.has(dispatchClaimId) && dispatch.value.turnId &&
5820
6058
  !directFollowupTurns.bind(threadId, dispatchClaimId, dispatch.value.turnId)) {
5821
6059
  // This exact queued turn completed before its dispatch ACK arrived.
@@ -5858,6 +6096,7 @@ async function startTurn(botId, text, opts) {
5858
6096
  }
5859
6097
  }
5860
6098
  catch (e) {
6099
+ handoffs.abandon(threadId, dispatchClaimId);
5861
6100
  if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
5862
6101
  computerSelectionTurns.delete(threadId);
5863
6102
  settleDirectFollowup(dispatchClaimId);
@@ -5868,8 +6107,7 @@ async function startTurn(botId, text, opts) {
5868
6107
  releaseTurnResources(resourceOwner);
5869
6108
  if (ownsLatestGeneration) {
5870
6109
  releaseLocalVmThread(threadId);
5871
- if (activeVpsThreads.get(bot.id) === threadId)
5872
- activeVpsThreads.delete(bot.id);
6110
+ vpsThreadEnded(bot.id, threadId);
5873
6111
  watchdog.settle(threadId);
5874
6112
  turnUsage.delete(threadId);
5875
6113
  turnContext.delete(threadId);
@@ -5910,6 +6148,7 @@ async function startTurn(botId, text, opts) {
5910
6148
  // for it, in its own thread, so it does not need a second channel.
5911
6149
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
5912
6150
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
6151
+ reportIncident({ kind: "could-not-start", bot, threadId, detail: message });
5913
6152
  }
5914
6153
  store.setTaskActivity(bot.id, threadId, "idle");
5915
6154
  directTurnBots.delete(threadId);
@@ -6167,6 +6406,7 @@ routines = new RoutineManager({
6167
6406
  const task = store.taskByThread(botId, threadId);
6168
6407
  if (task && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
6169
6408
  store.deleteTask(botId, threadId);
6409
+ handoffs.forget(threadId);
6170
6410
  }
6171
6411
  },
6172
6412
  startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
@@ -6207,6 +6447,7 @@ routines = new RoutineManager({
6207
6447
  const detail = run.error ? `${run.routineName}: ${run.error}` : run.routineName;
6208
6448
  const notificationBot = routineSourceOwner(run)?.bot ?? bot;
6209
6449
  notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
6450
+ reportIncident({ kind: "routine-failed", bot, threadId: run.threadId ?? bot.threadId, detail });
6210
6451
  },
6211
6452
  onRunDeferred: (run) => {
6212
6453
  const bot = store.bot(run.botId);
@@ -9273,8 +9514,7 @@ async function reloadProviders() {
9273
9514
  stopScreenPoller(botId, threadId);
9274
9515
  releaseLocalVmThread(threadId);
9275
9516
  releaseTurnResources(owner);
9276
- if (activeVpsThreads.get(botId) === threadId)
9277
- activeVpsThreads.delete(botId);
9517
+ vpsThreadEnded(botId, threadId);
9278
9518
  watchdog.settle(threadId);
9279
9519
  closeOpenApprovals(threadId);
9280
9520
  directTurnBots.delete(threadId);
@@ -10791,6 +11031,52 @@ const handleRequest = async (req, res) => {
10791
11031
  await new Promise((wake) => setTimeout(wake, 500));
10792
11032
  }
10793
11033
  }
11034
+ // A Chief resumes a teammate's broken thread (server/incidents.ts): the
11035
+ // same thread, its conversation and files, one more turn, with a line
11036
+ // saying who asked and why. Chief-only, for a teammate it can reach,
11037
+ // never a room (coordinate there) and never a thread still running.
11038
+ if (method === "POST" && path === "/api/internal/retry-thread") {
11039
+ const body = await readInternalBody();
11040
+ const from = internalSender;
11041
+ const fromThreadId = internalCapability.threadId;
11042
+ if (!from.chiefOfStaff || from.hidden)
11043
+ return json(res, 403, { error: "only a Chief of Staff can retry a teammate's thread" });
11044
+ // `toBotId`/`toThreadId`: the guard above reads bare botId/threadId as
11045
+ // the caller's own identity, the way every internal route does.
11046
+ const botId = typeof body.toBotId === "string" ? body.toBotId : "";
11047
+ const threadId = typeof body.toThreadId === "string" ? body.toThreadId : "";
11048
+ const note = typeof body.note === "string" ? body.note.trim().slice(0, 300) : "";
11049
+ const target = store.bot(botId);
11050
+ if (!target || target.id === from.id)
11051
+ return json(res, 404, { error: "no such teammate" });
11052
+ if (target.hidden || !canAccessTeam(from, target.section) || !peerAllowed(from, target.id)) {
11053
+ return json(res, 403, { error: "that bot is not on this Chief's team — call list_bots for the ones you can reach" });
11054
+ }
11055
+ if (store.groupByThread(threadId))
11056
+ return json(res, 400, { error: "that is a room thread — use coordinate_bots in the room instead" });
11057
+ const task = store.taskByThread(target.id, threadId);
11058
+ if (!task)
11059
+ return json(res, 404, { error: "no such thread on that bot" });
11060
+ if (threadBusy(target.id, threadId) || queuedThreadPosition(target.id, threadId) !== null) {
11061
+ return json(res, 409, { error: "that thread is still running — wait for it to settle before retrying" });
11062
+ }
11063
+ requireActiveInternalCapability();
11064
+ const unattended = isUnattended(from.id, fromThreadId);
11065
+ const text = `[Retry requested by ${from.name}, your Chief of Staff, after this thread's last run stopped.${note ? ` Note from ${from.name}: ${note}` : ""} Continue the request above from where it stopped and finish it. If the same problem comes back, say exactly what is blocking and stop.]`;
11066
+ try {
11067
+ await startTurn(target.id, text, { threadId, unattended, peerAsk: { botId: from.id, name: from.name, ...(unattended ? { unattended: true } : {}) } });
11068
+ }
11069
+ catch (error) {
11070
+ return json(res, 409, { error: error instanceof Error ? error.message : String(error) });
11071
+ }
11072
+ store.appendMessage(fromThreadId, {
11073
+ role: "bot",
11074
+ kind: "activity",
11075
+ tool: { name: `Retried ${target.name}'s thread #${task.title}`, ok: true },
11076
+ threadRef: { botId: target.id, threadId, title: task.title },
11077
+ });
11078
+ return json(res, 200, { started: true, message: `${target.name}'s thread #${task.title} is running again. Its result stays in that thread; you are not woken for it — check later with list_threads or session_search if you need to.` });
11079
+ }
10794
11080
  if (method === "POST" && path === "/api/internal/delegate-bot") {
10795
11081
  const body = await readInternalBody();
10796
11082
  const toBotRef = String(body.toBotId ?? "");
@@ -11492,7 +11778,7 @@ const handleRequest = async (req, res) => {
11492
11778
  // model into a screenshot loop against a claim that cannot land.
11493
11779
  return json(res, 200, {
11494
11780
  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.`,
11781
+ blockedReason: `This turn could not claim ${lazyClaim.label ?? "this computer"}${lazyClaim.failure ? ` (${lazyClaim.failure})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`,
11496
11782
  });
11497
11783
  }
11498
11784
  if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
@@ -14403,6 +14689,7 @@ const handleRequest = async (req, res) => {
14403
14689
  // message intact for the next ordinary turn, where central image
14404
14690
  // admission can hand it to the provider natively.
14405
14691
  const carriesImages = extractTurnImages(text).images.length > 0;
14692
+ const steerTarget = handoffs.current(threadId);
14406
14693
  if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
14407
14694
  steered = await instance.adapter
14408
14695
  .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
@@ -14447,6 +14734,8 @@ const handleRequest = async (req, res) => {
14447
14734
  sendId,
14448
14735
  steered: true,
14449
14736
  });
14737
+ // Offered to the next turn again unless the person stops this one.
14738
+ handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
14450
14739
  return { ok: true, steered: true, threadId, message };
14451
14740
  }
14452
14741
  if (!current.busy) {
@@ -14497,6 +14786,7 @@ const handleRequest = async (req, res) => {
14497
14786
  const currentAtStart = store.projectBotForTask(bot.id, bot.threadId);
14498
14787
  const instance = currentAtStart?.busy ? runningTurnInstance(currentAtStart, bot.threadId) : undefined;
14499
14788
  const prompt = held.items.map((item) => item.prompt).join("\n\n");
14789
+ const steerTarget = handoffs.current(bot.threadId);
14500
14790
  let steered = "refused";
14501
14791
  if (currentAtStart?.busy && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
14502
14792
  steered = await instance.adapter
@@ -14525,6 +14815,9 @@ const handleRequest = async (req, res) => {
14525
14815
  peerAsk: item.peerAsk,
14526
14816
  steered: true,
14527
14817
  }));
14818
+ // Offered to the next turn again unless the person stops this one.
14819
+ for (const message of messages)
14820
+ handoffs.steered(bot.threadId, steerTarget, instance?.instanceId, message.id);
14528
14821
  const queueIds = held.items.map((item) => item.messageId);
14529
14822
  settleHeldSteeredQueue(held);
14530
14823
  return json(res, 200, { ok: true, steered: true, threadId: bot.threadId, messages, queueIds });
@@ -14761,8 +15054,10 @@ const handleRequest = async (req, res) => {
14761
15054
  const routine = routines.activeBotRunForBot(bot.id);
14762
15055
  if (routine?.threadId === expectedThreadId)
14763
15056
  await routines.cancelRun(routine.id);
14764
- else
15057
+ else {
15058
+ handoffs.stoppedByPerson(expectedThreadId);
14765
15059
  await interruptDirectThread(bot.id, expectedThreadId);
15060
+ }
14766
15061
  return json(res, 200, { ok: true });
14767
15062
  }
14768
15063
  const directClaim = directTurnDispatchClaims.get(bot.threadId);
@@ -14798,6 +15093,7 @@ const handleRequest = async (req, res) => {
14798
15093
  directClaim?.threadId !== expectedThreadId) {
14799
15094
  return json(res, 409, { error: "the bot switched tasks before it could be interrupted" });
14800
15095
  }
15096
+ handoffs.stoppedByPerson(expectedThreadId ?? bot.threadId);
14801
15097
  await interruptDirectThread(bot.id, expectedThreadId ?? bot.threadId);
14802
15098
  return json(res, 200, { ok: true });
14803
15099
  }
@@ -15030,6 +15326,7 @@ const handleRequest = async (req, res) => {
15030
15326
  const updated = store.deleteTask(m[1], m[2]);
15031
15327
  if (!updated)
15032
15328
  return json(res, 404, { error: "no such task" });
15329
+ handoffs.forget(m[2]);
15033
15330
  settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
15034
15331
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
15035
15332
  const fresh = botWithThread(updated);
@@ -16929,12 +17226,17 @@ for (const row of chatFollowups()) {
16929
17226
  }
16930
17227
  settleChatFollowups([row.id], "interrupted");
16931
17228
  const messages = store.messagesFor(row.threadId);
16932
- if (!messages.some((message) => message.queueId === row.id && message.role === "user")) {
16933
- store.appendMessage(row.threadId, {
16934
- role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
16935
- sendId: row.payload.sendId, queueId: row.id,
16936
- ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
16937
- });
17229
+ const recovered = messages.find((message) => message.queueId === row.id && message.role === "user") ?? store.appendMessage(row.threadId, {
17230
+ role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
17231
+ sendId: row.payload.sendId, queueId: row.id,
17232
+ ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
17233
+ });
17234
+ // Nor as a message a resumed session has not seen: count it as handed.
17235
+ const recoveredTask = row.kind === "bot" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
17236
+ const order = store.activePath(row.threadId).filter(isContextMessage).map((m) => m.id);
17237
+ for (const [instanceId, state] of Object.entries(recoveredTask?.handedMessages ?? {})) {
17238
+ if (state.session !== undefined)
17239
+ store.setHandedMessages(row.ownerId, row.threadId, instanceId, recordHanded(state, order, [recovered.id]));
16938
17240
  }
16939
17241
  if (!messages.some((message) => message.queueId === row.id && message.kind === "activity")) {
16940
17242
  store.appendMessage(row.threadId, {