openmausbot 0.1.86 → 0.1.87

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 (166) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-BTiuL79F.js} +1 -1
  3. package/dist/assets/{arc-DW7mrI4p.js → arc-C88Blc2v.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-BZsJIZrb.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CVeMe9Ah.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-Cedk3Bhx.js} +1 -1
  7. package/dist/assets/channel-taCNRlUU.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-D-VRqzTB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-BChov01Y.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-C-TfwvHy.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-Db7CGj5Y.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-CNvIfqKT.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-C_bEp5tq.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Coi4BOls.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-CzylEuL3.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-m7p7ztaD.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BMy6o9x2.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-BDQ__0_J.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Djv4ghUb.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-BULrlGYH.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-D-jrT_hU.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-p2UhQmfL.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-DHvGWZ7q.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-ChhKDot-.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-B-T7A43R.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-CJm8ENNY.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-510tlza8.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-PkvRPHel.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-BVBVPoX3.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BgYMVm7L.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-BGqsnc_3.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-D_-UeReH.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-D0uooONx.js} +1 -1
  34. package/dist/assets/index-BwdMAQ6p.css +1 -0
  35. package/dist/assets/index-ByJennr7.js +581 -0
  36. package/dist/assets/{index-h9FFNwTu.js → index-zRsJERGJ.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-B9e1z77f.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-BB4vNwvD.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DpkosOTB.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-DbDP4kUu.js} +1 -1
  41. package/dist/assets/{linear-3su2TG93.js → linear-DBJGPmjT.js} +1 -1
  42. package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-DWPRi-ue.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-nszBbJmx.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-CWykJWu_.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-g3Da-Vnv.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-Wzrlpzgh.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-moHTFMC0.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-CwfXdFH8.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-3ZROYvnq.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-E1TBebP8.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-DHKEU5pu.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BguEj09D.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-C7IDYnhd.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-DCW-F5PW.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-xBMFtWYV.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-B5nIkuH8.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-DYFAhdbr.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-DeXwX6Cz.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/browser-proxy.js +2 -1
  62. package/dist-server/container-mcp.js +1029 -164
  63. package/dist-server/drivers/agents-proxy.js +64 -10
  64. package/dist-server/drivers/phone-proxy.js +33 -2
  65. package/dist-server/index.js +14577 -9001
  66. package/dist-server/local-computer-proxy.js +3 -3
  67. package/dist-server/local-computer.js +53 -17
  68. package/dist-server/openmausbot.js +3981 -1165
  69. package/dist-server/pair-cli.js +3981 -1165
  70. package/dist-server/permission-proxy.js +29 -9
  71. package/dist-server/server/attachments.js +55 -0
  72. package/dist-server/server/auto-approve.js +4 -3
  73. package/dist-server/server/auto-vm-claims.js +1 -0
  74. package/dist-server/server/bot-package.js +18 -292
  75. package/dist-server/server/bot-profile.js +5 -17
  76. package/dist-server/server/box-create-idempotency.js +3 -3
  77. package/dist-server/server/box.js +57 -55
  78. package/dist-server/server/browser-proxy.js +3 -2
  79. package/dist-server/server/browser-runtime.js +37 -1
  80. package/dist-server/server/cli.js +2 -2
  81. package/dist-server/server/command-allowlist.js +154 -0
  82. package/dist-server/server/computer-wait.js +22 -9
  83. package/dist-server/server/config.js +59 -9
  84. package/dist-server/server/connector-verdict.js +141 -0
  85. package/dist-server/server/container-computer.js +1 -1
  86. package/dist-server/server/default-model-selection.js +9 -0
  87. package/dist-server/server/delegations.js +85 -34
  88. package/dist-server/server/drivers/acp/core.js +57 -15
  89. package/dist-server/server/drivers/agents-call.js +42 -3
  90. package/dist-server/server/drivers/agents-catalog.js +34 -4
  91. package/dist-server/server/drivers/boxagent.js +174 -36
  92. package/dist-server/server/drivers/builtIn.js +2 -0
  93. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  94. package/dist-server/server/drivers/chat-images.js +54 -0
  95. package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
  96. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  97. package/dist-server/server/drivers/claude.js +7 -2
  98. package/dist-server/server/drivers/codex-instructions.js +48 -6
  99. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  100. package/dist-server/server/drivers/codex.js +95 -30
  101. package/dist-server/server/drivers/mistral.js +115 -0
  102. package/dist-server/server/drivers/openai-chat.js +155 -32
  103. package/dist-server/server/drivers/openai-compat.js +1 -0
  104. package/dist-server/server/drivers/permission-command.js +35 -0
  105. package/dist-server/server/drivers/phone-proxy.js +47 -2
  106. package/dist-server/server/drivers/pi.js +75 -4
  107. package/dist-server/server/drivers/prompt-split.js +87 -0
  108. package/dist-server/server/fleet.js +1 -1
  109. package/dist-server/server/group-thread-usage.js +103 -0
  110. package/dist-server/server/harness/registry.js +1 -0
  111. package/dist-server/server/hosted-models.js +1 -1
  112. package/dist-server/server/index.js +1385 -322
  113. package/dist-server/server/local-computer.js +72 -23
  114. package/dist-server/server/managed-desktop.js +1 -1
  115. package/dist-server/server/mcp-bridge.js +6 -6
  116. package/dist-server/server/new-bot-defaults.js +115 -0
  117. package/dist-server/server/org-library.js +1051 -0
  118. package/dist-server/server/package-export.js +627 -39
  119. package/dist-server/server/package-import.js +458 -0
  120. package/dist-server/server/package-parts.js +87 -0
  121. package/dist-server/server/peer-approval.js +16 -7
  122. package/dist-server/server/peer-roster.js +10 -0
  123. package/dist-server/server/presets.js +492 -0
  124. package/dist-server/server/provider-key-check.js +3 -2
  125. package/dist-server/server/published-teams.js +80 -0
  126. package/dist-server/server/room-handoffs.js +19 -3
  127. package/dist-server/server/routes/bot-presets.js +25 -0
  128. package/dist-server/server/routine-requests.js +6 -3
  129. package/dist-server/server/routines.js +41 -1
  130. package/dist-server/server/section-context.js +10 -0
  131. package/dist-server/server/skills.js +34 -61
  132. package/dist-server/server/steer-queue.js +3 -2
  133. package/dist-server/server/store.js +259 -15
  134. package/dist-server/server/surface.js +10 -4
  135. package/dist-server/server/system-prompt.js +12 -4
  136. package/dist-server/server/team-backup.js +6 -2
  137. package/dist-server/server/team-computers.js +10 -0
  138. package/dist-server/server/team-library.js +6 -2
  139. package/dist-server/server/team-manifest.js +4 -3
  140. package/dist-server/server/team-setup-requests.js +16 -5
  141. package/dist-server/server/thread-events.js +17 -1
  142. package/dist-server/server/turn-context.js +2 -0
  143. package/dist-server/server/turn-watchdog.js +12 -3
  144. package/dist-server/server/usage-ledger.js +12 -1
  145. package/dist-server/server/vps-computer.js +139 -51
  146. package/dist-server/server/vps-container-mcp.js +7 -0
  147. package/dist-server/server/vps-ssh.js +36 -7
  148. package/dist-server/server/workspace-backup-policy.js +12 -0
  149. package/dist-server/server/workspace-backup.js +2 -2
  150. package/dist-server/server/workspace.js +1 -1
  151. package/dist-server/shared/approval-mode.js +1 -1
  152. package/dist-server/shared/ask-question.js +188 -12
  153. package/dist-server/shared/bot-profile.js +15 -0
  154. package/dist-server/shared/command-allowlist.js +1 -0
  155. package/dist-server/shared/credential-request.js +1 -1
  156. package/dist-server/shared/package-format.js +967 -0
  157. package/dist-server/shared/skill-md.js +64 -0
  158. package/dist-server/shared/team-backup.js +7 -0
  159. package/dist-server/shared/wire.js +4 -0
  160. package/dist-server/vps-container-mcp.js +1129 -170
  161. package/enterprise/README.md +1 -1
  162. package/package.json +1 -1
  163. package/dist/assets/channel-BXFkgus9.js +0 -1
  164. package/dist/assets/index-BGgqlWPJ.css +0 -1
  165. package/dist/assets/index-BtHo8ZKl.js +0 -577
  166. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +0 -8
@@ -7,6 +7,7 @@ import { redactSecretsInText } from "./redact.js";
7
7
  const section = (value) => value?.trim() || "";
8
8
  const teamName = z.string().trim().min(1).max(60).refine(fitsOnOneLine).refine((value) => redactSecretsInText(value) === value, "Team names cannot contain credentials");
9
9
  const fieldsSchema = z.object({
10
+ chiefOfStaff: z.boolean().optional(),
10
11
  name: z.string().optional(), title: z.string().optional(), description: z.string().optional(), soul: z.string().optional(),
11
12
  section: z.string().trim().max(60).refine(fitsOnOneLine).refine((value) => redactSecretsInText(value) === value, "Team names cannot contain credentials").optional(),
12
13
  modelSelection: z.object({ instanceId: z.string().trim().min(1), model: z.string().trim().min(1), effort: z.string().optional() }).strict().optional(),
@@ -44,7 +45,7 @@ export class TeamSetupRequestService {
44
45
  return chief;
45
46
  }
46
47
  fields(input, current) {
47
- const { section: targetSection, modelSelection, ...profile } = input;
48
+ const { section: targetSection, modelSelection, chiefOfStaff, ...profile } = input;
48
49
  const safe = Object.fromEntries(Object.entries(profile).map(([key, value]) => [key, redactSecretsInText(value)]));
49
50
  const parsed = parseBotProfilePatch(safe, true);
50
51
  if (!parsed.ok)
@@ -52,6 +53,8 @@ export class TeamSetupRequestService {
52
53
  const result = { ...parsed.patch };
53
54
  if (targetSection !== undefined)
54
55
  result.section = targetSection;
56
+ if (chiefOfStaff !== undefined)
57
+ result.chiefOfStaff = chiefOfStaff;
55
58
  if (modelSelection) {
56
59
  const selection = modelSelection;
57
60
  const error = this.options.validateModel(selection, current);
@@ -108,7 +111,7 @@ export class TeamSetupRequestService {
108
111
  throw new TeamSetupError("The destination team is outside this Chief's authorized scope", 403);
109
112
  if (!request.newTeams.includes(section(destination)) && !existingTeams.has(section(destination)))
110
113
  throw new TeamSetupError("The destination team no longer exists", 409);
111
- const next = { id: operation.botId, name: fields.name ?? target.name, section: section(destination), chiefOfStaff: target?.chiefOfStaff, hidden: false };
114
+ const next = { id: operation.botId, name: fields.name ?? target.name, section: section(destination), chiefOfStaff: fields.chiefOfStaff ?? target?.chiefOfStaff, hidden: false };
112
115
  const at = projected.findIndex((bot) => bot.id === operation.botId);
113
116
  if (at < 0)
114
117
  projected.push(next);
@@ -125,7 +128,7 @@ export class TeamSetupRequestService {
125
128
  if (projected.some((bot) => bot.id !== candidate.id && !bot.hidden && bot.section === candidate.section && bot.name.trim().toLowerCase() === candidate.name.trim().toLowerCase()))
126
129
  throw new TeamSetupError(`@${candidate.name} already exists in that team`, 409);
127
130
  if (candidate.chiefOfStaff && projected.some((bot) => bot.id !== candidate.id && bot.chiefOfStaff && bot.section === candidate.section))
128
- throw new TeamSetupError("That move would put two Chiefs in one team", 409);
131
+ throw new TeamSetupError("Each team can have one Chief. Include the current Chief's demotion in this plan.", 409);
129
132
  }
130
133
  if (request.deletion) {
131
134
  const target = this.options.store.bot(request.deletion.botId);
@@ -195,12 +198,20 @@ export class TeamSetupRequestService {
195
198
  const lines = [`Why: ${request.reason}`];
196
199
  if (request.deletion)
197
200
  lines.push(`Delete @${request.deletion.name} (${request.deletion.botId}).`, "Permanently removes this bot, all its conversations, memory, instructions, skills, and any computer owned only by it. Generated project files and shared team computers remain. Active work or an unavailable provider can block deletion safely.");
198
- if (request.newTeams.length)
199
- lines.push(`Create teams: ${request.newTeams.map((name) => JSON.stringify(name)).join(", ")}.`, `Authorize @${chief.name} to coordinate and propose setup changes in these new teams.`);
201
+ if (request.newTeams.length) {
202
+ lines.push(`Create teams: ${request.newTeams.map((name) => JSON.stringify(name)).join(", ")}.`);
203
+ if (!request.operations.some(op => op.botId === chief.id && op.fields.chiefOfStaff === false)) {
204
+ lines.push(`Authorize @${chief.name} to coordinate and propose setup changes in these new teams.`);
205
+ }
206
+ }
200
207
  for (const operation of request.operations) {
201
208
  const current = this.options.store.bot(operation.botId);
202
209
  lines.push(`\n${operation.action === "create" ? "Create" : "Update"} @${operation.fields.name ?? current?.name} (${operation.action === "create" ? "new bot" : operation.botId})`);
203
210
  for (const [key, value] of Object.entries(operation.fields)) {
211
+ if (key === "chiefOfStaff") {
212
+ lines.push(`Chief of Staff: ${current?.chiefOfStaff ? "Yes" : "No"} → ${value ? "Yes" : "No"}.${value ? " May coordinate and configure bots in this team." : " Additional managed-team access is removed."}`);
213
+ continue;
214
+ }
204
215
  const before = current ? (key === "section" ? current.section || "General" : current[key]) : undefined;
205
216
  lines.push(`${key === "modelSelection" ? "Default engine/model" : key}: ${current ? `${JSON.stringify(before ?? "")} → ` : ""}${key === "section" ? JSON.stringify(value || "General") : JSON.stringify(value)}`);
206
217
  }
@@ -133,6 +133,10 @@ const stringOrMissing = (value) => value === undefined || typeof value === "stri
133
133
  const stringOrNullOrMissing = (value) => value === undefined || value === null || typeof value === "string";
134
134
  const numberOrNullOrMissing = (value) => value === undefined || value === null || typeof value === "number";
135
135
  const stringsOrMissing = (value) => value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string"));
136
+ /** A computer holder as the wait events carry it: a name, and the holding
137
+ * thread's title when one was known. */
138
+ const computerHolderOrMissing = (value) => value === undefined ||
139
+ (isRecord(value) && typeof value.name === "string" && stringOrMissing(value.task));
136
140
  /** A replayed structured ask. Only the shape the card actually reads is
137
141
  * required; the rest is optional and simply absent on an older event. */
138
142
  const askQuestionsOrMissing = (value) => value === undefined ||
@@ -175,6 +179,15 @@ function isRuntimeEvent(value) {
175
179
  stringsOrMissing(value.denials) &&
176
180
  (value.usage === undefined ||
177
181
  (isRecord(value.usage) && typeof value.usage.input === "number" && typeof value.usage.output === "number")));
182
+ case "turn.wait_started":
183
+ return typeof value.resource === "string" && computerHolderOrMissing(value.holder);
184
+ case "turn.wait_ended":
185
+ return (typeof value.resource === "string" &&
186
+ computerHolderOrMissing(value.holder) &&
187
+ typeof value.waitedMs === "number" &&
188
+ Number.isFinite(value.waitedMs) &&
189
+ value.waitedMs >= 0 &&
190
+ (value.outcome === "acquired" || value.outcome === "gave_up" || value.outcome === "stopped"));
178
191
  case "item.started":
179
192
  return (value.itemType === "tool" || value.itemType === "reasoning") && stringOrMissing(value.title);
180
193
  case "item.updated":
@@ -188,7 +201,10 @@ function isRuntimeEvent(value) {
188
201
  typeof value.tool === "string" &&
189
202
  typeof value.summary === "string" &&
190
203
  stringsOrMissing(value.choices) &&
191
- askQuestionsOrMissing(value.questions));
204
+ askQuestionsOrMissing(value.questions) &&
205
+ // Required fields only: events recorded before origin exists replay
206
+ // unchanged, and a value other than the two documented marks is not.
207
+ (value.origin === undefined || value.origin === "tool" || value.origin === "output"));
192
208
  case "request.resolved":
193
209
  return ((value.behavior === "allow" || value.behavior === "deny" || value.behavior === "answer") &&
194
210
  (value.source === "user" ||
@@ -1,4 +1,6 @@
1
1
  import { peerName } from "./peer-roster.js";
2
+ // All three use createOpenAIChatRuntime and get history via SendTurnInput.transcript.
3
+ export const NATIVELY_REPLAYING_DRIVER_KINDS = ["grok", "openai-compat", "minimax"];
2
4
  /** Does this engine need the thread replayed to it? True when a DIFFERENT
3
5
  * instance ran the last turn here — a cursor of our own is not enough,
4
6
  * because it only proves we once had a session covering some prefix of the
@@ -23,10 +23,10 @@ export class TurnWatchdog {
23
23
  this.timer = null;
24
24
  this.turns.clear();
25
25
  }
26
- /** A turn was dispatched on this thread. */
26
+ /** A turn was admitted on this thread. */
27
27
  watch(threadId, botId) {
28
28
  const at = this.now();
29
- this.turns.set(threadId, { threadId, botId, startedAt: at, lastEventAt: at, waitingOnHuman: false });
29
+ this.turns.set(threadId, { threadId, botId, startedAt: at, lastEventAt: at, waitingOnHuman: false, waitingOnComputer: false });
30
30
  }
31
31
  /** Any provider event for the thread proves the turn is alive. */
32
32
  touch(threadId) {
@@ -43,6 +43,15 @@ export class TurnWatchdog {
43
43
  turn.waitingOnHuman = waiting;
44
44
  turn.lastEventAt = this.now();
45
45
  }
46
+ /** Shared-computer waits have their own bounded deadline. Waiting for
47
+ * another turn to release the computer is not a silent provider stall. */
48
+ setWaitingOnComputer(threadId, waiting) {
49
+ const turn = this.turns.get(threadId);
50
+ if (!turn)
51
+ return;
52
+ turn.waitingOnComputer = waiting;
53
+ turn.lastEventAt = this.now();
54
+ }
46
55
  /** The turn settled normally — stop watching it. */
47
56
  settle(threadId) {
48
57
  this.turns.delete(threadId);
@@ -54,7 +63,7 @@ export class TurnWatchdog {
54
63
  sweep() {
55
64
  const at = this.now();
56
65
  for (const turn of this.turns.values()) {
57
- if (turn.waitingOnHuman)
66
+ if (turn.waitingOnHuman || turn.waitingOnComputer)
58
67
  continue;
59
68
  if (at - turn.lastEventAt < this.opts.stallMs)
60
69
  continue;
@@ -18,6 +18,14 @@ const DAY_MS = 24 * 60 * 60_000;
18
18
  const writeQueues = new Map();
19
19
  const clean = (value) => typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
20
20
  const finiteOrNull = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
21
+ const promptBytesOf = (value) => {
22
+ if (!value || typeof value !== "object" || Array.isArray(value))
23
+ return null;
24
+ const record = value;
25
+ return typeof record.stable === "number" && typeof record.volatile === "number"
26
+ ? { stable: clean(record.stable), volatile: clean(record.volatile) }
27
+ : null;
28
+ };
21
29
  function monthKey(at) {
22
30
  return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
23
31
  }
@@ -34,12 +42,15 @@ export function usageRowKey(row) {
34
42
  /** Append one settled turn. Fire-and-forget (see the module comment): the
35
43
  * returned promise never rejects, and says whether the row reached disk. */
36
44
  export function appendUsage(dataDir, row) {
45
+ const { promptBytes: rawPromptBytes, ...rest } = row;
46
+ const promptBytes = promptBytesOf(rawPromptBytes);
37
47
  const record = {
38
- ...row,
48
+ ...rest,
39
49
  at: row.at ?? new Date().toISOString(),
40
50
  input: clean(row.input),
41
51
  output: clean(row.output),
42
52
  ...(typeof row.cachedInput === "number" ? { cachedInput: clean(row.cachedInput) } : {}),
53
+ ...(promptBytes ? { promptBytes } : {}),
43
54
  costUsd: finiteOrNull(row.costUsd),
44
55
  };
45
56
  // A cost is labelled with where it came from; an unpriced row carries no label.
@@ -6,7 +6,8 @@ import { spawn } from "node:child_process";
6
6
  import { createConnection, createServer } from "node:net";
7
7
  import { BASE_IMAGE, CUA_DRIVER_VERSION, CUA_SOCKET, IMAGE as CUA_IMAGE, cuaExecArgs, dockerSecurityIsHardened, imageLabelsMatch, managedImageDockerfile, wholeScreenshot, BASE_IMAGE_DIGEST, BASE_IMAGE_LABEL, DRIVER_LABEL, IMAGE_LAYER_LABEL, IMAGE_LAYER_VERSION, MANAGED_LABEL, } from "./container-computer.js";
8
8
  import { DATA_DIR, isValidSshAlias, vpsSshAlias } from "./config.js";
9
- import { augmentedPath, resolveCliSpawn } from "./env-path.js";
9
+ import { augmentedPath } from "./env-path.js";
10
+ import { killCliTree, spawnCli } from "./procs.js";
10
11
  import { prepareVpsSsh } from "./vps-ssh.js";
11
12
  import { loadEnvironmentId } from "./environment.js";
12
13
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
@@ -28,7 +29,9 @@ function vpsEnvironmentId() {
28
29
  // SIGTERM must give ssh + docker time to tear down the remote exec before the
29
30
  // SIGKILL escalation; 1s was routinely too short over a WAN round-trip, and an
30
31
  // orphaned remote exec keeps the driver socket busy for the next command.
31
- const COMMAND_TIMEOUT_KILL_GRACE_MS = 5_000;
32
+ const COMMAND_TIMEOUT_TERM_GRACE_MS = 5_000;
33
+ // killCliTree also waits up to one second for the forced group exit.
34
+ const COMMAND_TIMEOUT_KILL_GRACE_MS = COMMAND_TIMEOUT_TERM_GRACE_MS + 1_000;
32
35
  const CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/;
33
36
  const CONTAINER_ID = /^[a-f0-9]{12,64}$/i;
34
37
  const FULL_CONTAINER_ID = /^[a-f0-9]{64}$/i;
@@ -63,20 +66,32 @@ function snapshotVpsConfig(cfg) {
63
66
  export function vpsLifecycleBusy() {
64
67
  return lifecycleLocks.size > 0;
65
68
  }
66
- // A held lock means a lifecycle mutation (worst case: a 10-minute image
67
- // build) is running. Waiting it out would wedge Sleep and the screenshot
68
- // poll behind it, so acquisition fails fast instead.
69
+ // Sleep, remove and preview requests must not queue behind a potentially
70
+ // 10-minute image build. Turn preparation may instead wait through a normal
71
+ // preview, whose bounded duration is accounted for below.
69
72
  const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
70
73
  // The panel polls status every 4-6s and the screen poller re-checks it before
71
74
  // every frame; each full status is several docker-over-SSH processes. Same
72
75
  // pattern as container-computer's screenshotStatusCache, and the same TTL.
73
76
  const STATUS_CACHE_TTL_MS = 10_000;
74
77
  const statusCache = new Map();
78
+ const pendingStatuses = new Map();
79
+ const pendingProvisions = new Map();
75
80
  const SCREENSHOT_BUDGET_MS = 45_000;
76
81
  const SCREENSHOT_CLEANUP_BUDGET_MS = 10_000;
82
+ // Turn preparation may wait for a normal preview to finish; destructive
83
+ // actions retain the short acquisition limit. Overlapping provisions share
84
+ // one operation below, so they never queue behind their own image build.
85
+ const PREPARATION_LOCK_TIMEOUT_MS = SCREENSHOT_BUDGET_MS + LOCK_ACQUIRE_TIMEOUT_MS;
77
86
  const pendingScreenshots = new Map();
78
87
  const viewerConnections = new Map();
79
88
  const desktopTunnels = new Map();
89
+ function invalidateVpsStatus(key) {
90
+ statusCache.delete(key);
91
+ // Detach old polls as well: an inspection begun before a mutation must
92
+ // neither be joined nor republish its old result after that mutation.
93
+ pendingStatuses.delete(key);
94
+ }
80
95
  /** Whether a turn may prepare or start the VPS container rather than only
81
96
  * reuse a running one. Explicit Cloud always may; Auto only when the person
82
97
  * switched on Start VPS automatically — except for unattended runs. A
@@ -216,11 +231,10 @@ function tailCollector() {
216
231
  }
217
232
  export function defaultRunner(args, options = {}) {
218
233
  return new Promise((resolve, reject) => {
219
- const command = resolveCliSpawn("docker", args);
220
234
  // docker's SSH transport runs the first `ssh` on PATH: the app's shim,
221
235
  // which shares one connection across every command of this VPS.
222
236
  const ssh = prepareVpsSsh(DATA_DIR, augmentedPath());
223
- const child = spawn(command.command, command.args, {
237
+ const child = spawnCli("docker", args, {
224
238
  shell: false,
225
239
  env: { ...process.env, PATH: ssh.path },
226
240
  stdio: ["pipe", "pipe", "pipe"],
@@ -228,31 +242,33 @@ export function defaultRunner(args, options = {}) {
228
242
  const stdout = tailCollector();
229
243
  const stderr = tailCollector();
230
244
  let settled = false;
231
- let timedOut = false;
232
- let killTimer;
245
+ let failure = null;
233
246
  let timeout;
234
247
  const settle = (finish) => {
235
248
  if (settled)
236
249
  return;
237
250
  settled = true;
238
251
  clearTimeout(timeout);
239
- if (killTimer)
240
- clearTimeout(killTimer);
241
252
  finish();
242
253
  };
243
- timeout = setTimeout(() => {
244
- if (settled)
254
+ const fail = (error) => {
255
+ if (settled || failure)
245
256
  return;
246
- timedOut = true;
247
- killTimer = setTimeout(() => {
248
- if (settled)
249
- return;
250
- child.kill("SIGKILL");
251
- settle(() => reject(new Error("Docker-over-SSH command timed out")));
252
- }, COMMAND_TIMEOUT_KILL_GRACE_MS);
253
- killTimer.unref?.();
254
- child.kill("SIGTERM");
255
- }, options.timeoutMs ?? 120_000);
257
+ failure = error;
258
+ clearTimeout(timeout);
259
+ // Docker launches ssh, which can retain these pipes after Docker exits.
260
+ // Only this command's private process group is ours to stop; persistent
261
+ // SSH masters detach from it and may serve other commands. Hold callers'
262
+ // lifecycle ownership until the bounded tree cleanup has completed.
263
+ const cleaned = () => {
264
+ child.stdin.destroy();
265
+ child.stdout.destroy();
266
+ child.stderr.destroy();
267
+ settle(() => reject(error));
268
+ };
269
+ void killCliTree(child, COMMAND_TIMEOUT_TERM_GRACE_MS).then(cleaned, cleaned);
270
+ };
271
+ timeout = setTimeout(() => fail(new Error("Docker-over-SSH command timed out")), options.timeoutMs ?? 120_000);
256
272
  timeout.unref?.();
257
273
  child.stdout.setEncoding("utf8");
258
274
  child.stderr.setEncoding("utf8");
@@ -263,18 +279,14 @@ export function defaultRunner(args, options = {}) {
263
279
  stderr.push(chunk);
264
280
  });
265
281
  child.stdin.on("error", (error) => {
266
- if (timedOut)
267
- return;
268
- settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error.message}`)));
282
+ fail(new Error(`Docker-over-SSH stdin failed: ${error.message}`));
269
283
  });
270
284
  child.on("error", (error) => {
271
- settle(() => reject(new Error(`Docker-over-SSH could not start: ${error.message}`)));
285
+ fail(new Error(`Docker-over-SSH could not start: ${error.message}`));
272
286
  });
273
287
  child.on("close", (code, signal) => {
274
- if (timedOut) {
275
- settle(() => reject(new Error("Docker-over-SSH command timed out")));
288
+ if (failure)
276
289
  return;
277
- }
278
290
  settle(() => {
279
291
  if (code === 0)
280
292
  return resolve({ stdout: stdout.text(), stderr: stderr.text() });
@@ -286,7 +298,7 @@ export function defaultRunner(args, options = {}) {
286
298
  child.stdin.end(options.input);
287
299
  }
288
300
  catch (error) {
289
- settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error instanceof Error ? error.message : String(error)}`)));
301
+ fail(new Error(`Docker-over-SSH stdin failed: ${error instanceof Error ? error.message : String(error)}`));
290
302
  }
291
303
  });
292
304
  }
@@ -531,15 +543,30 @@ export async function vpsComputerStatus(cfg, botId, runner = defaultRunner) {
531
543
  if (cacheable) {
532
544
  // A capture already checks this exact target. Let it finish instead of
533
545
  // opening another six SSH connections while its readiness check is cold.
534
- await pendingScreenshots.get(key)?.catch(() => { });
546
+ const capture = pendingScreenshots.get(key);
547
+ if (capture)
548
+ await capture.catch(() => { });
535
549
  const cached = statusCache.get(key);
536
550
  if (cached && cached.expiresAt > Date.now())
537
551
  return cached.status;
552
+ const pending = pendingStatuses.get(key);
553
+ if (pending)
554
+ return pending;
538
555
  }
539
- const status = await computeVpsComputerStatus(cfg, botId, runner);
556
+ const inspection = computeVpsComputerStatus(cfg, botId, runner);
540
557
  if (cacheable)
541
- statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
542
- return status;
558
+ pendingStatuses.set(key, inspection);
559
+ try {
560
+ const status = await inspection;
561
+ if (cacheable && pendingStatuses.get(key) === inspection && !lifecycleLocks.has(key)) {
562
+ statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
563
+ }
564
+ return status;
565
+ }
566
+ finally {
567
+ if (cacheable && pendingStatuses.get(key) === inspection)
568
+ pendingStatuses.delete(key);
569
+ }
543
570
  }
544
571
  const VPS_INVENTORY_LIMIT = 256;
545
572
  const VPS_INVENTORY_STATES = new Set([
@@ -761,14 +788,32 @@ async function prepareVpsImage(alias, runner) {
761
788
  * Backoff doubles 0.5s→4s because each poll is a real SSH connection, and
762
789
  * between polls only ONE cheap exec (`cua-driver status`) asks whether the
763
790
  * driver answers yet — the full multi-invocation status runs again only when
764
- * that predicate flips, and once more at the deadline, so the returned state
765
- * is always a complete inspection. */
791
+ * that predicate flips. Every command, including the diagnostic tail, is
792
+ * clamped to the readiness phase's deadline and transport cleanup allowance. */
766
793
  async function waitForVpsReady(cfg, botId, runner, budgetMs = 60_000) {
767
794
  const alias = vpsSshAlias(cfg);
768
795
  const deadline = Date.now() + budgetMs;
769
- let status = await computeVpsComputerStatus(cfg, botId, runner);
796
+ let budgetExpired = false;
797
+ const boundedRunner = async (args, options = {}) => {
798
+ // Include transport termination in the readiness phase's budget; a
799
+ // final failed probe or diagnostic tail must not keep its lock past it.
800
+ const remaining = deadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS;
801
+ if (remaining <= 0) {
802
+ budgetExpired = true;
803
+ throw new Error("VPS desktop readiness timed out");
804
+ }
805
+ try {
806
+ return await runner(args, { ...options, timeoutMs: Math.min(options.timeoutMs ?? 120_000, remaining) });
807
+ }
808
+ catch (error) {
809
+ if (Date.now() >= deadline - COMMAND_TIMEOUT_KILL_GRACE_MS)
810
+ budgetExpired = true;
811
+ throw error;
812
+ }
813
+ };
814
+ let status = await computeVpsComputerStatus(cfg, botId, boundedRunner);
770
815
  let delayMs = 500;
771
- while (!status.ready && alias && Date.now() < deadline) {
816
+ while (!status.ready && alias && !budgetExpired && Date.now() < deadline) {
772
817
  if (!status.daemonUp ||
773
818
  !status.image ||
774
819
  !status.imageMatches ||
@@ -785,14 +830,24 @@ async function waitForVpsReady(cfg, botId, runner, budgetMs = 60_000) {
785
830
  });
786
831
  delayMs = Math.min(delayMs * 2, 4_000);
787
832
  const container = status.container_id ?? status.container_name;
788
- const driverAnswers = await runner(vpsDockerArgs(alias, cuaExecArgs(["status", "--socket", CUA_SOCKET], { container })), { timeoutMs: 10_000 }).then(() => true, () => false);
833
+ const driverAnswers = await boundedRunner(vpsDockerArgs(alias, cuaExecArgs(["status", "--socket", CUA_SOCKET], { container })), { timeoutMs: 10_000 }).then(() => true, () => false);
834
+ if (budgetExpired)
835
+ break;
789
836
  if (!driverAnswers && Date.now() < deadline)
790
837
  continue;
791
- status = await computeVpsComputerStatus(cfg, botId, runner);
838
+ status = await computeVpsComputerStatus(cfg, botId, boundedRunner);
792
839
  }
840
+ if (budgetExpired)
841
+ return {
842
+ ...status,
843
+ ready: false,
844
+ desktopReady: false,
845
+ desktop_error: "VPS desktop readiness timed out",
846
+ problem: "The VPS desktop did not become ready in time — retry when the connection recovers",
847
+ };
793
848
  return status;
794
849
  }
795
- async function withVpsLifecycleLock(key, operation) {
850
+ async function withVpsLifecycleLock(key, operation, acquireTimeoutMs = LOCK_ACQUIRE_TIMEOUT_MS) {
796
851
  const previous = lifecycleLocks.get(key);
797
852
  let release;
798
853
  const current = new Promise((resolve) => {
@@ -804,7 +859,7 @@ async function withVpsLifecycleLock(key, operation) {
804
859
  const acquired = await Promise.race([
805
860
  previous.then(() => true),
806
861
  new Promise((resolve) => {
807
- acquireTimer = setTimeout(() => resolve(false), LOCK_ACQUIRE_TIMEOUT_MS);
862
+ acquireTimer = setTimeout(() => resolve(false), acquireTimeoutMs);
808
863
  acquireTimer.unref?.();
809
864
  }),
810
865
  ]);
@@ -849,6 +904,7 @@ export async function removeManagedVpsComputer(cfg, owners, containerName, confi
849
904
  }
850
905
  const key = `${alias}:${containerName}`;
851
906
  return withVpsLifecycleLock(key, async () => {
907
+ invalidateVpsStatus(key);
852
908
  const scan = await scanManagedVpsComputers(cfg, owners, runner);
853
909
  const inventory = scan.inventory;
854
910
  if (!inventory.available) {
@@ -874,13 +930,14 @@ export async function removeManagedVpsComputer(cfg, owners, containerName, confi
874
930
  await runner(vpsDockerArgs(alias, ["rm", "-f", containerId]), { timeoutMs: 2 * 60_000 });
875
931
  }
876
932
  catch (error) {
933
+ invalidateVpsStatus(key);
877
934
  const detail = (error instanceof Error ? error.message : String(error))
878
935
  .replace(/[\r\n\t]+/g, " ")
879
936
  .trim()
880
937
  .slice(0, 200);
881
938
  throw Object.assign(new Error(`The VPS refused to remove this computer${detail ? `: ${detail}` : ""}`), { status: 502 });
882
939
  }
883
- statusCache.delete(key);
940
+ invalidateVpsStatus(key);
884
941
  viewerConnections.delete(key);
885
942
  if (instance.ownerBotId)
886
943
  stopDesktopTunnel(instance.ownerBotId);
@@ -893,14 +950,19 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
893
950
  if (!alias)
894
951
  throw Object.assign(new Error("VPS is not configured — add an SSH config alias in App Settings → Connections"), { status: 409 });
895
952
  const key = `${alias}:${vpsContainerName(botId)}`;
953
+ const pending = action === "provision" ? pendingProvisions.get(key) : undefined;
954
+ if (pending)
955
+ return pending;
896
956
  const operation = async () => {
897
957
  // A mutation invalidates every cached poll answer, before and after: the
898
958
  // panel must never keep showing the pre-action world for a TTL.
899
- statusCache.delete(key);
959
+ invalidateVpsStatus(key);
900
960
  try {
901
961
  const before = await computeVpsComputerStatus(cfg, botId, runner);
902
962
  if (!before.daemonUp)
903
963
  throw Object.assign(new Error(before.problem ?? "Docker over SSH is not reachable"), { status: 409 });
964
+ if (action === "provision" && before.ready)
965
+ return before;
904
966
  // A real lifecycle change invalidates the remote endpoint. Provision
905
967
  // is also the turn-start idempotency path, so leave an already-running
906
968
  // viewer alone when no start/replacement will occur.
@@ -946,7 +1008,7 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
946
1008
  throw Object.assign(new Error("The VPS container name is occupied by a container OpenMausBot did not create — remove it on the VPS yourself"), { status: 409 });
947
1009
  }
948
1010
  await run(["rm", "-f", containerRef]);
949
- return computeVpsComputerStatus(cfg, botId, runner);
1011
+ return await computeVpsComputerStatus(cfg, botId, runner);
950
1012
  }
951
1013
  else {
952
1014
  if (before.container !== "running")
@@ -954,13 +1016,22 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
954
1016
  assertUsableContainer(before);
955
1017
  await run(["stop", containerRef]);
956
1018
  }
957
- return action === "stop" ? computeVpsComputerStatus(cfg, botId, runner) : waitForVpsReady(cfg, botId, runner);
1019
+ return await (action === "stop" ? computeVpsComputerStatus(cfg, botId, runner) : waitForVpsReady(cfg, botId, runner));
958
1020
  }
959
1021
  finally {
960
- statusCache.delete(key);
1022
+ invalidateVpsStatus(key);
961
1023
  }
962
1024
  };
963
- return withVpsLifecycleLock(key, operation);
1025
+ const preparation = withVpsLifecycleLock(key, operation, action === "provision" ? PREPARATION_LOCK_TIMEOUT_MS : LOCK_ACQUIRE_TIMEOUT_MS);
1026
+ if (action === "provision")
1027
+ pendingProvisions.set(key, preparation);
1028
+ try {
1029
+ return await preparation;
1030
+ }
1031
+ finally {
1032
+ if (pendingProvisions.get(key) === preparation)
1033
+ pendingProvisions.delete(key);
1034
+ }
964
1035
  }
965
1036
  /** Auto is intentionally read-only: it can attach only to an existing ready
966
1037
  * container. It recomputes rather than reading the poll cache — routing a
@@ -976,7 +1047,7 @@ export async function inspectVpsForAuto(cfg, botId, runner = defaultRunner) {
976
1047
  cfg = snapshotVpsConfig(cfg);
977
1048
  const key = vpsLockKey(cfg, botId);
978
1049
  return key
979
- ? withVpsLifecycleLock(key, () => computeVpsComputerStatus(cfg, botId, runner))
1050
+ ? withVpsLifecycleLock(key, () => computeVpsComputerStatus(cfg, botId, runner), PREPARATION_LOCK_TIMEOUT_MS)
980
1051
  : computeVpsComputerStatus(cfg, botId, runner);
981
1052
  }
982
1053
  /** Open a temporary noVNC connection through the configured SSH alias. The
@@ -1116,9 +1187,26 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
1116
1187
  // poller runs every few seconds, and re-verifying the whole container
1117
1188
  // between frames multiplied every frame's SSH cost.
1118
1189
  const cached = cacheable ? statusCache.get(key) : undefined;
1190
+ const pendingStatus = cacheable ? pendingStatuses.get(key) : undefined;
1191
+ // A poll which started first already owns the inspection. Sharing its
1192
+ // result must not extend this preview's own deadline; the poll remains
1193
+ // independently owned if it is still checking when this preview expires.
1194
+ let statusTimer;
1119
1195
  const status = cached && cached.expiresAt > Date.now()
1120
1196
  ? cached.status
1121
- : await computeVpsComputerStatus(cfg, botId, boundedRunner);
1197
+ : pendingStatus
1198
+ ? await Promise.race([
1199
+ pendingStatus,
1200
+ new Promise((_resolve, reject) => {
1201
+ statusTimer = setTimeout(() => {
1202
+ invalidateVpsStatus(key);
1203
+ reject(timeoutError());
1204
+ }, Math.max(0, workDeadline - Date.now()));
1205
+ statusTimer.unref?.();
1206
+ }),
1207
+ ]).finally(() => { if (statusTimer)
1208
+ clearTimeout(statusTimer); })
1209
+ : await computeVpsComputerStatus(cfg, botId, boundedRunner);
1122
1210
  // Status converts transport errors into displayable state. A deadline is
1123
1211
  // still a timeout, not evidence that this container became incompatible.
1124
1212
  if (budgetExpired) {
@@ -5,6 +5,9 @@
5
5
  // mcp-bridge.ts, shared with the Local VM entry point.
6
6
  import { runMcpBridge } from "./mcp-bridge.js";
7
7
  import { vpsContainerMcpArgs, vpsDockerArgs } from "./vps-computer.js";
8
+ import { DATA_DIR } from "./config.js";
9
+ import { augmentedPath } from "./env-path.js";
10
+ import { prepareVpsSsh } from "./vps-ssh.js";
8
11
  const [alias, containerName] = process.argv.slice(2);
9
12
  const sshAlias = alias ?? "";
10
13
  let args;
@@ -19,9 +22,13 @@ catch {
19
22
  // through `ps`, and the token guards a loopback endpoint.
20
23
  const controlUrl = process.env.OMB_CONTROL_URL ?? "";
21
24
  const controlToken = process.env.OMB_CONTROL_TOKEN ?? "";
25
+ const ssh = prepareVpsSsh(DATA_DIR, augmentedPath());
22
26
  runMcpBridge({
23
27
  command: "docker",
24
28
  args,
29
+ // Keep tool calls and the watchdog on the same bounded, shared transport
30
+ // as startup and previews; otherwise only the panel gets the SSH defaults.
31
+ env: { ...process.env, PATH: ssh.path },
25
32
  label: "VPS Cua Driver",
26
33
  // The probe checks the TRANSPORT (SSH + daemon), deliberately not the
27
34
  // driver: a busy desktop mid-tool-call must never look dead, while an