openmausbot 0.1.87 → 0.1.89

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 (150) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-BwaMaZEm.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-Be-8GKxd.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-Dp_U2Kv1.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-DQjUjYUk.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-Bd3aE4Kc.js} +1 -1
  7. package/dist/assets/channel-DqY_gMLg.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-BijjTBeJ.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-CMGdwvzW.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-0G09BfsZ.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-FOp3vSHe.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-Dv-20VIn.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CRgrdFcz.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-3hP4f7QB.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-CG98IjlG.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-BrSTC9xG.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-rpQQQDFX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-G6RSafHA.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-CXjv-IM2.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-Dw07MR9f.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-BwPnlTg2.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-Cm6xcMiz.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-CDpogPbE.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-BV8z3Pnc.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-CKCQhOOC.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-FGvJg69C.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-CwATvgnS.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-CH_c3SSC.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-3guZi71R.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BMHphR2n.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-Vm5DkW5x.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-DO-1ct07.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-nxeksEIN.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-BVPdbq2r.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-Oxk9x45i.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-Dkcvr0p7.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-BwVibkoO.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-ByiBZqgG.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-qqjgXBF3.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-CtJnKe6A.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-BEiDklrY.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-DfIYHia9.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-CYyRqa1a.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-hOToy_zH.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DKAsNN9I.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-BSA-d8JU.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-CSmlvb-p.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-CD5F6fnG.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-JUq5sx6d.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BjIS-Stk.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-DklkUxOg.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CsjIU6be.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-BkFqK7AB.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-70gJB1EU.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-83Tm62dN.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-tTmseoX9.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-R1LBA55a.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +8 -1
  62. package/dist-server/connector-proxy.js +105 -1
  63. package/dist-server/container-mcp.js +11 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23722 -20272
  66. package/dist-server/local-computer-proxy.js +2 -0
  67. package/dist-server/mcp-gate.js +2 -0
  68. package/dist-server/mcp-server.js +5 -3
  69. package/dist-server/message-search.worker.js +52 -0
  70. package/dist-server/openmausbot.js +755 -404
  71. package/dist-server/pair-cli.js +755 -404
  72. package/dist-server/server/admission.js +81 -0
  73. package/dist-server/server/aside-queue.js +294 -0
  74. package/dist-server/server/attachments.js +43 -1
  75. package/dist-server/server/automatic-recovery.js +15 -0
  76. package/dist-server/server/bot-attachment.js +107 -0
  77. package/dist-server/server/bot-overview.js +19 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +34 -3
  80. package/dist-server/server/connector-advertisement.js +165 -0
  81. package/dist-server/server/connector-proxy.js +14 -2
  82. package/dist-server/server/connector-verdict.js +31 -4
  83. package/dist-server/server/container-computer.js +70 -0
  84. package/dist-server/server/contracts.js +32 -0
  85. package/dist-server/server/drivers/acp/core.js +148 -23
  86. package/dist-server/server/drivers/acp/cursor.js +3 -7
  87. package/dist-server/server/drivers/acp/droid.js +4 -7
  88. package/dist-server/server/drivers/acp/gemini.js +4 -4
  89. package/dist-server/server/drivers/acp/grok.js +4 -9
  90. package/dist-server/server/drivers/acp/hermes.js +3 -5
  91. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  92. package/dist-server/server/drivers/acp/qwen.js +12 -15
  93. package/dist-server/server/drivers/agents-call.js +137 -10
  94. package/dist-server/server/drivers/agents-catalog.js +100 -6
  95. package/dist-server/server/drivers/boxagent.js +3 -17
  96. package/dist-server/server/drivers/claude.js +31 -17
  97. package/dist-server/server/drivers/codex-catalog.js +14 -6
  98. package/dist-server/server/drivers/codex-identity.js +2 -1
  99. package/dist-server/server/drivers/codex-release.js +48 -0
  100. package/dist-server/server/drivers/codex.js +7 -25
  101. package/dist-server/server/drivers/openai-chat.js +31 -3
  102. package/dist-server/server/drivers/pi.js +237 -30
  103. package/dist-server/server/drivers/prompt-split.js +32 -9
  104. package/dist-server/server/drivers/session-idle.js +29 -0
  105. package/dist-server/server/env-path.js +15 -0
  106. package/dist-server/server/guarded-requests.js +2 -2
  107. package/dist-server/server/harness-capabilities.js +61 -0
  108. package/dist-server/server/index.js +1173 -262
  109. package/dist-server/server/message-db.js +22 -42
  110. package/dist-server/server/message-file.js +9 -1
  111. package/dist-server/server/message-search-query.js +43 -0
  112. package/dist-server/server/message-search-worker.js +80 -0
  113. package/dist-server/server/message-search.worker.js +21 -0
  114. package/dist-server/server/model-requests.js +248 -0
  115. package/dist-server/server/options-card.js +21 -0
  116. package/dist-server/server/org-library.js +15 -1
  117. package/dist-server/server/peer-delivery.js +60 -0
  118. package/dist-server/server/peer-roster.js +57 -0
  119. package/dist-server/server/presets.js +32 -32
  120. package/dist-server/server/profile-requests.js +66 -39
  121. package/dist-server/server/profile-revision.js +9 -1
  122. package/dist-server/server/profile-versions.js +6 -3
  123. package/dist-server/server/routine-requests.js +29 -7
  124. package/dist-server/server/skill-fetch.js +72 -14
  125. package/dist-server/server/skills.js +1 -0
  126. package/dist-server/server/store.js +32 -2
  127. package/dist-server/server/system-prompt.js +37 -2
  128. package/dist-server/server/team-setup-requests.js +124 -23
  129. package/dist-server/server/thread-events.js +3 -1
  130. package/dist-server/server/tightening-requests.js +405 -0
  131. package/dist-server/server/tts/chatterbox.js +27 -5
  132. package/dist-server/server/usage-ledger.js +7 -1
  133. package/dist-server/server/webhooks.js +21 -3
  134. package/dist-server/server/workspace-backup.js +40 -3
  135. package/dist-server/server/workspace-backup.worker.js +10 -0
  136. package/dist-server/shared/ask-question.js +19 -0
  137. package/dist-server/shared/credential-request.js +5 -1
  138. package/dist-server/shared/line-diff.js +23 -0
  139. package/dist-server/shared/model-request.js +1 -0
  140. package/dist-server/shared/options-card.js +51 -0
  141. package/dist-server/shared/profile-request.js +5 -4
  142. package/dist-server/shared/tightening-request.js +87 -0
  143. package/dist-server/src/lib/composer-attachments.js +42 -3
  144. package/dist-server/vps-container-mcp.js +11 -1
  145. package/dist-server/workspace-backup.worker.js +19482 -0
  146. package/package.json +1 -1
  147. package/dist/assets/channel-taCNRlUU.js +0 -1
  148. package/dist/assets/index-BwdMAQ6p.css +0 -1
  149. package/dist/assets/index-ByJennr7.js +0 -581
  150. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -4,8 +4,8 @@
4
4
  // The generic protocol runtime lives in acp/core.ts; this file is only the
5
5
  // per-harness quirks. Verified against grok 1.0.0.
6
6
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
7
  import { join } from "node:path";
8
+ import { harnessHome } from "../../env-path.js";
9
9
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
10
10
  import { createAcpDriver } from "./core.js";
11
11
  export const STATIC_GROK_MODELS = {
@@ -17,11 +17,6 @@ export const STATIC_GROK_MODELS = {
17
17
  ],
18
18
  };
19
19
  const SLUG = /^[a-z0-9][a-z0-9._-]*$/i;
20
- function grokHome(env) {
21
- if (env.GROK_HOME)
22
- return env.GROK_HOME;
23
- return join(env.HOME || env.USERPROFILE || homedir(), ".grok");
24
- }
25
20
  function unquote(raw) {
26
21
  const value = raw.trim();
27
22
  if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
@@ -32,7 +27,7 @@ function unquote(raw) {
32
27
  /** Local slugs from ~/.grok/config.toml, plus the cloud defaults.
33
28
  * `grok -m <slug>` already accepts these; the picker just didn't list them. */
34
29
  export function readGrokModelCatalog(env = process.env) {
35
- const path = join(grokHome(env), "config.toml");
30
+ const path = join(env.GROK_HOME || harnessHome("grok", env), "config.toml");
36
31
  let text = "";
37
32
  try {
38
33
  text = readFileSync(path, "utf8");
@@ -114,7 +109,7 @@ export function ensureGrokInjectSlug(modelId, env = process.env) {
114
109
  const host = localHost(inject.host);
115
110
  if (!host)
116
111
  return modelId;
117
- const path = join(grokHome(env), "config.toml");
112
+ const path = join(env.GROK_HOME || harnessHome("grok", env), "config.toml");
118
113
  let text = "";
119
114
  try {
120
115
  text = readFileSync(path, "utf8");
@@ -258,7 +253,7 @@ const support = {
258
253
  // an unauthenticated CLI is a user action, not something to paper over.
259
254
  pickAuthMethod: (methods) => (methods.some((m) => m.id === "cached_token") ? "cached_token" : null),
260
255
  authFailure: "fail",
261
- isAuthenticated: () => existsSync(join(homedir(), ".grok", "auth.json")),
256
+ isAuthenticated: (env) => existsSync(join(harnessHome("grok", env), "auth.json")),
262
257
  // `--append-system-prompt`/`--rules` are accepted by the CLI but do NOT
263
258
  // reach the agent-stdio system prompt (verified against 1.0.0), so the
264
259
  // persona is prepended codex-style.
@@ -9,6 +9,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { parse as parseYaml } from "yaml";
12
+ import { harnessHome } from "../../env-path.js";
12
13
  import { resolveCli } from "../../procs.js";
13
14
  import { decodeInjectId, hostApiKey, INJECT_SEP, localHost, mergeLocalInject } from "../local-inject.js";
14
15
  import { createAcpDriver } from "./core.js";
@@ -25,9 +26,6 @@ export function bindHermesScreenshotCompat(env, modelId) {
25
26
  env[HERMES_OPENMAUS_SCREENSHOT_COMPAT] = "1";
26
27
  env[HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL] = inject.model;
27
28
  }
28
- function hermesHome(env) {
29
- return env.HERMES_HOME || join(env.HOME || env.USERPROFILE || homedir(), ".hermes");
30
- }
31
29
  function quoteYaml(value) {
32
30
  if (/^[\w./:+-]+$/.test(value))
33
31
  return value;
@@ -69,7 +67,7 @@ export function ensureHermesInjectProvider(modelId, env = process.env) {
69
67
  const host = localHost(inject.host);
70
68
  if (!host)
71
69
  return modelId;
72
- const dir = hermesHome(env);
70
+ const dir = env.HERMES_HOME || harnessHome("hermes", env);
73
71
  mkdirSync(dir, { recursive: true });
74
72
  const path = join(dir, "config.yaml");
75
73
  let text = "";
@@ -199,7 +197,7 @@ function hermesConfigDefault(text) {
199
197
  * catalog exactly as it was for local-only setups.
200
198
  */
201
199
  export function hermesConfiguredModel(env = process.env) {
202
- const dir = hermesHome(env);
200
+ const dir = env.HERMES_HOME || harnessHome("hermes", env);
203
201
  let secrets = "";
204
202
  try {
205
203
  secrets = readFileSync(join(dir, ".env"), "utf8");
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
8
8
  import { createAcpDriver } from "./core.js";
9
+ import { titleCaseModelId } from "../../contracts.js";
9
10
  import { execCli } from "../../procs.js";
10
11
  const STATIC_MODELS = {
11
12
  default: "opencode/x-preview-f-free",
@@ -21,11 +22,7 @@ let lastSuccessfulCatalog = null;
21
22
  const MODEL_PROBE_TTL_MS = 30_000;
22
23
  const modelProbeCache = new Map();
23
24
  function labelForModel(id) {
24
- return id
25
- .split(/[-_.]+/g)
26
- .filter(Boolean)
27
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
28
- .join(" ");
25
+ return titleCaseModelId(id, /[-_.]+/);
29
26
  }
30
27
  function providerLabel(id) {
31
28
  if (id === "opencode")
@@ -3,30 +3,20 @@
3
3
  // Custom and are written into ~/.qwen/settings.json modelProviders.
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { createHash } from "node:crypto";
6
- import { homedir } from "node:os";
7
6
  import { join } from "node:path";
7
+ import { qualifiedModelLabel } from "../../contracts.js";
8
+ import { harnessHome } from "../../env-path.js";
8
9
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
9
10
  import { createAcpDriver } from "./core.js";
10
11
  const EMPTY = { default: "", options: [] };
11
- function qwenHome(env) {
12
- const home = process.platform === "win32"
13
- ? env.USERPROFILE || env.HOME || homedir()
14
- : env.HOME || env.USERPROFILE || homedir();
15
- return join(home, ".qwen");
16
- }
17
12
  function isRecord(value) {
18
13
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
19
14
  }
20
- function qwenModelLabel(id, name) {
21
- if (!name || name.toLocaleLowerCase().endsWith(id.toLocaleLowerCase()))
22
- return id;
23
- return `${id} — ${name}`;
24
- }
25
15
  const PROTOCOLS = new Set(["openai", "anthropic", "gemini", "vertex-ai"]);
26
16
  function readQwenRoutes(env) {
27
17
  let settings;
28
18
  try {
29
- settings = JSON.parse(readFileSync(join(qwenHome(env), "settings.json"), "utf8"));
19
+ settings = JSON.parse(readFileSync(join(harnessHome("qwen", env), "settings.json"), "utf8"));
30
20
  }
31
21
  catch {
32
22
  return [];
@@ -102,7 +92,11 @@ function readQwenRoutes(env) {
102
92
  /** Public metadata only. Use Qwen's provider-qualified ACP selectors, not raw model IDs. */
103
93
  export function readQwenModelCatalog(env = process.env) {
104
94
  const options = readQwenRoutes(env).map(({ id, model, label, provider }) => ({
105
- id, label: qwenModelLabel(model, label), custom: true, provider,
95
+ id,
96
+ label: qualifiedModelLabel(model, label, {
97
+ redundantWhen: (id, name) => name.toLocaleLowerCase().endsWith(id.toLocaleLowerCase()),
98
+ }),
99
+ custom: true, provider,
106
100
  }));
107
101
  return { default: options[0]?.id ?? "", options };
108
102
  }
@@ -117,7 +111,7 @@ export function ensureQwenInjectModel(modelId, env = process.env) {
117
111
  const host = localHost(inject.host);
118
112
  if (!host)
119
113
  return modelId;
120
- const dir = qwenHome(env);
114
+ const dir = harnessHome("qwen", env);
121
115
  mkdirSync(dir, { recursive: true });
122
116
  const path = join(dir, "settings.json");
123
117
  let settings = {};
@@ -241,6 +235,9 @@ const support = {
241
235
  // A raw -m only changes the model within the saved provider. ACP switches
242
236
  // the complete route and confirms it before any prompt leaves OMB.
243
237
  spawnArgs: (config, turn) => ["--acp", ...qwenApprovalArgs(config.fullAuto, turn.approvalMode)],
238
+ // Qwen's live session/load returns its cached MCP clients, ignoring fresh
239
+ // credentials. Cold-load the same conversation when those inputs change.
240
+ restartOnMcpChange: true,
244
241
  selectModel: { configId: "model" },
245
242
  pickAuthMethod: () => null,
246
243
  authFailure: "continue",
@@ -7,8 +7,10 @@
7
7
  // Nothing here reads the environment or holds state of its own. The caller
8
8
  // passes a ToolCallContext, and the per-turn counters live on it.
9
9
  import { CREDENTIAL_TARGETS, isCredentialTargetId } from "../../shared/credential-request.js";
10
+ import { parseOptionsCardInput, WATCHER_OPTIONS_CARD_BOT_ID } from "../../shared/options-card.js";
10
11
  import { normalizeCronSchedule } from "../../shared/routine-schedule.js";
11
12
  import { peerName } from "../peer-roster.js";
13
+ import { renderPeerDeliveryReceipts } from "../peer-delivery.js";
12
14
  import { catalogProfileFromEnv, SHARED_COMPUTER_TOOL_NAMES, WEEKDAYS } from "./agents-catalog.js";
13
15
  import { harnessClientFromEnv } from "./agents-client.js";
14
16
  import { boundedAgentResult } from "./agents-result.js";
@@ -352,6 +354,29 @@ function recallSpeaker(hit) {
352
354
  return hit.from;
353
355
  return hit.role === "user" ? "user" : "you";
354
356
  }
357
+ const isPeerDeliveryOutcome = (value) => value === "queued" || value === "injected" || value === "failed";
358
+ /** The delivery receipt a peer-send answer carries, as one prose line.
359
+ * Unvalidated JSON stays out: a malformed receipt renders nothing rather
360
+ * than half a line of somebody else's text. */
361
+ function deliveryNote(r) {
362
+ const receipt = r.receipt;
363
+ if (!receipt || typeof receipt !== "object")
364
+ return "";
365
+ const { botId, botName, outcome, detail, taskId, requestId } = receipt;
366
+ if (typeof botId !== "string" || !botId || typeof detail !== "string" || !detail)
367
+ return "";
368
+ if (!isPeerDeliveryOutcome(outcome))
369
+ return "";
370
+ const parsed = {
371
+ botId,
372
+ ...(typeof botName === "string" && botName ? { botName } : {}),
373
+ outcome,
374
+ detail,
375
+ ...(typeof taskId === "string" && taskId ? { taskId } : {}),
376
+ ...(typeof requestId === "string" && requestId ? { requestId } : {}),
377
+ };
378
+ return renderPeerDeliveryReceipts([parsed]);
379
+ }
355
380
  /** When a recalled line was said, on the machine's own clock. `toISOString()`
356
381
  * answers in UTC, which disagrees with every other day the bot is shown: a
357
382
  * bare `since`/`until` date is read as local midnight (recent-work.ts
@@ -375,6 +400,49 @@ export async function callTool(name, args, context) {
375
400
  const { botId: BOT_ID, threadId: THREAD_ID, depth: DEPTH, externalRuntime: EXTERNAL_RUNTIME, coordinating: COORDINATING, turn } = context;
376
401
  const { delegationTaskIdsThisTurn } = turn;
377
402
  const { api, apiResponse } = context.client;
403
+ if (name === "vm_exec") {
404
+ const { ok, body } = await apiResponse("/api/internal/vm-exec", {
405
+ method: "POST",
406
+ body: JSON.stringify({ command: args.command, ...(typeof args.timeout_seconds === "number" ? { timeout_seconds: args.timeout_seconds } : {}) }),
407
+ });
408
+ if (!ok)
409
+ return { text: String(body.error ?? "Could not run that command."), isError: true };
410
+ const exitCode = Number(body.exitCode ?? 0);
411
+ const stdout = String(body.stdout ?? "");
412
+ const stderr = String(body.stderr ?? "");
413
+ const lines = [body.timedOut ? "The command was stopped: it ran past its time limit." : `exit code ${exitCode}`];
414
+ if (stdout)
415
+ lines.push("--- stdout ---", stdout.replace(/\s+$/, ""));
416
+ if (stderr)
417
+ lines.push("--- stderr ---", stderr.replace(/\s+$/, ""));
418
+ if (!stdout && !stderr && !body.timedOut)
419
+ lines.push("(no output)");
420
+ return { text: lines.join("\n"), ...(exitCode !== 0 || body.timedOut ? { isError: true } : {}) };
421
+ }
422
+ if (name === "attach_file") {
423
+ const { ok, body } = await apiResponse("/api/internal/attach-file", {
424
+ method: "POST",
425
+ body: JSON.stringify({ path: args.path, ...(typeof args.name === "string" ? { name: args.name } : {}) }),
426
+ });
427
+ if (!ok)
428
+ return { text: String(body.error ?? "Could not attach that file."), isError: true };
429
+ return { text: `Attached ${String(body.name ?? "the file")} (${Number(body.bytes ?? 0)} bytes). It now appears in the chat with a preview.` };
430
+ }
431
+ if (name === "create_options_card") {
432
+ if (BOT_ID !== WATCHER_OPTIONS_CARD_BOT_ID) {
433
+ return { text: "create_options_card is not enabled for this bot.", isError: true };
434
+ }
435
+ const parsed = parseOptionsCardInput(args);
436
+ if (!parsed.ok)
437
+ return { text: parsed.error, isError: true };
438
+ const result = await api("/api/internal/options-card", {
439
+ method: "POST",
440
+ body: JSON.stringify(parsed.value),
441
+ });
442
+ return {
443
+ text: `Rendered the native options card in this Watcher thread (message ${String(result.messageId ?? "created")}). Wait for the person's click or custom response; the card itself authorizes no external action.`,
444
+ };
445
+ }
378
446
  // Second lock. With sharing off the tool is not in the catalog, so a front
379
447
  // end already refuses the call as an unknown tool — the same answer a build
380
448
  // without the feature gives. This keeps the handler itself refusing if that
@@ -484,6 +552,7 @@ export async function callTool(name, args, context) {
484
552
  if (name === "post_to_room") {
485
553
  const groupId = String(args.group_id ?? "").trim();
486
554
  const message = String(args.message ?? "").trim();
555
+ const attachVoiceNote = args.attach_voice_note === true;
487
556
  if (!groupId || !message) {
488
557
  return { text: "post_to_room needs group_id (from list_rooms) and message.", isError: true };
489
558
  }
@@ -495,13 +564,13 @@ export async function callTool(name, args, context) {
495
564
  }
496
565
  const r = await api("/api/internal/post-to-room", {
497
566
  method: "POST",
498
- body: JSON.stringify({ fromBotId: BOT_ID, fromThreadId: THREAD_ID, groupId, message }),
567
+ body: JSON.stringify({ fromBotId: BOT_ID, fromThreadId: THREAD_ID, groupId, message, attachVoiceNote }),
499
568
  });
500
569
  if (r.error)
501
570
  return { text: String(r.error), isError: true };
502
571
  turn.roomPostsThisTurn += 1;
503
572
  return {
504
- text: `Posted in ${r.roomName ?? "the room"}. Nobody's turn was started, so expect no reply — tell the user it is posted.`,
573
+ text: `Posted in ${r.roomName ?? "the room"}${r.attachedVoiceNote ? " with the voice note attached" : ""}. Nobody's turn was started, so expect no reply — tell the user it is posted.`,
505
574
  };
506
575
  }
507
576
  if (name === "ask_bot") {
@@ -513,6 +582,7 @@ export async function callTool(name, args, context) {
513
582
  method: "POST",
514
583
  body: JSON.stringify({ fromBotId: BOT_ID, fromThreadId: THREAD_ID, toBotId, message, depth: DEPTH }),
515
584
  });
585
+ const note = deliveryNote(r);
516
586
  if (r.timeout) {
517
587
  // The peer's turn outlived the synchronous wait, so the harness
518
588
  // converted the ask into a delegation — the reply is not lost.
@@ -523,10 +593,25 @@ export async function callTool(name, args, context) {
523
593
  const amount = waitedSeconds < 60 ? waitedSeconds : Math.round(waitedSeconds / 60);
524
594
  const unit = waitedSeconds < 60 ? "second" : "minute";
525
595
  return {
526
- text: `${r.toBotName ?? "That bot"} is still working after ${amount} ${unit}${amount === 1 ? "" : "s"} — the ask was converted to a delegation so the reply is not lost. Task id: ${taskId}. ${EXTERNAL_RUNTIME ? EXTERNAL_STATUS_GUIDANCE : "Finish your turn now; the result will be delivered to this conversation automatically. Use check_delegation in a later turn only if the user asks for status."}`,
596
+ text: `${r.toBotName ?? "That bot"} is still working after ${amount} ${unit}${amount === 1 ? "" : "s"} — the ask was converted to a delegation so the reply is not lost. Task id: ${taskId}. ${EXTERNAL_RUNTIME ? EXTERNAL_STATUS_GUIDANCE : "Finish your turn now; the result will be delivered to this conversation automatically. Use check_delegation in a later turn only if the user asks for status."}${note ? `\n\n${note}` : ""}`,
527
597
  };
528
598
  }
529
599
  if (r.busy) {
600
+ // Aside lane: the teammate was mid-turn on a seam-capable engine, so
601
+ // the harness folded the message into their running work as peer
602
+ // context. No new turn started and no reply is coming back through
603
+ // this call — the receipt says whether the words are already in the
604
+ // live turn or waiting for it to settle.
605
+ if (r.aside === "injected") {
606
+ return {
607
+ text: `${r.toBotName ?? "That bot"} is mid-turn; your message was handed to them as an aside — peer context folded into their current work, not a request that interrupts or replies. Finish your turn and treat their eventual output as possibly informed by it.`,
608
+ };
609
+ }
610
+ if (r.aside === "queued") {
611
+ return {
612
+ text: `${r.toBotName ?? "That bot"} is mid-turn; your aside is queued and will reach them as context when the turn settles. No reply is expected — finish your turn.`,
613
+ };
614
+ }
530
615
  // The harness queues the message as a delegation when it can; the
531
616
  // task id is the asker's claim ticket for the eventual reply.
532
617
  const taskId = String(r.taskId ?? "").trim();
@@ -534,14 +619,14 @@ export async function callTool(name, args, context) {
534
619
  if (!EXTERNAL_RUNTIME)
535
620
  delegationTaskIdsThisTurn.add(taskId);
536
621
  return {
537
- text: `${r.toBotName ?? "That bot"} is busy right now, so your message was queued as a delegation instead — ${EXTERNAL_RUNTIME ? "it waits for the peer and any required approval" : "it runs after your current turn ends"}. Task id: ${taskId}. ${EXTERNAL_RUNTIME ? EXTERNAL_STATUS_GUIDANCE : "Finish your turn now; the result will be delivered to this conversation automatically. Use check_delegation in a later turn only if the user asks for status."}`,
622
+ text: `${r.toBotName ?? "That bot"} is busy right now, so your message was queued as a delegation instead — ${EXTERNAL_RUNTIME ? "it waits for the peer and any required approval" : "it runs after your current turn ends"}. Task id: ${taskId}. ${EXTERNAL_RUNTIME ? EXTERNAL_STATUS_GUIDANCE : "Finish your turn now; the result will be delivered to this conversation automatically. Use check_delegation in a later turn only if the user asks for status."}${note ? `\n\n${note}` : ""}`,
538
623
  };
539
624
  }
540
- return { text: `That bot is busy right now — try again after it finishes.` };
625
+ return { text: `That bot is busy right now — try again after it finishes.${note ? `\n\n${note}` : ""}` };
541
626
  }
542
627
  if (r.error)
543
- return { text: `Couldn't reach that bot: ${r.error}`, isError: true };
544
- return { text: `${r.botName ?? "Bot"} replied:\n${r.text ?? "(no reply)"}` };
628
+ return { text: `Couldn't reach that bot: ${r.error}${note ? `\n\n${note}` : ""}`, isError: true };
629
+ return { text: `${r.botName ?? "Bot"} replied:\n${r.text ?? "(no reply)"}${note ? `\n\n${note}` : ""}` };
545
630
  }
546
631
  if (name === "delegate_bot") {
547
632
  const toBotId = String(args.bot_id ?? "").trim();
@@ -559,8 +644,9 @@ export async function callTool(name, args, context) {
559
644
  if (reason)
560
645
  body.reason = reason;
561
646
  const r = await api(`/api/internal/delegate-bot`, { method: "POST", body: JSON.stringify(body) });
647
+ const delivery = deliveryNote(r);
562
648
  if (r.error)
563
- return { text: `Couldn't queue the delegation: ${r.error}`, isError: true };
649
+ return { text: `Couldn't queue the delegation: ${r.error}${delivery ? `\n\n${delivery}` : ""}`, isError: true };
564
650
  // Fire-and-forget by contract: the harness returns immediately, the
565
651
  // peer turn runs after our current turn finishes. The task id is the
566
652
  // bot's claim ticket for the outcome.
@@ -571,7 +657,7 @@ export async function callTool(name, args, context) {
571
657
  const suffix = taskId
572
658
  ? ` Task id: ${taskId}. ${EXTERNAL_RUNTIME ? EXTERNAL_STATUS_GUIDANCE : "Acknowledge the assignment and finish your turn; the result will be delivered to this conversation automatically. Do not check or wait for it in this turn."}`
573
659
  : "";
574
- return { text: `${note}${suffix}` };
660
+ return { text: `${note}${suffix}${delivery ? `\n\n${delivery}` : ""}` };
575
661
  }
576
662
  if (name === "check_delegation" || name === "wait_delegation") {
577
663
  const taskId = String(args.task_id ?? "").trim();
@@ -745,6 +831,7 @@ export async function callTool(name, args, context) {
745
831
  role,
746
832
  instructions,
747
833
  ...(args.modelSelection !== undefined ? { modelSelection: args.modelSelection } : {}),
834
+ ...(typeof args.cwd === "string" ? { cwd: args.cwd.trim() } : {}),
748
835
  }),
749
836
  });
750
837
  turn.createdThisTurn += 1;
@@ -923,6 +1010,13 @@ export async function callTool(name, args, context) {
923
1010
  return confirmationResult(r, `${action.replace("_", " ")} on routine ${routineId}`);
924
1011
  }
925
1012
  if (name === "propose_profile") {
1013
+ // A non-boolean toggle would be dropped silently while any valid half of
1014
+ // the request went through, applying a partial proposal the model never
1015
+ // described. Reject the whole call instead.
1016
+ if ((args.notifications !== undefined && typeof args.notifications !== "boolean")
1017
+ || (args.speakReplies !== undefined && typeof args.speakReplies !== "boolean")) {
1018
+ return { text: "propose_profile notifications and speakReplies must be true or false.", isError: true };
1019
+ }
926
1020
  const changes = {};
927
1021
  if (typeof args.name === "string")
928
1022
  changes.name = args.name.trim();
@@ -934,8 +1028,12 @@ export async function callTool(name, args, context) {
934
1028
  changes.soul = args.soul;
935
1029
  if (typeof args.cwd === "string")
936
1030
  changes.cwd = args.cwd.trim();
1031
+ if (typeof args.notifications === "boolean")
1032
+ changes.notifications = args.notifications;
1033
+ if (typeof args.speakReplies === "boolean")
1034
+ changes.speakReplies = args.speakReplies;
937
1035
  if (!Object.keys(changes).length) {
938
- return { text: "propose_profile needs at least one of name, title, description, soul, or cwd.", isError: true };
1036
+ return { text: "propose_profile needs at least one of name, title, description, soul, cwd, notifications, or speakReplies.", isError: true };
939
1037
  }
940
1038
  const forBotId = String(args.for_bot_id ?? "").trim();
941
1039
  const r = await api("/api/internal/profile-requests", {
@@ -951,6 +1049,35 @@ export async function callTool(name, args, context) {
951
1049
  });
952
1050
  return confirmationResult(r, "the profile change", "profile");
953
1051
  }
1052
+ if (name === "propose_model") {
1053
+ const raw = args.model_selection;
1054
+ const fields = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : null;
1055
+ if (!fields || typeof fields.instanceId !== "string" || !fields.instanceId.trim() || typeof fields.model !== "string" || !fields.model.trim()) {
1056
+ return { text: "propose_model needs model_selection with instanceId and model.", isError: true };
1057
+ }
1058
+ if ((fields.effort !== undefined && (typeof fields.effort !== "string" || !fields.effort.trim()))
1059
+ || (fields.variant !== undefined && (typeof fields.variant !== "string" || !fields.variant.trim()))) {
1060
+ return { text: "propose_model effort and variant must be non-empty strings when supplied.", isError: true };
1061
+ }
1062
+ const selection = { instanceId: fields.instanceId.trim(), model: fields.model.trim() };
1063
+ if (typeof fields.effort === "string" && fields.effort.trim())
1064
+ selection.effort = fields.effort.trim();
1065
+ if (typeof fields.variant === "string" && fields.variant.trim())
1066
+ selection.variant = fields.variant.trim();
1067
+ const forBotId = String(args.for_bot_id ?? "").trim();
1068
+ const r = await api("/api/internal/model-requests", {
1069
+ method: "POST",
1070
+ body: JSON.stringify({
1071
+ fromBotId: BOT_ID,
1072
+ fromThreadId: THREAD_ID,
1073
+ modelSelection: selection,
1074
+ reason: args.reason,
1075
+ // JSON.stringify drops the key entirely when no target was named
1076
+ forBotId: forBotId || undefined,
1077
+ }),
1078
+ });
1079
+ return confirmationResult(r, "the default model change", "model");
1080
+ }
954
1081
  if (name === "memory_update") {
955
1082
  if (!["append", "replace", "remove", "supersede"].includes(String(args.action))
956
1083
  || (args.action !== "remove" && (typeof args.text !== "string" || !args.text.trim()))
@@ -11,6 +11,7 @@
11
11
  // agents-catalog-wire.test.ts: change a description or a schema on purpose,
12
12
  // then update the goldens there.
13
13
  import { CREDENTIAL_TARGETS } from "../../shared/credential-request.js";
14
+ import { OPTIONS_CARD_LIMITS, WATCHER_OPTIONS_CARD_BOT_ID } from "../../shared/options-card.js";
14
15
  import { agentToolAnnotations } from "../agent-tool-policy.js";
15
16
  /** The profile a spawned proxy was given. Everything is off unless the
16
17
  * harness says "1". */
@@ -154,6 +155,26 @@ const PROPOSAL_OUTCOME = " Read the result: granted Full Access may apply the ch
154
155
  /** Every tool, in the order it is listed. Four peer tools are worded
155
156
  * differently for an external runtime, which may poll inside one process. */
156
157
  const toolDefinitions = (externalRuntime) => [
158
+ {
159
+ name: "create_options_card",
160
+ description: "Show the person a native card with 2-6 choices in this Watcher conversation. The card is passive: a click returns the selected words as a reply and never authorizes or performs an external action. Use it for Watcher's review and draft-selection steps, then wait for the person's response.",
161
+ inputSchema: {
162
+ type: "object",
163
+ additionalProperties: false,
164
+ properties: {
165
+ title: { type: "string", minLength: 1, maxLength: OPTIONS_CARD_LIMITS.title },
166
+ subtitle: { type: "string", minLength: 1, maxLength: OPTIONS_CARD_LIMITS.subtitle },
167
+ options: {
168
+ type: "array",
169
+ minItems: OPTIONS_CARD_LIMITS.minOptions,
170
+ maxItems: OPTIONS_CARD_LIMITS.maxOptions,
171
+ uniqueItems: true,
172
+ items: { type: "string", minLength: 1, maxLength: OPTIONS_CARD_LIMITS.option },
173
+ },
174
+ },
175
+ required: ["title", "subtitle", "options"],
176
+ },
177
+ },
157
178
  {
158
179
  name: "tool_result_read",
159
180
  description: "Read a missing portion of an oversized agents-tool result using the saved id and next offset from its notice. Returns at most 16,000 characters, only from this bot in this conversation. Use only when the preview is insufficient; do not load every page by default. Results expire after one hour, on app restart, or under cache pressure. This never reruns the original action.",
@@ -305,15 +326,42 @@ const toolDefinitions = (externalRuntime) => [
305
326
  required: ["title", "message"],
306
327
  },
307
328
  },
329
+ {
330
+ name: "vm_exec",
331
+ description: "Run a shell command inside your own Local VM (as the desktop user, starting in /home/cua/workspace) and get its exit code, stdout and stderr back as text. Use this for all command-line work in the VM: pip install --user, running a script, generating or converting a file, checking that a file exists. Do not type commands into a terminal window and read screenshots: that is slow and unreliable. GUI programs you start appear on the VM desktop. For a long job raise timeout_seconds (default 60, at most 300). Only available while you have a Local VM desktop.",
332
+ inputSchema: {
333
+ type: "object",
334
+ additionalProperties: false,
335
+ properties: {
336
+ command: { type: "string", description: "The shell command to run, for example: python3 make_report.py && ls -l report.pdf" },
337
+ timeout_seconds: { type: "integer", minimum: 1, maximum: 300, description: "How long it may run before it is stopped. Default 60." },
338
+ },
339
+ required: ["command"],
340
+ },
341
+ },
342
+ {
343
+ name: "attach_file",
344
+ description: "Attach a finished file to the chat so the user can preview and download it: an image, video, audio clip, PDF, spreadsheet, slide deck or other document you made. Pass its path: a path inside your computer's /home/cua/workspace (for example /home/cua/workspace/report.pdf), or a file in your working folder. Do this instead of pasting a VM path as a link; a path inside a VM cannot be opened from chat. Supported: images (png, jpg, gif, webp), video (mp4, webm, mov), audio (mp3, m4a, aac, wav, ogg, opus, flac), pdf, Word/Excel/PowerPoint and OpenDocument files, and csv, tsv, txt, md, json, rtf. Up to 25 MB (images 10 MB). Finish writing the file first, then call this directly: it reports an error if the file is missing, so you do not need to list or open the folder to check.",
345
+ inputSchema: {
346
+ type: "object",
347
+ additionalProperties: false,
348
+ properties: {
349
+ path: { type: "string", description: "The file's path, for example /home/cua/workspace/report.pdf." },
350
+ name: { type: "string", description: "Optional file name to show the user. Defaults to the file's own name." },
351
+ },
352
+ required: ["path"],
353
+ },
354
+ },
308
355
  {
309
356
  name: "post_to_room",
310
- description: "Put one message into a shared room you belong to, for example when the user asks you to tell the team something. Get group_id from list_rooms. This posts and returns: no room member's turn starts, nobody replies, and nothing comes back except confirmation — so never use it to ask a question or hand out work (use ask_bot or delegate_bot for those). Post once, say it in full, and tell the user what you posted. If a post is refused, do not retry it: say what you wanted to post in your reply instead.",
357
+ description: "Put one message into a shared room you belong to, for example when the user asks you to tell the team something. Get group_id from list_rooms. This posts and returns: no room member's turn starts, nobody replies, and nothing comes back except confirmation — so never use it to ask a question or hand out work (use ask_bot or delegate_bot for those). Post once, say it in full, and tell the user what you posted. Set attach_voice_note true to attach this turn's voice note. If a post is refused, do not retry it: say what you wanted to post in your reply instead.",
311
358
  inputSchema: {
312
359
  type: "object",
313
360
  additionalProperties: false,
314
361
  properties: {
315
362
  group_id: { type: "string", description: "The room's id, copied exactly from list_rooms." },
316
363
  message: { type: "string", description: "The complete message to post, written for the room to read as it stands." },
364
+ attach_voice_note: { type: "boolean", description: "True to attach this turn's send_voice_note recording; the message is its caption. Call send_voice_note first." },
317
365
  },
318
366
  required: ["group_id", "message"],
319
367
  },
@@ -331,6 +379,11 @@ const toolDefinitions = (externalRuntime) => [
331
379
  instanceId: { type: "string" }, model: { type: "string" },
332
380
  effort: { type: "string" }, variant: { type: "string" },
333
381
  }, required: ["instanceId", "model"] },
382
+ cwd: {
383
+ type: "string",
384
+ maxLength: 1024,
385
+ description: "Absolute path of the folder this specialist's tools read and write in (for example /Users/me/Projects/site). It must already exist. Leave it out for the specialist's private workspace.",
386
+ },
334
387
  },
335
388
  required: ["name", "role", "instructions"],
336
389
  },
@@ -358,9 +411,10 @@ const toolDefinitions = (externalRuntime) => [
358
411
  name: { type: "string", maxLength: 100 }, title: { type: "string", maxLength: 200 },
359
412
  chiefOfStaff: { type: "boolean", description: "Appoint or remove this team's Chief. At most one Chief per team: explicitly demote the current Chief in the same plan when replacing them. Does not grant access to other teams or change execution permissions." },
360
413
  description: { type: "string", maxLength: 4000 }, soul: { type: "string", description: "Standing instructions; required with name/title/modelSelection for every new bot." },
414
+ cwd: { type: "string", maxLength: 1024, description: "Create only: absolute path of the folder the new bot's tools read and write in. It must already exist. Leave it out for a private workspace." },
361
415
  section: { type: "string", maxLength: 60, description: "Exact authorized existing team, or a team explicitly named in newTeams. Empty string means General." },
362
416
  modelSelection: { type: "object", additionalProperties: false, properties: {
363
- instanceId: { type: "string" }, model: { type: "string" }, effort: { type: "string" },
417
+ instanceId: { type: "string" }, model: { type: "string" }, effort: { type: "string" }, variant: { type: "string" },
364
418
  }, required: ["instanceId", "model"] },
365
419
  } },
366
420
  }, required: ["action", "fields"],
@@ -583,7 +637,7 @@ const toolDefinitions = (externalRuntime) => [
583
637
  },
584
638
  {
585
639
  name: "propose_profile",
586
- description: "Submit user-requested changes to your own name, title, description, standing instructions (SOUL.md), or working folder (cwd). 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,
640
+ 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,
587
641
  inputSchema: {
588
642
  type: "object",
589
643
  additionalProperties: false,
@@ -597,6 +651,14 @@ const toolDefinitions = (externalRuntime) => [
597
651
  maxLength: 1024,
598
652
  description: "Absolute path of the folder your tools read and write in (for example /Users/me/Projects/site). It must already exist. An empty string means your private workspace.",
599
653
  },
654
+ notifications: {
655
+ type: "boolean",
656
+ description: "Completion and attention notifications for this bot on the host and paired clients.",
657
+ },
658
+ speakReplies: {
659
+ type: "boolean",
660
+ description: "Speak this bot's replies aloud as they settle, without being asked.",
661
+ },
600
662
  reason: { type: "string", minLength: 1, maxLength: 500, description: "One sentence the user will see explaining why." },
601
663
  for_bot_id: {
602
664
  type: "string",
@@ -606,6 +668,34 @@ const toolDefinitions = (externalRuntime) => [
606
668
  required: ["reason"],
607
669
  },
608
670
  },
671
+ {
672
+ name: "propose_model",
673
+ description: "Submit a user-requested switch of this bot's default engine and model. Use the exact instance and model ids the person named, or for a Chief the ids from the team-setup catalog. The card warns about capabilities the switch gains or loses; existing threads keep their current models." + PROPOSAL_OUTCOME,
674
+ inputSchema: {
675
+ type: "object",
676
+ additionalProperties: false,
677
+ properties: {
678
+ model_selection: {
679
+ type: "object",
680
+ additionalProperties: false,
681
+ properties: {
682
+ instanceId: { type: "string", minLength: 1, description: "Engine instance id, for example codex or claude." },
683
+ model: { type: "string", minLength: 1, description: "Exact model id on that instance." },
684
+ effort: { type: "string", description: "Optional effort level the instance offers; omit for the engine default." },
685
+ variant: { type: "string", description: "Optional explicit model variant; choose this or effort, not both." },
686
+ },
687
+ required: ["instanceId", "model"],
688
+ description: "The new default selection.",
689
+ },
690
+ reason: { type: "string", minLength: 1, maxLength: 500, description: "One sentence the user will see explaining why." },
691
+ for_bot_id: {
692
+ type: "string",
693
+ description: "Chief of Staff only: the id of another bot in your section whose default model this changes. Omit to change your own.",
694
+ },
695
+ },
696
+ required: ["model_selection", "reason"],
697
+ },
698
+ },
609
699
  {
610
700
  name: "skills_list",
611
701
  description: "List this bot's imported skills (enabled and disabled) and any staged skill writes waiting for the user to confirm. Use this before skill_manage to avoid duplicate names. Listing does not enable anything.",
@@ -662,12 +752,16 @@ const VOICE_TOOL_NAMES = new Set(["send_voice_note"]);
662
752
  const ROOM_ONLY_TOOLS = new Set(["list_room_targets", "coordinate_bots"]);
663
753
  const ROOM_REPLACED_TOOLS = new Set(["ask_bot", "delegate_bot", "check_delegation", "wait_delegation", "start_thread", "send_to_thread", "wait_thread"]);
664
754
  const EXTERNAL_TOOL_NAMES = new Set(["list_bots", "ask_bot", "delegate_bot", "check_delegation", "wait_delegation"]);
755
+ const WATCHER_TOOL_NAMES = new Set(["create_options_card"]);
665
756
  /** The tools one turn is shown, exactly as tools/list serializes them. */
666
757
  export function availableTools(profile) {
667
758
  const TOOLS = toolDefinitions(profile.externalRuntime);
668
- const AUTHORING_TOOLS = profile.skillAuthoring
759
+ const BOT_SCOPED_TOOLS = profile.botId === WATCHER_OPTIONS_CARD_BOT_ID
669
760
  ? TOOLS
670
- : TOOLS.filter((tool) => !SKILL_TOOL_NAMES.has(tool.name));
761
+ : TOOLS.filter((tool) => !WATCHER_TOOL_NAMES.has(tool.name));
762
+ const AUTHORING_TOOLS = profile.skillAuthoring
763
+ ? BOT_SCOPED_TOOLS
764
+ : BOT_SCOPED_TOOLS.filter((tool) => !SKILL_TOOL_NAMES.has(tool.name));
671
765
  const SHAREABLE_TOOLS = profile.sharedComputers
672
766
  ? AUTHORING_TOOLS
673
767
  : AUTHORING_TOOLS.filter((tool) => !SHARED_COMPUTER_TOOL_NAMES.has(tool.name));
@@ -675,7 +769,7 @@ export function availableTools(profile) {
675
769
  ? SHAREABLE_TOOLS
676
770
  : SHAREABLE_TOOLS.filter((tool) => !VOICE_TOOL_NAMES.has(tool.name));
677
771
  return profile.externalRuntime
678
- ? TOOLS.filter(tool => EXTERNAL_TOOL_NAMES.has(tool.name))
772
+ ? BOT_SCOPED_TOOLS.filter(tool => EXTERNAL_TOOL_NAMES.has(tool.name))
679
773
  : profile.coordinating
680
774
  ? VOICE_READY_TOOLS.filter(tool => !ROOM_REPLACED_TOOLS.has(tool.name) || (tool.name === "start_thread" && profile.ownThreadCreation))
681
775
  .map(tool => tool.name === "start_thread" ? {