openmausbot 0.1.81 → 0.1.82

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.
package/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/app-icon.svg" />
7
7
  <title>OpenMausBot</title>
8
- <script type="module" crossorigin src="/assets/index-peLFiSH8.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BLLsDK2F.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CKysBq-V.css">
10
10
  </head>
11
11
  <body>
@@ -40642,7 +40642,7 @@ function buildNotification(kind, bot, threadId, detail, extra) {
40642
40642
  if (bot.notifications === false) return null;
40643
40643
  const body = summarize(detail);
40644
40644
  const who = extra?.group ? `${bot.name} in ${extra.group.name}` : bot.name;
40645
- const title = kind === "approval" ? `${who} needs approval` : kind === "question" ? `${who} has a question` : kind === "takeover" ? `${who} needs your hands` : kind === "routine-failed" ? `${who}'s routine failed` : kind === "turn-failed" ? `${who} couldn't start` : `${who} finished`;
40645
+ const title = kind === "approval" ? `${who} needs approval` : kind === "question" ? `${who} has a question` : kind === "takeover" ? `${who} needs your hands` : kind === "routine-failed" ? `${who}'s routine failed` : kind === "routine-deferred" ? `${who}'s routine is waiting` : kind === "turn-failed" ? `${who} couldn't start` : `${who} finished`;
40646
40646
  if (kind === "done" && !body) return null;
40647
40647
  const notification = { kind, botId: bot.id, botName: bot.name, threadId, title, body };
40648
40648
  if (extra?.avatarUrl) notification.avatarUrl = extra.avatarUrl;
@@ -41468,7 +41468,7 @@ function createAcpDriver(support11) {
41468
41468
  return { turnId };
41469
41469
  }
41470
41470
  const resolvedModel = support11.resolveTurnModel?.(turn.model, env);
41471
- support11.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model });
41471
+ support11.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
41472
41472
  const cliTurn = resolvedModel !== void 0 && resolvedModel !== turn.model ? { ...turn, model: resolvedModel } : turn;
41473
41473
  const mcpServers = acpMcpServers(turn);
41474
41474
  let launch;
@@ -47714,6 +47714,28 @@ var support7 = (loadCatalog2) => ({
47714
47714
  modelVariants: true,
47715
47715
  resolveTurnModel: (model2, env) => model2 ? ensureOpenCodeInjectModel(normalizeLegacyOpenCodeModel(model2, env), env) : model2,
47716
47716
  transformEnv: stripForeignProviderKeys,
47717
+ applyTurnEnv: (env, { fullAuto }) => {
47718
+ if (!fullAuto) return;
47719
+ env.OPENCODE_PERMISSION = JSON.stringify(Object.fromEntries([
47720
+ "*",
47721
+ "external_directory",
47722
+ "read",
47723
+ "edit",
47724
+ "bash",
47725
+ "glob",
47726
+ "grep",
47727
+ "list",
47728
+ "task",
47729
+ "lsp",
47730
+ "skill",
47731
+ "webfetch",
47732
+ "websearch",
47733
+ "codesearch",
47734
+ "todoread",
47735
+ "todowrite",
47736
+ "doom_loop"
47737
+ ].map((permission) => [permission, "allow"])));
47738
+ },
47717
47739
  pickAuthMethod: () => null,
47718
47740
  authFailure: "continue",
47719
47741
  isAuthenticated: async (env, config2) => Boolean(env.OPENCODE_API_KEY) || hasStoredOpenCodeAuth(env) || await canListOpenCodeModels(env, config2.cli),
@@ -60755,10 +60777,14 @@ import { realpathSync as realpathSync6 } from "node:fs";
60755
60777
  import { relative as relative3, resolve as resolve12, sep as sep2 } from "node:path";
60756
60778
  var TurnResources = class {
60757
60779
  owners = /* @__PURE__ */ new Map();
60758
- claim(resource, owner2) {
60780
+ blocker(resource, owner2) {
60759
60781
  for (const [key3, current2] of this.owners) {
60760
- if (overlaps(key3, resource) && !sameOwner(current2, owner2)) return false;
60782
+ if (overlaps(key3, resource) && !sameOwner(current2, owner2)) return current2;
60761
60783
  }
60784
+ return void 0;
60785
+ }
60786
+ claim(resource, owner2) {
60787
+ if (this.blocker(resource, owner2)) return false;
60762
60788
  this.owners.set(resource, owner2);
60763
60789
  return true;
60764
60790
  }
@@ -64739,6 +64765,7 @@ function routineRequestOwnerKey(owner2) {
64739
64765
  var ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
64740
64766
  var CATCH_UP_MS = 12 * 60 * 6e4;
64741
64767
  var WAKE_HORIZON_MS = 60 * 6e4;
64768
+ var ROUTINE_DEFERRAL_NOTICE_MS = 30 * 6e4;
64742
64769
  var MAX_DATE_MS = 864e13;
64743
64770
  var LOCAL_DAY_MS = 24 * 60 * 6e4;
64744
64771
  var INTERVAL_RESTRICTION_SEARCH_MS = 9 * LOCAL_DAY_MS;
@@ -65713,7 +65740,20 @@ var RoutineManager = class {
65713
65740
  }
65714
65741
  }
65715
65742
  const state = this.targetState(run3);
65716
- if (state === "busy") continue;
65743
+ if (state === "busy") {
65744
+ if (run3.deferredAt == null) {
65745
+ run3.deferredAt = now;
65746
+ this.save();
65747
+ this.emitRun(run3);
65748
+ }
65749
+ if (run3.deferredNoticeAt == null && now - run3.deferredAt >= ROUTINE_DEFERRAL_NOTICE_MS) {
65750
+ run3.deferredNoticeAt = now;
65751
+ this.save();
65752
+ this.emitRun(run3);
65753
+ this.options.onRunDeferred?.(cloneRun(run3));
65754
+ }
65755
+ continue;
65756
+ }
65717
65757
  if (state === "missing") {
65718
65758
  this.failRun(run3, this.missingTargetMessage(run3.target));
65719
65759
  continue;
@@ -81627,9 +81667,33 @@ function releaseTurnResources(owner2) {
81627
81667
  teamComputerTurns.delete(owner2.threadId);
81628
81668
  }
81629
81669
  }
81630
- function bindTurnComputer(owner2, resource, exclusive = false) {
81631
- if (exclusive && !claimTurnResource(owner2, resource)) {
81632
- throw Object.assign(new Error("another thread is using this computer \u2014 wait for it to finish"), { status: 409, code: "computer_busy" });
81670
+ async function bindTurnComputer(owner2, resource, exclusive = false) {
81671
+ const active = () => activeInternalGenerationByThread.get(owner2.threadId) === owner2.generation && turnResourceOwners.get(owner2.threadId)?.generation === owner2.generation;
81672
+ let waitingMessage;
81673
+ const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
81674
+ try {
81675
+ while (true) {
81676
+ if (!active()) throw new DirectTurnSetupCancelled("Computer wait cancelled");
81677
+ if (!exclusive || claimTurnResource(owner2, resource)) break;
81678
+ if (!waitingMessage) {
81679
+ const blocker = turnResources.blocker(resource, owner2);
81680
+ const holderBot = blocker && store.botByThread(blocker.threadId);
81681
+ const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
81682
+ const holder = holderBot ? `${holderBot.name}${holderTask?.title ? ` / ${holderTask.title}` : ""}` : blocker && store.groupByThread(blocker.threadId)?.name;
81683
+ waitingMessage = store.appendMessage(owner2.threadId, {
81684
+ role: "bot",
81685
+ kind: "activity",
81686
+ tool: { name: `Waiting for computer${holder ? ` \u2014 ${holder} is using it` : ""}; will continue automatically` },
81687
+ ...holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}
81688
+ });
81689
+ }
81690
+ if (Date.now() >= deadline) throw new Error("Computer is still busy. Stop the turn using it, then retry.");
81691
+ await new Promise((resolve16) => setTimeout(resolve16, 100));
81692
+ }
81693
+ } finally {
81694
+ if (waitingMessage) store.patchMessage(owner2.threadId, waitingMessage.id, {
81695
+ tool: { name: active() && turnResources.owns(resource, owner2) ? "Computer available \u2014 continuing" : "Computer wait ended", ok: true }
81696
+ });
81633
81697
  }
81634
81698
  turnResourceOwners.set(owner2.threadId, owner2);
81635
81699
  turnComputerResources.set(owner2.threadId, { owner: owner2, resource });
@@ -83426,10 +83490,10 @@ var watchdog = new TurnWatchdog({
83426
83490
  groupSpeakers.delete(turn.threadId);
83427
83491
  store.patchGroup(group.id, { busyBotId: null, unread: true });
83428
83492
  }
83493
+ releaseTurnResources(stalledResourceOwner);
83429
83494
  const currentBot = store.bot(turn.botId);
83430
83495
  if (currentBot?.busy) {
83431
83496
  stopScreenPoller(currentBot.id, turn.threadId);
83432
- releaseTurnResources(stalledResourceOwner);
83433
83497
  if (activeVpsThreads.get(currentBot.id) === turn.threadId) activeVpsThreads.delete(currentBot.id);
83434
83498
  if (store.taskByThread(currentBot.id, turn.threadId)) store.setTaskActivity(currentBot.id, turn.threadId, "idle");
83435
83499
  else store.setActivity(currentBot.id, "idle");
@@ -83611,11 +83675,11 @@ async function attachTeamBox(computer, botId, owner2, canMount, remoteAgent) {
83611
83675
  const ownerId = teamComputerOwner(computer.id);
83612
83676
  if (boxLifecycleBusyBots.has(ownerId)) throw new Error("The team computer is being changed; wait for it to finish");
83613
83677
  if (computerControl.snapshot(ownerId).held) throw new Error("Release human control of the team computer before starting another turn");
83614
- bindTurnComputer(owner2, `computer:box-bot:${ownerId}`, true);
83678
+ await bindTurnComputer(owner2, `computer:box-bot:${ownerId}`, true);
83615
83679
  teamComputerTurns.set(owner2.threadId, { owner: owner2, computerId: computer.id, botId, remoteAgent });
83616
83680
  let machine = await findBox(cfg, ownerId);
83617
83681
  if (!machine) throw new Error("The team's Box computer is missing; explicitly create or retry it from the Team map");
83618
- bindTurnComputer(owner2, `computer:box:${machine.id}`, true);
83682
+ await bindTurnComputer(owner2, `computer:box:${machine.id}`, true);
83619
83683
  const action = boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
83620
83684
  if (action === "wake") machine = await readyBox(cfg, ownerId);
83621
83685
  if (!machine || boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
@@ -85013,7 +85077,7 @@ async function startTurn(botId, text8, opts) {
85013
85077
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) return false;
85014
85078
  }
85015
85079
  try {
85016
- bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
85080
+ await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
85017
85081
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
85018
85082
  throw new Error("this Local VM is being started, stopped, or replaced \u2014 wait for setup to finish");
85019
85083
  }
@@ -85058,7 +85122,7 @@ async function startTurn(botId, text8, opts) {
85058
85122
  }
85059
85123
  const cua = readCuaConnection();
85060
85124
  if (!cua) throw new Error("CUA Driver is not ready for this computer \u2014 check permissions and restart OpenMausBot");
85061
- bindTurnComputer(resourceOwner, "computer:host");
85125
+ await bindTurnComputer(resourceOwner, "computer:host");
85062
85126
  integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
85063
85127
  computerKind = "local";
85064
85128
  }
@@ -85067,7 +85131,7 @@ async function startTurn(botId, text8, opts) {
85067
85131
  if (unsupported && wants2 === "cloud") throw new Error(unsupported);
85068
85132
  if (unsupported && wants2 === void 0) autoVpsProblem = unsupported;
85069
85133
  if (!unsupported) {
85070
- bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
85134
+ await bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
85071
85135
  activeVpsThreads.set(bot.id, threadId);
85072
85136
  let remote;
85073
85137
  remote = vpsStartsForTurn({ wants: wants2, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource }) ? await vpsComputerAction("provision", cfg, bot.id) : await inspectVpsForAuto(cfg, bot.id);
@@ -85097,7 +85161,7 @@ async function startTurn(botId, text8, opts) {
85097
85161
  computerKind = "box";
85098
85162
  }
85099
85163
  if (!teamComputer && mountsCloudComputer && (wants2 === "cloud" || wants2 === void 0) && cloudBackend === "box" && boxConfigured(cfg)) {
85100
- if (wants2 === "cloud") bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
85164
+ if (wants2 === "cloud") await bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
85101
85165
  if (!mountsCloudComputer && wants2 === "cloud") {
85102
85166
  throw new Error("this model engine cannot use computer tools \u2014 choose Claude, an ACP engine, or the Computer engine");
85103
85167
  }
@@ -85133,7 +85197,7 @@ async function startTurn(botId, text8, opts) {
85133
85197
  });
85134
85198
  }
85135
85199
  if (b3 && lifecycle === "attach") {
85136
- bindTurnComputer(resourceOwner, `computer:box:${b3.id}`, instance.driverKind === "boxAgent");
85200
+ await bindTurnComputer(resourceOwner, `computer:box:${b3.id}`, instance.driverKind === "boxAgent");
85137
85201
  previewCapture = () => screenshotBox(cfg, bot.id, b3.id);
85138
85202
  if (mountsCloudComputer) {
85139
85203
  integrations.computer = {
@@ -85161,7 +85225,7 @@ async function startTurn(botId, text8, opts) {
85161
85225
  })) {
85162
85226
  const cua = readCuaConnection();
85163
85227
  if (cua) {
85164
- bindTurnComputer(resourceOwner, "computer:host");
85228
+ await bindTurnComputer(resourceOwner, "computer:host");
85165
85229
  integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
85166
85230
  computerKind = "local";
85167
85231
  }
@@ -85415,6 +85479,7 @@ function routineRunCard(run3) {
85415
85479
  status: run3.status
85416
85480
  };
85417
85481
  if (run3.goalStatus) card.goalStatus = run3.goalStatus;
85482
+ if (run3.deferredAt != null && run3.status === "queued") card.deferredAt = run3.deferredAt;
85418
85483
  if (run3.threadId) card.executionThreadId = run3.threadId;
85419
85484
  if (summary) card.summary = summary;
85420
85485
  if (error51) card.error = error51;
@@ -85422,7 +85487,7 @@ function routineRunCard(run3) {
85422
85487
  }
85423
85488
  function routineRunFallbackText(card) {
85424
85489
  const goalState = card.goalStatus === "needs-input" ? "needs your input" : card.goalStatus === "blocked" ? "was blocked" : card.goalStatus === "limit-reached" ? "reached its limit" : card.goalStatus === "stopped" ? "was stopped" : card.goalStatus === "failed" ? "failed" : void 0;
85425
- const state = goalState ?? (card.status === "waiting" ? "needs your attention" : card.status === "completed" ? "completed" : card.status === "failed" ? "failed" : card.status === "cancelled" ? "was cancelled" : card.status === "missed" ? "was missed" : card.status);
85490
+ const state = goalState ?? (card.status === "waiting" ? "needs your attention" : card.status === "completed" ? "completed" : card.status === "failed" ? "failed" : card.status === "cancelled" ? "was cancelled" : card.status === "missed" ? "was missed" : card.status === "queued" && card.deferredAt != null ? "deferred: target busy" : card.status);
85426
85491
  return `Routine \u201C${card.routineName}\u201D ${state}`;
85427
85492
  }
85428
85493
  function syncRoutineRunToSource(run3) {
@@ -85585,6 +85650,14 @@ routines = new RoutineManager({
85585
85650
  const detail = run3.error ? `${run3.routineName}: ${run3.error}` : run3.routineName;
85586
85651
  const notificationBot = routineSourceOwner(run3)?.bot ?? bot;
85587
85652
  notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run3) ?? run3.threadId ?? bot.threadId, detail));
85653
+ },
85654
+ onRunDeferred: (run3) => {
85655
+ const bot = store.bot(run3.botId);
85656
+ if (!bot) return;
85657
+ const minutes = run3.deferredAt != null && run3.deferredNoticeAt != null ? Math.max(1, Math.round((run3.deferredNoticeAt - run3.deferredAt) / 6e4)) : null;
85658
+ const detail = `${redactSecretsInText(run3.routineName)}: target busy${minutes != null ? ` for ${minutes} minutes` : ""}`;
85659
+ const notificationBot = routineSourceOwner(run3)?.bot ?? bot;
85660
+ notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run3) ?? bot.threadId, detail));
85588
85661
  }
85589
85662
  });
85590
85663
  var recoveredRoutineGoalRuns = new Map(
@@ -86424,7 +86497,7 @@ async function runGroupMemberTurn(groupId, threadId, botId, hop, spoken = /* @__
86424
86497
  throw new Error("this model engine cannot use the Local VM");
86425
86498
  }
86426
86499
  const target = { ...localVmTargetForBot(readyBot.id) };
86427
- bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
86500
+ await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
86428
86501
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
86429
86502
  throw new Error("this Local VM is being started, stopped, or replaced");
86430
86503
  }
@@ -86775,6 +86848,7 @@ ${e.text}`;
86775
86848
  return true;
86776
86849
  } catch (error51) {
86777
86850
  if (providerDispatched) throw error51;
86851
+ if (error51 instanceof DirectTurnSetupCancelled) return false;
86778
86852
  const isSpendCap = typeof error51 === "object" && error51 !== null && error51.code === "spend_cap";
86779
86853
  const isVariantError = typeof error51 === "object" && error51 !== null && error51.code === "unsupported_model_variant";
86780
86854
  if (!roomSpeaker && !isSpendCap && !isVariantError) throw error51;
@@ -20054,7 +20054,7 @@ function createAcpDriver(support11) {
20054
20054
  return { turnId };
20055
20055
  }
20056
20056
  const resolvedModel = support11.resolveTurnModel?.(turn.model, env);
20057
- support11.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model });
20057
+ support11.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
20058
20058
  const cliTurn = resolvedModel !== void 0 && resolvedModel !== turn.model ? { ...turn, model: resolvedModel } : turn;
20059
20059
  const mcpServers = acpMcpServers(turn);
20060
20060
  let launch;
@@ -36256,6 +36256,28 @@ var init_opencode_go = __esm({
36256
36256
  modelVariants: true,
36257
36257
  resolveTurnModel: (model, env) => model ? ensureOpenCodeInjectModel(normalizeLegacyOpenCodeModel(model, env), env) : model,
36258
36258
  transformEnv: stripForeignProviderKeys,
36259
+ applyTurnEnv: (env, { fullAuto }) => {
36260
+ if (!fullAuto) return;
36261
+ env.OPENCODE_PERMISSION = JSON.stringify(Object.fromEntries([
36262
+ "*",
36263
+ "external_directory",
36264
+ "read",
36265
+ "edit",
36266
+ "bash",
36267
+ "glob",
36268
+ "grep",
36269
+ "list",
36270
+ "task",
36271
+ "lsp",
36272
+ "skill",
36273
+ "webfetch",
36274
+ "websearch",
36275
+ "codesearch",
36276
+ "todoread",
36277
+ "todowrite",
36278
+ "doom_loop"
36279
+ ].map((permission) => [permission, "allow"])));
36280
+ },
36259
36281
  pickAuthMethod: () => null,
36260
36282
  authFailure: "continue",
36261
36283
  isAuthenticated: async (env, config2) => Boolean(env.OPENCODE_API_KEY) || hasStoredOpenCodeAuth(env) || await canListOpenCodeModels(env, config2.cli),
@@ -20054,7 +20054,7 @@ function createAcpDriver(support11) {
20054
20054
  return { turnId };
20055
20055
  }
20056
20056
  const resolvedModel = support11.resolveTurnModel?.(turn.model, env);
20057
- support11.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model });
20057
+ support11.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
20058
20058
  const cliTurn = resolvedModel !== void 0 && resolvedModel !== turn.model ? { ...turn, model: resolvedModel } : turn;
20059
20059
  const mcpServers = acpMcpServers(turn);
20060
20060
  let launch;
@@ -36256,6 +36256,28 @@ var init_opencode_go = __esm({
36256
36256
  modelVariants: true,
36257
36257
  resolveTurnModel: (model, env) => model ? ensureOpenCodeInjectModel(normalizeLegacyOpenCodeModel(model, env), env) : model,
36258
36258
  transformEnv: stripForeignProviderKeys,
36259
+ applyTurnEnv: (env, { fullAuto }) => {
36260
+ if (!fullAuto) return;
36261
+ env.OPENCODE_PERMISSION = JSON.stringify(Object.fromEntries([
36262
+ "*",
36263
+ "external_directory",
36264
+ "read",
36265
+ "edit",
36266
+ "bash",
36267
+ "glob",
36268
+ "grep",
36269
+ "list",
36270
+ "task",
36271
+ "lsp",
36272
+ "skill",
36273
+ "webfetch",
36274
+ "websearch",
36275
+ "codesearch",
36276
+ "todoread",
36277
+ "todowrite",
36278
+ "doom_loop"
36279
+ ].map((permission) => [permission, "allow"])));
36280
+ },
36259
36281
  pickAuthMethod: () => null,
36260
36282
  authFailure: "continue",
36261
36283
  isAuthenticated: async (env, config2) => Boolean(env.OPENCODE_API_KEY) || hasStoredOpenCodeAuth(env) || await canListOpenCodeModels(env, config2.cli),
@@ -327,7 +327,7 @@ export function createAcpDriver(support) {
327
327
  return { turnId };
328
328
  }
329
329
  const resolvedModel = support.resolveTurnModel?.(turn.model, env);
330
- support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model });
330
+ support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
331
331
  const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
332
332
  ? { ...turn, model: resolvedModel }
333
333
  : turn;
@@ -340,6 +340,19 @@ const support = (loadCatalog) => ({
340
340
  ? ensureOpenCodeInjectModel(normalizeLegacyOpenCodeModel(model, env), env)
341
341
  : model,
342
342
  transformEnv: stripForeignProviderKeys,
343
+ applyTurnEnv: (env, { fullAuto }) => {
344
+ if (!fullAuto)
345
+ return;
346
+ // Scope native permissions to this child, not the user's OpenCode config.
347
+ // A wildcard alone leaves OpenCode's more-specific external-directory and
348
+ // read rules in place. Replace the built-in rules as well, including path
349
+ // maps, so Full means the same thing inside the provider and in OMB.
350
+ env.OPENCODE_PERMISSION = JSON.stringify(Object.fromEntries([
351
+ "*", "external_directory", "read", "edit", "bash", "glob", "grep",
352
+ "list", "task", "lsp", "skill", "webfetch", "websearch", "codesearch",
353
+ "todoread", "todowrite", "doom_loop",
354
+ ].map((permission) => [permission, "allow"])));
355
+ },
343
356
  pickAuthMethod: () => null,
344
357
  authFailure: "continue",
345
358
  isAuthenticated: async (env, config) => (Boolean(env.OPENCODE_API_KEY)
@@ -616,9 +616,39 @@ function releaseTurnResources(owner) {
616
616
  teamComputerTurns.delete(owner.threadId);
617
617
  }
618
618
  }
619
- function bindTurnComputer(owner, resource, exclusive = false) {
620
- if (exclusive && !claimTurnResource(owner, resource)) {
621
- throw Object.assign(new Error("another thread is using this computer — wait for it to finish"), { status: 409, code: "computer_busy" });
619
+ async function bindTurnComputer(owner, resource, exclusive = false) {
620
+ const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
621
+ turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
622
+ let waitingMessage;
623
+ const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
624
+ try {
625
+ while (true) {
626
+ if (!active())
627
+ throw new DirectTurnSetupCancelled("Computer wait cancelled");
628
+ if (!exclusive || claimTurnResource(owner, resource))
629
+ break;
630
+ if (!waitingMessage) {
631
+ const blocker = turnResources.blocker(resource, owner);
632
+ const holderBot = blocker && store.botByThread(blocker.threadId);
633
+ const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
634
+ const holder = holderBot ? `${holderBot.name}${holderTask?.title ? ` / ${holderTask.title}` : ""}`
635
+ : blocker && store.groupByThread(blocker.threadId)?.name;
636
+ waitingMessage = store.appendMessage(owner.threadId, {
637
+ role: "bot", kind: "activity",
638
+ tool: { name: `Waiting for computer${holder ? ` — ${holder} is using it` : ""}; will continue automatically` },
639
+ ...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
640
+ });
641
+ }
642
+ if (Date.now() >= deadline)
643
+ throw new Error("Computer is still busy. Stop the turn using it, then retry.");
644
+ await new Promise(resolve => setTimeout(resolve, 100));
645
+ }
646
+ }
647
+ finally {
648
+ if (waitingMessage)
649
+ store.patchMessage(owner.threadId, waitingMessage.id, {
650
+ tool: { name: active() && turnResources.owns(resource, owner) ? "Computer available — continuing" : "Computer wait ended", ok: true },
651
+ });
622
652
  }
623
653
  turnResourceOwners.set(owner.threadId, owner);
624
654
  turnComputerResources.set(owner.threadId, { owner, resource });
@@ -2943,10 +2973,12 @@ const watchdog = new TurnWatchdog({
2943
2973
  groupSpeakers.delete(turn.threadId);
2944
2974
  store.patchGroup(group.id, { busyBotId: null, unread: true });
2945
2975
  }
2976
+ // A cleared UI busy flag must not strand this finished generation's
2977
+ // computer claim. The generation checks above protect replacements.
2978
+ releaseTurnResources(stalledResourceOwner);
2946
2979
  const currentBot = store.bot(turn.botId);
2947
2980
  if (currentBot?.busy) {
2948
2981
  stopScreenPoller(currentBot.id, turn.threadId);
2949
- releaseTurnResources(stalledResourceOwner);
2950
2982
  if (activeVpsThreads.get(currentBot.id) === turn.threadId)
2951
2983
  activeVpsThreads.delete(currentBot.id);
2952
2984
  if (store.taskByThread(currentBot.id, turn.threadId))
@@ -3223,12 +3255,12 @@ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
3223
3255
  throw new Error("The team computer is being changed; wait for it to finish");
3224
3256
  if (computerControl.snapshot(ownerId).held)
3225
3257
  throw new Error("Release human control of the team computer before starting another turn");
3226
- bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
3258
+ await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
3227
3259
  teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
3228
3260
  let machine = await box.findBox(cfg, ownerId);
3229
3261
  if (!machine)
3230
3262
  throw new Error("The team's Box computer is missing; explicitly create or retry it from the Team map");
3231
- bindTurnComputer(owner, `computer:box:${machine.id}`, true);
3263
+ await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
3232
3264
  const action = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
3233
3265
  if (action === "wake")
3234
3266
  machine = await box.readyBox(cfg, ownerId);
@@ -5209,7 +5241,7 @@ async function startTurn(botId, text, opts) {
5209
5241
  return false;
5210
5242
  }
5211
5243
  try {
5212
- bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
5244
+ await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
5213
5245
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
5214
5246
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
5215
5247
  }
@@ -5268,7 +5300,7 @@ async function startTurn(botId, text, opts) {
5268
5300
  const cua = readCuaConnection();
5269
5301
  if (!cua)
5270
5302
  throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
5271
- bindTurnComputer(resourceOwner, "computer:host");
5303
+ await bindTurnComputer(resourceOwner, "computer:host");
5272
5304
  integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
5273
5305
  computerKind = "local";
5274
5306
  }
@@ -5284,7 +5316,7 @@ async function startTurn(botId, text, opts) {
5284
5316
  if (!unsupported) {
5285
5317
  // The remote lifecycle and container are shared by this bot. Keep
5286
5318
  // its explicit computer turns serialized; ordinary threads still run.
5287
- bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
5319
+ await bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
5288
5320
  activeVpsThreads.set(bot.id, threadId);
5289
5321
  let remote;
5290
5322
  remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
@@ -5322,7 +5354,7 @@ async function startTurn(botId, text, opts) {
5322
5354
  // Explicit cloud turns can provision/wake the same bot's Box. Claim
5323
5355
  // before any network await so setup itself cannot race another turn.
5324
5356
  if (wants === "cloud")
5325
- bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
5357
+ await bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
5326
5358
  if (!mountsCloudComputer && wants === "cloud") {
5327
5359
  throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
5328
5360
  }
@@ -5366,7 +5398,7 @@ async function startTurn(botId, text, opts) {
5366
5398
  });
5367
5399
  }
5368
5400
  if (b && lifecycle === "attach") {
5369
- bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
5401
+ await bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
5370
5402
  previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
5371
5403
  if (mountsCloudComputer) {
5372
5404
  integrations.computer = {
@@ -5407,7 +5439,7 @@ async function startTurn(botId, text, opts) {
5407
5439
  })) {
5408
5440
  const cua = readCuaConnection();
5409
5441
  if (cua) {
5410
- bindTurnComputer(resourceOwner, "computer:host");
5442
+ await bindTurnComputer(resourceOwner, "computer:host");
5411
5443
  integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
5412
5444
  computerKind = "local";
5413
5445
  }
@@ -5771,6 +5803,8 @@ function routineRunCard(run) {
5771
5803
  };
5772
5804
  if (run.goalStatus)
5773
5805
  card.goalStatus = run.goalStatus;
5806
+ if (run.deferredAt != null && run.status === "queued")
5807
+ card.deferredAt = run.deferredAt;
5774
5808
  if (run.threadId)
5775
5809
  card.executionThreadId = run.threadId;
5776
5810
  if (summary)
@@ -5801,7 +5835,9 @@ function routineRunFallbackText(card) {
5801
5835
  ? "was cancelled"
5802
5836
  : card.status === "missed"
5803
5837
  ? "was missed"
5804
- : card.status);
5838
+ : card.status === "queued" && card.deferredAt != null
5839
+ ? "deferred: target busy"
5840
+ : card.status);
5805
5841
  return `Routine “${card.routineName}” ${state}`;
5806
5842
  }
5807
5843
  /** Upsert one durable lifecycle card per run. Replaying the same transition,
@@ -6005,6 +6041,17 @@ routines = new RoutineManager({
6005
6041
  const notificationBot = routineSourceOwner(run)?.bot ?? bot;
6006
6042
  notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
6007
6043
  },
6044
+ onRunDeferred: (run) => {
6045
+ const bot = store.bot(run.botId);
6046
+ if (!bot)
6047
+ return;
6048
+ const minutes = run.deferredAt != null && run.deferredNoticeAt != null
6049
+ ? Math.max(1, Math.round((run.deferredNoticeAt - run.deferredAt) / 60_000))
6050
+ : null;
6051
+ const detail = `${redactSecretsInText(run.routineName)}: target busy${minutes != null ? ` for ${minutes} minutes` : ""}`;
6052
+ const notificationBot = routineSourceOwner(run)?.bot ?? bot;
6053
+ notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
6054
+ },
6008
6055
  });
6009
6056
  // The scheduler receipt and room transcript live in separate durable stores.
6010
6057
  // If the process exited between those two writes, prefer the correlated
@@ -7009,7 +7056,7 @@ setupRetry = 0) {
7009
7056
  }
7010
7057
  // A distinct identity fences cleanup even in shared mode on the same room thread.
7011
7058
  const target = { ...localVmTargetForBot(readyBot.id) };
7012
- bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
7059
+ await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
7013
7060
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
7014
7061
  throw new Error("this Local VM is being started, stopped, or replaced");
7015
7062
  }
@@ -7449,6 +7496,8 @@ setupRetry = 0) {
7449
7496
  catch (error) {
7450
7497
  if (providerDispatched)
7451
7498
  throw error;
7499
+ if (error instanceof DirectTurnSetupCancelled)
7500
+ return false;
7452
7501
  const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
7453
7502
  const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
7454
7503
  if (!roomSpeaker && !isSpendCap && !isVariantError)
@@ -10,6 +10,9 @@
10
10
  // bot is not working, and the fix is usually a setting only a person can
11
11
  // change, so a retry cannot clear it. A routine failure already buzzed;
12
12
  // this makes an interactive turn behave the same way.
13
+ // A routine parked behind a busy target earns one notice after half an
14
+ // hour: from outside the schedule looks broken, and only a person can
15
+ // decide whether the busy work or the routine matters more.
13
16
  //
14
17
  // Delivery is a separate concern. The harness emits a frame; whoever is
15
18
  // listening decides what to do with it — desktop and paired-phone local
@@ -53,9 +56,11 @@ export function buildNotification(kind, bot, threadId, detail, extra) {
53
56
  ? `${who} needs your hands`
54
57
  : kind === "routine-failed"
55
58
  ? `${who}'s routine failed`
56
- : kind === "turn-failed"
57
- ? `${who} couldn't start`
58
- : `${who} finished`;
59
+ : kind === "routine-deferred"
60
+ ? `${who}'s routine is waiting`
61
+ : kind === "turn-failed"
62
+ ? `${who} couldn't start`
63
+ : `${who} finished`;
59
64
  // A "finished" with nothing to say is not worth a notification — the
60
65
  // badge in the sidebar already carries that much.
61
66
  if (kind === "done" && !body)
@@ -22,6 +22,9 @@ const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
22
22
  const CATCH_UP_MS = 12 * 60 * 60_000;
23
23
  /** How long before a due routine the computer is asked to stay awake. */
24
24
  const WAKE_HORIZON_MS = 60 * 60_000;
25
+ /** How long a run may sit deferred behind a busy target before the user
26
+ * hears about it once. Surfacing only; dispatch semantics stay unchanged. */
27
+ export const ROUTINE_DEFERRAL_NOTICE_MS = 30 * 60_000;
25
28
  const MAX_DATE_MS = 8_640_000_000_000_000;
26
29
  const LOCAL_DAY_MS = 24 * 60 * 60_000;
27
30
  const INTERVAL_RESTRICTION_SEARCH_MS = 9 * LOCAL_DAY_MS;
@@ -1168,8 +1171,23 @@ export class RoutineManager {
1168
1171
  }
1169
1172
  }
1170
1173
  const state = this.targetState(run);
1171
- if (state === "busy")
1174
+ if (state === "busy") {
1175
+ // A queued run behind a busy target is deferred, not silent. Stamp
1176
+ // the wait once so receipts and cards can say how long it has been
1177
+ // held; the run still dispatches the moment the target frees.
1178
+ if (run.deferredAt == null) {
1179
+ run.deferredAt = now;
1180
+ this.save();
1181
+ this.emitRun(run);
1182
+ }
1183
+ if (run.deferredNoticeAt == null && now - run.deferredAt >= ROUTINE_DEFERRAL_NOTICE_MS) {
1184
+ run.deferredNoticeAt = now;
1185
+ this.save();
1186
+ this.emitRun(run);
1187
+ this.options.onRunDeferred?.(cloneRun(run));
1188
+ }
1172
1189
  continue;
1190
+ }
1173
1191
  if (state === "missing") {
1174
1192
  this.failRun(run, this.missingTargetMessage(run.target));
1175
1193
  continue;
@@ -6,11 +6,16 @@ import { relative, resolve, sep } from "node:path";
6
6
  * are not a sandbox for arbitrary shell commands. */
7
7
  export class TurnResources {
8
8
  owners = new Map();
9
- claim(resource, owner) {
9
+ blocker(resource, owner) {
10
10
  for (const [key, current] of this.owners) {
11
11
  if (overlaps(key, resource) && !sameOwner(current, owner))
12
- return false;
12
+ return current;
13
13
  }
14
+ return undefined;
15
+ }
16
+ claim(resource, owner) {
17
+ if (this.blocker(resource, owner))
18
+ return false;
14
19
  this.owners.set(resource, owner);
15
20
  return true;
16
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openmausbot",
3
- "version": "0.1.81",
3
+ "version": "0.1.82",
4
4
  "description": "Run the OpenMausBot server anywhere and pair your devices to it",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",