openmausbot 0.1.92 → 0.1.94

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 (163) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +1 -0
  62. package/dist-server/companion/src/routes.js +45 -1
  63. package/dist-server/container-mcp.js +99 -77
  64. package/dist-server/drivers/agents-proxy.js +40 -1
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +1 -0
  67. package/dist-server/index.js +18361 -13794
  68. package/dist-server/local-computer-proxy.js +5 -1
  69. package/dist-server/local-computer.js +1 -0
  70. package/dist-server/mcp-gate.js +152 -20
  71. package/dist-server/mcp-remote-proxy.js +437 -0
  72. package/dist-server/openmausbot.js +8872 -7620
  73. package/dist-server/pair-cli.js +8872 -7620
  74. package/dist-server/permission-proxy.js +65 -1
  75. package/dist-server/proxy-paths.js +1 -0
  76. package/dist-server/server/auto-approve.js +33 -11
  77. package/dist-server/server/boat.js +3 -3
  78. package/dist-server/server/browser-live.js +14 -5
  79. package/dist-server/server/browser-runtime.js +6 -0
  80. package/dist-server/server/channel-queue.js +4 -1
  81. package/dist-server/server/cli.js +32 -12
  82. package/dist-server/server/cloud-move-http.js +9 -3
  83. package/dist-server/server/cloud-move.js +18 -0
  84. package/dist-server/server/cloud-owner.js +1 -1
  85. package/dist-server/server/computer-control.js +6 -0
  86. package/dist-server/server/computer-wait.js +33 -13
  87. package/dist-server/server/config.js +57 -10
  88. package/dist-server/server/container-computer.js +43 -32
  89. package/dist-server/server/decision-log.js +9 -6
  90. package/dist-server/server/delegations.js +47 -22
  91. package/dist-server/server/digest.js +12 -2
  92. package/dist-server/server/drivers/acp/core.js +229 -20
  93. package/dist-server/server/drivers/acp/cursor.js +13 -1
  94. package/dist-server/server/drivers/acp/grok.js +208 -3
  95. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  96. package/dist-server/server/drivers/acp/qwen.js +18 -0
  97. package/dist-server/server/drivers/agents-call.js +28 -0
  98. package/dist-server/server/drivers/agents-catalog.js +10 -1
  99. package/dist-server/server/drivers/boatagent.js +2 -0
  100. package/dist-server/server/drivers/builtIn.js +2 -0
  101. package/dist-server/server/drivers/cerebras.js +108 -0
  102. package/dist-server/server/drivers/chat-mcp-tools.js +45 -15
  103. package/dist-server/server/drivers/claude.js +35 -9
  104. package/dist-server/server/drivers/codex.js +84 -10
  105. package/dist-server/server/drivers/openai-chat.js +37 -5
  106. package/dist-server/server/drivers/pi.js +48 -4
  107. package/dist-server/server/drivers/prompt-split.js +2 -0
  108. package/dist-server/server/env-path.js +6 -2
  109. package/dist-server/server/environment.js +3 -0
  110. package/dist-server/server/guarded-requests.js +2 -1
  111. package/dist-server/server/index.js +1353 -311
  112. package/dist-server/server/live-call-controller.js +868 -0
  113. package/dist-server/server/live-call.js +216 -0
  114. package/dist-server/server/local-vm-stop-reason.js +26 -0
  115. package/dist-server/server/mcp-gate-config.js +43 -12
  116. package/dist-server/server/mcp-gate.js +127 -25
  117. package/dist-server/server/mcp-http.js +46 -8
  118. package/dist-server/server/mcp-remote-proxy.js +108 -0
  119. package/dist-server/server/new-bot-defaults.js +3 -0
  120. package/dist-server/server/peer-provenance.js +4 -2
  121. package/dist-server/server/permission-proxy.js +15 -1
  122. package/dist-server/server/plan-usage.js +797 -0
  123. package/dist-server/server/provider-key-check.js +2 -1
  124. package/dist-server/server/proxy-paths.js +1 -0
  125. package/dist-server/server/redact.js +2 -0
  126. package/dist-server/server/request-auth.js +3 -2
  127. package/dist-server/server/routes/desktop-viewer.js +50 -6
  128. package/dist-server/server/routes/live.js +98 -0
  129. package/dist-server/server/routines.js +6 -3
  130. package/dist-server/server/skill-library.js +207 -0
  131. package/dist-server/server/skills-library-migration.js +235 -0
  132. package/dist-server/server/skills.js +66 -3
  133. package/dist-server/server/steer-queue.js +13 -1
  134. package/dist-server/server/store.js +86 -4
  135. package/dist-server/server/surface.js +9 -3
  136. package/dist-server/server/team-backup.js +2 -1
  137. package/dist-server/server/thread-events.js +1 -1
  138. package/dist-server/server/tunnel.js +20 -1
  139. package/dist-server/server/turn-log.js +10 -0
  140. package/dist-server/server/turn-resources.js +124 -0
  141. package/dist-server/server/vps-computer.js +1 -1
  142. package/dist-server/server/workspace-backup.js +22 -0
  143. package/dist-server/server/workspace.js +1 -1
  144. package/dist-server/shared/approval-mode.js +1 -1
  145. package/dist-server/shared/ask-question.js +13 -0
  146. package/dist-server/shared/call-consent.js +28 -0
  147. package/dist-server/shared/live-approval.js +124 -0
  148. package/dist-server/shared/live-call.js +176 -0
  149. package/dist-server/shared/redact.js +1 -0
  150. package/dist-server/shared/skill-md.js +10 -0
  151. package/dist-server/shared/team-backup.js +3 -0
  152. package/dist-server/shared/tool-name.js +22 -0
  153. package/dist-server/shared/tool-scope-support.js +25 -0
  154. package/dist-server/shared/tool-scope.js +136 -0
  155. package/dist-server/src/lib/thread-refs.js +406 -0
  156. package/dist-server/vps-container-mcp.js +99 -77
  157. package/dist-server/workspace-backup.worker.js +103 -100
  158. package/enterprise/server/index.js +1 -0
  159. package/package.json +1 -1
  160. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  161. package/dist/assets/index-Cdmjrl28.css +0 -1
  162. package/dist/assets/index-DR7YPMkW.js +0 -597
  163. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -1,4 +1,4 @@
1
- export const PROVIDER_KEY_KINDS = ["anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral"];
1
+ export const PROVIDER_KEY_KINDS = ["anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "cerebras"];
2
2
  const DEFAULT_URLS = {
3
3
  anthropic: "https://api.anthropic.com",
4
4
  openai: "https://api.openai.com/v1",
@@ -6,6 +6,7 @@ const DEFAULT_URLS = {
6
6
  openaiCompat: "https://openrouter.ai/api/v1",
7
7
  xai: "https://api.x.ai/v1",
8
8
  mistral: "https://api.mistral.ai/v1",
9
+ cerebras: "https://api.cerebras.ai/v1",
9
10
  };
10
11
  const MAX_MODELS = 5;
11
12
  function isLoopback(hostname) {
@@ -40,6 +40,7 @@ export const SPAWNED_PROXIES = {
40
40
  dweb: resolveProxy("drivers/dweb-proxy"),
41
41
  connectors: resolveProxy("connector-proxy"),
42
42
  mcpGate: resolveProxy("mcp-gate"),
43
+ mcpRemote: resolveProxy("mcp-remote-proxy"),
43
44
  phone: resolveProxy("drivers/phone-proxy"),
44
45
  hook: resolveProxy("hooks/omb-hook"),
45
46
  // Loaded by the external `pi` process via `-e`, not by this server — but
@@ -18,6 +18,8 @@ const SECRET_KEY_PARTS = ["token", "secret", "password", "passwd", "apikey", "ap
18
18
  * every real one is spelled (API_KEY, consumer-key, xai_key). */
19
19
  function isSecretName(name) {
20
20
  const lower = name.toLowerCase();
21
+ if (lower === "omb_gate_upstream" || lower === "omb_remote_mcp_server")
22
+ return true;
21
23
  if (SECRET_KEY_PARTS.some((part) => lower.includes(part)))
22
24
  return true;
23
25
  return /(^|[_.-])keys?$/.test(lower);
@@ -1,6 +1,6 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { isIP } from "node:net";
3
- import { denyReason as companionDenial } from "../companion/src/routes.js";
3
+ import { denyReason as companionDenial, isCompanionNotice } from "../companion/src/routes.js";
4
4
  const LOOPBACK_SCOPES = ["admin", "client"];
5
5
  const SERVICE_SCOPES = ["client"];
6
6
  /** What a `service`-trust loopback caller may reach, and nothing else:
@@ -442,7 +442,8 @@ export function resolveRequestAuth(req, options) {
442
442
  if (!secureTokenMatch(companionToken, options.companionMutationToken ?? "") ||
443
443
  req.headers["x-openmausbot-companion"] !== "1" ||
444
444
  !/^[\w-]{1,128}$/.test(headerValue(req.headers["x-openmausbot-companion-device"]) ?? "") ||
445
- companionDenial({ path, method, authenticated: true }))
445
+ // a phone's allowlisted route, or the companion's own notice (an unpaired phone)
446
+ (companionDenial({ path, method, authenticated: true }) && !isCompanionNotice(method, path)))
446
447
  return deny(403, "forbidden: invalid companion request");
447
448
  return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
448
449
  }
@@ -10,6 +10,8 @@ const ROUTE = /^\/api\/desktop-viewer\/(local\/(?:shared|bot-[a-f0-9]{64}|pool-\
10
10
  const HANDSHAKE_MS = 10_000;
11
11
  const RECHECK_MS = 5_000;
12
12
  const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
13
+ const BOT_ID = /^[\w-]+$/;
14
+ const CONTROL_LEASE = /^[A-Za-z0-9_-]{16,120}$/;
13
15
  export function desktopViewerUrl(target, threadId) {
14
16
  const params = new URLSearchParams({ target, ...(threadId ? { threadId } : {}) });
15
17
  return `/desktop-viewer#${params}`;
@@ -67,7 +69,7 @@ export function createDesktopViewer(deps) {
67
69
  }).finally(() => clearTimeout(timeout));
68
70
  });
69
71
  }
70
- const route = async ({ req, res, path, method, auth, json }) => {
72
+ const route = async ({ req, res, url, path, method, auth, json }) => {
71
73
  const match = ROUTE.exec(path);
72
74
  if (!match)
73
75
  return PASS;
@@ -78,12 +80,39 @@ export function createDesktopViewer(deps) {
78
80
  // to admin, like the existing Local VM status and control endpoints.
79
81
  if (!auth.scopes.includes("admin") || !isSameOrigin(req))
80
82
  return json(res, 403, { error: "forbidden" });
83
+ // A viewer bound to a control lease: both names or neither, well formed,
84
+ // and holding before anything is inspected. Checked again with every
85
+ // liveness pass, so handing back closes the desktop within seconds. The
86
+ // conversation, when named, picks the VM seat the join picked.
87
+ const botId = url.searchParams.get("botId");
88
+ const controlLeaseId = url.searchParams.get("controlLeaseId");
89
+ const threadId = url.searchParams.get("threadId") ?? undefined;
90
+ if ((botId === null) !== (controlLeaseId === null)) {
91
+ return json(res, 400, { error: "botId and controlLeaseId go together" });
92
+ }
93
+ if (botId !== null && (!BOT_ID.test(botId) || !CONTROL_LEASE.test(controlLeaseId) || !deps.lease
94
+ || (threadId !== undefined && !BOT_ID.test(threadId)))) {
95
+ return json(res, 400, { error: "botId, threadId or controlLeaseId is not valid" });
96
+ }
97
+ if (botId === null && threadId !== undefined)
98
+ return json(res, 400, { error: "threadId needs botId and controlLeaseId" });
99
+ // Do not let a saved or constructed socket URL bypass the join refusal:
100
+ // a bot's control lease does not exclude other users of a shared desktop.
101
+ if (botId !== null && (match[1] === "local/shared" || match[1].startsWith("local/pool-"))) {
102
+ return json(res, 409, { error: "Phone control requires a per-bot Local VM. Select per-bot mode in Settings → Computers." });
103
+ }
104
+ const bound = botId === null ? undefined : deps.lease(match[1], botId, controlLeaseId, threadId);
105
+ if (botId !== null && !bound)
106
+ return json(res, 409, { error: "Take control of this computer first" });
107
+ const holds = () => bound?.() ?? true;
81
108
  const target = deps.target(match[1]);
82
109
  if (!target)
83
110
  return json(res, 404, { error: "Desktop not found" });
84
111
  const upgrade = upgrades.get(req);
85
112
  if (upgrade && auth.kind === "session")
86
113
  upgrade.owner = auth.session.id;
114
+ if (upgrade && botId !== null)
115
+ upgrade.botId = botId;
87
116
  let connection;
88
117
  try {
89
118
  connection = await target.resolve();
@@ -98,6 +127,8 @@ export function createDesktopViewer(deps) {
98
127
  const live = () => deps.live(auth) && deps.target(match[1])?.key === target.key && (connection.live?.() ?? true);
99
128
  if (!live())
100
129
  return json(res, 401, { error: "Viewer access expired" });
130
+ if (!holds())
131
+ return json(res, 409, { error: "Take control of this computer first" });
101
132
  if (!Number.isInteger(connection.port) || connection.port < 1 || connection.port > 65535) {
102
133
  return json(res, 409, { error: "The desktop viewer is not available." });
103
134
  }
@@ -150,7 +181,7 @@ export function createDesktopViewer(deps) {
150
181
  upstream.once("response", (answer) => { answer.resume(); fail(); });
151
182
  upstream.once("upgrade", (answer, remote, remoteHead) => {
152
183
  const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
153
- if (!live() || socket.destroyed || answer.headers["sec-websocket-accept"] !== accept
184
+ if (!live() || !holds() || socket.destroyed || answer.headers["sec-websocket-accept"] !== accept
154
185
  || answer.headers.upgrade?.toLowerCase() !== "websocket") {
155
186
  remote.destroy();
156
187
  fail();
@@ -171,7 +202,7 @@ export function createDesktopViewer(deps) {
171
202
  socket.pipe(remote).pipe(socket);
172
203
  connection.touch?.();
173
204
  recheck = setInterval(() => {
174
- if (!live())
205
+ if (!live() || !holds())
175
206
  upgrade.close();
176
207
  else
177
208
  connection.touch?.();
@@ -182,9 +213,22 @@ export function createDesktopViewer(deps) {
182
213
  };
183
214
  return {
184
215
  route, attach,
185
- closeForOwner: (owner) => { for (const upgrade of upgrades.values())
186
- if (upgrade.owner === owner)
187
- upgrade.close(); },
216
+ /** Close a session's viewers: all of them (sign-out, revocation), or only
217
+ * those it opened under a lease on one bot (that bot's hand-back). */
218
+ closeForOwner: (owner, botId) => {
219
+ let closed = 0;
220
+ for (const upgrade of upgrades.values()) {
221
+ if (upgrade.owner !== owner || (botId !== undefined && upgrade.botId !== botId))
222
+ continue;
223
+ // A viewer already closed stays listed until its socket's close
224
+ // event, which arrives later on Windows: not closed or counted again.
225
+ if (upgrade.socket.destroyed)
226
+ continue;
227
+ upgrade.close();
228
+ closed++;
229
+ }
230
+ return closed;
231
+ },
188
232
  closeAll: () => {
189
233
  stopped = true;
190
234
  for (const upgrade of upgrades.values())
@@ -0,0 +1,98 @@
1
+ import { z } from "zod";
2
+ import { LiveSessionError, MAX_SDP_BYTES } from "../live-call.js";
3
+ import { LiveCallBusyError } from "../live-call-controller.js";
4
+ import { PASS } from "./table.js";
5
+ const id = z.string().regex(/^[\w-]{1,120}$/);
6
+ const sessionBody = z.object({
7
+ botId: id,
8
+ threadId: id.optional(),
9
+ // Not trimmed: the SDP offer goes to OpenAI byte for byte.
10
+ sdp: z.string().min(1).refine((sdp) => Buffer.byteLength(sdp) <= MAX_SDP_BYTES),
11
+ client: z.enum(["desktop", "ios", "android"]),
12
+ }).strict();
13
+ const endBody = z.object({ callId: z.string().min(1).max(120) }).strict();
14
+ // Strict, so `key` (or anything else) is refused rather than ignored: the key
15
+ // is only ever set on the computer that runs the harness.
16
+ const settingsBody = z.object({
17
+ voice: z.string().trim().max(40).regex(/^[a-z]*$/).optional(),
18
+ readTypedReplies: z.boolean().optional(),
19
+ idleMinutes: z.number().int().min(1).max(60).optional(),
20
+ }).strict();
21
+ const DEVICE_ID = /^[\w-]{1,128}$/;
22
+ /** The paired phone the companion vouched for, or undefined. The companion
23
+ * sends its own id for the phone that authenticated (never one the phone
24
+ * chose); in the desktop app the harness has already checked the companion's
25
+ * private token before this runs (server/request-auth.ts). */
26
+ function companionDevice(req) {
27
+ if (req.headers["x-openmausbot-companion"] !== "1")
28
+ return undefined;
29
+ const device = req.headers["x-openmausbot-companion-device"];
30
+ return typeof device === "string" && DEVICE_ID.test(device) ? device : undefined;
31
+ }
32
+ /** The parsed JSON body, or undefined when it is not JSON (the schema then refuses it). */
33
+ async function bodyOf(req, readBody) {
34
+ try {
35
+ return await readBody(req);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ export function createLiveRoutes(deps) {
42
+ return async ({ req, res, path, method, auth, json, readBody }) => {
43
+ if (!path.startsWith("/api/live/"))
44
+ return PASS;
45
+ if (method === "POST" && path === "/api/live/session") {
46
+ const parsed = sessionBody.safeParse(await bodyOf(req, readBody));
47
+ if (!parsed.success)
48
+ return json(res, 400, { error: "The call request was not valid." });
49
+ const target = deps.resolveTarget(parsed.data.botId, parsed.data.threadId);
50
+ if (!target)
51
+ return json(res, 404, { error: "That bot or chat does not exist." });
52
+ try {
53
+ // A phone's call is bound to the phone, so unpairing it ends the call.
54
+ const device = companionDevice(req);
55
+ const { call, sdp } = await deps.calls.start({ auth, ...(device ? { device } : {}), ...target, client: parsed.data.client, sdp: parsed.data.sdp });
56
+ return json(res, 201, { call, transport: { type: "webrtc", sdp } });
57
+ }
58
+ catch (error) {
59
+ if (error instanceof LiveCallBusyError)
60
+ return json(res, 409, { error: error.message, activeCall: error.call });
61
+ if (error instanceof LiveSessionError) {
62
+ // The one 409 a session refuses with is a missing key.
63
+ return json(res, error.status, error.status === 409 ? { error: error.message, needsKey: true } : { error: error.message });
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ if (method === "POST" && path === "/api/live/call/end") {
69
+ const parsed = endBody.safeParse(await bodyOf(req, readBody));
70
+ if (!parsed.success)
71
+ return json(res, 400, { error: "The request was not valid." });
72
+ const call = await deps.calls.end(parsed.data.callId);
73
+ return call ? json(res, 200, { call }) : json(res, 404, { error: "That call is not running." });
74
+ }
75
+ if (method === "GET" && path === "/api/live/call")
76
+ return json(res, 200, { call: deps.calls.current() });
77
+ // The companion unpaired a phone: end the call that phone holds, if any.
78
+ if (method === "POST" && path === "/api/live/device-revoked") {
79
+ if (req.headers["x-openmausbot-companion"] !== "1")
80
+ return json(res, 403, { error: "Only the phone companion can report an unpaired phone." });
81
+ const device = companionDevice(req);
82
+ if (!device)
83
+ return json(res, 400, { error: "The unpaired phone was not named." });
84
+ return json(res, 200, { call: deps.calls.deviceRevoked(device) });
85
+ }
86
+ if (method === "PATCH" && path === "/api/live/settings") {
87
+ const parsed = settingsBody.safeParse(await bodyOf(req, readBody));
88
+ if (!parsed.success) {
89
+ return json(res, 400, { error: "Those Live settings are not valid. The OpenAI key can only be changed on the computer that runs OpenMausBot." });
90
+ }
91
+ // as PUT /api/config: an empty patch is not saved (or broadcast)
92
+ if (!Object.keys(parsed.data).length)
93
+ return json(res, 400, { error: "nothing to save" });
94
+ return json(res, 200, { live: await deps.saveSettings(parsed.data) });
95
+ }
96
+ return PASS;
97
+ };
98
+ }
@@ -1286,14 +1286,17 @@ export class RoutineManager {
1286
1286
  // A webhook is an incoming message, so make its task the bot's live
1287
1287
  // chat immediately. Scheduled work stays in its own task unless the
1288
1288
  // workspace has asked for runs to join the conversation they report to.
1289
+ const startedAt = this.now();
1290
+ const suffix = ` · ${new Date(startedAt).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
1291
+ const title = `${run.routineName.slice(0, 80 - suffix.length).trimEnd()}${suffix}`;
1289
1292
  const joined = run.target === "room-goal" ? null : this.options.joinConversation?.(run) || null;
1290
1293
  const task = joined
1291
1294
  ? { threadId: joined }
1292
1295
  : run.target === "room-goal"
1293
1296
  ? run.groupId
1294
- ? this.options.createGoalTask?.(run.groupId, run.routineName) ?? null
1297
+ ? this.options.createGoalTask?.(run.groupId, title) ?? null
1295
1298
  : null
1296
- : this.options.createTask(run.botId, run.routineName, run.triggerSource === "webhook", run.routineId);
1299
+ : this.options.createTask(run.botId, title, run.triggerSource === "webhook", run.routineId);
1297
1300
  if (!task) {
1298
1301
  this.failRun(run, run.target === "room-goal"
1299
1302
  ? "Could not create a room task for this goal"
@@ -1301,7 +1304,7 @@ export class RoutineManager {
1301
1304
  continue;
1302
1305
  }
1303
1306
  run.threadId = task.threadId;
1304
- run.startedAt = this.now();
1307
+ run.startedAt = startedAt;
1305
1308
  run.status = "running";
1306
1309
  this.save();
1307
1310
  this.emitRun(run);
@@ -4,6 +4,13 @@
4
4
  // declared trigger terms and mounted capabilities.
5
5
  import { existsSync, readFileSync, readdirSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import { EventEmitter } from "node:events";
9
+ import { mkdirSync } from "node:fs";
10
+ import { z } from "zod";
11
+ import { writeFileAtomic } from "./atomic.js";
12
+ import { DATA_DIR } from "./config.js";
13
+ import { isSkillName, parseSkillMd, SKILL_FILE_MAX_BYTES } from "../shared/skill-md.js";
7
14
  const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
15
  function strings(value) {
9
16
  return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim())
@@ -114,3 +121,203 @@ export function renderSkillInstructions(selected, { includeRoot = false } = {})
114
121
  return "";
115
122
  return selected.map(({ manifest, instructions, directory }) => `\n\n<openmaus-skill id=${JSON.stringify(manifest.id)} version=${JSON.stringify(manifest.version)}${includeRoot ? ` root=${JSON.stringify(directory)}` : ""}>\n${instructions}\n</openmaus-skill>`).join("");
116
123
  }
124
+ const skillLibraryEntrySchema = z.object({
125
+ name: z.string(),
126
+ description: z.string(),
127
+ source: z.string(),
128
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
129
+ importedAt: z.string(),
130
+ reviewState: z.enum(["approved", "disabled"]),
131
+ license: z.string().optional(),
132
+ compatibility: z.string().optional(),
133
+ tags: z.array(z.string().regex(/^[a-z0-9][a-z0-9-]{0,23}$/)).max(8).optional(),
134
+ warnings: z.array(z.string()),
135
+ skippedFiles: z.array(z.string()),
136
+ package: z.object({
137
+ installId: z.string().regex(/^[a-f0-9]{32}$/),
138
+ key: z.string().min(1).max(64),
139
+ release: z.string().min(1).max(40),
140
+ r: z.string().regex(/^[a-f0-9]{64}$/),
141
+ w: z.string().regex(/^[a-f0-9]{64}$/),
142
+ via: z.literal("preset").optional(),
143
+ }).optional(),
144
+ });
145
+ const skillLibraryIndexSchema = z.record(z.string(), skillLibraryEntrySchema);
146
+ /** Every library write emits here so later skills-lane consumers can drop
147
+ * caches without polling. The payload names what changed. */
148
+ export const skillLibraryEvents = new EventEmitter();
149
+ export function skillsLibraryRoot(dataDir = DATA_DIR) {
150
+ return join(dataDir, "skills-library");
151
+ }
152
+ function libraryIndexPath(root) {
153
+ return join(root, "index.json");
154
+ }
155
+ export function librarySkillDirectory(root, name) {
156
+ return join(root, "skills", name);
157
+ }
158
+ /** The path agents are told to read for an assigned library skill. */
159
+ export function librarySkillFilePath(name, root = skillsLibraryRoot()) {
160
+ return join(root, "skills", name, "SKILL.md");
161
+ }
162
+ /** Where a migrated per-bot copy is preserved. Archives are write-once:
163
+ * migration never deletes the original bytes, it moves them here. */
164
+ export function libraryArchiveDirectory(root, botId, name) {
165
+ return join(root, "archive", botId, name);
166
+ }
167
+ /** A missing index is a legitimate empty library; an index that exists but
168
+ * cannot be read or validated is not, and writers must refuse rather than
169
+ * replace it and strand every stored skill. */
170
+ function readSkillLibraryIndexState(root) {
171
+ try {
172
+ const parsed = JSON.parse(readFileSync(libraryIndexPath(root), "utf8"));
173
+ const result = skillLibraryIndexSchema.safeParse(parsed);
174
+ return result.success
175
+ ? { index: result.data }
176
+ : { error: "the skills library index is invalid" };
177
+ }
178
+ catch (error) {
179
+ if (error.code === "ENOENT")
180
+ return { index: {} };
181
+ return { error: "the skills library index could not be read" };
182
+ }
183
+ }
184
+ export function readSkillLibraryIndex(root = skillsLibraryRoot()) {
185
+ const result = readSkillLibraryIndexState(root);
186
+ return "error" in result ? {} : result.index;
187
+ }
188
+ function writeSkillLibraryIndex(root, index) {
189
+ mkdirSync(root, { recursive: true, mode: 0o700 });
190
+ writeFileAtomic(libraryIndexPath(root), `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 });
191
+ }
192
+ function sha256Hex(text) {
193
+ return createHash("sha256").update(text).digest("hex");
194
+ }
195
+ function libraryContentMatches(root, entry) {
196
+ try {
197
+ const file = join(librarySkillDirectory(root, entry.name), "SKILL.md");
198
+ if (!existsSync(file))
199
+ return false;
200
+ return sha256Hex(readFileSync(file, "utf8")) === entry.sha256;
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ function librarySkillListing(root, entry) {
207
+ const intact = libraryContentMatches(root, entry);
208
+ return {
209
+ name: entry.name,
210
+ description: entry.description,
211
+ enabled: entry.reviewState === "approved" && intact,
212
+ editable: false,
213
+ source: entry.source,
214
+ sha256: entry.sha256,
215
+ importedAt: entry.importedAt,
216
+ ...(entry.license ? { license: entry.license } : {}),
217
+ ...(entry.compatibility ? { compatibility: entry.compatibility } : {}),
218
+ tags: entry.tags ?? [],
219
+ warnings: intact
220
+ ? entry.warnings
221
+ : [...entry.warnings, "stored SKILL.md changed after review — enablement is blocked"],
222
+ skippedFiles: entry.skippedFiles,
223
+ };
224
+ }
225
+ export function listLibrarySkills(root = skillsLibraryRoot()) {
226
+ return Object.values(readSkillLibraryIndex(root))
227
+ .map((entry) => librarySkillListing(root, entry))
228
+ .sort((a, b) => a.name.localeCompare(b.name));
229
+ }
230
+ /** Put a skill in the library. Installing the same name with the same
231
+ * SKILL.md bytes is idempotent (the existing entry wins); the same name
232
+ * with different bytes is refused, mirroring the per-bot import rule.
233
+ * Every failure returns `{ error }` — callers fall back. */
234
+ export function installLibrarySkill(input) {
235
+ const root = input.root ?? skillsLibraryRoot();
236
+ if (!isSkillName(input.name))
237
+ return { error: "invalid skill name" };
238
+ if (Buffer.byteLength(input.instructions, "utf8") > SKILL_FILE_MAX_BYTES) {
239
+ return { error: `SKILL.md is larger than ${SKILL_FILE_MAX_BYTES / 1024}KB` };
240
+ }
241
+ const parsed = parseSkillMd(input.instructions);
242
+ if ("error" in parsed)
243
+ return parsed;
244
+ if (parsed.name !== input.name)
245
+ return { error: `the skill is named "${parsed.name}", not "${input.name}"` };
246
+ const sha256 = sha256Hex(input.instructions);
247
+ const indexRead = readSkillLibraryIndexState(root);
248
+ if ("error" in indexRead)
249
+ return indexRead;
250
+ const index = indexRead.index;
251
+ const existing = index[input.name];
252
+ if (existing) {
253
+ if (existing.sha256 === sha256)
254
+ return librarySkillListing(root, existing);
255
+ return { error: `a skill named "${input.name}" is already in the library — choose a different name` };
256
+ }
257
+ // Callers such as the library migration do not pass tags; frontmatter
258
+ // tags parsed from the instructions are the source of record then.
259
+ const tags = input.tags?.length ? input.tags : parsed.tags;
260
+ const entry = {
261
+ name: input.name,
262
+ description: input.description?.trim() || parsed.description,
263
+ source: input.source,
264
+ sha256,
265
+ importedAt: new Date().toISOString(),
266
+ reviewState: input.reviewState ?? "disabled",
267
+ ...(input.license ? { license: input.license } : {}),
268
+ ...(input.compatibility ? { compatibility: input.compatibility } : {}),
269
+ ...(tags?.length ? { tags } : {}),
270
+ warnings: input.warnings ?? [],
271
+ skippedFiles: [],
272
+ ...(input.package ? { package: { ...input.package } } : {}),
273
+ };
274
+ // Bytes first, index second: the index is the commit point, so a crash
275
+ // between the two leaves unreferenced bytes, never a dangling entry.
276
+ mkdirSync(librarySkillDirectory(root, input.name), { recursive: true, mode: 0o700 });
277
+ writeFileAtomic(join(librarySkillDirectory(root, input.name), "SKILL.md"), input.instructions, { mode: 0o600 });
278
+ index[input.name] = entry;
279
+ writeSkillLibraryIndex(root, index);
280
+ skillLibraryEvents.emit("invalidate", { kind: "install", name: input.name });
281
+ return librarySkillListing(root, entry);
282
+ }
283
+ export function readLibrarySkillFile(name, root = skillsLibraryRoot()) {
284
+ if (!isSkillName(name))
285
+ return null;
286
+ const entry = readSkillLibraryIndex(root)[name];
287
+ if (!entry)
288
+ return null;
289
+ try {
290
+ const text = readFileSync(join(librarySkillDirectory(root, name), "SKILL.md"), "utf8");
291
+ return sha256Hex(text) === entry.sha256 ? text : null;
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ export function setLibrarySkillReviewState(name, reviewState, root = skillsLibraryRoot()) {
298
+ if (!isSkillName(name))
299
+ return { error: "invalid skill name" };
300
+ const index = readSkillLibraryIndex(root);
301
+ const entry = index[name];
302
+ if (!entry)
303
+ return { error: `no library skill named "${name}"` };
304
+ if (reviewState === "approved" && !libraryContentMatches(root, entry)) {
305
+ return { error: "stored SKILL.md changed after review — the library entry cannot be approved" };
306
+ }
307
+ entry.reviewState = reviewState;
308
+ writeSkillLibraryIndex(root, index);
309
+ skillLibraryEvents.emit("invalidate", { kind: "review-state", name });
310
+ return librarySkillListing(root, entry);
311
+ }
312
+ /** The lane's one resolution rule, stated once: a skill name means the
313
+ * bot-private copy when one exists, else the library entry, else the
314
+ * bundled skill. Later slices route every lookup through this order. */
315
+ export function resolveSkillSourceOrder(name, sources) {
316
+ if (sources.private !== undefined)
317
+ return { source: "private", name, value: sources.private };
318
+ if (sources.library !== undefined)
319
+ return { source: "library", name, value: sources.library };
320
+ if (sources.bundled !== undefined)
321
+ return { source: "bundled", name, value: sources.bundled };
322
+ return null;
323
+ }