@chibitek/chibi 0.1.154 → 0.1.155

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 (79) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-CbIXKQpc.js → abnfDiagram-O67JEVCF-B8LdsCr1.js} +1 -1
  2. package/dist/assets/{arc-D1xLD8SN.js → arc-DU5fSmKU.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-xZe7iJ9y.js → architectureDiagram-NJMV4G6O-DdQSIFKv.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-Cq35uL03.js → blockDiagram-BEXU5L5S-CaPcGYSS.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-DjlN_-0r.js → c4Diagram-YGBWAQC7-unt0ePgO.js} +1 -1
  6. package/dist/assets/channel-GBaPOaz-.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BQpywZWX.js → chunk-2Q5K7J3B-Babg5Fzu.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-BajOpOBu.js → chunk-3FUC2YCW-D1-dWVHo.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-DuZpQtDE.js → chunk-5VM5RSS4-DU7nSMbT.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-Dn8xlUsI.js → chunk-GTNCS2PH-QIjCJIhV.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-Xrus5lAx.js → chunk-GWA4HPMP-pSByUE5R.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-Dhf8RCqM.js → chunk-JWPE2WC7-Dj2AaR1b.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-BQXmOQMl.js → chunk-NETBCI7D-pZbN0-FP.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-BeraxyW-.js → chunk-WEXAMYUT-Bqarwe4D.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BsKuu9tP.js → chunk-XXDRQBXY-WV8lm3aa.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-CcR0y3zf.js → classDiagram-v2-NBCMYWYE-BANBtky1.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-Dd2y43ln.js → cose-bilkent-JH36ORCC-CtrktgBC.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-DL4Zf255.js → cynefin-OW5HDTMX-DnhVVfba.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-CvfZKzI5.js → cynefinDiagram-VND7K2PF-B42SRR6x.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-CZfm2BmL.js → dagre-6A5THRUB-DM--vFMK.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-DEEoufNr.js → diagram-22UHCM2B-ZBupBrPU.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-C1R6O8j1.js → diagram-3UASUU5V-BPQzNIOd.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-D9dPGToa.js → diagram-ATOU4E4O-5kO8vf8Z.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-BHy3uSmJ.js → diagram-CDSNMT55-DtxogB86.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-CEo2WbRd.js → diagram-MLGK6HIB-Co9-nYw_.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-BrZrCrGC.js → diagram-MPIPVDR6-DWRuMh5y.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-CtChmI9r.js → ebnfDiagram-ZINNZB2B-DU_nmoG9.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-C-qEle2N.js → elk-276RUBZZ-Bcje7S94.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-BEdc5DT9.js → erDiagram-OPXOYQCR-9aCo_2CI.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-CT-fOp9_.js → flowDiagram-KWPJA3E3-D4FYFyc5.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-LWgFxnGJ.js → ganttDiagram-FUAMR5RP-WQHX5rbx.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-xHSa3-lE.js → gitGraphDiagram-X574FWY7-DRyzBdiV.js} +1 -1
  33. package/dist/assets/{index-Dy25lFJN.js → index-CG1OvRgO.js} +1 -1
  34. package/dist/assets/{index-BpHS2C5J.js → index-D_8SD_0K.js} +3 -3
  35. package/dist/assets/{infoDiagram-VRGFBTTK-BOyODyhP.js → infoDiagram-VRGFBTTK-D7SOmp4n.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-OU5B5YK6-D3F47TMU.js → ishikawaDiagram-OU5B5YK6-CdxOnDVX.js} +1 -1
  37. package/dist/assets/{journeyDiagram-ZHPQQLJL-B8h_aacv.js → journeyDiagram-ZHPQQLJL-CH7477Oa.js} +1 -1
  38. package/dist/assets/{kanban-definition-PNTS6WVX-nqNasrdQ.js → kanban-definition-PNTS6WVX-BdpVvkHd.js} +1 -1
  39. package/dist/assets/{linear-CDofN9-p.js → linear-DTGq6qn5.js} +1 -1
  40. package/dist/assets/{mermaid.core-k18eNV07.js → mermaid.core-Sn2sv0Ti.js} +5 -5
  41. package/dist/assets/{mindmap-definition-NLK3R4M7-BYNmNDXA.js → mindmap-definition-NLK3R4M7-koCmkYuq.js} +1 -1
  42. package/dist/assets/{pegDiagram-GJSIUBJH-44c5V9Pa.js → pegDiagram-GJSIUBJH-C3sqhm8e.js} +1 -1
  43. package/dist/assets/{pieDiagram-5QR66LMP-BDvznIYT.js → pieDiagram-5QR66LMP-BELBK122.js} +1 -1
  44. package/dist/assets/{quadrantDiagram-O4NWA36T-BWrzS8SI.js → quadrantDiagram-O4NWA36T-Cksi5LSm.js} +1 -1
  45. package/dist/assets/{railroadDiagram-XR7U4H2S-H_3uxTrq.js → railroadDiagram-XR7U4H2S-f1av_EZW.js} +1 -1
  46. package/dist/assets/{requirementDiagram-PLB6GJNP-BiyKTF3R.js → requirementDiagram-PLB6GJNP-CW4O5nER.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-IPEJSGJF-43RmpN9N.js → sankeyDiagram-IPEJSGJF-Dz8ADHNY.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-PO4LG4MO-B0OUYqhV.js → sequenceDiagram-PO4LG4MO-Cc3HE0iZ.js} +1 -1
  49. package/dist/assets/{sizeCapture-INFHLROL-xiXiEFtk.js → sizeCapture-INFHLROL-gdaZNHnK.js} +1 -1
  50. package/dist/assets/{stateDiagram-v2-GCMORJYK-C4-l5TZJ.js → stateDiagram-v2-GCMORJYK--u8LCnab.js} +1 -1
  51. package/dist/assets/{swimlanes-2SLR337P-B58p5Kvz.js → swimlanes-2SLR337P-4MAw4kdO.js} +1 -1
  52. package/dist/assets/swimlanesDiagram-TC7HE7FX-CamSa0NE.js +8 -0
  53. package/dist/assets/{timeline-definition-EJHVYXUP-DUgR2Qsq.js → timeline-definition-EJHVYXUP-CL89YkCT.js} +1 -1
  54. package/dist/assets/{usecaseDiagram-POWQR4AR-DcicVGBU.js → usecaseDiagram-POWQR4AR-C92l7YQe.js} +1 -1
  55. package/dist/assets/{vennDiagram-UO4OBE2U-9pThMhQ1.js → vennDiagram-UO4OBE2U-17siYhSj.js} +1 -1
  56. package/dist/assets/{wardleyDiagram-VNRHLVJA-CStNs1Wv.js → wardleyDiagram-VNRHLVJA-CNwa9VmN.js} +1 -1
  57. package/dist/assets/{xychartDiagram-PMCCYNJV-JmQMXi-y.js → xychartDiagram-PMCCYNJV-Dnu6Xzih.js} +1 -1
  58. package/dist/index.html +1 -1
  59. package/dist-server/connector-proxy.js +205 -0
  60. package/dist-server/index.js +93 -38
  61. package/dist-server/mcp-gate.js +7 -4
  62. package/dist-server/openmausbot.js +16 -22
  63. package/dist-server/pair-cli.js +16 -22
  64. package/dist-server/server/composio.js +1051 -0
  65. package/dist-server/server/connector-proxy.js +249 -0
  66. package/dist-server/server/drivers/codex.js +18 -23
  67. package/dist-server/server/incidents.js +8 -4
  68. package/dist-server/server/index.js +14 -16
  69. package/dist-server/server/mcp-auth-signal.js +2 -1
  70. package/dist-server/server/mcp-gate-config.js +1 -0
  71. package/dist-server/server/mcp-gate.js +7 -4
  72. package/dist-server/server/mcp-health.js +14 -4
  73. package/dist-server/server/mcp-public-registry.js +325 -0
  74. package/dist-server/server/mcp-turn.js +26 -0
  75. package/dist-server/server/system-prompt.js +14 -6
  76. package/dist-server/shared/connector-availability.js +12 -0
  77. package/package.json +1 -1
  78. package/dist/assets/channel-Bm-MISs3.js +0 -1
  79. package/dist/assets/swimlanesDiagram-TC7HE7FX-CPlL77A3.js +0 -8
@@ -0,0 +1,249 @@
1
+ // Harness-owned Composio MCP bridge.
2
+ //
3
+ // Provider CLIs only see this stdio server. Ordinary MCP traffic is relayed
4
+ // to the configured Composio Session, but connection requests are converted
5
+ // into first-class OpenMausBot chat cards. The agent never authors an auth
6
+ // URL and credentials never pass through its transcript.
7
+ //
8
+ // stdout is the MCP transport. Never log there.
9
+ import readline from "node:readline";
10
+ import { randomUUID } from "node:crypto";
11
+ const UPSTREAM = process.env.OMB_CONNECTOR_UPSTREAM_URL ?? "";
12
+ const HARNESS = process.env.OMB_HARNESS_URL ?? "http://127.0.0.1:8799";
13
+ const BOT_ID = process.env.OMB_BOT_ID ?? "";
14
+ const THREAD_ID = process.env.OMB_THREAD_ID ?? "";
15
+ const TOKEN = process.env.OMB_CONNECTOR_TOKEN ?? process.env.OMB_COMMS_TOKEN ?? "";
16
+ const MAX_RESPONSE_BYTES = 20 * 1024 * 1024;
17
+ const INITIALIZE_RELAY_TIMEOUT_MS = 1_000;
18
+ const RELAY_TIMEOUT_MS = 10 * 60_000;
19
+ function parsedHeaders() {
20
+ try {
21
+ const value = JSON.parse(process.env.OMB_CONNECTOR_UPSTREAM_HEADERS ?? "{}");
22
+ if (!value || typeof value !== "object" || Array.isArray(value))
23
+ return {};
24
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ const upstreamHeaders = parsedHeaders();
31
+ let upstreamSessionId = "";
32
+ const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
33
+ function textResult(id, text, isError = false) {
34
+ return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) } };
35
+ }
36
+ function jsonRpcError(id, message) {
37
+ return { jsonrpc: "2.0", id, error: { code: -32000, message } };
38
+ }
39
+ function initializeResult(id, protocolVersion) {
40
+ return {
41
+ jsonrpc: "2.0",
42
+ id,
43
+ result: {
44
+ protocolVersion: typeof protocolVersion === "string" && protocolVersion ? protocolVersion : "2024-11-05",
45
+ capabilities: { tools: {} },
46
+ serverInfo: { name: "openmausbot-connectors", version: "1" },
47
+ },
48
+ };
49
+ }
50
+ async function readBounded(response) {
51
+ const declared = Number(response.headers.get("content-length") ?? "0");
52
+ if (declared > MAX_RESPONSE_BYTES)
53
+ throw new Error("connector response exceeded 20 MB");
54
+ if (!response.body)
55
+ return "";
56
+ const reader = response.body.getReader();
57
+ const decoder = new TextDecoder();
58
+ let size = 0;
59
+ let text = "";
60
+ for (;;) {
61
+ const { done, value } = await reader.read();
62
+ if (done)
63
+ break;
64
+ size += value.byteLength;
65
+ if (size > MAX_RESPONSE_BYTES) {
66
+ await reader.cancel();
67
+ throw new Error("connector response exceeded 20 MB");
68
+ }
69
+ text += decoder.decode(value, { stream: true });
70
+ }
71
+ return text + decoder.decode();
72
+ }
73
+ function parseUpstream(text, id) {
74
+ const trimmed = text.trim();
75
+ if (!trimmed)
76
+ return null;
77
+ if (trimmed.startsWith("{"))
78
+ return JSON.parse(trimmed);
79
+ const frames = trimmed
80
+ .split(/\r?\n/)
81
+ .filter((line) => line.startsWith("data:"))
82
+ .map((line) => line.slice(5).trim())
83
+ .filter((line) => line && line !== "[DONE]")
84
+ .flatMap((line) => {
85
+ try {
86
+ return [JSON.parse(line)];
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ });
92
+ return frames.findLast((frame) => frame.id === id) ?? frames.at(-1) ?? null;
93
+ }
94
+ async function relay(message, timeoutMs = RELAY_TIMEOUT_MS) {
95
+ if (!UPSTREAM)
96
+ throw new Error("connected apps are unavailable");
97
+ const response = await fetch(UPSTREAM, {
98
+ method: "POST",
99
+ headers: {
100
+ "content-type": "application/json",
101
+ accept: "application/json, text/event-stream",
102
+ ...upstreamHeaders,
103
+ ...(upstreamSessionId ? { "mcp-session-id": upstreamSessionId } : {}),
104
+ },
105
+ body: JSON.stringify(message),
106
+ signal: AbortSignal.timeout(timeoutMs),
107
+ });
108
+ const nextSession = response.headers.get("mcp-session-id");
109
+ if (nextSession)
110
+ upstreamSessionId = nextSession;
111
+ if (!response.ok)
112
+ throw new Error(`connector service returned HTTP ${response.status}`);
113
+ return parseUpstream(await readBounded(response), message.id);
114
+ }
115
+ function connectorAdds(args) {
116
+ if (!args || typeof args !== "object" || Array.isArray(args))
117
+ return [];
118
+ const toolkits = args.toolkits;
119
+ if (!Array.isArray(toolkits))
120
+ return [];
121
+ const seen = new Set();
122
+ const requests = [];
123
+ for (const item of toolkits) {
124
+ if (typeof item === "string") {
125
+ const slug = item.trim().toLowerCase();
126
+ const key = JSON.stringify([slug, ""]);
127
+ if (!seen.has(key)) {
128
+ seen.add(key);
129
+ requests.push({ slug });
130
+ }
131
+ continue;
132
+ }
133
+ if (!item || typeof item !== "object" || Array.isArray(item))
134
+ continue;
135
+ const row = item;
136
+ const action = String(row.action ?? "add").toLowerCase();
137
+ if (!["add", "connect", "initiate"].includes(action))
138
+ continue;
139
+ const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
140
+ if (typeof slug !== "string")
141
+ continue;
142
+ const normalized = slug.trim().toLowerCase();
143
+ const alias = (typeof row.alias === "string" ? row.alias : typeof row.account === "string" ? row.account : "").trim();
144
+ const key = JSON.stringify([normalized, alias.toLowerCase()]);
145
+ if (seen.has(key))
146
+ continue;
147
+ seen.add(key);
148
+ requests.push({ slug: normalized, ...(alias ? { alias } : {}) });
149
+ }
150
+ return requests;
151
+ }
152
+ async function showConnectorCards(items) {
153
+ const response = await fetch(`${HARNESS}/api/internal/connectors/request`, {
154
+ method: "POST",
155
+ headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
156
+ body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, items, resumeKey: randomUUID() }),
157
+ signal: AbortSignal.timeout(30_000),
158
+ });
159
+ if (!response.ok) {
160
+ const body = (await response.json().catch(() => ({})));
161
+ throw new Error(String(body.error ?? `could not show connection card (HTTP ${response.status})`));
162
+ }
163
+ }
164
+ async function handle(message) {
165
+ const id = message.id;
166
+ const method = String(message.method ?? "");
167
+ // OpenCode (and other MCP clients) mark a stdio server failed unless
168
+ // initialize returns capabilities/serverInfo. Relaying that handshake to
169
+ // Composio can time out, return a newer protocolVersion, or throw when the
170
+ // upstream URL never reached the child env — all of which previously
171
+ // surfaced as a tools/call-shaped {content,isError} payload.
172
+ if (method === "notifications/initialized" || method === "initialized") {
173
+ if (UPSTREAM)
174
+ void relay(message).catch(() => { });
175
+ return;
176
+ }
177
+ if (method === "initialize") {
178
+ if (UPSTREAM) {
179
+ try {
180
+ // Capture the upstream session id when the service is healthy, but
181
+ // never let a stalled provider prevent the local MCP client from
182
+ // mounting the connector tools. The client sends initialized only
183
+ // after this bounded attempt and the local initialize response.
184
+ await relay(message, INITIALIZE_RELAY_TIMEOUT_MS);
185
+ }
186
+ catch {
187
+ // Best-effort session setup. The client still needs a valid result.
188
+ }
189
+ }
190
+ if (id !== undefined) {
191
+ const params = (message.params ?? {});
192
+ send(initializeResult(id, params.protocolVersion));
193
+ }
194
+ return;
195
+ }
196
+ if (method === "tools/call") {
197
+ const params = (message.params ?? {});
198
+ const name = String(params.name ?? "");
199
+ const requests = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
200
+ if (requests.length) {
201
+ await showConnectorCards(requests);
202
+ const labels = requests.map((r) => (r.alias ? `${r.slug} (${r.alias})` : r.slug)).join(", ");
203
+ send(textResult(id, `Atomic showed the user a secure connection card for ${labels}. End this turn now. The app will continue the task automatically after the connection finishes.`));
204
+ return;
205
+ }
206
+ if (/WAIT_FOR_CONNECTIONS$/i.test(name)) {
207
+ send(textResult(id, "Atomic is handling connection completion and will continue the task automatically."));
208
+ return;
209
+ }
210
+ }
211
+ try {
212
+ const response = await relay(message);
213
+ if (response && id !== undefined)
214
+ send(response);
215
+ }
216
+ catch (error) {
217
+ if (id === undefined)
218
+ return;
219
+ const messageText = error instanceof Error ? error.message : String(error);
220
+ if (method === "tools/call")
221
+ send(textResult(id, messageText, true));
222
+ else
223
+ send(jsonRpcError(id, messageText));
224
+ }
225
+ }
226
+ const input = readline.createInterface({ input: process.stdin, terminal: false });
227
+ input.on("line", (line) => {
228
+ const trimmed = line.trim();
229
+ if (!trimmed)
230
+ return;
231
+ let message;
232
+ try {
233
+ message = JSON.parse(trimmed);
234
+ }
235
+ catch {
236
+ return;
237
+ }
238
+ void handle(message).catch((error) => {
239
+ if (message.id === undefined)
240
+ return;
241
+ const method = String(message.method ?? "");
242
+ const messageText = error instanceof Error ? error.message : String(error);
243
+ if (method === "tools/call")
244
+ send(textResult(message.id, messageText, true));
245
+ else
246
+ send(jsonRpcError(message.id, messageText));
247
+ });
248
+ });
249
+ input.on("close", () => process.exit(0));
@@ -156,20 +156,13 @@ export function managedCodexArgs(config) {
156
156
  }
157
157
  const QUESTION_TIMEOUT_NOTE = "No answer was given — use your best judgment.";
158
158
  const DENY_TIMEOUT_NOTE = "Atomic: nobody answered this permission request in time. Skip this action and finish what you can without it.";
159
- const skippedSseServers = new Set();
160
- const renamedMcpServers = new Set();
161
- /** Logged once per name: the rename is deliberate, not a lost server. */
162
- function noteRenamedMcpServer(name, mountName) {
163
- if (renamedMcpServers.has(name))
159
+ const notedMcpServers = new Set();
160
+ /** Logged once per server and reason: a rename or a left-out server is deliberate, not a lost one. */
161
+ function noteMcpServerOnce(key, line) {
162
+ if (notedMcpServers.has(key))
164
163
  return;
165
- renamedMcpServers.add(name);
166
- console.error(`codex: MCP server ${JSON.stringify(name)} is also declared in Codex's own config.toml — mounted as ${JSON.stringify(mountName)} for this bot so the two do not merge`);
167
- }
168
- function noteSkippedSseServer(name) {
169
- if (skippedSseServers.has(name))
170
- return;
171
- skippedSseServers.add(name);
172
- console.error(`codex: MCP server ${JSON.stringify(name)} uses the SSE transport, which codex does not speak — it is available to Claude bots only`);
164
+ notedMcpServers.add(key);
165
+ console.error(`codex: MCP server ${line}`);
173
166
  }
174
167
  /** A TOML inline table for a `-c key=value` override; JSON string quoting
175
168
  * is valid TOML basic-string quoting. */
@@ -645,16 +638,18 @@ export const CodexDriver = {
645
638
  // instance's legacy fullAuto setting. Harness turns always send an
646
639
  // explicit mode, which takes precedence.
647
640
  const approvalMode = turn.approvalMode ?? (config.fullAuto ? "full" : "ask");
641
+ // The person's servers this turn mounts. One that would clobber a
642
+ // harness-owned variable is left out, not fatal: the rest still mount.
643
+ const customServers = {};
648
644
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
649
- if ("url" in server)
650
- continue;
651
645
  // The mcp-remote bridge runs on Atomic's own Electron-as-Node and sets
652
646
  // ELECTRON_RUN_AS_NODE=1, the value the harness mounts already share.
653
- const reserved = Object.entries(server.env)
647
+ const reserved = "url" in server ? undefined : Object.entries(server.env)
654
648
  .find(([key, value]) => isHarnessOwnedMcpEnvName(key) && !(key === "ELECTRON_RUN_AS_NODE" && value === "1"))?.[0];
655
- if (reserved) {
656
- throw new Error(`Custom MCP server “${name}” cannot set reserved environment variable “${reserved}”`);
657
- }
649
+ if (reserved)
650
+ noteMcpServerOnce(`reserved:${name}`, `${JSON.stringify(name)} sets reserved environment variable ${JSON.stringify(reserved)} — left out of this bot's turns until its settings change`);
651
+ else
652
+ customServers[name] = server;
658
653
  }
659
654
  let autoAcceptPermissions = approvalMode === "full";
660
655
  if (active.has(threadId))
@@ -692,7 +687,7 @@ export const CodexDriver = {
692
687
  // this child actually loads. The mounts assign the same values again,
693
688
  // in the same order, so the final environment is unchanged.
694
689
  const childCwd = turn.cwd ?? homedir();
695
- for (const server of Object.values(turn.integrations?.custom ?? {})) {
690
+ for (const server of Object.values(customServers)) {
696
691
  if (!("url" in server))
697
692
  Object.assign(env, server.env);
698
693
  }
@@ -720,17 +715,17 @@ export const CodexDriver = {
720
715
  // mount name → configured name, so an approval request naming the
721
716
  // mounted server can be traced back to the person's own entry
722
717
  const customByMountName = new Map();
723
- for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
718
+ for (const [name, server] of Object.entries(customServers)) {
724
719
  // codex speaks streamable HTTP to a remote server, not the older
725
720
  // SSE transport: such an entry still reaches Claude bots, and is
726
721
  // left out here rather than mounted as something it is not
727
722
  if ("url" in server && server.type === "sse") {
728
- noteSkippedSseServer(name);
723
+ noteMcpServerOnce(`sse:${name}`, `${JSON.stringify(name)} uses the SSE transport, which codex does not speak — it is available to Claude bots only`);
729
724
  continue;
730
725
  }
731
726
  const mountName = mountedMcpServerName(name, declaredInCodexConfig);
732
727
  if (mountName !== name)
733
- noteRenamedMcpServer(name, mountName);
728
+ noteMcpServerOnce(`renamed:${name}`, `${JSON.stringify(name)} is also declared in Codex's own config.toml — mounted as ${JSON.stringify(mountName)} for this bot so the two do not merge`);
734
729
  customByMountName.set(mountName, name);
735
730
  mountMcpServer(appServerArgs, env, mountName, server, false);
736
731
  }
@@ -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
@@ -92,7 +92,7 @@ import { probeMcpServer } from "./mcp-probe.js";
92
92
  import { McpToolHintStore, readOnlyToolAllowed } from "./mcp-tool-hints.js";
93
93
  import { McpAuthStateStore } from "./mcp-auth-state.js";
94
94
  import { McpHealthStore } from "./mcp-health.js";
95
- import { drainMcpAuthSignals } from "./mcp-auth-signal.js";
95
+ import { mcpTurnPolicy } from "./mcp-turn.js";
96
96
  import { mcpAuthSignalDir } from "./mcp-gate-config.js";
97
97
  import { bridgeMcpRemote } from "./mcp-remote-bridge.js";
98
98
  import { mcpToolRefFromLabel } from "../shared/mcp-tool-ref.js";
@@ -8258,6 +8258,8 @@ bus.subscribe((event) => {
8258
8258
  lastReply.delete(event.threadId);
8259
8259
  const turnError = lastTurnError.get(event.threadId);
8260
8260
  lastTurnError.delete(event.threadId);
8261
+ // A tool server that broke under this turn is reported with the turn (mcp-turn.ts), not at whatever turn next starts.
8262
+ syncMcpAuthState();
8261
8263
  // A run that broke — not one the person stopped, and not a routine's,
8262
8264
  // which reports through its own failure path — is the Chief's to see.
8263
8265
  // A lazy computer-claim rejection already reported its failure and
@@ -8625,11 +8627,11 @@ function incidentContext(threadId) {
8625
8627
  }
8626
8628
  function reportIncident(input) {
8627
8629
  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";
8630
+ // A fallback, or a turn that ran on without a broken tool server, is a run that went on, not a broken one.
8631
+ const quiet = input.kind === "model-fallback" || input.kind === "engine-fallback" || input.kind === "mcp-unavailable";
8630
8632
  // The Primary Bot's pre-check reads broken runs from here (a failed
8631
8633
  // 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") {
8634
+ if (input.kind !== "routine-failed" && input.kind !== "model-fallback" && input.kind !== "engine-fallback" && input.kind !== "mcp-unavailable") {
8633
8635
  primaryBotStore.noteFailure({ botId: bot.id, threadId, kind: input.kind, detail: redactSecretsInText(input.detail).slice(0, 300), at: Date.now() });
8634
8636
  }
8635
8637
  const task = store.taskByThread(bot.id, threadId);
@@ -10775,7 +10777,7 @@ async function startTurn(botId, text, opts) {
10775
10777
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
10776
10778
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
10777
10779
  { 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 ?? {})) },
10780
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {}), instance.adapter.capabilities.customMcp === true ? mcpTurn.unavailable(engineMcpServers(bot)) : []) },
10779
10781
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
10780
10782
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
10781
10783
  { id: "assignment", label: "Teammate task", text: coordinationNode ? `\n${coordinationSystemInstructions()}` : "" },
@@ -12623,7 +12625,7 @@ failover) {
12623
12625
  { id: "private-bot", label: "Private Bot", text: isPersonalBot(bot) ? privateBotPrompt(false) : "" },
12624
12626
  { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
12625
12627
  { 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 ?? {})) },
12628
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {}), instance.adapter.capabilities.customMcp === true ? mcpTurn.unavailable(engineMcpServers(bot)) : []) },
12627
12629
  { 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
12630
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
12629
12631
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmMounted ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
@@ -14781,23 +14783,19 @@ function warmMcpToolHints(custom) {
14781
14783
  /** Each configured server's sign-in state, and whether it is otherwise broken. */
14782
14784
  const mcpAuthState = new McpAuthStateStore(join(DATA_DIR, "mcp-auth-state.json"));
14783
14785
  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
- }
14786
+ const mcpTurn = mcpTurnPolicy({ auth: mcpAuthState, health: mcpHealth, signalDir: mcpAuthSignalDir, specs: () => customMcpServers(cfg),
14787
+ reportUnavailable: (threadId, detail) => { const bot = store.botByThread(threadId); if (bot)
14788
+ reportIncident({ kind: "mcp-unavailable", bot, threadId, detail }); } });
14789
+ function syncMcpAuthState() { mcpTurn.sync(); }
14791
14790
  /** How a start runs an mcp-remote bridge: background starts never open a
14792
14791
  * browser; only a person's click in Settings may (server/mcp-remote-bridge.ts). */
14793
14792
  function bridgedMcpServer(spec, allowBrowser) {
14794
14793
  return bridgeMcpRemote(spec, { bridge: resolveMcpRemoteBridge(), execPath: process.execPath, allowBrowser });
14795
14794
  }
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. */
14795
+ /** The servers one turn starts (mcp-turn.ts), after folding in what the gates reported. */
14798
14796
  function turnMcpServers(bot) {
14799
14797
  syncMcpAuthState();
14800
- return Object.fromEntries(Object.entries(engineMcpServers(bot)).filter(([name, spec]) => mcpAuthState.mountable(name, spec) && mcpHealth.mountable(name, spec)));
14798
+ return mcpTurn.mountable(engineMcpServers(bot));
14801
14799
  }
14802
14800
  function engineMcpServers(bot) {
14803
14801
  // Rule 4 (server/personal-bot.ts): a bot with no list of its own gets every
@@ -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
  }