openmausbot 0.1.97 → 0.1.98

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 (144) hide show
  1. package/dist/assets/ActivityPanel-H6D3SD39.js +1 -0
  2. package/dist/assets/BotSettingsDialog-Dqz_AiBZ.js +7 -0
  3. package/dist/assets/CloudBackendPicker-iezpJFpF.js +1 -0
  4. package/dist/assets/ComputerPanel-DZPj-IWu.js +1 -0
  5. package/dist/assets/DesktopViewer-utONms1p.js +5 -0
  6. package/dist/assets/InspectorPanel-OVaBVrzC.js +5 -0
  7. package/dist/assets/KeyboardShortcutsModal-gk9mvHj_.js +1 -0
  8. package/dist/assets/LocalVmWorkspace-DLIvd2kC.js +1 -0
  9. package/dist/assets/MacLocalControl-bJv8yiPB.js +1 -0
  10. package/dist/assets/NewBotDialog-1qGOAdUM.js +2 -0
  11. package/dist/assets/RemoteAgentSettingsPanel-DqKr0HkO.js +1 -0
  12. package/dist/assets/RoutinesPage-BM2D4qGi.js +1 -0
  13. package/dist/assets/RoutinesSection-Dny9ra-c.js +1 -0
  14. package/dist/assets/SettingsModal-gC6XJJmt.js +11 -0
  15. package/dist/assets/TeamMapPage-B6fuu4r_.js +8 -0
  16. package/dist/assets/TriggersPanel-j0qTP2CO.js +1 -0
  17. package/dist/assets/VisibilitySection-DT0FqPvu.js +12 -0
  18. package/dist/assets/WebhooksPanel--YQyCjKO.js +3 -0
  19. package/dist/assets/{abnfDiagram-O67JEVCF-k1UCNXb7.js → abnfDiagram-O67JEVCF-BsjcFX0-.js} +1 -1
  20. package/dist/assets/activity-BxOkayvD.js +1 -0
  21. package/dist/assets/{arc-BUWGa9UU.js → arc-CJNA94c6.js} +1 -1
  22. package/dist/assets/architectureDiagram-NJMV4G6O-dz2ud7lP.js +36 -0
  23. package/dist/assets/{blockDiagram-BEXU5L5S-Ag9nLW4H.js → blockDiagram-BEXU5L5S-ChBEALoR.js} +1 -1
  24. package/dist/assets/brain-CFZ7PEXc.js +1 -0
  25. package/dist/assets/{c4Diagram-YGBWAQC7-Bh0sjgUa.js → c4Diagram-YGBWAQC7-KZNRKDQe.js} +1 -1
  26. package/dist/assets/channel-WhFyujhZ.js +1 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-BFBcIIqu.js → chunk-2Q5K7J3B-BLLwmCET.js} +1 -1
  28. package/dist/assets/{chunk-3FUC2YCW-Dvfpex-f.js → chunk-3FUC2YCW-CQxyreBZ.js} +2 -2
  29. package/dist/assets/{chunk-5VM5RSS4-CMjP2YZW.js → chunk-5VM5RSS4-D6sFMW0u.js} +1 -1
  30. package/dist/assets/{chunk-GTNCS2PH-BryoeTc7.js → chunk-GTNCS2PH-BaZ1YgLF.js} +1 -1
  31. package/dist/assets/{chunk-GWA4HPMP-BhfvcchO.js → chunk-GWA4HPMP-CcalphmC.js} +1 -1
  32. package/dist/assets/{chunk-JWPE2WC7-bcCVp8rp.js → chunk-JWPE2WC7-CSk8R3ve.js} +1 -1
  33. package/dist/assets/{chunk-NETBCI7D-yKIjIlkQ.js → chunk-NETBCI7D-CTmLjA57.js} +1 -1
  34. package/dist/assets/{chunk-WEXAMYUT-D3Zqbmup.js → chunk-WEXAMYUT-CM9Z77Ne.js} +1 -1
  35. package/dist/assets/{chunk-XXDRQBXY-CjVoIMG6.js → chunk-XXDRQBXY-B1fxpMS2.js} +1 -1
  36. package/dist/assets/{classDiagram-v2-NBCMYWYE-mUCWdVyH.js → classDiagram-v2-NBCMYWYE-DCCyoLpj.js} +1 -1
  37. package/dist/assets/computer-control-NLov352p.js +1 -0
  38. package/dist/assets/cose-bilkent-JH36ORCC-KyImBKvE.js +1 -0
  39. package/dist/assets/{cynefin-OW5HDTMX-BpRrxoO4.js → cynefin-OW5HDTMX-fsZc6MGa.js} +1 -1
  40. package/dist/assets/{cynefinDiagram-VND7K2PF-D3VBWCPK.js → cynefinDiagram-VND7K2PF-CGmHsShB.js} +1 -1
  41. package/dist/assets/{dagre-6A5THRUB-DxEIACOO.js → dagre-6A5THRUB-Dah1h-CH.js} +1 -1
  42. package/dist/assets/{diagram-22UHCM2B-BANgZEOS.js → diagram-22UHCM2B-BIu71OyV.js} +1 -1
  43. package/dist/assets/{diagram-3UASUU5V-Cnq7nJir.js → diagram-3UASUU5V-gxhUB_Mu.js} +1 -1
  44. package/dist/assets/{diagram-ATOU4E4O-4TBDIWnz.js → diagram-ATOU4E4O-CBhusUBn.js} +1 -1
  45. package/dist/assets/{diagram-CDSNMT55-CD_B39D4.js → diagram-CDSNMT55-BdPrc86x.js} +1 -1
  46. package/dist/assets/{diagram-MLGK6HIB-CzvnFhEC.js → diagram-MLGK6HIB-Clp110R_.js} +1 -1
  47. package/dist/assets/{diagram-MPIPVDR6-C4w2SNml.js → diagram-MPIPVDR6-DJKfbugw.js} +1 -1
  48. package/dist/assets/{ebnfDiagram-ZINNZB2B-gkUEBm2s.js → ebnfDiagram-ZINNZB2B-DnsltoBu.js} +1 -1
  49. package/dist/assets/{elk-276RUBZZ-B_56X6mw.js → elk-276RUBZZ-jWdq6gqH.js} +1 -1
  50. package/dist/assets/{erDiagram-OPXOYQCR-C_oByoDS.js → erDiagram-OPXOYQCR-DArPVf8u.js} +1 -1
  51. package/dist/assets/{flowDiagram-KWPJA3E3-CR9xXJkC.js → flowDiagram-KWPJA3E3-D2HZ-2f7.js} +1 -1
  52. package/dist/assets/ganttDiagram-FUAMR5RP--o0KIxEo.js +292 -0
  53. package/dist/assets/{gitGraphDiagram-X574FWY7-VJ6-Tp_p.js → gitGraphDiagram-X574FWY7-CsoXr2D7.js} +1 -1
  54. package/dist/assets/image-plus-BbVjGHin.js +1 -0
  55. package/dist/assets/index-BrPW5HDO.css +1 -0
  56. package/dist/assets/index-C3NqfdVU.js +585 -0
  57. package/dist/assets/{index-Cp4S4NOL.js → index-CJTp9JEp.js} +1 -1
  58. package/dist/assets/{infoDiagram-VRGFBTTK-2Lu9te4D.js → infoDiagram-VRGFBTTK-g-6lNp7r.js} +1 -1
  59. package/dist/assets/{ishikawaDiagram-OU5B5YK6-DR49XY-e.js → ishikawaDiagram-OU5B5YK6-B1d6qVTV.js} +1 -1
  60. package/dist/assets/{journeyDiagram-ZHPQQLJL-ZoR0M305.js → journeyDiagram-ZHPQQLJL-BTPuiU6T.js} +1 -1
  61. package/dist/assets/{kanban-definition-PNTS6WVX-DLKHxrO-.js → kanban-definition-PNTS6WVX-bDDuRJvP.js} +1 -1
  62. package/dist/assets/{linear-BASsfWly.js → linear-BVVKbEMy.js} +1 -1
  63. package/dist/assets/{mermaid.core-BVidiPkk.js → mermaid.core-JJdoKyKB.js} +5 -5
  64. package/dist/assets/{mindmap-definition-NLK3R4M7-g_Qx0U5C.js → mindmap-definition-NLK3R4M7-B4aE3T0q.js} +1 -1
  65. package/dist/assets/module-DCUsy2BQ.js +3 -0
  66. package/dist/assets/page-visible-BBo_Zm8g.js +1 -0
  67. package/dist/assets/{pegDiagram-GJSIUBJH-DI9Cjs8w.js → pegDiagram-GJSIUBJH-DFKQWpR7.js} +1 -1
  68. package/dist/assets/{pieDiagram-5QR66LMP-sRI0Uhr5.js → pieDiagram-5QR66LMP-Iv7OKVJi.js} +1 -1
  69. package/dist/assets/{quadrantDiagram-O4NWA36T-CamKTby3.js → quadrantDiagram-O4NWA36T-U2YnfwBk.js} +1 -1
  70. package/dist/assets/{railroadDiagram-XR7U4H2S-DOLlkbf3.js → railroadDiagram-XR7U4H2S-CuPy6RqM.js} +1 -1
  71. package/dist/assets/remote-desktop-panel-BNEj7tUx.js +1 -0
  72. package/dist/assets/{requirementDiagram-PLB6GJNP-DrRQ5r97.js → requirementDiagram-PLB6GJNP-bMI5AK7T.js} +1 -1
  73. package/dist/assets/{sankeyDiagram-IPEJSGJF-DGS_lXcX.js → sankeyDiagram-IPEJSGJF-BKcFE2WQ.js} +1 -1
  74. package/dist/assets/{sequenceDiagram-PO4LG4MO-CkjWqwtf.js → sequenceDiagram-PO4LG4MO-Bn9q88kl.js} +1 -1
  75. package/dist/assets/{sizeCapture-INFHLROL-CepRgful.js → sizeCapture-INFHLROL-Cvmdq9HD.js} +1 -1
  76. package/dist/assets/{stateDiagram-v2-GCMORJYK-hykr-9RS.js → stateDiagram-v2-GCMORJYK-JJQYw3eQ.js} +1 -1
  77. package/dist/assets/{swimlanes-2SLR337P-CKOHEZbI.js → swimlanes-2SLR337P-BkSCBEwp.js} +1 -1
  78. package/dist/assets/swimlanesDiagram-TC7HE7FX-CmTirbVT.js +8 -0
  79. package/dist/assets/table-file.worker-KkMx-SNx.js +10 -0
  80. package/dist/assets/{timeline-definition-EJHVYXUP-CB8pJ6AC.js → timeline-definition-EJHVYXUP-DsaM4GHr.js} +1 -1
  81. package/dist/assets/{usecaseDiagram-POWQR4AR-DrclJHdl.js → usecaseDiagram-POWQR4AR-3FOwLq7B.js} +1 -1
  82. package/dist/assets/user-DcZBnAuv.js +1 -0
  83. package/dist/assets/{vennDiagram-UO4OBE2U-zS23qjv8.js → vennDiagram-UO4OBE2U-DyNjJsKt.js} +1 -1
  84. package/dist/assets/{wardleyDiagram-VNRHLVJA-52jgeKxz.js → wardleyDiagram-VNRHLVJA-Y4ZMcnBC.js} +1 -1
  85. package/dist/assets/{xychartDiagram-PMCCYNJV-DSdl9rQ8.js → xychartDiagram-PMCCYNJV-BXZpg955.js} +1 -1
  86. package/dist/index.html +2 -2
  87. package/dist-server/cloud-home-start.js +19 -8
  88. package/dist-server/container-mcp.js +8 -0
  89. package/dist-server/desktop-entry.mjs +54 -0
  90. package/dist-server/drivers/agents-proxy.js +21 -4
  91. package/dist-server/index.js +1412 -821
  92. package/dist-server/openmausbot.js +837 -571
  93. package/dist-server/pair-cli.js +837 -571
  94. package/dist-server/permission-proxy.js +2 -0
  95. package/dist-server/server/atomic.js +70 -2
  96. package/dist-server/server/cloud-home-start.js +3 -3
  97. package/dist-server/server/config.js +22 -1
  98. package/dist-server/server/connector-card-text.js +15 -0
  99. package/dist-server/server/default-model-selection.js +7 -4
  100. package/dist-server/server/drivers/acp/core.js +10 -0
  101. package/dist-server/server/drivers/acp/grok.js +47 -5
  102. package/dist-server/server/drivers/agents-catalog.js +24 -4
  103. package/dist-server/server/drivers/agents-proxy.js +1 -0
  104. package/dist-server/server/drivers/chat-mcp-tools.js +5 -3
  105. package/dist-server/server/drivers/claude-login-auth.js +1 -1
  106. package/dist-server/server/drivers/claude.js +35 -11
  107. package/dist-server/server/drivers/codex-device-auth.js +27 -319
  108. package/dist-server/server/drivers/device-auth.js +342 -0
  109. package/dist-server/server/drivers/openai-chat.js +12 -4
  110. package/dist-server/server/fleet-agent.js +18 -5
  111. package/dist-server/server/harness/http.js +5 -2
  112. package/dist-server/server/harness/registry.js +19 -0
  113. package/dist-server/server/harness-capabilities.js +7 -4
  114. package/dist-server/server/hosted-models.js +3 -1
  115. package/dist-server/server/index.js +268 -54
  116. package/dist-server/server/key-rejections.js +57 -0
  117. package/dist-server/server/message-file.js +7 -4
  118. package/dist-server/server/permission-proxy.js +4 -0
  119. package/dist-server/server/provider-key-check.js +5 -1
  120. package/dist-server/server/request-auth.js +1 -1
  121. package/dist-server/server/room-turn-timeout.js +28 -0
  122. package/dist-server/server/routes/thread-models.js +36 -0
  123. package/dist-server/server/routines.js +2 -2
  124. package/dist-server/server/skills.js +3 -2
  125. package/dist-server/server/store.js +148 -60
  126. package/dist-server/server/team-backup.js +19 -4
  127. package/dist-server/server/thread-model.js +25 -0
  128. package/dist-server/server/turn-continuation.js +8 -2
  129. package/dist-server/server/webhook-ingress.js +8 -3
  130. package/dist-server/server/webhooks.js +40 -5
  131. package/dist-server/shared/device-sign-in.js +57 -0
  132. package/dist-server/shared/team-backup.js +2 -0
  133. package/dist-server/shared/thread-model.js +13 -0
  134. package/dist-server/vps-container-mcp.js +8 -0
  135. package/dist-server/workspace-backup.worker.js +41 -22
  136. package/package.json +1 -1
  137. package/dist/assets/DesktopViewer-CPidMCtt.js +0 -5
  138. package/dist/assets/architectureDiagram-NJMV4G6O-DiPor3UT.js +0 -36
  139. package/dist/assets/channel-DR8eD6QW.js +0 -1
  140. package/dist/assets/cose-bilkent-JH36ORCC-BBmR1bbX.js +0 -1
  141. package/dist/assets/ganttDiagram-FUAMR5RP-gtYBTu0B.js +0 -292
  142. package/dist/assets/index-CpaIXRLp.js +0 -614
  143. package/dist/assets/index-Z81SMsgR.css +0 -1
  144. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +0 -8
@@ -157,6 +157,7 @@ var allowQuestion = allowsTool(selection.scope, { kind: "mcp", server: "ogb", na
157
157
  var socketPath = process.argv[2] ?? "";
158
158
  var waiting = /* @__PURE__ */ new Map();
159
159
  var conn = connect(socketPath);
160
+ conn.setEncoding("utf8");
160
161
  var dead = () => {
161
162
  for (const resolve of waiting.values()) {
162
163
  resolve({ behavior: "deny", message: "OpenMausBot: permission broker unavailable \u2014 skip this action" });
@@ -295,6 +296,7 @@ async function handle(msg) {
295
296
  }
296
297
  }
297
298
  var inBuf = "";
299
+ process.stdin.setEncoding("utf8");
298
300
  process.stdin.on("data", (chunk) => {
299
301
  inBuf += chunk;
300
302
  let nl;
@@ -4,8 +4,15 @@
4
4
  // reader always sees either the complete old contents or the complete new
5
5
  // ones. Without this, an interrupted writeFileSync produces half-written JSON
6
6
  // that fails to parse on next boot and is silently treated as empty state.
7
+ //
8
+ // The fsync is the expensive part: on macOS it is F_FULLFSYNC, about 4 ms of
9
+ // blocked event loop per call. `durable: false` drops only the fsync, for
10
+ // files whose loss after a power cut costs nothing (a derived cache, a
11
+ // session-scoped token). The temp file and rename stay, so an app crash or
12
+ // kill -9 still never leaves a torn file; only a power loss or kernel panic
13
+ // can leave such a file empty.
7
14
  import { randomUUID } from "node:crypto";
8
- import { closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
9
16
  /** Windows refuses a rename onto an existing path while anything else holds a
10
17
  * handle to either file, and a virus scanner or the search indexer opening a
11
18
  * just-closed file for a few milliseconds is enough. It surfaces as EPERM or
@@ -51,7 +58,8 @@ export function writeFileAtomic(path, data, options = {}) {
51
58
  // config file visible between the write and a later chmod.
52
59
  fd = openSync(tmp, "w", options.mode);
53
60
  writeFileSync(fd, data);
54
- fsyncSync(fd);
61
+ if (options.durable !== false)
62
+ fsyncSync(fd);
55
63
  closeSync(fd);
56
64
  fd = null;
57
65
  renameWithRetry(tmp, path);
@@ -74,3 +82,63 @@ export function writeFileAtomic(path, data, options = {}) {
74
82
  throw e;
75
83
  }
76
84
  }
85
+ /** True only when `path` is already exactly `data`: a regular file (never a
86
+ * symlink, FIFO or directory), owned by this user, with permissions no
87
+ * broader than `mode`. Any doubt or error answers false, so the caller falls
88
+ * back to a full write — this check must never be the reason a save fails.
89
+ *
90
+ * It reads the file on disk rather than remembering the last write: a
91
+ * workspace restore or a hand edit can change the file underneath, and the
92
+ * next save has to put its own bytes back exactly as before. */
93
+ function holdsExactly(path, data, mode) {
94
+ let fd = null;
95
+ try {
96
+ if (process.platform === "win32") {
97
+ // No O_NOFOLLOW on Windows; a reparse point is replaced, not read.
98
+ if (!lstatSync(path).isFile())
99
+ return false;
100
+ fd = openSync(path, "r");
101
+ }
102
+ else {
103
+ // Inspect and read the same inode the open landed on, so a swap
104
+ // between a stat and the read cannot be mistaken for a match.
105
+ // O_NONBLOCK keeps a FIFO planted at the path from hanging the open.
106
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
107
+ }
108
+ const stat = fstatSync(fd);
109
+ if (!stat.isFile() || stat.size !== Buffer.byteLength(data))
110
+ return false;
111
+ const uid = process.getuid?.();
112
+ if (uid !== undefined && stat.uid !== uid)
113
+ return false;
114
+ // Windows reports 0o666 for any writable file; its mode bits mean nothing.
115
+ if (mode !== undefined && process.platform !== "win32" && (stat.mode & 0o777 & ~mode) !== 0)
116
+ return false;
117
+ // Bytes, not strings: decoding and comparing a megabyte-sized registry as
118
+ // a JS string costs several times more than a memcmp of the raw bytes.
119
+ return readFileSync(fd).equals(Buffer.from(data));
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ finally {
125
+ if (fd !== null) {
126
+ try {
127
+ closeSync(fd);
128
+ }
129
+ catch {
130
+ /* best-effort cleanup */
131
+ }
132
+ }
133
+ }
134
+ }
135
+ /** writeFileAtomic for saves that often repeat what the file already holds.
136
+ * A save that changes nothing costs one open and read instead of a temp
137
+ * file, a rename and an fsync; a save whose size differs pays only an
138
+ * open and fstat before the normal write. Returns whether it wrote. */
139
+ export function writeFileAtomicIfChanged(path, data, options = {}) {
140
+ if (holdsExactly(path, data, options.mode))
141
+ return false;
142
+ writeFileAtomic(path, data, options);
143
+ return true;
144
+ }
@@ -1,4 +1,4 @@
1
- // Entry point of the OMB Cloud Pro home image (deploy/fly/Dockerfile).
1
+ // Entry point of the OMB Cloud Pro home image (the Dockerfile's cloud-home target).
2
2
  //
3
3
  // Starts as root, hands a fresh Fly volume (mounted root-owned at /data) to
4
4
  // the unprivileged `maus` user, and stays a small root supervisor of two
@@ -77,7 +77,7 @@ export function cloudHomeChildEnvironments(config, env, home) {
77
77
  /** Why the root supervisor must not run or trust `files`, or null: each
78
78
  * must be root's and not writable by anyone else (nor any folder above it),
79
79
  * and none may live on the volume `home`, which `maus` owns. The image makes its code
80
- * root's (deploy/fly/Dockerfile); a file `maus` could rewrite would run as
80
+ * root's (the Dockerfile's cloud-home target); a file `maus` could rewrite would run as
81
81
  * root at the next start, or be handed the secrets. */
82
82
  export function codeTrustProblem(files, home, stat = statSync) {
83
83
  const volume = posix.join(home, "/");
@@ -155,7 +155,7 @@ export function startCloudHome(env = process.env) {
155
155
  if (ids) {
156
156
  const problem = codeTrustProblem([process.execPath, fileURLToPath(import.meta.url), join(here, "index.js"), edgeBin, edgeConfig], home);
157
157
  if (problem)
158
- throw new Error(`This image's code is not safe to run as root: ${problem}. Rebuild it from deploy/fly/Dockerfile.`);
158
+ throw new Error(`This image's code is not safe to run as root: ${problem}. Rebuild it with docker build --target cloud-home.`);
159
159
  }
160
160
  const { server, edge, secrets, dropped } = cloudHomeChildEnvironments(config, env, home);
161
161
  // Names only, never values: what the server does not get from here.
@@ -24,6 +24,13 @@ export const FISH_TTS_MODELS = ["s2.1-pro", "s2.1-pro-free"];
24
24
  export const DEFAULT_ROOM_TURN_TIMEOUT_MINUTES = 5;
25
25
  export const MIN_ROOM_TURN_TIMEOUT_MINUTES = 1;
26
26
  export const MAX_ROOM_TURN_TIMEOUT_MINUTES = 1_440;
27
+ /** Per-call ceiling for a bot's MCP tools (tools/call on the chat MCP
28
+ * transport). 10 minutes matches the historic constant in
29
+ * chat-mcp-tools.ts; a single tool call that runs longer is cut at this
30
+ * deadline because its execution outcome can no longer be trusted. */
31
+ export const DEFAULT_MCP_CALL_TIMEOUT_MINUTES = 10;
32
+ export const MIN_MCP_CALL_TIMEOUT_MINUTES = 1;
33
+ export const MAX_MCP_CALL_TIMEOUT_MINUTES = 60;
27
34
  export const DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES = 30;
28
35
  export const DEFAULT_ROOM_HANDOFF_MIN_RUNWAY_MINUTES = 10;
29
36
  export const DEFAULT_ROOM_HANDOFF_HARD_CAP_MINUTES = 240;
@@ -105,6 +112,16 @@ const roomConfigSchema = z.object({
105
112
  (rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) &&
106
113
  (rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) <=
107
114
  (rooms.handoffHardCapMinutes ?? DEFAULT_ROOM_HANDOFF_HARD_CAP_MINUTES), { message: "rooms handoff bounds must satisfy handoffMinRunwayMinutes <= handoffLifetimeMinutes <= handoffHardCapMinutes" });
115
+ const mcpConfigSchema = z.object({
116
+ /** Ceiling (minutes) for one bot MCP tool call. Missing values resolve to
117
+ * 10, preserving the historic chat-mcp-tools constant. */
118
+ callTimeoutMinutes: z
119
+ .number()
120
+ .int()
121
+ .min(MIN_MCP_CALL_TIMEOUT_MINUTES)
122
+ .max(MAX_MCP_CALL_TIMEOUT_MINUTES)
123
+ .optional(),
124
+ }).strict();
108
125
  /** Isolation for bot desktops. Migration note (issue #1654): switching
109
126
  * modes changes lease keys and vm-home directories, so desktops cold-start
110
127
  * under the new mode — a pool seat lives in vm-homes/pool-N — while the old
@@ -503,6 +520,7 @@ const appConfigSchema = z.object({
503
520
  * system language. Unknown tags degrade to English in the renderer. */
504
521
  language: optionalText,
505
522
  rooms: roomConfigSchema.optional(),
523
+ mcp: mcpConfigSchema.optional(),
506
524
  context: z.object({
507
525
  rebuildBytes: z.number().int().min(1_024).max(1_000_000).optional(),
508
526
  compactAt: z.number().positive().max(10_000_000).optional(),
@@ -635,6 +653,9 @@ export function browserEngineAttachCdpUrl(cfg) {
635
653
  export function roomTurnTimeoutMinutes(cfg) {
636
654
  return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
637
655
  }
656
+ export function mcpCallTimeoutMinutes(cfg) {
657
+ return cfg.mcp?.callTimeoutMinutes ?? DEFAULT_MCP_CALL_TIMEOUT_MINUTES;
658
+ }
638
659
  export const LIVE_IDLE_MINUTES_DEFAULT = 5;
639
660
  /** Non-secret Live settings. The key only shows up as `configured`. */
640
661
  export function liveSettingsFor(cfg) {
@@ -1204,7 +1225,7 @@ export function saveConfig(patch, options = {}) {
1204
1225
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
1205
1226
  if (storedProfiles.success)
1206
1227
  disk.browserProfiles = storedProfiles.data;
1207
- for (const key of ["xai", "anthropic", "mistral", "cerebras", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "live", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1228
+ for (const key of ["xai", "anthropic", "mistral", "cerebras", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "live", "profile", "rooms", "mcp", "threads", "context", "memory", "localVm", "features", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1208
1229
  const section = checkedPatch[key];
1209
1230
  if (!section)
1210
1231
  continue;
@@ -0,0 +1,15 @@
1
+ // The plain line a connection card carries beside its `connector` payload.
2
+ //
3
+ // Desktop and current phones draw `kind: "connector"` as a card and never
4
+ // read this. It exists for clients that do not know the kind yet — phone
5
+ // builds from before the in-chat card shipped decode it as "unknown" and draw
6
+ // whatever `text` a message has, so without it they showed nothing at all
7
+ // while the bot sat waiting for a connection. Exports and backups read it too.
8
+ // It names the app and points at the card; it never carries a link, because
9
+ // the authorization URL is only ever handed to the person's own client.
10
+ /** "Connect GitHub to continue." — or, for a second account, the alias too. */
11
+ export function connectorCardText(label, alias) {
12
+ const app = label.replace(/\s+/g, " ").trim() || "this app";
13
+ const account = alias?.replace(/\s+/g, " ").trim();
14
+ return account ? `Connect ${app} as “${account}” to continue.` : `Connect ${app} to continue.`;
15
+ }
@@ -1,10 +1,13 @@
1
1
  const claudeFirst = (instances) => instances.find((instance) => instance.driverKind === "claudeAgent") ?? instances[0];
2
+ /** Installed and waiting on a sign-in. A custom endpoint brings its own
3
+ * credential, so its sign-in state does not count. */
4
+ export function signedOut(instance) {
5
+ return instance.snapshot.state === "available" && instance.access !== "custom" && instance.snapshot.authenticated === false;
6
+ }
2
7
  /** The ready rule: an engine can run a turn now when it is available, the
3
- * organisation allows it, and it is not signed out. A custom endpoint brings
4
- * its own credential, so its sign-in state does not count. */
8
+ * organisation allows it, and it is not signed out (signedOut). */
5
9
  export function readyToRun(instance, context = {}) {
6
- return instance.snapshot.state === "available" && context.refusal?.(instance) === undefined &&
7
- (instance.access === "custom" || instance.snapshot.authenticated !== false);
10
+ return instance.snapshot.state === "available" && context.refusal?.(instance) === undefined && !signedOut(instance);
8
11
  }
9
12
  /** The engine a new bot gets, and the one a bot moved off a removed engine
10
13
  * gets (computer-engine-removal.ts): one picker for both.
@@ -31,6 +31,7 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
31
31
  import { createHash } from "node:crypto";
32
32
  import { PROVIDER_CREDENTIAL_ENV, stripControlPlaneEnv, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
33
33
  import { decodeInjectId } from "../local-inject.js";
34
+ import { DeviceAuthController } from "../device-auth.js";
34
35
  import { deletePromptSplitReceipt, promptHalves, readPromptSplitReceipt, splitSessionPrompt, writePromptSplitReceipt } from "../prompt-split.js";
35
36
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.js";
36
37
  import { classifyQuiet, describeQuiet, lastSeenPhrase, LogTail, quietKey, sampleProcessTree, } from "./quiet-status.js";
@@ -373,6 +374,9 @@ export function createAcpDriver(support) {
373
374
  };
374
375
  if (support.resolveModelsOnCreate !== false)
375
376
  await refreshModels();
377
+ const deviceSignIn = support.deviceSignIn
378
+ ? new DeviceAuthController(support.deviceSignIn, { cli: config.cli, environment: () => childEnv(), onAuthenticated: refreshModels })
379
+ : null;
376
380
  const listeners = new Set();
377
381
  const active = new Map();
378
382
  // One live agent process per thread, kept across turns — the Claude
@@ -1904,6 +1908,11 @@ export function createAcpDriver(support) {
1904
1908
  return models;
1905
1909
  },
1906
1910
  refreshModels: support.resolveModels ? refreshModels : undefined,
1911
+ ...(deviceSignIn ? {
1912
+ startAuthentication: () => deviceSignIn.start(),
1913
+ getAuthentication: (flowId) => deviceSignIn.get(flowId),
1914
+ cancelAuthentication: () => deviceSignIn.cancel(),
1915
+ } : {}),
1907
1916
  snapshot,
1908
1917
  adapter: {
1909
1918
  provider: DRIVER_KIND,
@@ -1950,6 +1959,7 @@ export function createAcpDriver(support) {
1950
1959
  },
1951
1960
  },
1952
1961
  dispose: async () => {
1962
+ await deviceSignIn?.dispose();
1953
1963
  for (const { stop } of active.values())
1954
1964
  stop();
1955
1965
  for (const threadId of Array.from(sessions.keys()))
@@ -20,6 +20,45 @@ export const STATIC_GROK_MODELS = {
20
20
  ],
21
21
  };
22
22
  const SLUG = /^[a-z0-9][a-z0-9._-]*$/i;
23
+ /** Grok's own home: `$GROK_HOME`, else `~/.grok`. Its grok.com sign-in,
24
+ * config and catalog all live here. */
25
+ export function grokHome(env = process.env) {
26
+ return env.GROK_HOME || harnessHome("grok", env);
27
+ }
28
+ /** The grok.com sign-in a turn runs on is stored. */
29
+ export function grokSignedIn(env) {
30
+ return existsSync(join(grokHome(env), "auth.json"));
31
+ }
32
+ function grokLoginFailure(output) {
33
+ if (/(unexpected argument|unrecognized (argument|option)|unknown option).*device-(auth|code)/i.test(output)) {
34
+ return "This server's Grok is too old to sign in with a code. Update Grok on the server, then try again.";
35
+ }
36
+ if (/device-code login is not available/i.test(output)) {
37
+ return "This Grok account can't sign in with a code. Use an xAI API key instead.";
38
+ }
39
+ if (/expired/i.test(output))
40
+ return "The Grok sign-in code expired. Start sign-in again for a new code.";
41
+ if (/denied|rejected/i.test(output))
42
+ return "Grok sign-in was declined in the browser. Start sign-in again to try once more.";
43
+ return "Grok sign-in did not finish. Check the server's connection, then try again.";
44
+ }
45
+ /** Grok Build's half of the in-app sign-in (device-auth.ts): `grok login
46
+ * --device-auth` on the grok.com subscription. Grok has no status command,
47
+ * so nothing is checked first, and a stored login that a failed turn just
48
+ * refused is signed in again; the sign-in is confirmed the way a turn checks
49
+ * it. Checked against grok 1.0.25 and 1.0.41: `login --help` and the
50
+ * device-code text in their binaries. */
51
+ export const GROK_DEVICE_SIGN_IN = {
52
+ provider: "grok",
53
+ product: "Grok",
54
+ account: "Grok",
55
+ homeEnv: "GROK_HOME",
56
+ homeDir: ".grok",
57
+ loginArgs: ["login", "--device-auth"],
58
+ install: "Install it with xAI's installer (https://x.ai/cli), then try again.",
59
+ failure: grokLoginFailure,
60
+ signedIn: grokSignedIn,
61
+ };
23
62
  function unquote(raw) {
24
63
  const value = raw.trim();
25
64
  if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
@@ -30,7 +69,7 @@ function unquote(raw) {
30
69
  /** Local slugs from ~/.grok/config.toml, plus the cloud defaults.
31
70
  * `grok -m <slug>` already accepts these; the picker just didn't list them. */
32
71
  export function readGrokModelCatalog(env = process.env) {
33
- const path = join(env.GROK_HOME || harnessHome("grok", env), "config.toml");
72
+ const path = join(grokHome(env), "config.toml");
34
73
  let text = "";
35
74
  try {
36
75
  text = readFileSync(path, "utf8");
@@ -112,7 +151,7 @@ export function ensureGrokInjectSlug(modelId, env = process.env) {
112
151
  const host = localHost(inject.host);
113
152
  if (!host)
114
153
  return modelId;
115
- const path = join(env.GROK_HOME || harnessHome("grok", env), "config.toml");
154
+ const path = join(grokHome(env), "config.toml");
116
155
  let text = "";
117
156
  try {
118
157
  text = readFileSync(path, "utf8");
@@ -215,7 +254,7 @@ export function grokInheritedProfile(cli, env, cwd) {
215
254
  let path = flag >= 0 ? (args[flag].includes("=") ? args[flag].slice(args[flag].indexOf("=") + 1) : args[flag + 1]) : undefined;
216
255
  if (flag >= 0 && (!path || path.startsWith("-")))
217
256
  return unsupported();
218
- const files = [join(env.GROK_HOME || harnessHome("grok", env), "config.toml")];
257
+ const files = [join(grokHome(env), "config.toml")];
219
258
  for (let directory = resolve(cwd);;) {
220
259
  const file = join(directory, ".grok", "config.toml");
221
260
  if (!files.includes(file))
@@ -331,7 +370,7 @@ const support = {
331
370
  effortLevels: ["low", "medium", "high"],
332
371
  defaultCli: "grok",
333
372
  nativeSource: "grok.acp",
334
- loginNote: "Grok CLI is not signed in — run `grok login` in a terminal",
373
+ loginNote: "Grok is not signed in to your grok.com account — choose Sign in to Grok in engine setup",
335
374
  // No Windows one-liner: the installer is a POSIX shell script, and offering
336
375
  // `curl … | bash` there would be advice that cannot run. Windows falls back
337
376
  // to docsUrl, which is honest rather than broken.
@@ -341,8 +380,11 @@ const support = {
341
380
  linux: "curl -fsSL https://x.ai/cli/install.sh | bash",
342
381
  },
343
382
  docsUrl: "https://x.ai/cli",
383
+ // The terminal route, for people who prefer it; the app's own sign-in
384
+ // is the device code below, which needs no terminal.
344
385
  signInCommand: "grok login",
345
386
  },
387
+ deviceSignIn: GROK_DEVICE_SIGN_IN,
346
388
  // Write the [model.slug] block with the instance HOME/GROK_HOME, then pass
347
389
  // the slug on argv. spawnArgs must not call ensureGrokInjectSlug itself —
348
390
  // that helper defaults to process.env and would miss the instance override.
@@ -458,7 +500,7 @@ const support = {
458
500
  // an unauthenticated CLI is a user action, not something to paper over.
459
501
  pickAuthMethod: (methods) => (methods.some((m) => m.id === "cached_token") ? "cached_token" : null),
460
502
  authFailure: "fail",
461
- isAuthenticated: (env) => existsSync(join(harnessHome("grok", env), "auth.json")),
503
+ isAuthenticated: (env) => grokSignedIn(env),
462
504
  // `--append-system-prompt`/`--rules` are accepted by the CLI but do NOT
463
505
  // reach the agent-stdio system prompt (verified against 1.0.0), so the
464
506
  // persona is prepended codex-style.
@@ -26,6 +26,7 @@ export function catalogProfileFromEnv(env) {
26
26
  voiceNotes: env.OMB_VOICE_NOTES === "1",
27
27
  cloudHome: env.OMB_CLOUD_HOME === "1",
28
28
  memoryEnabled: env.OMB_MEMORY_ENABLED !== "0",
29
+ chief: env.OMB_CHIEF_OF_STAFF === "1",
29
30
  botId: env.OMB_BOT_ID ?? "",
30
31
  };
31
32
  }
@@ -146,7 +147,7 @@ const ROUTINE_FIELDS_SCHEMA = {
146
147
  },
147
148
  continuity: {
148
149
  type: "boolean",
149
- description: "Opt in to using the latest completed run's bounded report as historical context. Defaults to false; set false in an update to start fresh again. Included in the applied result or pending confirmation.",
150
+ description: "Opt in to using the latest completed run's bounded report as historical context. Use it for recurring work that builds on last time, such as QA passes, monitoring or follow-ups. Defaults to false; set false in an update to start fresh again. Included in the applied result or pending confirmation.",
150
151
  },
151
152
  overlap: {
152
153
  type: "string",
@@ -154,6 +155,9 @@ const ROUTINE_FIELDS_SCHEMA = {
154
155
  description: "While this routine is still working, skip scheduled occurrences (default) or queue at most one run. Queue skips further occurrences until the pending run starts; it never builds an unlimited backlog. Manual and webhook requests are separate.",
155
156
  },
156
157
  };
158
+ // propose_profile's one sentence about for_bot_id, which a bot that is not a
159
+ // Chief is not shown (catalogTools), along with the parameter itself.
160
+ const CHIEF_PROFILE_TARGET = " A Chief of Staff may pass for_bot_id (from list_bots) for a requested change to another bot in its section.";
157
161
  const PROPOSAL_OUTCOME = " Read the result: granted Full Access may apply the change immediately. If applied, continue the requested work without another confirmation. Only a pending result requires ending the turn and waiting for the in-app decision. Never claim success from the permission mode alone; report failed or cancelled results honestly. This does not elevate another bot's execution permissions.";
158
162
  /** Every tool, in the order it is listed. Four peer tools are worded
159
163
  * differently for an external runtime, which may poll inside one process. */
@@ -652,7 +656,7 @@ const toolDefinitions = (externalRuntime) => [
652
656
  },
653
657
  {
654
658
  name: "propose_profile",
655
- description: "Submit user-requested changes to your own name, title, description, standing instructions (SOUL.md), working folder (cwd), or your alert and voice toggles (notifications, speakReplies). Keep SOUL.md short — who you are and the rules you never break; put step-by-step procedure into a skill instead. A Chief of Staff may pass for_bot_id (from list_bots) for a requested change to another bot in its section." + PROPOSAL_OUTCOME,
659
+ description: "Submit user-requested changes to your own name, title, description, standing instructions (SOUL.md), working folder (cwd), or your alert and voice toggles (notifications, speakReplies). Keep SOUL.md short — who you are and the rules you never break; put step-by-step procedure into a skill instead." + CHIEF_PROFILE_TARGET + PROPOSAL_OUTCOME,
656
660
  inputSchema: {
657
661
  type: "object",
658
662
  additionalProperties: false,
@@ -842,6 +846,14 @@ export const SHARED_COMPUTER_TOOL_NAMES = new Set(["list_shared_computers", "sha
842
846
  // tool whose every call would end in a setup error. The route behind it
843
847
  // refuses regardless; this keeps the catalog honest about what can work.
844
848
  const VOICE_TOOL_NAMES = new Set(["send_voice_note"]);
849
+ // And for a role: every route behind these refuses a bot that is not its
850
+ // section's Chief of Staff, as it does a for_bot_id naming another bot on
851
+ // propose_profile or propose_model. (A routine's for_bot_id is open to any
852
+ // bot that can reach that peer, so it stays.)
853
+ const CHIEF_ONLY_TOOL_NAMES = new Set([
854
+ "create_bot", "list_team_setup", "propose_team_setup", "propose_bot_deletion", "create_room", "manage_room", "retry_thread",
855
+ ]);
856
+ const CHIEF_TARGET_TOOL_NAMES = new Set(["propose_profile", "propose_model"]);
845
857
  // One teamwork path in room turns; keep all unrelated integrations available.
846
858
  // Ordinary direct chats use this same bounded coordinator. Goal-owned turns
847
859
  // retain their independent loop and cannot start a second coordinator.
@@ -878,10 +890,18 @@ function catalogTools(profile) {
878
890
  const VOICE_READY_TOOLS = profile.voiceNotes
879
891
  ? SHAREABLE_TOOLS
880
892
  : SHAREABLE_TOOLS.filter((tool) => !VOICE_TOOL_NAMES.has(tool.name));
893
+ const ROLE_TOOLS = profile.chief
894
+ ? VOICE_READY_TOOLS
895
+ : VOICE_READY_TOOLS.filter((tool) => !CHIEF_ONLY_TOOL_NAMES.has(tool.name)).map((tool) => {
896
+ if (!CHIEF_TARGET_TOOL_NAMES.has(tool.name))
897
+ return tool;
898
+ const properties = Object.fromEntries(Object.entries(tool.inputSchema.properties).filter(([key]) => key !== "for_bot_id"));
899
+ return { ...tool, description: tool.description.replace(CHIEF_PROFILE_TARGET, ""), inputSchema: { ...tool.inputSchema, properties } };
900
+ });
881
901
  return profile.externalRuntime
882
902
  ? BOT_SCOPED_TOOLS.filter(tool => EXTERNAL_TOOL_NAMES.has(tool.name))
883
903
  : profile.coordinating
884
- ? VOICE_READY_TOOLS.filter(tool => !ROOM_REPLACED_TOOLS.has(tool.name) || (tool.name === "start_thread" && profile.ownThreadCreation))
904
+ ? ROLE_TOOLS.filter(tool => !ROOM_REPLACED_TOOLS.has(tool.name) || (tool.name === "start_thread" && profile.ownThreadCreation))
885
905
  .map(tool => tool.name === "start_thread" ? {
886
906
  ...tool,
887
907
  description: "Open a separate job on yourself with its own history and run, without switching the person's selected conversation. Use only when the user requests independent jobs (for example one review per pull request). Give a short specific title and complete instructions; you can open at most five per turn. This is not a teammate handoff: use coordinate_bots for teammates and their automatic replies. Self-opened jobs cannot recursively open more jobs. If refused, do not retry; explain what remains.",
@@ -889,5 +909,5 @@ function catalogTools(profile) {
889
909
  bot_id: { type: "string", enum: [profile.botId], description: "Leave out, or use your own bot ID. For teammates use coordinate_bots." },
890
910
  } },
891
911
  } : tool)
892
- : VOICE_READY_TOOLS.filter(tool => !ROOM_ONLY_TOOLS.has(tool.name));
912
+ : ROLE_TOOLS.filter(tool => !ROOM_ONLY_TOOLS.has(tool.name));
893
913
  }
@@ -12,6 +12,7 @@
12
12
  // OMB_COMMS_TOKEN shared secret for the localhost-only internal endpoints
13
13
  // OMB_TURN_DEPTH this turn's comms depth (the harness refuses recursion)
14
14
  // OMB_EXTERNAL_RUNTIME "1" for a standing process: peer tools and polling only
15
+ // OMB_CHIEF_OF_STAFF "1" for a Chief of Staff, the only bot shown the Chief-only tools
15
16
  //
16
17
  // This file is the stdio front end only. What the tools are and which a turn
17
18
  // sees: agents-catalog.ts. What a call does: agents-call.ts. How the harness
@@ -14,7 +14,9 @@ import { allowsTool, canUseMcpServer, parseToolScope } from "../../shared/tool-s
14
14
  export class ChatToolSessionError extends Error {
15
15
  }
16
16
  const STARTUP_MS = 8_000;
17
- const CALL_MS = 10 * 60_000;
17
+ /** Historic per-call ceiling for a bot's MCP tools; a turn can override it
18
+ * with SendTurnInput.mcpCallTimeoutMs (server config `mcp.callTimeoutMinutes`). */
19
+ const DEFAULT_CALL_MS = 10 * 60_000;
18
20
  const FRAME_BYTES = 2 * 1024 * 1024;
19
21
  const OUTPUT_BYTES = 50 * 1024;
20
22
  const TOOL_COUNT = 128;
@@ -283,7 +285,7 @@ function omitBlankDefaults(builtInBrowser, tool, args) {
283
285
  delete args[field];
284
286
  }
285
287
  }
286
- export async function mountChatTools(integrations, signal, computerUse = false, toolScope) {
288
+ export async function mountChatTools(integrations, signal, computerUse = false, toolScope, callTimeoutMs = DEFAULT_CALL_MS) {
287
289
  const parsed = parseToolScope(toolScope);
288
290
  if (!parsed.ok)
289
291
  throw new Error(parsed.error);
@@ -411,7 +413,7 @@ export async function mountChatTools(integrations, signal, computerUse = false,
411
413
  }
412
414
  const tool = registered.get(name);
413
415
  try {
414
- const result = await tool.client.call("tools/call", { name: tool.name, arguments: args }, AbortSignal.any([signal, callSignal]), CALL_MS);
416
+ const result = await tool.client.call("tools/call", { name: tool.name, arguments: args }, AbortSignal.any([signal, callSignal]), callTimeoutMs);
415
417
  if (signal.aborted || callSignal.aborted)
416
418
  throw aborted();
417
419
  if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== "boolean"))
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
4
  import { stripVTControlCharacters } from "node:util";
5
5
  import { killCliTree, spawnCli } from "../procs.js";
6
- import { canonicalPath } from "./codex-device-auth.js";
6
+ import { canonicalPath } from "./device-auth.js";
7
7
  const MAX_OUTPUT = 32_768;
8
8
  /** Where Anthropic's sign-in lives. Anything else in the CLI's output is not a link we show. */
9
9
  const SIGN_IN_HOSTS = ["claude.com", "claude.ai", "console.anthropic.com", "platform.claude.com"];
@@ -15,7 +15,7 @@ import { createServer as createNetServer } from "node:net";
15
15
  import { homedir, tmpdir } from "node:os";
16
16
  import { join, dirname, isAbsolute, normalize } from "node:path";
17
17
  import { DATA_DIR, stripWorkspaceCredentialEnv } from "../config.js";
18
- import { writeFileAtomic } from "../atomic.js";
18
+ import { writeFileAtomic, writeFileAtomicIfChanged } from "../atomic.js";
19
19
  import { augmentedPath } from "../env-path.js";
20
20
  import { brokerSocketPath, describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
21
21
  import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
@@ -599,6 +599,9 @@ export async function createPermissionBroker(opts) {
599
599
  let boundPath = opts.socketPaths[0] ?? "";
600
600
  const connectionHandler = (conn) => {
601
601
  conn.on("error", () => { });
602
+ // An ask carries the whole tool input (a Write's file content), so one
603
+ // line spans many reads. Decode as a stream so no character is split.
604
+ conn.setEncoding("utf8");
602
605
  let buf = "";
603
606
  conn.on("data", (chunk) => {
604
607
  buf += chunk;
@@ -932,8 +935,10 @@ function recordCostState(sessionId, state) {
932
935
  const ids = Object.keys(history);
933
936
  for (const id of ids.slice(0, Math.max(0, ids.length - COST_HISTORY_SESSIONS)))
934
937
  delete history[id];
938
+ // Written after every turn, so not fsynced: what a power cut could lose is
939
+ // the same thing a failed write already gives up (below).
935
940
  try {
936
- writeFileAtomic(COST_HISTORY_FILE, JSON.stringify(history), { mode: 0o600 });
941
+ writeFileAtomic(COST_HISTORY_FILE, JSON.stringify(history), { mode: 0o600, durable: false });
937
942
  }
938
943
  catch {
939
944
  // a lost state only means a later resume keeps its whole figure
@@ -1051,6 +1056,26 @@ export const ClaudeDriver = {
1051
1056
  const readCliVersion = (env) => new Promise((resolve) => {
1052
1057
  execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim() || null));
1053
1058
  });
1059
+ // The --help read while it runs: snapshots that overlap (the server's
1060
+ // read at start and the app's first one) share it.
1061
+ let helpRead = null;
1062
+ const readCliHelp = (version, env) => {
1063
+ if (helpRead?.version === version)
1064
+ return helpRead.done;
1065
+ const read = {
1066
+ version,
1067
+ done: new Promise((resolve) => {
1068
+ execCli(config.cli, ["--help"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout));
1069
+ }).then((help) => {
1070
+ cliHasAutocompact = help === null ? null : /^\s*--autocompact\b/m.test(help);
1071
+ cliHelpVersion = version;
1072
+ if (helpRead === read)
1073
+ helpRead = null;
1074
+ }),
1075
+ };
1076
+ helpRead = read;
1077
+ return read.done;
1078
+ };
1054
1079
  const listeners = new Set();
1055
1080
  // one active turn per thread; a second send while busy is a caller bug
1056
1081
  const active = new Map();
@@ -1411,14 +1436,18 @@ export const ClaudeDriver = {
1411
1436
  ? readClaudeAuthSettings(env, input.environment) : {};
1412
1437
  // Harness hooks (item 0.2): one helper command for the events the
1413
1438
  // harness observes. The helper reads its bearer from a per-thread file
1414
- // the harness rewrites every turn, so a long-lived CLI process never
1439
+ // the harness refreshes every turn, so a long-lived CLI process never
1415
1440
  // presents a stale token. Registered through the same private
1416
1441
  // --settings file as the auth override; both are 0600 and per launch.
1417
1442
  const hooks = turn.integrations?.hooks;
1418
1443
  const hookTokenPath = hooks ? hookTokenFile(threadId, botId) : null;
1419
1444
  if (hooks && hookTokenPath) {
1420
1445
  mkdirSync(dirname(hookTokenPath), { recursive: true, mode: 0o700 });
1421
- writeFileAtomic(hookTokenPath, hooks.token, { mode: 0o600 });
1446
+ // The bearer is usually the same as last turn, and it only lives in
1447
+ // this process's memory, so a restart invalidates the file anyway:
1448
+ // skip identical bytes and the fsync. A rotated bearer differs from
1449
+ // what is on disk, so it is always written before the CLI launches.
1450
+ writeFileAtomicIfChanged(hookTokenPath, hooks.token, { mode: 0o600, durable: false });
1422
1451
  env.OMB_HOOK_URL = hooks.url;
1423
1452
  env.OMB_HOOK_TOKEN_FILE = hookTokenPath;
1424
1453
  env.OMB_HOOK_NODE = process.execPath;
@@ -2303,13 +2332,8 @@ export const ClaudeDriver = {
2303
2332
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
2304
2333
  cliVersion = parseClaudeCliVersion(version);
2305
2334
  cliVersionChecked = true;
2306
- if (version !== cliHelpVersion) {
2307
- const help = await new Promise((resolve) => {
2308
- execCli(config.cli, ["--help"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout));
2309
- });
2310
- cliHasAutocompact = help === null ? null : /^\s*--autocompact\b/m.test(help);
2311
- cliHelpVersion = version;
2312
- }
2335
+ if (version !== cliHelpVersion)
2336
+ await readCliHelp(version, env);
2313
2337
  const update = claudeCliUpdate(version, config.cli);
2314
2338
  const warning = claudeInheritWarning(env);
2315
2339
  if (config.requireApiKey) {