@chibitek/chibi 0.1.154 → 0.1.157

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 (95) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-CbIXKQpc.js → abnfDiagram-O67JEVCF-BkYbog6D.js} +1 -1
  2. package/dist/assets/{arc-D1xLD8SN.js → arc-QwTuMsxp.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-xZe7iJ9y.js → architectureDiagram-NJMV4G6O-GdicO2PQ.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-Cq35uL03.js → blockDiagram-BEXU5L5S-Dkr29TFF.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-DjlN_-0r.js → c4Diagram-YGBWAQC7-Dz_3dstx.js} +1 -1
  6. package/dist/assets/channel-D2y0i2ps.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BQpywZWX.js → chunk-2Q5K7J3B-CGsfu-PI.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-BajOpOBu.js → chunk-3FUC2YCW-CDqI37_6.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-DuZpQtDE.js → chunk-5VM5RSS4-DnmSuyTb.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-Dn8xlUsI.js → chunk-GTNCS2PH-Dh7tXcJT.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-Xrus5lAx.js → chunk-GWA4HPMP-D_SM1ZrY.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-Dhf8RCqM.js → chunk-JWPE2WC7-DmeMuTsI.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-BQXmOQMl.js → chunk-NETBCI7D-R1UT9b5f.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-BeraxyW-.js → chunk-WEXAMYUT-DpPfU1yO.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BsKuu9tP.js → chunk-XXDRQBXY-DlihfTkM.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-CcR0y3zf.js → classDiagram-v2-NBCMYWYE-BlTV6lSC.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-Dd2y43ln.js → cose-bilkent-JH36ORCC-Br1kPFWw.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-DL4Zf255.js → cynefin-OW5HDTMX-BqPp3ZMd.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-CvfZKzI5.js → cynefinDiagram-VND7K2PF-DFfs_k8-.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-CZfm2BmL.js → dagre-6A5THRUB-Ch3xIJA7.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-DEEoufNr.js → diagram-22UHCM2B-DIHUD87N.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-C1R6O8j1.js → diagram-3UASUU5V-CAgQQdeF.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-D9dPGToa.js → diagram-ATOU4E4O-9HVKodrI.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-BHy3uSmJ.js → diagram-CDSNMT55-DU2kDq_h.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-CEo2WbRd.js → diagram-MLGK6HIB-D-qTAhHD.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-BrZrCrGC.js → diagram-MPIPVDR6-C31SnI9P.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-CtChmI9r.js → ebnfDiagram-ZINNZB2B-BHrdiBVd.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-C-qEle2N.js → elk-276RUBZZ-DWyljRo5.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-BEdc5DT9.js → erDiagram-OPXOYQCR-DyY59XPp.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-CT-fOp9_.js → flowDiagram-KWPJA3E3-BevMIeAy.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-LWgFxnGJ.js → ganttDiagram-FUAMR5RP-Ct18WHf4.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-xHSa3-lE.js → gitGraphDiagram-X574FWY7-yhixO1N3.js} +1 -1
  33. package/dist/assets/{index-Dy25lFJN.js → index-BEJ8EKQt.js} +1 -1
  34. package/dist/assets/index-DGWeJcck.css +1 -0
  35. package/dist/assets/{index-BpHS2C5J.js → index-ziyH_uer.js} +170 -168
  36. package/dist/assets/{infoDiagram-VRGFBTTK-BOyODyhP.js → infoDiagram-VRGFBTTK-CYa-r_S5.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-D3F47TMU.js → ishikawaDiagram-OU5B5YK6-CnxwM7gY.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-B8h_aacv.js → journeyDiagram-ZHPQQLJL-RKQfXBrp.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-nqNasrdQ.js → kanban-definition-PNTS6WVX-CSlRDIF6.js} +1 -1
  40. package/dist/assets/{linear-CDofN9-p.js → linear-Ch19KVyt.js} +1 -1
  41. package/dist/assets/{mermaid.core-k18eNV07.js → mermaid.core-Bek7I_t6.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-BYNmNDXA.js → mindmap-definition-NLK3R4M7-B_LiP8su.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-44c5V9Pa.js → pegDiagram-GJSIUBJH-DhzQeiZf.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-BDvznIYT.js → pieDiagram-5QR66LMP-D-BYNKHS.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-BWrzS8SI.js → quadrantDiagram-O4NWA36T-DnCm0heP.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-H_3uxTrq.js → railroadDiagram-XR7U4H2S-COqgYnhn.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP-BiyKTF3R.js → requirementDiagram-PLB6GJNP-Dvt72ajf.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-43RmpN9N.js → sankeyDiagram-IPEJSGJF-CisC9nbp.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-B0OUYqhV.js → sequenceDiagram-PO4LG4MO-BO8xqvxa.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-xiXiEFtk.js → sizeCapture-INFHLROL-DLk1jBzf.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK-C4-l5TZJ.js → stateDiagram-v2-GCMORJYK-C_wutSE7.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-B58p5Kvz.js → swimlanes-2SLR337P-hqo1UlEi.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-D15JqTL9.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-DUgR2Qsq.js → timeline-definition-EJHVYXUP-C_DJldPg.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-DcicVGBU.js → usecaseDiagram-POWQR4AR-CsidVXMA.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-9pThMhQ1.js → vennDiagram-UO4OBE2U-Bs-IIqn_.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-CStNs1Wv.js → wardleyDiagram-VNRHLVJA-CXEUyxaj.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-JmQMXi-y.js → xychartDiagram-PMCCYNJV-B9qvmm4P.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/container-mcp.js +81 -81
  61. package/dist-server/drivers/agents-proxy.js +81 -81
  62. package/dist-server/index.js +2447 -1767
  63. package/dist-server/mcp-gate.js +7 -4
  64. package/dist-server/openmausbot.js +692 -665
  65. package/dist-server/pair-cli.js +692 -665
  66. package/dist-server/server/app-installers.js +107 -7
  67. package/dist-server/server/cli-help.js +74 -0
  68. package/dist-server/server/cli.js +32 -74
  69. package/dist-server/server/device-routing.js +60 -0
  70. package/dist-server/server/drivers/claude.js +13 -11
  71. package/dist-server/server/drivers/codex.js +18 -23
  72. package/dist-server/server/drivers/local-inject.js +3 -4
  73. package/dist-server/server/engine-tiers.js +5 -14
  74. package/dist-server/server/incidents.js +8 -4
  75. package/dist-server/server/index.js +98 -120
  76. package/dist-server/server/mcp-auth-signal.js +2 -1
  77. package/dist-server/server/mcp-gate-config.js +1 -0
  78. package/dist-server/server/mcp-gate.js +7 -4
  79. package/dist-server/server/mcp-health.js +14 -4
  80. package/dist-server/server/mcp-turn.js +26 -0
  81. package/dist-server/server/personal-goal-runtime.js +86 -0
  82. package/dist-server/server/personal-goal-turn.js +125 -0
  83. package/dist-server/server/personal-goals.js +195 -0
  84. package/dist-server/server/routes/local-models.js +3 -2
  85. package/dist-server/server/skill-fetch.js +14 -0
  86. package/dist-server/server/system-prompt.js +14 -6
  87. package/dist-server/shared/engine-choices.js +15 -0
  88. package/dist-server/shared/engine-tiers.js +74 -2
  89. package/dist-server/shared/local-models.js +1 -3
  90. package/dist-server/shared/personal-goal.js +43 -0
  91. package/dist-server/vps-container-mcp.js +81 -81
  92. package/package.json +1 -1
  93. package/dist/assets/channel-Bm-MISs3.js +0 -1
  94. package/dist/assets/index-C8YP7Zxj.css +0 -1
  95. package/dist/assets/swimlanesDiagram-TC7HE7FX-CPlL77A3.js +0 -8
@@ -78,6 +78,8 @@ function whatHappened(incident) {
78
78
  return `${incident.bot.name}'s run ${where} continued on another model because its engine refused the one it was set to${detail}`;
79
79
  case "engine-fallback":
80
80
  return `${incident.bot.name}'s run ${where} continued on another engine after its selected engine failed${detail}`;
81
+ case "mcp-unavailable":
82
+ return `${incident.bot.name}'s run ${where} continued without one of its tool servers${detail}`;
81
83
  default:
82
84
  return `${incident.bot.name}'s run ${where} failed${detail}`;
83
85
  }
@@ -99,13 +101,15 @@ export function incidentText(incident, count) {
99
101
  lines.push(`${incident.bot.name} last said: "${fold(incident.lastReply, 300)}"`);
100
102
  if (count.count > 1)
101
103
  lines.push(`This is the ${ordinal(count.count)} incident on that thread within the hour.`);
102
- if (incident.kind === "model-fallback" || incident.kind === "engine-fallback") {
103
- // Nothing broke: the turn ran. The failed selection or engine still
104
- // needs attention, but the completed work must never be retried.
104
+ if (incident.kind === "model-fallback" || incident.kind === "engine-fallback" || incident.kind === "mcp-unavailable") {
105
+ // Nothing broke: the turn ran. The failed selection, engine or tool
106
+ // server still needs attention, but the completed work must never be retried.
105
107
  lines.push("The run was not interrupted and needs no retry; do not call retry_thread.");
106
108
  lines.push(incident.kind === "model-fallback"
107
109
  ? "Tell the person in one or two plain sentences that this engine no longer offers that model and that the bot's or the conversation's model setting should be changed, then stop."
108
- : "Use the incident detail to correct the failed engine if action is needed, then report what happened in one or two plain sentences and stop.");
110
+ : incident.kind === "mcp-unavailable"
111
+ ? "Tell the person in one or two plain sentences which tool server is not working, that bots carry on without it until it answers again, and that its command and settings in Settings may need a look, then stop."
112
+ : "Use the incident detail to correct the failed engine if action is needed, then report what happened in one or two plain sentences and stop.");
109
113
  return lines.join("\n");
110
114
  }
111
115
  lines.push(count.mayRetry
@@ -1,3 +1,7 @@
1
+ import { hasUnboundGoalGuard, goalGuardForEvent, addGoalGuard, removeGoalGuard, runPersonalGoalTurn, updatePersonalGoalReceipt } from "./personal-goal-turn.js";
2
+ import { PersonalGoalRuntime, receiveGoalCommand } from "./personal-goal-runtime.js";
3
+ import { personalGoalInstructions } from "./personal-goals.js";
4
+ import { parseGoalCommand } from "../shared/personal-goal.js";
1
5
  // OpenMausBot server — the harness host. Clients hold no transports
2
6
  // (upstream rule): the React app dispatches typed commands over HTTP and
3
7
  // folds one SSE event stream; every provider process runs here.
@@ -92,7 +96,7 @@ import { probeMcpServer } from "./mcp-probe.js";
92
96
  import { McpToolHintStore, readOnlyToolAllowed } from "./mcp-tool-hints.js";
93
97
  import { McpAuthStateStore } from "./mcp-auth-state.js";
94
98
  import { McpHealthStore } from "./mcp-health.js";
95
- import { drainMcpAuthSignals } from "./mcp-auth-signal.js";
99
+ import { mcpTurnPolicy } from "./mcp-turn.js";
96
100
  import { mcpAuthSignalDir } from "./mcp-gate-config.js";
97
101
  import { bridgeMcpRemote } from "./mcp-remote-bridge.js";
98
102
  import { mcpToolRefFromLabel } from "../shared/mcp-tool-ref.js";
@@ -143,7 +147,7 @@ import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openFol
143
147
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
144
148
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
145
149
  import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
146
- import { fetchSkillFromSource } from "./skill-fetch.js";
150
+ import { resolveSkillImport, skillImportSchema } from "./skill-fetch.js";
147
151
  import { pickBotName } from "./names.js";
148
152
  import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
149
153
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
@@ -235,6 +239,7 @@ import { activityCsv, activityEntries, appendAdminAction, botAuditSnapshot, botC
235
239
  import { frameForMember, memberBody, memberBot, noteSeen, notFoundFor, audienceEquals, memberGroup, narrowestAudience, parseVisibility, pathSubject, roomFeeds, routineVisible, sameAudience, SEES_EVERYTHING, SEES_ALL_BUT_PRIVATE, VisibleSet, } from "./bot-visibility.js";
236
240
  import { describeBrand, loadBrand } from "./brand.js";
237
241
  import { deliverSseFrame } from "./sse-fanout.js";
242
+ import { DeviceRouteRegistry, removeStream } from "./device-routing.js";
238
243
  import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, PhoneSecretSubmissionRegistry, assertPhoneSecretRequestMatches, phoneSecretOperationId, } from "./phone-secret.js";
239
244
  // Keep these two last: a route module may import any server module, and
240
245
  // loading the table after everything above leaves module start-up order as is.
@@ -248,7 +253,7 @@ import { createModelLevelsRoutes } from "./routes/model-levels.js";
248
253
  import { createMcpMarketplaceRoutes } from "./routes/mcp-marketplace.js";
249
254
  import { createLocalModelRoutes, OLLAMA_LOCAL_URL, ollamaInstalledHere } from "./routes/local-models.js";
250
255
  import { createSetupRoutes } from "./routes/setup.js";
251
- import { installOllamaApp, openPodmanInstaller, runTool, startPodmanMachine } from "./app-installers.js";
256
+ import { installOllamaApp, openPodmanInstaller, startOllamaApp, runTool, startPodmanMachine } from "./app-installers.js";
252
257
  import { CliLoginRunner } from "./cli-login.js";
253
258
  import { JobFailure, SetupJobs } from "./setup-jobs.js";
254
259
  import { localHost, probeLocalInjects } from "./drivers/local-inject.js";
@@ -1820,6 +1825,7 @@ function requestedTaskBot(botId, rawThreadId) {
1820
1825
  return task;
1821
1826
  }
1822
1827
  async function interruptDirectThread(botId, threadId) {
1828
+ personalGoals.steer(threadId);
1823
1829
  const requestOwner = directRequestOwners.get(threadId);
1824
1830
  if (requestOwner) {
1825
1831
  requestOwner.stopped = true;
@@ -3350,7 +3356,7 @@ function localModeTurn(resolution, turn) {
3350
3356
  return { ...resolution, local: true, choice: { ...choice, selection: placement.selection, candidate: placement.candidate } };
3351
3357
  }
3352
3358
  const payPerUsePausedLine = (until) => `Pay-per-use is paused until ${restTime(until)}, when a subscription is back, so this turn stopped.`;
3353
- /** A model this machine serves: never a stand-in for Engineering and above. */
3359
+ /** A model this machine serves: a rung when a ladder names it, else local rules only. */
3354
3360
  const isLocalModel = (candidate, model) => localModel(candidate, model) || modelLevels.classOf(candidate.driverKind, model)?.local === true;
3355
3361
  // ── silent automatic failover (docs/chibitek/engine-tiers.md, step 2) ────
3356
3362
  // Resting engines are shared by every bot and survive a restart.
@@ -3589,10 +3595,7 @@ const eventBase = (event) => ({
3589
3595
  ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}),
3590
3596
  });
3591
3597
  const restTime = (at) => new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3592
- /** A watched turn ended failed because its engine ran out or was signed
3593
- * out. Rest the engine, then run the same turn on the next engine at the
3594
- * same level, silently; or, when nothing is left, stop it with one line
3595
- * naming the resting engines. Returns what subscribers see. */
3598
+ /** Rest a refused engine and continue on an eligible engine, or show an actionable line. */
3596
3599
  function engineFailover(turn, completion) {
3597
3600
  const failure = turn.failure;
3598
3601
  const record = failoverTurns.get(turn.threadId);
@@ -3615,17 +3618,14 @@ function engineFailover(turn, completion) {
3615
3618
  const restFailed = () => engineRests.rest(turn.instanceId, failedCandidate ? engineAccount(failedCandidate) : undefined, failure.kind, { resetAt: failure.resetAt, resetText: failure.resetText, windowMs: failure.windowMs, message: redactSecretsInText(failure.message),
3616
3619
  ...(failure.permanent ? { permanent: true } : {}), ...(scope ? { scope } : {}), ...(ran?.model ? { model: ran.model } : {}),
3617
3620
  ...(failure.kind === "auth_failed" && failedCandidate ? { reportedSignedIn: failedCandidate.snapshot.state === "available" && failedCandidate.snapshot.authenticated === true } : {}) });
3618
- // An unrecognised failure (provider_error) is only a reason to try the
3619
- // next engine: the engine rests (the circuit breaker) only once the turn
3620
- // really moves, and with nowhere to go the turn ends with the engine's own
3621
- // report, exactly as before, rather than a "resting" line about it.
3621
+ // Unknown failures rest only when the turn moves; otherwise preserve their report.
3622
3622
  const rest = failure.kind === "provider_error" ? undefined : restFailed();
3623
3623
  const label = (instanceId) => candidates.find((candidate) => candidate.instanceId === instanceId)?.displayName ?? instanceId;
3624
3624
  // The one plain line, never the provider's own words: which account needs
3625
3625
  // the person and where to fix it, or that every engine is resting.
3626
3626
  const stop = (line, category = "failover_exhausted") => [
3627
3627
  { ...eventBase(completion), type: "runtime.error", message: line, terminal: true, category, ...(failure.setup ? { setup: true } : {}) },
3628
- completion,
3628
+ failure.kind === "auth_failed" ? { ...completion, stopReason: "auth_required" } : completion,
3629
3629
  ];
3630
3630
  const restingNamed = (instanceIds) => [...new Set(instanceIds)].flatMap((instanceId) => {
3631
3631
  const candidate = candidates.find((entry) => entry.instanceId === instanceId);
@@ -3648,13 +3648,11 @@ function engineFailover(turn, completion) {
3648
3648
  return stop(pick.line, pick.category);
3649
3649
  const { choice } = pick;
3650
3650
  const tried = new Set(record.tried);
3651
- // Move the turn. The attempt that failed is retired quietly afterwards: no
3652
- // chip, no notification, no incident, and nothing settled that the same
3653
- // turn still owns.
3651
+ // Retire the failed attempt without settling the request that the next engine owns.
3654
3652
  const moved = ran ?? record.selection;
3655
3653
  const failedOwner = turnResourceOwners.get(turn.threadId);
3656
3654
  const tokens = completion.usage ?? turnUsage.get(turn.threadId);
3657
- const partialText = turn.partial.trim();
3655
+ const partialText = groupGoalCoordinatorTurns.get(turn.threadId)?.size ? parseGroupGoalDecision(turn.partial).visibleText : turn.partial.trim();
3658
3656
  const requestOwner = directRequestOwners.get(turn.threadId);
3659
3657
  if (partialText) {
3660
3658
  // The answer it was in the middle of stays: the person saw it stream.
@@ -3666,6 +3664,12 @@ function engineFailover(turn, completion) {
3666
3664
  const tools = earlier.filter((message) => message.kind === "activity" && message.tool?.name).map((message) => message.tool.name);
3667
3665
  const note = continuationNote(said.map((message) => message.text ?? ""), tools);
3668
3666
  const attempt = record.attempt + 1;
3667
+ for (const guard of groupGoalCoordinatorTurns.get(turn.threadId) ?? []) {
3668
+ if (!guard.discard && (guard.turnId === completion.turnId || guard.turnId === turn.turnId)) {
3669
+ guard.turnId = undefined;
3670
+ guard.assistantItems = [];
3671
+ }
3672
+ }
3669
3673
  const rerun = startTurn(turn.botId, note ? `${record.text}\n\n${note}` : record.text, {
3670
3674
  ...record.opts,
3671
3675
  threadId: turn.threadId,
@@ -3703,6 +3707,11 @@ function engineFailover(turn, completion) {
3703
3707
  });
3704
3708
  }
3705
3709
  retireFailedAttempt(turn.botId, turn.threadId, turn.generation, failedOwner, completion);
3710
+ if (failure.kind === "auth_failed")
3711
+ reportIncident({
3712
+ kind: "engine-fallback", bot: profile, threadId: turn.threadId, notifyChief: true,
3713
+ detail: `${engineLabel(candidates, moved)} needs signing in again in Settings > Engines; the turn continued on ${engineLabel(candidates, choice.selection)}`,
3714
+ });
3706
3715
  console.error(`[omb-failover] thread=${turn.threadId} ${failure.kind}: ${moved.instanceId} -> ${choice.selection.instanceId} (attempt ${attempt})`);
3707
3716
  // No turnId: the failed provider turn is retired, and its id would hide
3708
3717
  // this record from every window.
@@ -5013,67 +5022,21 @@ const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => B
5013
5022
  const groupTurnOperations = new Map();
5014
5023
  const groupGoalCoordinatorTurns = new Map();
5015
5024
  const GROUP_GOAL_COORDINATOR_GUARD_MS = 5 * 60_000;
5016
- function addGroupGoalCoordinatorTurn(threadId, turn) {
5017
- const turns = groupGoalCoordinatorTurns.get(threadId) ?? new Set();
5018
- turns.add(turn);
5019
- groupGoalCoordinatorTurns.set(threadId, turns);
5020
- }
5021
- function removeGroupGoalCoordinatorTurn(threadId, turn) {
5022
- if (turn.cleanupTimer)
5023
- clearTimeout(turn.cleanupTimer);
5024
- const turns = groupGoalCoordinatorTurns.get(threadId);
5025
- turns?.delete(turn);
5026
- if (turns?.size === 0)
5027
- groupGoalCoordinatorTurns.delete(threadId);
5028
- }
5029
- function hasUnboundDiscardedGroupGoalTurn(threadId) {
5030
- return [...(groupGoalCoordinatorTurns.get(threadId) ?? [])]
5031
- .some((turn) => turn.discard && !turn.turnId);
5032
- }
5033
- /** Match private coordinator output to one provider turn, never merely to a
5034
- * reusable room thread. Most adapters emit turn.started before sendTurn
5035
- * resolves, so the first stable event may bind an otherwise pending guard. */
5036
- function groupGoalCoordinatorTurnForEvent(event) {
5037
- const turns = groupGoalCoordinatorTurns.get(event.threadId);
5038
- if (!turns?.size)
5039
- return undefined;
5040
- const candidates = [...turns];
5041
- if (event.turnId) {
5042
- const exact = candidates.find((turn) => turn.turnId === event.turnId);
5043
- if (exact)
5044
- return exact;
5045
- // Until an interrupted handshake returns its own id, no new id can be
5046
- // attributed safely. The stall fallback keeps this thread unavailable in
5047
- // that narrow window; private text is suppressed below until sendTurn's
5048
- // result binds the old guard or its bounded expiry releases ownership.
5049
- const unboundDiscarded = candidates.filter((turn) => turn.discard && !turn.turnId);
5050
- if (unboundDiscarded.length > 0) {
5051
- // The ownership fallback below keeps a lone abandoned handshake's
5052
- // thread closed, so its first eventual id can safely bind here. More
5053
- // than one unbound candidate is genuinely ambiguous and stays gated.
5054
- if (candidates.length === 1) {
5055
- unboundDiscarded[0].turnId = event.turnId;
5056
- // This event is the first stable identity for an already-abandoned
5057
- // provider turn. Tombstone it immediately so this event and every
5058
- // later completion/request cannot settle a replacement on the same
5059
- // room thread.
5060
- retireProviderTurn(event.turnId);
5061
- return unboundDiscarded[0];
5062
- }
5063
- return undefined;
5064
- }
5065
- const pending = candidates.findLast((turn) => !turn.turnId && !turn.discard);
5066
- if (pending && !pending.turnId) {
5067
- pending.turnId = event.turnId;
5068
- return pending;
5069
- }
5070
- return undefined;
5071
- }
5072
- // Turn-scoped events normally carry an id. If an adapter omits it, fail
5073
- // closed for private text; with multiple overlapping guards there is no
5074
- // safe way to attribute a completion, so leave cleanup to the bounded timer.
5075
- return candidates.length === 1 ? candidates[0] : undefined;
5076
- }
5025
+ const personalGoals = new PersonalGoalRuntime(join(DATA_DIR, "personal-goals.json"), {
5026
+ busy: (botId, threadId) => threadBusy(botId, threadId) || Boolean(activeGroupTurnForBot(botId)),
5027
+ interrupt: async (botId, threadId, goalId) => {
5028
+ const owns = [...(groupGoalCoordinatorTurns.get(threadId) ?? [])].some(guard => guard.personalGoalId === goalId && !guard.discard && (!guard.turnId || guard.turnId === liveTurnByThread.get(threadId)));
5029
+ if (owns)
5030
+ await interruptDirectThread(botId, threadId);
5031
+ },
5032
+ changed: (goal) => updatePersonalGoalReceipt(goal, store),
5033
+ run: (goal) => runPersonalGoalTurn(goal, { store, addGuard: addGroupGoalCoordinatorTurn, removeGuard: removeGroupGoalCoordinatorTurn,
5034
+ subscribe: (listener) => bus.subscribe(listener), ignore: shouldIgnoreProviderEvent, busy: threadBusy, start: startTurn }),
5035
+ });
5036
+ const addGroupGoalCoordinatorTurn = (threadId, turn) => addGoalGuard(groupGoalCoordinatorTurns, threadId, turn);
5037
+ const removeGroupGoalCoordinatorTurn = (threadId, turn) => removeGoalGuard(groupGoalCoordinatorTurns, threadId, turn);
5038
+ const hasUnboundDiscardedGroupGoalTurn = (threadId) => hasUnboundGoalGuard(groupGoalCoordinatorTurns, threadId);
5039
+ const groupGoalCoordinatorTurnForEvent = (event) => goalGuardForEvent(groupGoalCoordinatorTurns, retireProviderTurn, event);
5077
5040
  function groupIsWorking(group) {
5078
5041
  return Boolean(group.busyBotId) || Boolean(groupTurnOperations.get(group.id)?.size);
5079
5042
  }
@@ -6024,6 +5987,9 @@ function messageWindow(threadId, messageId, limit) {
6024
5987
  return { messages: all.slice(start, stop).map(slimMessage), hasMore: start > 0 };
6025
5988
  }
6026
5989
  const sseClients = new Set();
5990
+ // Approval device routing: docs/specs/remote-approval/spec.md.
5991
+ const deviceRoutes = new DeviceRouteRegistry();
5992
+ const removeSseClient = (client) => removeStream(sseClients, deviceRoutes, client);
6027
5993
  /** The frame at which a bot's audience last changed (see the SSE route). */
6028
5994
  let audienceChangedAt = 0;
6029
5995
  /** A bot's audience changed: end every member stream. Each reconnects at
@@ -6035,27 +6001,15 @@ function audienceChanged() {
6035
6001
  for (const client of sseClients) {
6036
6002
  if (client.viewer.kind !== "member")
6037
6003
  continue;
6038
- sseClients.delete(client);
6039
- try {
6040
- client.res.end();
6041
- }
6042
- catch {
6043
- /* already gone */
6044
- }
6004
+ removeSseClient(client);
6045
6005
  }
6046
6006
  }
6047
6007
  function closeSessionStreams(sessionId) {
6048
6008
  browserLive.closeForOwner(sessionId);
6049
- for (const client of sseClients) {
6009
+ for (const client of Array.from(sseClients)) {
6050
6010
  if (client.sessionId !== sessionId)
6051
6011
  continue;
6052
- sseClients.delete(client);
6053
- try {
6054
- client.res.end();
6055
- }
6056
- catch {
6057
- /* already gone */
6058
- }
6012
+ removeSseClient(client);
6059
6013
  }
6060
6014
  }
6061
6015
  sessions.onSessionRevoked((sessionId) => {
@@ -6253,7 +6207,7 @@ function broadcast(payload, options = {}) {
6253
6207
  // Screen frames are replaceable and durable events are not: see
6254
6208
  // ./sse-fanout.ts for the backpressure/bound decision this makes.
6255
6209
  if (deliverSseFrame(client, kind, out) === "disconnected") {
6256
- sseClients.delete(client);
6210
+ removeSseClient(client);
6257
6211
  }
6258
6212
  }
6259
6213
  }
@@ -8258,6 +8212,8 @@ bus.subscribe((event) => {
8258
8212
  lastReply.delete(event.threadId);
8259
8213
  const turnError = lastTurnError.get(event.threadId);
8260
8214
  lastTurnError.delete(event.threadId);
8215
+ // A tool server that broke under this turn is reported with the turn (mcp-turn.ts), not at whatever turn next starts.
8216
+ syncMcpAuthState();
8261
8217
  // A run that broke — not one the person stopped, and not a routine's,
8262
8218
  // which reports through its own failure path — is the Chief's to see.
8263
8219
  // A lazy computer-claim rejection already reported its failure and
@@ -8269,7 +8225,7 @@ bus.subscribe((event) => {
8269
8225
  if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !routines?.runForThread(event.threadId)) {
8270
8226
  const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
8271
8227
  if (broken)
8272
- reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: failedRunDetail(event.stopReason, turnError) });
8228
+ reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: failedRunDetail(event.stopReason, turnError), notifyChief: event.stopReason === "auth_required" });
8273
8229
  }
8274
8230
  const lastReported = turnUsage.get(event.threadId);
8275
8231
  turnUsage.delete(event.threadId);
@@ -8625,17 +8581,17 @@ function incidentContext(threadId) {
8625
8581
  }
8626
8582
  function reportIncident(input) {
8627
8583
  const { bot, threadId } = input;
8628
- // A fallback is a run that went on, not a broken one.
8629
- const quiet = input.kind === "model-fallback" || input.kind === "engine-fallback";
8584
+ // A fallback, or a turn that ran on without a broken tool server, is a run that went on, not a broken one.
8585
+ const quiet = input.kind === "model-fallback" || input.kind === "engine-fallback" || input.kind === "mcp-unavailable";
8630
8586
  // The Primary Bot's pre-check reads broken runs from here (a failed
8631
8587
  // routine has its own run record, so it is not noted twice).
8632
- if (input.kind !== "routine-failed" && input.kind !== "model-fallback" && input.kind !== "engine-fallback") {
8588
+ if (input.kind !== "routine-failed" && input.kind !== "model-fallback" && input.kind !== "engine-fallback" && input.kind !== "mcp-unavailable") {
8633
8589
  primaryBotStore.noteFailure({ botId: bot.id, threadId, kind: input.kind, detail: redactSecretsInText(input.detail).slice(0, 300), at: Date.now() });
8634
8590
  }
8635
8591
  const task = store.taskByThread(bot.id, threadId);
8636
8592
  // A thread another bot opened and is watching is that bot's to handle:
8637
8593
  // the delegator is woken with the failure already (wakeDelegationSource).
8638
- if (task?.openedBy?.delegationId || delegationWatch.has(threadId) || roomHandoffs.activeDirect(threadId))
8594
+ if (!input.notifyChief && (task?.openedBy?.delegationId || delegationWatch.has(threadId) || roomHandoffs.activeDirect(threadId)))
8639
8595
  return;
8640
8596
  const group = store.groupByThread(threadId);
8641
8597
  const incident = {
@@ -10758,6 +10714,7 @@ async function startTurn(botId, text, opts) {
10758
10714
  ? await nexusMemory.readBack({ bot: liveBot ?? bot, threadId, query: providerText })
10759
10715
  : "";
10760
10716
  const prompt = buildSystemPrompt(persona, liveBot?.soul ?? bot.soul ?? "", roomPromptSections(roomTurn, [
10717
+ { id: "personal-goal", label: "Active goal", text: personalGoals.goals.get(threadId)?.status === "working" ? personalGoalInstructions(personalGoals.goals.get(threadId)) : "" },
10761
10718
  { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
10762
10719
  // the ICM folder is on this machine; a cloud run cannot reach it
10763
10720
  // first after the soul: the block names agent tools, so it only goes
@@ -10775,7 +10732,7 @@ async function startTurn(botId, text, opts) {
10775
10732
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
10776
10733
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
10777
10734
  { id: "accounts", label: "Google and Microsoft accounts", text: integrations.accounts ? accountsPrompt(botAccountEmails(bot)) : "" },
10778
- { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
10735
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {}), instance.adapter.capabilities.customMcp === true ? mcpTurn.unavailable(engineMcpServers(bot)) : []) },
10779
10736
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
10780
10737
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
10781
10738
  { id: "assignment", label: "Teammate task", text: coordinationNode ? `\n${coordinationSystemInstructions()}` : "" },
@@ -12623,7 +12580,7 @@ failover) {
12623
12580
  { id: "private-bot", label: "Private Bot", text: isPersonalBot(bot) ? privateBotPrompt(false) : "" },
12624
12581
  { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
12625
12582
  { id: "repos", label: "Linked repos", text: repoPlan ? repoPromptSection(repoPlan, repoPlace(undefined, roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmMounted ? "vm" : roomComputerKind), cwd) : "" },
12626
- { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
12583
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {}), instance.adapter.capabilities.customMcp === true ? mcpTurn.unavailable(engineMcpServers(bot)) : []) },
12627
12584
  { id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer || roomComputerKind === "box" ? instance.driverKind === "boxAgent" ? "box-agent" : instance.adapter.capabilities.cloudComputerMcp ? "box-chat" : "box" : roomVmMounted ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : roomComputerKind) },
12628
12585
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
12629
12586
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmMounted ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
@@ -14781,23 +14738,19 @@ function warmMcpToolHints(custom) {
14781
14738
  /** Each configured server's sign-in state, and whether it is otherwise broken. */
14782
14739
  const mcpAuthState = new McpAuthStateStore(join(DATA_DIR, "mcp-auth-state.json"));
14783
14740
  const mcpHealth = new McpHealthStore();
14784
- /** Fold in what gates and the chat runtime reported since the last look. */
14785
- function syncMcpAuthState() {
14786
- const signals = drainMcpAuthSignals(mcpAuthSignalDir());
14787
- if (signals.length)
14788
- for (const state of [mcpAuthState, mcpHealth])
14789
- state.ingest(signals, customMcpServers(cfg));
14790
- }
14741
+ const mcpTurn = mcpTurnPolicy({ auth: mcpAuthState, health: mcpHealth, signalDir: mcpAuthSignalDir, specs: () => customMcpServers(cfg),
14742
+ reportUnavailable: (threadId, detail) => { const bot = store.botByThread(threadId); if (bot)
14743
+ reportIncident({ kind: "mcp-unavailable", bot, threadId, detail }); } });
14744
+ function syncMcpAuthState() { mcpTurn.sync(); }
14791
14745
  /** How a start runs an mcp-remote bridge: background starts never open a
14792
14746
  * browser; only a person's click in Settings may (server/mcp-remote-bridge.ts). */
14793
14747
  function bridgedMcpServer(spec, allowBrowser) {
14794
14748
  return bridgeMcpRemote(spec, { bridge: resolveMcpRemoteBridge(), execPath: process.execPath, allowBrowser });
14795
14749
  }
14796
- /** The servers one turn starts: the bot's servers, minus any whose sign-in
14797
- * is backing off or stopped, or that lately would not start or crashed. */
14750
+ /** The servers one turn starts (mcp-turn.ts), after folding in what the gates reported. */
14798
14751
  function turnMcpServers(bot) {
14799
14752
  syncMcpAuthState();
14800
- return Object.fromEntries(Object.entries(engineMcpServers(bot)).filter(([name, spec]) => mcpAuthState.mountable(name, spec) && mcpHealth.mountable(name, spec)));
14753
+ return mcpTurn.mountable(engineMcpServers(bot));
14801
14754
  }
14802
14755
  function engineMcpServers(bot) {
14803
14756
  // Rule 4 (server/personal-bot.ts): a bot with no list of its own gets every
@@ -16131,7 +16084,7 @@ ROUTES.push(createSetupRoutes({
16131
16084
  }
16132
16085
  },
16133
16086
  startOllama: async (context) => {
16134
- await runTool("/usr/bin/open", ["-a", "Ollama"]);
16087
+ startOllamaApp();
16135
16088
  for (let attempt = 0; attempt < 30 && !context.signal.aborted; attempt++) {
16136
16089
  if ((await setupOllamaState()) === "running")
16137
16090
  return;
@@ -19064,6 +19017,7 @@ const handleRequest = async (req, res) => {
19064
19017
  admin: auth.scopes.includes("admin"),
19065
19018
  screens: url.searchParams.get("screens") !== "off",
19066
19019
  backpressured: false,
19020
+ authKind: auth.kind,
19067
19021
  viewer,
19068
19022
  // What this person's hydration (GET /api/bots, narrowed the same way) showed them.
19069
19023
  seen: viewer.kind === "all" && viewer.privateOwner ? { bots: new Set(), groups: new Set() } : {
@@ -19107,6 +19061,7 @@ const handleRequest = async (req, res) => {
19107
19061
  // A client that offered no cursor gets false too, which is exactly
19108
19062
  // what a cold start should do.
19109
19063
  resumed,
19064
+ approvalTarget: deviceRoutes.route(),
19110
19065
  })}\n\n`);
19111
19066
  if (resumed) {
19112
19067
  for (const buffered of replayBuffer) {
@@ -19122,6 +19077,7 @@ const handleRequest = async (req, res) => {
19122
19077
  }
19123
19078
  }
19124
19079
  sseClients.add(client);
19080
+ deviceRoutes.streamOpened(client.authKind);
19125
19081
  // Keep this long-lived response out of socket idle-timeout handling
19126
19082
  // without weakening timeouts for every other API request.
19127
19083
  req.socket.setTimeout(0);
@@ -19141,7 +19097,7 @@ const handleRequest = async (req, res) => {
19141
19097
  }, SSE_HEARTBEAT_MS);
19142
19098
  req.on("close", () => {
19143
19099
  clearInterval(keepalive);
19144
- sseClients.delete(client);
19100
+ removeSseClient(client);
19145
19101
  });
19146
19102
  return;
19147
19103
  }
@@ -20586,11 +20542,11 @@ const handleRequest = async (req, res) => {
20586
20542
  });
20587
20543
  }
20588
20544
  if (method === "POST" && path === "/api/bot-defaults/skills/preview") {
20589
- const checked = z.object({ source: z.string().min(1).max(2000) }).strict().safeParse(await readBody(req));
20545
+ // Drafts accept the same uploaded bytes as existing bots, without installing them.
20546
+ const checked = skillImportSchema.safeParse(await readBody(req));
20590
20547
  if (!checked.success)
20591
20548
  return json(res, 400, { error: checked.error.message });
20592
- const input = checked.data;
20593
- const fetched = await fetchSkillFromSource(input.source);
20549
+ const fetched = await resolveSkillImport(checked.data);
20594
20550
  if ("error" in fetched)
20595
20551
  return json(res, 422, { error: fetched.error });
20596
20552
  const skills = [];
@@ -21703,10 +21659,10 @@ const handleRequest = async (req, res) => {
21703
21659
  if (m && method === "POST") {
21704
21660
  if (!store.bot(m[1]))
21705
21661
  return json(res, 404, { error: "no such bot" });
21706
- const parsed = z.object({ source: z.string().min(1).max(2000) }).safeParse(await readBody(req));
21662
+ const parsed = skillImportSchema.safeParse(await readBody(req));
21707
21663
  if (!parsed.success)
21708
- return json(res, 400, { error: "source must be a GitHub URL or owner/repo" });
21709
- const fetched = await fetchSkillFromSource(parsed.data.source);
21664
+ return json(res, 400, { error: "Choose a Markdown skill file or enter a GitHub source (maximum 256 KiB per file)." });
21665
+ const fetched = await resolveSkillImport(parsed.data);
21710
21666
  if ("error" in fetched)
21711
21667
  return json(res, 422, { error: fetched.error });
21712
21668
  const results = fetched.skills.map((skill) => installSkill(m[1], skill.source, skill.files));
@@ -22235,10 +22191,20 @@ const handleRequest = async (req, res) => {
22235
22191
  userAgent: typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
22236
22192
  relayed: Boolean(onBehalfOf),
22237
22193
  }));
22194
+ let goalCommand;
22195
+ try {
22196
+ goalCommand = parseGoalCommand(text);
22197
+ }
22198
+ catch (error) {
22199
+ return json(res, 400, { error: error instanceof Error ? error.message : "Invalid goal command" });
22200
+ }
22201
+ if (guarded && goalCommand)
22202
+ return json(res, 400, { error: "Goal commands require the conversation command interface." });
22238
22203
  // The send is acknowledged before the turn starts, so a workspace at its
22239
22204
  // spend limit is refused here, where the person can see it.
22240
22205
  try {
22241
- assertWithinBudget(cfg, DATA_DIR);
22206
+ if (!goalCommand || goalCommand.action === "start" || goalCommand.action === "resume")
22207
+ assertWithinBudget(cfg, DATA_DIR);
22242
22208
  }
22243
22209
  catch (error) {
22244
22210
  return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
@@ -22307,6 +22273,12 @@ const handleRequest = async (req, res) => {
22307
22273
  const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
22308
22274
  return { ok: true, threadId, message };
22309
22275
  }
22276
+ if (goalCommand) {
22277
+ if (auth.kind === "assistant")
22278
+ throw Object.assign(new Error("Only the person controls goals."), { status: 403 });
22279
+ return receiveGoalCommand(personalGoals, store, bot.id, threadId, goalCommand, text, sendId, messageSender(auth), trigger);
22280
+ }
22281
+ personalGoals.steer(threadId);
22310
22282
  // Claude can accept the message inside its live turn. If the write
22311
22283
  // loses a race with turn settlement, or the engine cannot steer, the
22312
22284
  // existing server-side queue records it atomically for the next turn.
@@ -23735,6 +23707,8 @@ const handleRequest = async (req, res) => {
23735
23707
  if (method === "GET" && authStatus) {
23736
23708
  res.setHeader("cache-control", "no-store");
23737
23709
  const state = await providerAuthSessions.status(authStatus[1], auth.kind === "session" ? auth.session.id : "loopback", url.searchParams.get("flowId") ?? "");
23710
+ if (state.phase === "succeeded")
23711
+ engineRests.clear(authStatus[1], "signed-in");
23738
23712
  return json(res, 200, { auth: state });
23739
23713
  }
23740
23714
  const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel|auth\/sign-out)$/.exec(path);
@@ -23776,6 +23750,8 @@ const handleRequest = async (req, res) => {
23776
23750
  providerAuthSessions.revokeOwner(owner);
23777
23751
  return json(res, 401, { error: "Your session ended. Start a new sign-in." });
23778
23752
  }
23753
+ if (started.phase === "succeeded")
23754
+ engineRests.clear(instanceId, "signed-in");
23779
23755
  return json(res, 200, { auth: started });
23780
23756
  }
23781
23757
  if (action === "auth/sign-out") {
@@ -23793,6 +23769,8 @@ const handleRequest = async (req, res) => {
23793
23769
  if (!flowId || !callbackUrl)
23794
23770
  return json(res, 400, { error: "flowId and a code or callbackUrl are required" });
23795
23771
  await providerAuthSessions.complete(instanceId, owner, flowId, callbackUrl);
23772
+ engineRests.clear(instanceId, "signed-in");
23773
+ await describeEngines();
23796
23774
  return json(res, 200, { ok: true });
23797
23775
  }
23798
23776
  const body = await readBody(req, 4096);
@@ -45,7 +45,8 @@ export function drainMcpAuthSignals(dir) {
45
45
  const parsed = JSON.parse(readFileSync(file, "utf8"));
46
46
  if (typeof parsed.name === "string" && MCP_SIGNAL_KINDS.includes(parsed.kind)
47
47
  && typeof parsed.at === "number" && Number.isFinite(parsed.at)) {
48
- signals.push({ name: parsed.name, kind: parsed.kind, at: parsed.at });
48
+ signals.push({ name: parsed.name, kind: parsed.kind, at: parsed.at,
49
+ ...(typeof parsed.threadId === "string" && parsed.threadId ? { threadId: parsed.threadId } : {}) });
49
50
  }
50
51
  }
51
52
  catch {
@@ -57,6 +57,7 @@ export function gateServer(input) {
57
57
  OMB_GATE_SPILL_DIR: spillDir(input.threadId),
58
58
  OMB_GATE_BUDGET: String(budget),
59
59
  OMB_GATE_AUTH_DIR: mcpAuthSignalDir(),
60
+ OMB_GATE_THREAD: input.threadId,
60
61
  },
61
62
  };
62
63
  }
@@ -40,9 +40,12 @@ const SPILL_MAX_AGE_MS = 24 * 60 * 60_000;
40
40
  const SPILL_HINT = process.env.OMB_GATE_SPILL_HINT === "1";
41
41
  /** The gate's own settings never reach the upstream server's environment. */
42
42
  const GATE_ENV_KEYS = ["OMB_GATE_NAME", "OMB_GATE_SPILL_DIR", "OMB_GATE_BUDGET", "OMB_GATE_UPSTREAM", "OMB_GATE_SPILL_HINT", "OMB_GATE_AUTH_DIR",
43
- "OMB_GATE_START_TIMEOUT_MS"];
43
+ "OMB_GATE_START_TIMEOUT_MS", "OMB_GATE_THREAD"];
44
44
  /** Where this gate reports whether its server's sign-in worked. */
45
45
  const AUTH_DIR = process.env.OMB_GATE_AUTH_DIR || "";
46
+ /** The turn this gate serves, so a failure can be reported for its bot. */
47
+ const THREAD = process.env.OMB_GATE_THREAD || "";
48
+ const signalHarness = (kind) => writeMcpAuthSignal(AUTH_DIR, { name: NAME, kind, at: Date.now(), ...(THREAD ? { threadId: THREAD } : {}) });
46
49
  function fail(message) {
47
50
  process.stderr.write(`mcp-gate(${NAME}): ${message}\n`);
48
51
  process.exit(1);
@@ -196,7 +199,7 @@ function goDown(reason, why, signal = true) {
196
199
  clearTimeout(startTimer);
197
200
  process.stderr.write(`mcp-gate(${NAME}): ${why}; answering as an empty server so the turn carries on without it\n`);
198
201
  if (signal)
199
- writeMcpAuthSignal(AUTH_DIR, { name: NAME, kind: reason, at: Date.now() });
202
+ signalHarness(reason);
200
203
  for (const message of inFlight.values())
201
204
  answer(strandedResponse(NAME, message));
202
205
  inFlight.clear();
@@ -290,7 +293,7 @@ createInterface({ input: child.stdout }).on("line", (line) => {
290
293
  reportedOk = true;
291
294
  if (startTimer)
292
295
  clearTimeout(startTimer);
293
- writeMcpAuthSignal(AUTH_DIR, { name: NAME, kind: "ok", at: Date.now() });
296
+ signalHarness("ok");
294
297
  }
295
298
  const tool = id === undefined ? undefined : pending.get(id);
296
299
  if (id !== undefined)
@@ -317,7 +320,7 @@ child.on("exit", (code, signal) => {
317
320
  return;
318
321
  if (!reportedOk && exitMeansSignInRequired(code, stderrTail)) {
319
322
  process.stderr.write(`mcp-gate(${NAME}): sign-in required; not opening a browser from a background start\n`);
320
- writeMcpAuthSignal(AUTH_DIR, { name: NAME, kind: "sign-in-required", at: Date.now() });
323
+ signalHarness("sign-in-required");
321
324
  if (!clientGone)
322
325
  return goDown("start-failed", "needs a sign-in", false);
323
326
  }
@@ -29,12 +29,13 @@ export class McpHealthStore {
29
29
  noteFailed(name, spec, kind, at = this.now()) {
30
30
  const entry = this.entry(name, spec);
31
31
  if (entry && at < entry.retryAt)
32
- return;
32
+ return false;
33
33
  const failures = (entry?.failures ?? 0) + 1;
34
34
  const wait = MCP_FAILURE_BACKOFF_MS[Math.min(failures, MCP_FAILURE_BACKOFF_MS.length) - 1];
35
35
  const retryAt = at + wait;
36
36
  this.servers.set(name, { fingerprint: mcpServerFingerprint(spec), kind, failures, lastFailureAt: at, retryAt });
37
37
  this.log(`[mcp] "${name}" ${kind === "crashed" ? "stopped during a turn" : "would not start"}; turns run without it until ${formatTime(retryAt)}`);
38
+ return true;
38
39
  }
39
40
  /** The server answered: whatever was wrong is over. An answer from a start
40
41
  * that began before a later failure does not clear that failure. */
@@ -54,17 +55,26 @@ export class McpHealthStore {
54
55
  const when = this.now() >= entry.retryAt ? "Atomic tries it again on the next turn." : `Atomic tries it again at ${formatTime(entry.retryAt)}.`;
55
56
  return `This server ${what}, so bots are working without it for now. ${when} Check its command and settings if this keeps happening.`;
56
57
  }
58
+ /** Why a server is left out of turns right now, or undefined when it mounts. */
59
+ unavailableKind(name, spec) {
60
+ const entry = this.entry(name, spec);
61
+ return entry && this.now() < entry.retryAt ? entry.kind : undefined;
62
+ }
57
63
  /** Apply what gates and the chat runtime reported, in order. `specs` are
58
- * the servers as configured now; a signal for a removed server is dropped. */
64
+ * the servers as configured now; a signal for a removed server is dropped.
65
+ * Returns the signals that recorded a new failure (a repeat inside the
66
+ * backoff window is not one), so the harness can raise one incident each. */
59
67
  ingest(signals, specs) {
68
+ const fresh = [];
60
69
  for (const signal of signals) {
61
70
  const spec = specs[signal.name];
62
71
  if (!spec)
63
72
  continue;
64
73
  if (signal.kind === "ok")
65
74
  this.noteOk(signal.name, signal.at);
66
- else if (signal.kind === "start-failed" || signal.kind === "crashed")
67
- this.noteFailed(signal.name, spec, signal.kind, signal.at);
75
+ else if ((signal.kind === "start-failed" || signal.kind === "crashed") && this.noteFailed(signal.name, spec, signal.kind, signal.at))
76
+ fresh.push(signal);
68
77
  }
78
+ return fresh;
69
79
  }
70
80
  }