openmausbot 0.1.93 → 0.1.95

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 (192) hide show
  1. package/dist/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-BwWpc4RK.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-jsQkIqTn.js} +1 -1
  3. package/dist/assets/{arc-BBdBuCgB.js → arc-7kei4Hsu.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-CRWDFDMq.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-CTKW_IKs.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-BBTW7-6M.js} +1 -1
  7. package/dist/assets/channel-DS2NbT5x.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-M_Ox-MkD.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-D2YiN-Ud.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-BmVYJ0f8.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtiLVVnZ.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-DFYSb6J6.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-mknKBzgV.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-DgSYGiOu.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-C7ZvuguH.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-CPqxINpA.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-Cd6xfJaC.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BSXY5qRh.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-D_lhEMj1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-Da9FrFwk.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-T77jMOXe.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-Dkj96X5E.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-CMi9zlD_.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-CXK6YUfS.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-BE7B1u9Q.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-CCMQphKr.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-iHJ_-tGH.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-DONsCWQ7.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-CU_WOaXk.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-BeYwM04G.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-Bdw9DqEl.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-C9iwq3qm.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-Dx-VWzUg.js} +1 -1
  34. package/dist/assets/{index-BkYwilkG.js → index-KygHRE3E.js} +1 -1
  35. package/dist/assets/index-NF7XCHAy.css +1 -0
  36. package/dist/assets/index-X_5ydXIE.js +610 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-BG7uaEd4.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-D72WAXnM.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-DKH1gjYT.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-3upm9JUT.js} +1 -1
  41. package/dist/assets/{linear-bq1PTDTW.js → linear-CcR4pc0-.js} +1 -1
  42. package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-C6yU5FSS.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DOI2xEuv.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BVWJSgLd.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-jtbYezhq.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-g0u8mtPX.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-k8689Wny.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DsJ22PDt.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CneWBRgG.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-DWsFCWap.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-Bvf0YE3S.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-Bxb3Iei8.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.js → swimlanes-2SLR337P-DLkk7W4A.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BKpQmzrT.js → timeline-definition-EJHVYXUP-KR9bIHYC.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-DfsOrj8t.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-DRSTzG3h.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-DtEdhPgT.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.js → xychartDiagram-PMCCYNJV-B-XhTxjG.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +3 -1
  62. package/dist-server/companion/src/routes.js +60 -0
  63. package/dist-server/container-mcp.js +94 -77
  64. package/dist-server/drivers/agents-proxy.js +96 -39
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +3 -1
  67. package/dist-server/{browser-proxy.js → harness-mcp-proxy.js} +35 -19
  68. package/dist-server/index.js +18242 -13645
  69. package/dist-server/local-computer.js +3 -1
  70. package/dist-server/mcp-gate.js +147 -19
  71. package/dist-server/mcp-remote-proxy.js +437 -0
  72. package/dist-server/openmausbot.js +9618 -10058
  73. package/dist-server/pair-cli.js +9618 -10058
  74. package/dist-server/permission-proxy.js +65 -1
  75. package/dist-server/proxy-paths.js +3 -1
  76. package/dist-server/server/account-signin.js +0 -2
  77. package/dist-server/server/activity.js +201 -0
  78. package/dist-server/server/auto-approve.js +39 -11
  79. package/dist-server/server/automatic-recovery.js +1 -1
  80. package/dist-server/server/boat-create-idempotency.js +0 -7
  81. package/dist-server/server/boat-delete-journal.js +0 -4
  82. package/dist-server/server/bot-package.js +1 -19
  83. package/dist-server/server/browser-live.js +14 -5
  84. package/dist-server/server/browser-runtime.js +7 -2
  85. package/dist-server/server/channel-queue.js +4 -1
  86. package/dist-server/server/chief-of-staff.js +2 -3
  87. package/dist-server/server/cli.js +32 -12
  88. package/dist-server/server/cloud-backend.js +0 -4
  89. package/dist-server/server/cloud-computer-tools.js +110 -0
  90. package/dist-server/server/cloud-home.js +0 -1
  91. package/dist-server/server/cloud-lending.js +0 -3
  92. package/dist-server/server/cloud-move-http.js +9 -3
  93. package/dist-server/server/cloud-move.js +18 -0
  94. package/dist-server/server/composio.js +31 -2
  95. package/dist-server/server/computer-control.js +0 -15
  96. package/dist-server/server/computer-observation.js +0 -74
  97. package/dist-server/server/computer-wait.js +33 -13
  98. package/dist-server/server/config.js +73 -11
  99. package/dist-server/server/container-computer.js +7 -19
  100. package/dist-server/server/contracts.js +9 -0
  101. package/dist-server/server/control-client.js +4 -10
  102. package/dist-server/server/decision-log.js +9 -6
  103. package/dist-server/server/delegations.js +47 -22
  104. package/dist-server/server/drivers/acp/core.js +64 -13
  105. package/dist-server/server/drivers/acp/grok.js +208 -3
  106. package/dist-server/server/drivers/agents-call.js +70 -29
  107. package/dist-server/server/drivers/agents-catalog.js +34 -12
  108. package/dist-server/server/drivers/agents-proxy.js +8 -30
  109. package/dist-server/server/drivers/antigravity.js +1 -4
  110. package/dist-server/server/drivers/boatagent.js +25 -4
  111. package/dist-server/server/drivers/cerebras.js +3 -3
  112. package/dist-server/server/drivers/chat-mcp-tools.js +29 -19
  113. package/dist-server/server/drivers/claude.js +33 -19
  114. package/dist-server/server/drivers/codex.js +84 -10
  115. package/dist-server/server/drivers/openai-chat.js +14 -9
  116. package/dist-server/server/drivers/pi.js +48 -4
  117. package/dist-server/server/drivers/retry.js +8 -4
  118. package/dist-server/server/engine-install.js +1 -5
  119. package/dist-server/server/environment.js +3 -0
  120. package/dist-server/server/guarded-requests.js +2 -1
  121. package/dist-server/server/harness/registry.js +0 -1
  122. package/dist-server/server/{browser-proxy.js → harness-mcp-proxy.js} +40 -20
  123. package/dist-server/server/index.js +1929 -507
  124. package/dist-server/server/lending-memory.js +12 -8
  125. package/dist-server/server/live-call-controller.js +868 -0
  126. package/dist-server/server/live-call.js +216 -0
  127. package/dist-server/server/mcp-gate-config.js +43 -12
  128. package/dist-server/server/mcp-gate.js +127 -25
  129. package/dist-server/server/mcp-http.js +46 -8
  130. package/dist-server/server/mcp-remote-proxy.js +108 -0
  131. package/dist-server/server/memory-entries.js +38 -8
  132. package/dist-server/server/memory-organize.js +1 -8
  133. package/dist-server/server/memory-topics.js +4 -4
  134. package/dist-server/server/memory-upkeep.js +13 -15
  135. package/dist-server/server/message-file.js +5 -9
  136. package/dist-server/server/new-bot-defaults.js +3 -0
  137. package/dist-server/server/outbound-counts.js +79 -0
  138. package/dist-server/server/outbound-requests.js +60 -0
  139. package/dist-server/server/package-import.js +1 -1
  140. package/dist-server/server/peer-delivery.js +1 -0
  141. package/dist-server/server/peer-provenance.js +4 -2
  142. package/dist-server/server/permission-proxy.js +15 -1
  143. package/dist-server/server/plan-usage.js +797 -0
  144. package/dist-server/server/proxy-paths.js +3 -1
  145. package/dist-server/server/redact.js +2 -0
  146. package/dist-server/server/request-auth.js +3 -2
  147. package/dist-server/server/room-handoffs.js +5 -13
  148. package/dist-server/server/routes/live.js +98 -0
  149. package/dist-server/server/routine-requests.js +1 -1
  150. package/dist-server/server/routines.js +5 -1
  151. package/dist-server/server/setup-mode.js +0 -2
  152. package/dist-server/server/skill-learn.js +0 -1
  153. package/dist-server/server/skill-library.js +195 -3
  154. package/dist-server/server/skills-library-migration.js +235 -0
  155. package/dist-server/server/skills.js +66 -3
  156. package/dist-server/server/steer-queue.js +13 -1
  157. package/dist-server/server/store.js +41 -5
  158. package/dist-server/server/surface.js +74 -23
  159. package/dist-server/server/system-prompt.js +4 -4
  160. package/dist-server/server/team-backup.js +2 -1
  161. package/dist-server/server/team-memory.js +209 -0
  162. package/dist-server/server/thread-events.js +3 -3
  163. package/dist-server/server/tunnel.js +20 -1
  164. package/dist-server/server/turn-log.js +10 -0
  165. package/dist-server/server/turn-resources.js +124 -0
  166. package/dist-server/server/vps-computer.js +4 -14
  167. package/dist-server/server/workspace.js +85 -47
  168. package/dist-server/shared/activity.js +3 -0
  169. package/dist-server/shared/ask-question.js +13 -0
  170. package/dist-server/shared/bot-avatar.js +8 -0
  171. package/dist-server/shared/call-consent.js +28 -0
  172. package/dist-server/shared/connector-scopes.js +78 -0
  173. package/dist-server/shared/live-approval.js +124 -0
  174. package/dist-server/shared/live-call.js +176 -0
  175. package/dist-server/shared/outbound.js +128 -0
  176. package/dist-server/shared/skill-md.js +10 -0
  177. package/dist-server/shared/team-backup.js +3 -0
  178. package/dist-server/shared/tool-scope-support.js +25 -0
  179. package/dist-server/shared/tool-scope.js +136 -0
  180. package/dist-server/shared/tool-surface.js +3 -2
  181. package/dist-server/src/lib/thread-refs.js +406 -0
  182. package/dist-server/vps-container-mcp.js +94 -77
  183. package/dist-server/workspace-backup.worker.js +89 -100
  184. package/enterprise/server/index.js +3 -1
  185. package/package.json +1 -1
  186. package/dist/assets/channel-BU94VpxE.js +0 -1
  187. package/dist/assets/index-Biji-bfL.css +0 -1
  188. package/dist/assets/index-DZlhoES0.js +0 -597
  189. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +0 -8
  190. package/dist-server/server/drivers/chat-boat-tools.js +0 -90
  191. package/dist-server/server/drivers/grokagent.js +0 -4
  192. package/dist-server/server/openmaus-status-capsule.js +0 -329
@@ -41,6 +41,12 @@ import { classifyQuiet, describeQuiet, lastSeenPhrase, LogTail, quietKey, sample
41
41
  export function skipSubscriptionAuthForLocalInject(model) {
42
42
  return Boolean(decodeInjectId(model));
43
43
  }
44
+ /** A catalog can contain credential-bearing native configuration, including late replies. */
45
+ export function acpNativeIncomingLogMessage(message, privateResponses) {
46
+ if (!privateResponses.has(message?.id))
47
+ return message;
48
+ return { jsonrpc: message.jsonrpc, id: message.id, ...(message.error !== undefined ? { error: "[MCP catalog error omitted]" } : { result: "[MCP catalog omitted]" }) };
49
+ }
44
50
  import { newEventId, newId, TurnNotStartedError } from "../../contracts.js";
45
51
  import { augmentedPath } from "../../env-path.js";
46
52
  import { supportsApprovalMode } from "../../../shared/approval-mode.js";
@@ -53,6 +59,8 @@ import { redactSecretsInText } from "../../redact.js";
53
59
  import { recoveryPromptFor } from "../../resume-recovery.js";
54
60
  import { sessionIdlePolicy } from "../session-idle.js";
55
61
  import { classifyError } from "../retry.js";
62
+ import { canUseMcpServer, narrowsNativeTools, parseToolScope } from "../../../shared/tool-scope.js";
63
+ import { gateServer } from "../../mcp-gate-config.js";
56
64
  const ACCOUNT_ERROR_CODES = new Set([
57
65
  "invalid_credentials", "inactive_subscription", "insufficient_funds", "quota_or_region_restriction",
58
66
  ]);
@@ -83,10 +91,12 @@ function acpRpcError(value, method) {
83
91
  });
84
92
  }
85
93
  const envOr = (key, fallback) => Number(process.env[key] ?? fallback);
86
- const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
87
- const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 300_000); // configureSession's per-request default
88
- const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
89
- const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
94
+ const INIT_TIMEOUT = 300_000;
95
+ const SESSION_CONFIG_TIMEOUT = 300_000; // configureSession's per-request default
96
+ const NEW_SESSION_TIMEOUT = 300_000;
97
+ const LOAD_SESSION_TIMEOUT = 120_000; // history replay on a long thread is slow
98
+ /** How long a quiet agent shows no sign of life before it is called stuck. */
99
+ const ACP_STUCK_AFTER_MS = 180_000;
90
100
  /** ACP agents may compact their own history without telling the client;
91
101
  * re-send the full prompt after this many bare turns as a backstop to
92
102
  * compaction detection. */
@@ -133,6 +143,18 @@ function trackRunningTool(current, update, defaultStatus) {
133
143
  else if (status === "pending" || status === "in_progress")
134
144
  current.runningTools.add(update.toolCallId);
135
145
  }
146
+ let unkeyedToolSeq = 0;
147
+ /** Stamp an unkeyed `tool_call` with a synthetic id and queue it for the
148
+ * call's first terminal update, which carries no id of its own to match.
149
+ * FIFO is the best available pairing when the agent omits `toolCallId`:
150
+ * the ids exist to pair start with completion, not to order concurrent
151
+ * unkeyed calls, and the synthetic id deliberately stays out of
152
+ * `runningTools` so turn-settle semantics are unchanged. */
153
+ function nextUnkeyedToolId(current) {
154
+ const id = `acp-tool-unkeyed-${++unkeyedToolSeq}`;
155
+ current.unkeyedToolIds.push(id);
156
+ return id;
157
+ }
136
158
  const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
137
159
  function acpVariantOption(result) {
138
160
  const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.type === "select" && typeof entry.id === "string"
@@ -509,7 +531,18 @@ export function createAcpDriver(support) {
509
531
  }
510
532
  servers.push({ name, command: server.command, args: server.args, env: acpEnv(server.env) });
511
533
  }
512
- return servers;
534
+ if (turn.toolScope === undefined)
535
+ return servers;
536
+ return servers.filter((server) => canUseMcpServer(turn.toolScope, server.name)).map((server) => {
537
+ const original = "url" in server
538
+ ? { type: server.type, url: server.url, headers: Object.fromEntries(server.headers.map(({ name, value }) => [name, value])) }
539
+ : { command: server.command, args: server.args, env: Object.fromEntries(server.env.map(({ name, value }) => [name, value])) };
540
+ const gated = gateServer({ name: server.name, server: original, threadId: turn.threadId, budget: 0,
541
+ toolScope: turn.toolScope, nodeEnv: { ELECTRON_RUN_AS_NODE: "1" } });
542
+ if (!gated)
543
+ throw new Error("Tool selection requires an MCP gate.");
544
+ return { name: server.name, command: gated.command, args: gated.args, env: acpEnv(gated.env) };
545
+ });
513
546
  };
514
547
  /** While a prompt runs, notice when the agent goes quiet and tell the
515
548
  * person what it is doing: retrying, compressing, waiting on its
@@ -583,7 +616,7 @@ export function createAcpDriver(support) {
583
616
  // "no sign of life" is called only once that has lasted minutes.
584
617
  if (next.kind === "no-signs") {
585
618
  noSignsSince ??= now;
586
- if (now - noSignsSince < envOr("OMB_ACP_STUCK_AFTER_MS", 180_000))
619
+ if (now - noSignsSince < ACP_STUCK_AFTER_MS)
587
620
  next = { kind: "between-requests" };
588
621
  }
589
622
  else {
@@ -667,6 +700,7 @@ export function createAcpDriver(support) {
667
700
  stdio: ["pipe", "pipe", "pipe"],
668
701
  });
669
702
  let nextId = 1;
703
+ const privateResponses = new Set();
670
704
  const rpcPending = new Map();
671
705
  const send = (obj) => {
672
706
  // A permission/file response resumes an agent that was waiting on us.
@@ -683,6 +717,8 @@ export function createAcpDriver(support) {
683
717
  };
684
718
  const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => new Promise((resolve, reject) => {
685
719
  const id = nextId++;
720
+ if (method === "_x.ai/mcp/list")
721
+ privateResponses.add(id);
686
722
  let timer = null;
687
723
  if (timeoutMs) {
688
724
  timer = setTimeout(() => {
@@ -1007,7 +1043,7 @@ export function createAcpDriver(support) {
1007
1043
  ...base(threadId, current.turnId),
1008
1044
  type: "item.started",
1009
1045
  itemType: "tool",
1010
- itemId: u.toolCallId,
1046
+ itemId: (typeof u.toolCallId === "string" && u.toolCallId) ? u.toolCallId : nextUnkeyedToolId(current),
1011
1047
  title: String(u.rawInput?.command ?? u.title ?? "tool").slice(0, 80),
1012
1048
  summary: commandSummary(u.rawInput),
1013
1049
  input: toolDetailPreview(u.rawInput),
@@ -1022,7 +1058,7 @@ export function createAcpDriver(support) {
1022
1058
  ...base(threadId, current.turnId),
1023
1059
  type: "item.completed",
1024
1060
  itemType: "tool",
1025
- itemId: u.toolCallId,
1061
+ itemId: (typeof u.toolCallId === "string" && u.toolCallId) ? u.toolCallId : current.unkeyedToolIds.shift(),
1026
1062
  ok: u.status !== "failed",
1027
1063
  output: toolDetailPreview(u.rawOutput ?? u.content),
1028
1064
  });
@@ -1084,7 +1120,7 @@ export function createAcpDriver(support) {
1084
1120
  catch {
1085
1121
  continue;
1086
1122
  }
1087
- appendNative(threadId, { dir: "in", source: SOURCE, msg: nativeLogMessage(msg) });
1123
+ appendNative(threadId, { dir: "in", source: SOURCE, msg: nativeLogMessage(acpNativeIncomingLogMessage(msg, privateResponses)) });
1088
1124
  // Inbound traffic proves the child is alive and making progress,
1089
1125
  // so every idle deadline restarts; only total silence trips it.
1090
1126
  lastInboundAt = Date.now();
@@ -1160,6 +1196,13 @@ export function createAcpDriver(support) {
1160
1196
  return session;
1161
1197
  };
1162
1198
  const sendTurn = async (turn) => {
1199
+ const parsedScope = parseToolScope(turn.toolScope);
1200
+ if (!parsedScope.ok)
1201
+ throw new Error(parsedScope.error);
1202
+ turn = { ...turn, toolScope: parsedScope.scope };
1203
+ if (narrowsNativeTools(turn.toolScope) && !support.toolScopeSessionParams) {
1204
+ throw new Error(`${support.displayName}: native tool selection is not supported by this engine. Keep native:* in the selection or choose a supported engine.`);
1205
+ }
1163
1206
  const { threadId } = turn;
1164
1207
  if (active.has(threadId))
1165
1208
  throw new Error("a turn is already running on this thread");
@@ -1190,7 +1233,7 @@ export function createAcpDriver(support) {
1190
1233
  }
1191
1234
  const resolvedModel = support.resolveTurnModel?.(turn.model, env);
1192
1235
  support.applyTurnEnv?.(env, {
1193
- model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true, botId: turn.botId, cwd,
1236
+ model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true, botId: turn.botId, cwd, toolScope: turn.toolScope,
1194
1237
  });
1195
1238
  // Rebound once, before the prompt, when a fallbackModel support swaps
1196
1239
  // a model this session does not offer for one it does.
@@ -1234,6 +1277,8 @@ export function createAcpDriver(support) {
1234
1277
  // riding a child spawned under the old env. Hash it so secrets
1235
1278
  // never sit in the key itself.
1236
1279
  const envFingerprint = createHash("sha256").update(JSON.stringify(spawnEnv)).digest("hex").slice(0, 16);
1280
+ const inheritedScopeFingerprint = narrowsNativeTools(turn.toolScope) && support.toolScopeCacheKey
1281
+ ? createHash("sha256").update(support.toolScopeCacheKey({ config: turnConfig, env, cwd })).digest("hex") : null;
1237
1282
  // A support that applies the approval mode to the session on every
1238
1283
  // turn gets the same process whatever the mode: an approval change
1239
1284
  // is one RPC, not a cold start.
@@ -1241,6 +1286,8 @@ export function createAcpDriver(support) {
1241
1286
  launch.command, launch.args ?? [], spawnArgs, cwd,
1242
1287
  support.sessionScopedApproval ? null : turnConfig.fullAuto === true, envFingerprint,
1243
1288
  support.spawnFingerprint?.(spawnEnv) ?? null,
1289
+ turn.toolScope ?? null,
1290
+ inheritedScopeFingerprint,
1244
1291
  ]);
1245
1292
  // The new-session default model rides the spawn env but stays out of
1246
1293
  // the fingerprint above: only session/new reads it, and a pooled
@@ -1259,7 +1306,7 @@ export function createAcpDriver(support) {
1259
1306
  launchedThisTurn = true;
1260
1307
  return opened;
1261
1308
  };
1262
- const sessionKey = JSON.stringify(mcpServers);
1309
+ const sessionKey = JSON.stringify([mcpServers, turn.toolScope ?? null, inheritedScopeFingerprint]);
1263
1310
  if (turn.sessionReset) {
1264
1311
  closeSession(threadId, "reset");
1265
1312
  sessionAllows.delete(threadId);
@@ -1357,6 +1404,7 @@ export function createAcpDriver(support) {
1357
1404
  acknowledge,
1358
1405
  asks,
1359
1406
  runningTools: new Set(),
1407
+ unkeyedToolIds: [],
1360
1408
  interruptTimer: null,
1361
1409
  stopQuietWatch: null,
1362
1410
  flushAssistantText,
@@ -1467,10 +1515,12 @@ export function createAcpDriver(support) {
1467
1515
  // an agent that advertised its transport, so an agent without
1468
1516
  // http/sse never sees an entry it would refuse the session over
1469
1517
  const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
1518
+ const selectionParams = narrowsNativeTools(turn.toolScope)
1519
+ ? support.toolScopeSessionParams(turn, init, sessionServers.length > 0, { config: turnConfig, env, cwd }) : {};
1470
1520
  let loaded = false;
1471
1521
  if (cursor) {
1472
1522
  try {
1473
- await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers }, LOAD_SESSION_TIMEOUT, (result) => {
1523
+ await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers, ...selectionParams }, LOAD_SESSION_TIMEOUT, (result) => {
1474
1524
  if (result) {
1475
1525
  loaded = true;
1476
1526
  session.sessionId = cursor;
@@ -1526,7 +1576,7 @@ export function createAcpDriver(support) {
1526
1576
  promptTurn = { ...turn, text: recovery.text };
1527
1577
  rebuiltFromReplay = recovery.replayed;
1528
1578
  }
1529
- sessionResult = await request("session/new", { cwd, mcpServers: sessionServers }, NEW_SESSION_TIMEOUT, (result) => {
1579
+ sessionResult = await request("session/new", { cwd, mcpServers: sessionServers, ...selectionParams }, NEW_SESSION_TIMEOUT, (result) => {
1530
1580
  session.sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
1531
1581
  session.sessionKey = sessionKey;
1532
1582
  receiveModelVariants(result);
@@ -1612,6 +1662,7 @@ export function createAcpDriver(support) {
1612
1662
  sessionModels: Array.isArray(sessionResult?.models?.availableModels)
1613
1663
  ? sessionResult.models.availableModels
1614
1664
  : [],
1665
+ currentModelId: session.sessionConfigResult?.models?.currentModelId,
1615
1666
  });
1616
1667
  approvalUnconfirmed = false;
1617
1668
  // initialize's currentModelId is the CLI default,
@@ -4,10 +4,13 @@
4
4
  // The generic protocol runtime lives in acp/core.ts; this file is only the
5
5
  // per-harness quirks. Verified against grok 1.0.0.
6
6
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { harnessHome } from "../../env-path.js";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { harnessHome, splitCliString } from "../../env-path.js";
9
11
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
10
12
  import { createAcpDriver } from "./core.js";
13
+ import { allowsTool, canUseMcpServer, narrowsNativeTools, parseToolScope } from "../../../shared/tool-scope.js";
11
14
  export const STATIC_GROK_MODELS = {
12
15
  default: "grok-4.7",
13
16
  options: [
@@ -181,6 +184,140 @@ export function grokAcceptsUnadvertisedImages(init) {
181
184
  const version = /^1\.0\.(\d+)$/.exec(meta.agentVersion);
182
185
  return Boolean(version && Number(version[1]) >= 25);
183
186
  }
187
+ // Original names in the official 1.0.41 default registry, not arbitrary
188
+ // owner strings forwarded into Grok's inherit-all profile allowlist.
189
+ const GROK_NATIVE_TOOLS = [
190
+ ["read_file", "GrokBuild:read_file"], ["search_replace", "GrokBuild:search_replace"], ["write", "OpenCode:write"],
191
+ ["run_terminal_command", "GrokBuild:run_terminal_cmd"], ["list_dir", "GrokBuild:list_dir"], ["grep", "GrokBuild:grep"],
192
+ ["kill_command_or_subagent", "GrokBuild:kill_task"], ["todo_write", "GrokBuild:todo_write"],
193
+ ["get_command_or_subagent_output", "GrokBuild:get_task_output"], ["spawn_subagent", "GrokBuild:task"],
194
+ ["scheduler_create", "GrokBuild:scheduler_create"], ["scheduler_delete", "GrokBuild:scheduler_delete"], ["scheduler_list", "GrokBuild:scheduler_list"],
195
+ ["monitor", "GrokBuild:monitor"], ["search_tool", "GrokBuild:search_tool"], ["use_tool", "GrokBuild:use_tool"],
196
+ ["workflow", "GrokBuild:workflow"], ["enter_plan_mode", "GrokBuild:enter_plan_mode"], ["exit_plan_mode", "GrokBuild:exit_plan_mode"],
197
+ ["ask_user_question", "GrokBuild:ask_user_question"], ["send_feedback", "GrokBuild:send_feedback"],
198
+ ["image_gen", "GrokBuild:image_gen"], ["image_edit", "GrokBuild:image_edit"],
199
+ ["image_to_video", "GrokBuild:image_to_video"], ["reference_to_video", "GrokBuild:reference_to_video"],
200
+ ];
201
+ /** Read a supported on-disk profile rather than overriding its restriction. */
202
+ export function grokInheritedProfile(cli, env, cwd) {
203
+ const unsupported = () => { throw new Error("Grok's existing agent profile cannot be safely intersected with tool selection. Use a profile file with explicit tools or a separate default Grok account."); };
204
+ if ((env.GROK_AGENT && env.GROK_AGENT !== "grok-build") || env.GROK_CONFIG || env.GROK_CONFIG_PATH)
205
+ return unsupported();
206
+ const args = splitCliString(cli);
207
+ // Operator filters are applied after ACP's profile by the CLI. Refuse
208
+ // unverified overrides rather than replacing an inherited restriction.
209
+ if (args.some(arg => /^(--tools|--disallowed-tools|--disallowedTools|--agent)(=|$)/.test(arg)))
210
+ return unsupported();
211
+ const flags = args.flatMap((arg, index) => arg === "--agent-profile" || arg.startsWith("--agent-profile=") ? [index] : []);
212
+ if (flags.length > 1)
213
+ return unsupported();
214
+ const flag = flags[0] ?? -1;
215
+ let path = flag >= 0 ? (args[flag].includes("=") ? args[flag].slice(args[flag].indexOf("=") + 1) : args[flag + 1]) : undefined;
216
+ if (flag >= 0 && (!path || path.startsWith("-")))
217
+ return unsupported();
218
+ const files = [join(env.GROK_HOME || harnessHome("grok", env), "config.toml")];
219
+ for (let directory = resolve(cwd);;) {
220
+ const file = join(directory, ".grok", "config.toml");
221
+ if (!files.includes(file))
222
+ files.push(file);
223
+ const parent = dirname(directory);
224
+ if (parent === directory)
225
+ break;
226
+ directory = parent;
227
+ }
228
+ for (const file of files) {
229
+ if (!existsSync(file))
230
+ continue;
231
+ const text = readFileSync(file, "utf8");
232
+ if (text.length > 262_144)
233
+ return unsupported();
234
+ // Support a deliberately small, unambiguous TOML subset. Scan the whole
235
+ // file: quoted/dotted keys, inline agent tables and multiline constructs
236
+ // must never be silently missed by a section regex. Unsupported syntax
237
+ // refuses this scoped turn; ordinary unrestricted turns are unaffected.
238
+ let section = "";
239
+ for (const line of text.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith("#"))) {
240
+ const header = /^\[([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)\]\s*(?:#.*)?$/.exec(line);
241
+ if (header) {
242
+ section = header[1];
243
+ if (section.startsWith("agent."))
244
+ return unsupported();
245
+ continue;
246
+ }
247
+ const assignment = /^([A-Za-z0-9_-]+)\s*=\s*(.+)$/.exec(line);
248
+ if (!assignment || /'''|"""/.test(assignment[2]) || (!section && assignment[1] === "agent"))
249
+ return unsupported();
250
+ if (section !== "agent")
251
+ continue;
252
+ const match = /^(name|definition)\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?$/.exec(line);
253
+ if (!match)
254
+ return unsupported();
255
+ let value;
256
+ try {
257
+ value = match[2].startsWith('"') ? JSON.parse(match[2]) : match[2].slice(1, -1);
258
+ }
259
+ catch {
260
+ return unsupported();
261
+ }
262
+ // Grok expands environment variables in paths. Do not resolve a
263
+ // different literal file or fall back when that contract is unknown.
264
+ if (value.includes("$") || !value)
265
+ return unsupported();
266
+ if (match[1] === "name" && value !== "grok-build")
267
+ return unsupported();
268
+ if (match[1] === "definition" && flag < 0) {
269
+ if (path !== undefined && path !== value)
270
+ return unsupported();
271
+ path = value;
272
+ }
273
+ }
274
+ }
275
+ if (!path)
276
+ return;
277
+ const text = readFileSync(resolve(cwd, path), "utf8");
278
+ if (text.length > 262_144)
279
+ return unsupported();
280
+ const match = /^\s*---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/.exec(text);
281
+ if (!match)
282
+ return unsupported();
283
+ const profile = parseYaml(match[1]);
284
+ if (!profile || typeof profile.name !== "string" || typeof profile.description !== "string"
285
+ || (profile.tools !== undefined && (!Array.isArray(profile.tools) || !profile.tools.every((tool) => typeof tool === "string")))
286
+ || (profile.disallowedTools !== undefined && (!Array.isArray(profile.disallowedTools) || !profile.disallowedTools.every((tool) => typeof tool === "string"))))
287
+ return unsupported();
288
+ const toolConfig = profile.toolConfig ?? { tools: GROK_NATIVE_TOOLS.map(([name, id]) => ({ id, name_override: name })) };
289
+ if (!Array.isArray(toolConfig.tools) || !toolConfig.tools.every((tool) => typeof tool?.id === "string" && (tool.name_override === undefined || typeof tool.name_override === "string")))
290
+ return unsupported();
291
+ return { ...profile, name: profile.name, description: profile.description, toolConfig, ...(match[2]?.trim() ? { promptBody: match[2].trim() } : {}) };
292
+ }
293
+ /** A curated registry: empty/unknown selections never inherit Grok's defaults. */
294
+ export function grokToolScopeProfile(scope, init, inherited, hasMcp = false) {
295
+ const parsed = parseToolScope(scope);
296
+ if (!parsed.ok)
297
+ throw new Error(parsed.error);
298
+ const meta = init?._meta;
299
+ if (meta?.grokShell !== true || meta.agentVersion !== "1.0.41") {
300
+ throw new Error("This Grok runtime does not have verified native tool selection. Use Grok 1.0.41 or Pi; update Grok support before using another version.");
301
+ }
302
+ if (hasMcp && (!allowsTool(scope, { kind: "native", name: "search_tool" }) || !allowsTool(scope, { kind: "native", name: "use_tool" }))) {
303
+ throw new Error("Grok requires native:search_tool and native:use_tool to find and call selected MCP tools. Allow both explicitly or use Pi.");
304
+ }
305
+ const candidates = inherited?.toolConfig.tools ?? GROK_NATIVE_TOOLS.map(([name, id]) => ({ id, name_override: name }));
306
+ const selected = candidates.filter((tool) => {
307
+ const name = tool.name_override ?? GROK_NATIVE_TOOLS.find(([, id]) => id === tool.id)?.[0];
308
+ return name !== undefined && allowsTool(scope, { kind: "native", name })
309
+ && (!inherited?.tools?.length || inherited.tools.includes(name) || inherited.tools.includes(tool.id))
310
+ && !inherited?.disallowedTools?.some((denied) => denied === name || denied === tool.id);
311
+ });
312
+ // Grok rejects an empty curated construction. A real read entry explicitly
313
+ // disabled by its exact native name constructs an empty final registry.
314
+ return {
315
+ ...inherited, name: inherited?.name ?? "openmausbot-tools", description: inherited?.description ?? "Owner-selected tools",
316
+ injectDefaultTools: false, discoverSkills: false,
317
+ toolConfig: { ...inherited?.toolConfig, tools: selected.length ? selected : [{ id: "GrokBuild:read_file", name_override: "read_file" }] },
318
+ tools: [], disallowedTools: [...new Set([...(inherited?.disallowedTools ?? []), ...(selected.length ? [] : ["read_file"])])],
319
+ };
320
+ }
184
321
  const support = {
185
322
  driverKind: "grokAgent",
186
323
  displayName: "Grok",
@@ -225,17 +362,69 @@ const support = {
225
362
  ? "bypassPermissions"
226
363
  : turn.approvalMode === "auto" ? "auto" : turn.approvalMode === "edits" ? "acceptEdits" : "default",
227
364
  "agent",
365
+ ...(turn.toolScope !== undefined ? ["--no-leader"] : []),
228
366
  ...(turn.model ? ["-m", turn.model] : []),
229
367
  // long form on purpose: `--effort` is documented as an alias, and an
230
368
  // alias is the part a CLI is free to rename
231
369
  ...(turn.effort ? ["--reasoning-effort", turn.effort] : []),
232
370
  "stdio",
233
371
  ],
372
+ toolScopeSessionParams: (turn, init, hasMcp, { config, env, cwd }) => {
373
+ if (!narrowsNativeTools(turn.toolScope))
374
+ return {};
375
+ const profile = grokToolScopeProfile(turn.toolScope, init, grokInheritedProfile(config.cli, env, cwd), hasMcp);
376
+ // Establish on the selected model. Changing harnesses after session/new
377
+ // can discard an ACP profile and restore the model's default tools.
378
+ if (turn.model)
379
+ profile.model = ensureGrokInjectSlug(turn.model, env);
380
+ return { _meta: { agentProfile: profile } };
381
+ },
382
+ toolScopeCacheKey: ({ config, env, cwd }) => JSON.stringify(grokInheritedProfile(config.cli, env, cwd) ?? null),
234
383
  // -m on argv is necessary but not sufficient: session/new still starts on
235
384
  // [models].default. Pin the slug over the wire, same as Hermes/Droid.
236
- async configureSession({ request, sessionId, turn }) {
385
+ async configureSession({ request, sessionId, turn, currentModelId }) {
386
+ if (turn.toolScope !== undefined) {
387
+ const available = new Set([
388
+ ...(turn.integrations?.agents ? ["agents"] : []), ...(turn.integrations?.composio ? ["composio"] : []),
389
+ ...(turn.integrations?.browser ? ["browser"] : []), ...(turn.integrations?.localComputer ? ["computer"] : []),
390
+ ...Object.keys(turn.integrations?.custom ?? {}),
391
+ ].filter((name) => canUseMcpServer(turn.toolScope, name)));
392
+ const deadline = Date.now() + 10_000;
393
+ for (;;) {
394
+ const reply = await request("_x.ai/mcp/list", { sessionId, cache: true }, 10_000);
395
+ const catalog = reply?.result;
396
+ if (!Array.isArray(catalog?.servers))
397
+ throw new Error("Grok could not confirm its MCP catalog. No prompt was sent.");
398
+ for (const server of catalog.servers) {
399
+ if (server?.session?.enabled === false)
400
+ continue;
401
+ if (!available.has(server?.name))
402
+ throw new Error("Grok has an MCP connection outside the bot's selection. Disable native MCP connections before using tool selection. No prompt was sent.");
403
+ if (Array.isArray(server?.session?.tools) && server.session.tools.some((tool) => typeof tool.name !== "string" || !allowsTool(turn.toolScope, { kind: "mcp", server: server.name, name: tool.name }))) {
404
+ throw new Error("Grok could not enforce the selected MCP catalog. No prompt was sent.");
405
+ }
406
+ }
407
+ if (catalog.sessionMcpResolved === true) {
408
+ if ([...available].some((name) => !catalog.servers.some((server) => server.name === name && server.session?.enabled === true && server.session.status === "ready"))) {
409
+ throw new Error("Grok could not connect a selected MCP server. No prompt was sent.");
410
+ }
411
+ break;
412
+ }
413
+ if (Date.now() >= deadline)
414
+ throw new Error("Grok did not confirm its selected MCP catalog in time. No prompt was sent.");
415
+ await delay(50);
416
+ }
417
+ }
237
418
  if (!turn.model)
238
419
  return;
420
+ if (narrowsNativeTools(turn.toolScope)) {
421
+ if (currentModelId !== turn.model) {
422
+ throw new Error("Grok could not confirm the selected model without replacing its tool profile. Start a new conversation or choose a model with a supported Grok profile. No prompt was sent.");
423
+ }
424
+ // The inline profile already pins this model. Repeating set_model can
425
+ // rebuild the harness, or reject an otherwise valid resumed profile.
426
+ return;
427
+ }
239
428
  try {
240
429
  await request("session/set_model", { sessionId, modelId: turn.model });
241
430
  }
@@ -249,6 +438,22 @@ const support = {
249
438
  transformEnv: (env) => {
250
439
  delete env.XAI_API_KEY;
251
440
  },
441
+ applyTurnEnv: (env, { toolScope }) => {
442
+ if (toolScope !== undefined) {
443
+ // The verified runtime gives these local env switches priority over
444
+ // account defaults, preventing an unfiltered managed gateway fallback.
445
+ env.GROK_MANAGED_MCPS_ENABLED = "false";
446
+ env.GROK_MANAGED_MCP_GATEWAY_TOOLS_ENABLED = "false";
447
+ if (narrowsNativeTools(toolScope)) {
448
+ if (env.GROK_AGENT && env.GROK_AGENT !== "grok-build") {
449
+ throw new Error("Grok's existing agent profile cannot be safely intersected with tool selection. Use a separate default Grok account.");
450
+ }
451
+ // A model's agent_type otherwise takes priority over ACP profiles.
452
+ // Preserve profile-file restrictions when intersecting them below.
453
+ env.GROK_AGENT = "grok-build";
454
+ }
455
+ }
456
+ },
252
457
  // Bind the grok.com subscription login. No API-key fallback by design —
253
458
  // an unauthenticated CLI is a user action, not something to paper over.
254
459
  pickAuthMethod: (methods) => (methods.some((m) => m.id === "cached_token") ? "cached_token" : null),
@@ -31,7 +31,6 @@ export function toolCallContextFromEnv(env) {
31
31
  createdThisTurn: 0,
32
32
  roomPostsThisTurn: 0,
33
33
  threadsOpenedThisTurn: 0,
34
- memoryRefusalsThisTurn: 0,
35
34
  delegationTaskIdsThisTurn: new Set(),
36
35
  },
37
36
  };
@@ -49,11 +48,9 @@ const MAX_ROOM_POSTS_PER_TURN = 3;
49
48
  // deciding. The harness holds the same ceiling; this copy exists so the
50
49
  // refusal reaches the model without a round trip.
51
50
  const MAX_THREADS_PER_TURN = 5;
52
- // A memory write the harness refused (a stale passage, a full file) needs
53
- // one re-read and one corrected retry, not a loop of the same append. The
54
- // third refusal in a turn closes the tool so the turn ends with the person
55
- // told what did not fit instead of a transcript of retries.
56
- const MAX_MEMORY_REFUSALS_PER_TURN = 3;
51
+ /** How many entries a memory_update reply names when its write moved some
52
+ * to the archive; the rest are counted. */
53
+ const MOVED_ENTRIES_SHOWN = 5;
57
54
  const SHORT_WEEKDAYS = {
58
55
  mon: "monday",
59
56
  tue: "tuesday",
@@ -278,7 +275,7 @@ function routineFields(args) {
278
275
  const runOn = destination(args.run_on ?? args.runOn);
279
276
  const timeoutMinutes = args.timeout_minutes ?? args.timeoutMinutes;
280
277
  if (runOn != null && runOn !== "maus" && runOn !== "cloud") {
281
- return { fields, error: 'Use run_on="maus" for the bot’s current model and configured computer (including VPS), or run_on="box" only for the Boat-hosted agent. Legacy "cloud" also means Boat.' };
278
+ return { fields, error: 'Use run_on="maus" for the bot’s current model and configured computer (including VPS), or run_on="box" only for the bot’s Boat cloud computer. Legacy "cloud" also means Boat.' };
282
279
  }
283
280
  if (timeoutMinutes != null && (typeof timeoutMinutes !== "number" || !Number.isInteger(timeoutMinutes) || timeoutMinutes < 5 || timeoutMinutes > 240)) {
284
281
  return { fields, error: "timeout_minutes must be a whole number from 5 to 240. Use clear_timeout to remove a limit." };
@@ -483,30 +480,54 @@ export async function callTool(name, args, context) {
483
480
  // retry needs instead of a generic validation error (#1239).
484
481
  const canonical = { ...args };
485
482
  delete canonical.botIds;
486
- delete canonical.requestKey;
487
483
  delete canonical.groupId;
488
484
  if (canonical.bot_ids === undefined)
489
485
  canonical.bot_ids = args.botIds;
490
- if (canonical.request_key === undefined)
491
- canonical.request_key = args.requestKey;
492
486
  if (canonical.group_id === undefined)
493
487
  canonical.group_id = args.groupId;
494
488
  const ids = canonical.bot_ids;
495
489
  const usable = Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string")
496
- && typeof canonical.message === "string" && canonical.message.trim().length > 0
497
- && typeof canonical.request_key === "string" && canonical.request_key.trim().length > 0;
490
+ && typeof canonical.message === "string" && canonical.message.trim().length > 0;
498
491
  if (!usable) {
499
492
  return {
500
- text: `coordinate_bots takes snake_case arguments: bot_ids (an array of 1-4 teammate ids), message and request_key are required; group_id, rework and label are optional. Received: ${Object.keys(args).join(", ") || "none"}.`,
493
+ text: `coordinate_bots takes snake_case arguments: bot_ids (an array of 1-4 teammate ids) and message are required; group_id and rework are optional. Received: ${Object.keys(args).join(", ") || "none"}.`,
501
494
  isError: true,
502
495
  };
503
496
  }
504
497
  const r = await api("/api/internal/coordinate-bots", { method: "POST", body: JSON.stringify({
505
498
  groupId: canonical.group_id, botIds: ids, message: canonical.message,
506
- requestKey: canonical.request_key, rework: canonical.rework, label: canonical.label,
499
+ rework: canonical.rework,
507
500
  }) });
508
501
  return { text: JSON.stringify(r), ...(r.error ? { isError: true } : {}) };
509
502
  }
503
+ if (name === "send_to_bot") {
504
+ const botId = String(args.bot_id ?? "").trim();
505
+ const title = String(args.title ?? "").trim();
506
+ const message = String(args.message ?? "").trim();
507
+ if (!botId || !title || !message) {
508
+ return { text: "send_to_bot needs bot_id, title and message.", isError: true };
509
+ }
510
+ if (botId === context.botId) {
511
+ return { text: "send_to_bot is cross-bot only. Use start_thread to send independent work to yourself.", isError: true };
512
+ }
513
+ if (turn.threadsOpenedThisTurn >= MAX_THREADS_PER_TURN) {
514
+ return { text: `You have already opened ${MAX_THREADS_PER_TURN} threads this turn, which is the limit.`, isError: true };
515
+ }
516
+ const r = await api("/api/internal/threads", { method: "POST", body: JSON.stringify({
517
+ fromBotId: context.botId, fromThreadId: context.threadId, toBotId: botId,
518
+ title, message, depth: context.depth, oneWay: true,
519
+ }) });
520
+ if (r.error)
521
+ return { text: `Couldn't send to that bot: ${String(r.error)}`, isError: true };
522
+ turn.threadsOpenedThisTurn += 1;
523
+ const destination = `@${String(r.botName ?? "that bot")} in #${String(r.title ?? title)} [thread id: ${String(r.threadId ?? "")}]`;
524
+ const state = r.approvalRequired === true
525
+ ? `Send to ${destination} is pending approval. The person's approval card appears after this turn ends; dispatch starts once approved.`
526
+ : r.state === "queued"
527
+ ? `Send to ${destination} is queued ${ordinal(Number(r.position) || 1)} for a free slot and can dispatch after this turn ends.`
528
+ : `Send to ${destination} is pending until this turn ends, then dispatch starts.`;
529
+ return { text: `${state} Its result stays in that thread; nothing there will resume you.` };
530
+ }
510
531
  if (name === "list_bots") {
511
532
  const r = await api(`/api/internal/agents?self=${encodeURIComponent(BOT_ID)}`);
512
533
  const bots = r.bots ?? [];
@@ -1087,12 +1108,6 @@ export async function callTool(name, args, context) {
1087
1108
  || (args.action !== "append" && (typeof args.old_text !== "string" || !args.old_text.trim()))) {
1088
1109
  return { text: "Use memory_update action=append with text, replace or supersede with text and old_text, or remove with old_text.", isError: true };
1089
1110
  }
1090
- if (turn.memoryRefusalsThisTurn >= MAX_MEMORY_REFUSALS_PER_TURN) {
1091
- return {
1092
- text: `Memory updates are closed for the rest of this turn: ${MAX_MEMORY_REFUSALS_PER_TURN} were refused. Do not retry. Tell the person what you wanted to keep and why it did not fit; they can tidy MEMORY.md in Settings, and you can try again in your next turn.`,
1093
- isError: true,
1094
- };
1095
- }
1096
1111
  const { body: r } = await apiResponse("/api/internal/memory", {
1097
1112
  method: "POST",
1098
1113
  body: JSON.stringify({
@@ -1104,16 +1119,23 @@ export async function callTool(name, args, context) {
1104
1119
  ...(typeof args.until === "string" && args.until.trim() ? { until: args.until.trim() } : {}),
1105
1120
  }),
1106
1121
  });
1107
- if (r.error || r.ok !== true) {
1108
- turn.memoryRefusalsThisTurn += 1;
1109
- const recent = Array.isArray(r.recent) ? r.recent.filter((line) => typeof line === "string") : [];
1110
- // A full file: the refusal carries the newest entries so the model
1111
- // can merge them in this same turn without a read round trip.
1112
- const tail = r.code === "over-budget" && recent.length ? `\n\nMost recent entries, oldest first:\n${recent.join("\n")}` : "";
1113
- return { text: `${String(r.error ?? "Memory update was not confirmed.")}${tail}`, isError: true };
1114
- }
1122
+ if (r.error || r.ok !== true)
1123
+ return { text: String(r.error ?? "Memory update was not confirmed."), isError: true };
1115
1124
  const entry = typeof r.entry === "string" && r.entry ? ` Entry: ${r.entry}` : "";
1116
- return { text: `Memory updated.${entry}${r.truncated ? " MEMORY.md exceeds the prompt load budget; keep it short and curated." : ""}` };
1125
+ const moved = Array.isArray(r.moved) ? r.moved.filter((line) => typeof line === "string") : [];
1126
+ // A write never fails for size: the bot hears where older notes went,
1127
+ // and the one case it cannot fix itself, in one plain sentence.
1128
+ const full = r.truncated
1129
+ ? "\nSaved, but the lines that never move out of MEMORY.md (hand-written ones, health and safety facts) fill what loads each session, so the newest entries do not load. Ask the person to trim MEMORY.md in Settings."
1130
+ : "";
1131
+ // Named by their first line, a few at most: a file grown far past the
1132
+ // budget by hand can move hundreds in one write.
1133
+ const shown = moved.slice(0, MOVED_ENTRIES_SHOWN).map((text) => text.split("\n")[0]);
1134
+ const more = moved.length > shown.length ? `\n…and ${moved.length - shown.length} more` : "";
1135
+ const movedNote = moved.length
1136
+ ? `\nTo stay within what loads each session, moved ${moved.length === 1 ? "1 older entry" : `${moved.length} older entries`} to memory/archive.md (session_search finds them):\n${shown.join("\n")}${more}`
1137
+ : "";
1138
+ return { text: `Memory updated.${entry}${full}${movedNote}` };
1117
1139
  }
1118
1140
  if (name === "retry_thread") {
1119
1141
  const botId = String(args.bot_id ?? "").trim();
@@ -1141,6 +1163,25 @@ export async function callTool(name, args, context) {
1141
1163
  return { text: String(r.error ?? "The log line was not confirmed."), isError: true };
1142
1164
  return { text: `Logged to ${String(r.file)}: ${String(r.line)}` };
1143
1165
  }
1166
+ if (name === "propose_team_memory") {
1167
+ const kind = String(args.kind ?? "").trim();
1168
+ const entryName = String(args.name ?? "").trim();
1169
+ const detail = String(args.detail ?? "").trim();
1170
+ if (!kind || !entryName || !detail)
1171
+ return { text: "propose_team_memory needs kind, name, and detail.", isError: true };
1172
+ const r = await api("/api/internal/team-memory", {
1173
+ method: "POST",
1174
+ body: JSON.stringify({
1175
+ fromBotId: BOT_ID,
1176
+ fromThreadId: THREAD_ID,
1177
+ kind,
1178
+ name: entryName,
1179
+ detail,
1180
+ aliases: Array.isArray(args.aliases) ? args.aliases.filter((alias) => typeof alias === "string") : undefined,
1181
+ }),
1182
+ });
1183
+ return confirmationResult(r, `remembering ${entryName} for the team`, "entry");
1184
+ }
1144
1185
  if (name === "session_search") {
1145
1186
  const q = String(args.query ?? "").trim();
1146
1187
  const since = typeof args.since === "string" ? args.since.trim() : "";