@mono-agent/agent-app 0.6.2 → 0.8.0

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 (211) hide show
  1. package/README.md +210 -19
  2. package/dist/adapter-send-proxy.d.ts +18 -0
  3. package/dist/adapter-send-proxy.d.ts.map +1 -0
  4. package/dist/adapter-send-proxy.js +229 -0
  5. package/dist/adapter-send-proxy.js.map +1 -0
  6. package/dist/adapter-send-tools-main.js +62 -4
  7. package/dist/adapter-send-tools-main.js.map +1 -1
  8. package/dist/adapter-send-tools.d.ts +33 -6
  9. package/dist/adapter-send-tools.d.ts.map +1 -1
  10. package/dist/adapter-send-tools.js +485 -87
  11. package/dist/adapter-send-tools.js.map +1 -1
  12. package/dist/app-config.d.ts.map +1 -1
  13. package/dist/app-config.js +8 -0
  14. package/dist/app-config.js.map +1 -1
  15. package/dist/app.d.ts +2 -0
  16. package/dist/app.d.ts.map +1 -1
  17. package/dist/app.js +528 -135
  18. package/dist/app.js.map +1 -1
  19. package/dist/broadcast-recorder.d.ts.map +1 -1
  20. package/dist/broadcast-recorder.js +24 -2
  21. package/dist/broadcast-recorder.js.map +1 -1
  22. package/dist/channels.d.ts.map +1 -1
  23. package/dist/channels.js +16 -6
  24. package/dist/channels.js.map +1 -1
  25. package/dist/cli.d.ts +76 -4
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +2102 -102
  28. package/dist/cli.js.map +1 -1
  29. package/dist/config-reference.d.ts.map +1 -1
  30. package/dist/config-reference.js +195 -6
  31. package/dist/config-reference.js.map +1 -1
  32. package/dist/configuration-proposal-main.d.ts +3 -0
  33. package/dist/configuration-proposal-main.d.ts.map +1 -0
  34. package/dist/configuration-proposal-main.js +13 -0
  35. package/dist/configuration-proposal-main.js.map +1 -0
  36. package/dist/configuration-proposal-tool.d.ts +26 -0
  37. package/dist/configuration-proposal-tool.d.ts.map +1 -0
  38. package/dist/configuration-proposal-tool.js +139 -0
  39. package/dist/configuration-proposal-tool.js.map +1 -0
  40. package/dist/configured-agent.d.ts +12 -0
  41. package/dist/configured-agent.d.ts.map +1 -1
  42. package/dist/configured-agent.js +150 -18
  43. package/dist/configured-agent.js.map +1 -1
  44. package/dist/consumer-contract.d.ts +3 -0
  45. package/dist/consumer-contract.d.ts.map +1 -1
  46. package/dist/consumer-contract.js +44 -1
  47. package/dist/consumer-contract.js.map +1 -1
  48. package/dist/doctor.d.ts +27 -5
  49. package/dist/doctor.d.ts.map +1 -1
  50. package/dist/doctor.js +1197 -101
  51. package/dist/doctor.js.map +1 -1
  52. package/dist/first-run-managed-memory.d.ts +39 -0
  53. package/dist/first-run-managed-memory.d.ts.map +1 -0
  54. package/dist/first-run-managed-memory.js +521 -0
  55. package/dist/first-run-managed-memory.js.map +1 -0
  56. package/dist/first-run-readiness.d.ts +111 -0
  57. package/dist/first-run-readiness.d.ts.map +1 -0
  58. package/dist/first-run-readiness.js +757 -0
  59. package/dist/first-run-readiness.js.map +1 -0
  60. package/dist/index.d.ts +8 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +4 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/init.d.ts +88 -3
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +1212 -23
  67. package/dist/init.js.map +1 -1
  68. package/dist/interaction-bridge.d.ts +25 -1
  69. package/dist/interaction-bridge.d.ts.map +1 -1
  70. package/dist/interaction-bridge.js +281 -19
  71. package/dist/interaction-bridge.js.map +1 -1
  72. package/dist/local-configuration.d.ts +56 -0
  73. package/dist/local-configuration.d.ts.map +1 -0
  74. package/dist/local-configuration.js +914 -0
  75. package/dist/local-configuration.js.map +1 -0
  76. package/dist/memory-command.d.ts +4 -0
  77. package/dist/memory-command.d.ts.map +1 -1
  78. package/dist/memory-command.js +751 -27
  79. package/dist/memory-command.js.map +1 -1
  80. package/dist/memory-recall.d.ts +38 -10
  81. package/dist/memory-recall.d.ts.map +1 -1
  82. package/dist/memory-recall.js +82 -25
  83. package/dist/memory-recall.js.map +1 -1
  84. package/dist/memory-retrieval.d.ts +66 -0
  85. package/dist/memory-retrieval.d.ts.map +1 -0
  86. package/dist/memory-retrieval.js +332 -0
  87. package/dist/memory-retrieval.js.map +1 -0
  88. package/dist/modules/base.d.ts +6 -5
  89. package/dist/modules/base.d.ts.map +1 -1
  90. package/dist/modules/base.js +11 -7
  91. package/dist/modules/base.js.map +1 -1
  92. package/dist/modules/catalog.d.ts.map +1 -1
  93. package/dist/modules/catalog.js +54 -20
  94. package/dist/modules/catalog.js.map +1 -1
  95. package/dist/modules/index.d.ts +2 -2
  96. package/dist/modules/index.d.ts.map +1 -1
  97. package/dist/modules/index.js +1 -1
  98. package/dist/modules/index.js.map +1 -1
  99. package/dist/modules/known-tools.d.ts +3 -0
  100. package/dist/modules/known-tools.d.ts.map +1 -1
  101. package/dist/modules/known-tools.js +11 -1
  102. package/dist/modules/known-tools.js.map +1 -1
  103. package/dist/modules/types.d.ts +20 -6
  104. package/dist/modules/types.d.ts.map +1 -1
  105. package/dist/modules/types.js.map +1 -1
  106. package/dist/package-version.d.ts +3 -0
  107. package/dist/package-version.d.ts.map +1 -0
  108. package/dist/package-version.js +14 -0
  109. package/dist/package-version.js.map +1 -0
  110. package/dist/pi-auth-store-inspection.d.ts +21 -0
  111. package/dist/pi-auth-store-inspection.d.ts.map +1 -0
  112. package/dist/pi-auth-store-inspection.js +120 -0
  113. package/dist/pi-auth-store-inspection.js.map +1 -0
  114. package/dist/pi-oauth-login-main.d.ts +3 -0
  115. package/dist/pi-oauth-login-main.d.ts.map +1 -0
  116. package/dist/pi-oauth-login-main.js +14 -0
  117. package/dist/pi-oauth-login-main.js.map +1 -0
  118. package/dist/pi-oauth-login.d.ts +22 -0
  119. package/dist/pi-oauth-login.d.ts.map +1 -0
  120. package/dist/pi-oauth-login.js +79 -0
  121. package/dist/pi-oauth-login.js.map +1 -0
  122. package/dist/project-skills.d.ts +40 -0
  123. package/dist/project-skills.d.ts.map +1 -0
  124. package/dist/project-skills.js +573 -0
  125. package/dist/project-skills.js.map +1 -0
  126. package/dist/provider-setup.d.ts +117 -2
  127. package/dist/provider-setup.d.ts.map +1 -1
  128. package/dist/provider-setup.js +1505 -49
  129. package/dist/provider-setup.js.map +1 -1
  130. package/dist/readiness-probe-worker.d.ts +12 -0
  131. package/dist/readiness-probe-worker.d.ts.map +1 -0
  132. package/dist/readiness-probe-worker.js +276 -0
  133. package/dist/readiness-probe-worker.js.map +1 -0
  134. package/dist/readiness-probe.d.ts +100 -0
  135. package/dist/readiness-probe.d.ts.map +1 -0
  136. package/dist/readiness-probe.js +780 -0
  137. package/dist/readiness-probe.js.map +1 -0
  138. package/dist/request-model-override.d.ts +43 -1
  139. package/dist/request-model-override.d.ts.map +1 -1
  140. package/dist/request-model-override.js +178 -19
  141. package/dist/request-model-override.js.map +1 -1
  142. package/dist/run-history.d.ts +32 -0
  143. package/dist/run-history.d.ts.map +1 -0
  144. package/dist/run-history.js +859 -0
  145. package/dist/run-history.js.map +1 -0
  146. package/dist/runs-health.d.ts.map +1 -1
  147. package/dist/runs-health.js +12 -4
  148. package/dist/runs-health.js.map +1 -1
  149. package/dist/runtime-option-extensions.d.ts +14 -0
  150. package/dist/runtime-option-extensions.d.ts.map +1 -0
  151. package/dist/runtime-option-extensions.js +98 -0
  152. package/dist/runtime-option-extensions.js.map +1 -0
  153. package/dist/runtime-routes.d.ts +9 -0
  154. package/dist/runtime-routes.d.ts.map +1 -0
  155. package/dist/runtime-routes.js +13 -0
  156. package/dist/runtime-routes.js.map +1 -0
  157. package/dist/sandbox-manager.d.ts +69 -0
  158. package/dist/sandbox-manager.d.ts.map +1 -0
  159. package/dist/sandbox-manager.js +856 -0
  160. package/dist/sandbox-manager.js.map +1 -0
  161. package/dist/supermemory-plugin.d.ts +54 -0
  162. package/dist/supermemory-plugin.d.ts.map +1 -0
  163. package/dist/supermemory-plugin.js +136 -0
  164. package/dist/supermemory-plugin.js.map +1 -0
  165. package/dist/tui-command.d.ts +15 -0
  166. package/dist/tui-command.d.ts.map +1 -1
  167. package/dist/tui-command.js +46 -6
  168. package/dist/tui-command.js.map +1 -1
  169. package/dist/web-command.d.ts +8 -0
  170. package/dist/web-command.d.ts.map +1 -1
  171. package/dist/web-command.js +174 -17
  172. package/dist/web-command.js.map +1 -1
  173. package/dist/wizard/answers.d.ts +31 -4
  174. package/dist/wizard/answers.d.ts.map +1 -1
  175. package/dist/wizard/answers.js +145 -16
  176. package/dist/wizard/answers.js.map +1 -1
  177. package/dist/wizard/from-flags.d.ts +7 -0
  178. package/dist/wizard/from-flags.d.ts.map +1 -1
  179. package/dist/wizard/from-flags.js +36 -2
  180. package/dist/wizard/from-flags.js.map +1 -1
  181. package/dist/wizard/index.d.ts +4 -4
  182. package/dist/wizard/index.d.ts.map +1 -1
  183. package/dist/wizard/index.js +2 -2
  184. package/dist/wizard/index.js.map +1 -1
  185. package/dist/wizard/model-discovery.d.ts +33 -2
  186. package/dist/wizard/model-discovery.d.ts.map +1 -1
  187. package/dist/wizard/model-discovery.js +826 -108
  188. package/dist/wizard/model-discovery.js.map +1 -1
  189. package/dist/wizard/presets.d.ts +1 -1
  190. package/dist/wizard/presets.d.ts.map +1 -1
  191. package/dist/wizard/presets.js +7 -12
  192. package/dist/wizard/presets.js.map +1 -1
  193. package/dist/wizard/prompts.d.ts +56 -9
  194. package/dist/wizard/prompts.d.ts.map +1 -1
  195. package/dist/wizard/prompts.js +172 -20
  196. package/dist/wizard/prompts.js.map +1 -1
  197. package/dist/wizard/run.d.ts +59 -3
  198. package/dist/wizard/run.d.ts.map +1 -1
  199. package/dist/wizard/run.js +1133 -191
  200. package/dist/wizard/run.js.map +1 -1
  201. package/package.json +21 -16
  202. package/resources/srt/package-lock.json +66 -0
  203. package/resources/srt/package.json +9 -0
  204. package/schema/mono-agent.config.schema.json +381 -17
  205. package/skills/mono-agent-composer/SKILL.md +4 -4
  206. package/skills/mono-agent-composer/references/config-blueprint.md +5 -4
  207. package/skills/mono-agent-composer/references/discovery-questions.md +8 -8
  208. package/skills/mono-agent-composer/references/feature-coverage.md +8 -6
  209. package/skills/mono-agent-composer/references/package-map.md +1 -1
  210. package/skills/mono-agent-composer/references/playbooks.md +8 -7
  211. package/skills/mono-agent-composer/references/validation.md +1 -1
@@ -1,5 +1,6 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { basename, resolve as resolvePath } from "node:path";
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { lstat, mkdir, open, readFile, realpath, rm, stat } from "node:fs/promises";
3
+ import { basename, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
3
4
  import process from "node:process";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
5
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -13,6 +14,8 @@ let telegramModule;
13
14
  const loadSlackModule = async () => (slackModule ??= await import("@mono-agent/slack-adapter"));
14
15
  const loadTelegramModule = async () => (telegramModule ??= await import("@mono-agent/telegram-adapter"));
15
16
  const SLACK_SEND_MESSAGE_MAX_CHARS = 40_000;
17
+ /** Keep cancellation responsive even when the loopback bridge is wedged. */
18
+ const ASK_BRIDGE_CLEANUP_TIMEOUT_MS = 1_000;
16
19
  /**
17
20
  * Model-visible send tools for explicitly allowed, already-enabled communication adapters.
18
21
  *
@@ -26,10 +29,13 @@ const SLACK_SEND_MESSAGE_MAX_CHARS = 40_000;
26
29
  export const ADAPTER_SEND_TOOLS_MCP_SERVER_NAME = "mono-agent-adapter-send";
27
30
  export async function resolveAdapterSendToolsSettings(input, options = {}) {
28
31
  const telegramSendAllowed = isAdapterToolAllowed("TelegramSendMessage", options);
29
- const telegramAskAllowed = isAdapterToolAllowed("TelegramAskButtons", options);
32
+ const telegramAskAllowed = options.suppressInteractionTools !== true
33
+ && isAdapterToolAllowed("TelegramAskButtons", options);
30
34
  const telegramFileAllowed = isAdapterToolAllowed("TelegramSendFile", options);
31
35
  const telegramAnyAllowed = telegramSendAllowed || telegramAskAllowed || telegramFileAllowed;
32
- const telegramAskBridge = telegramAskAllowed ? resolveAskUserToolSettings(input.env) : undefined;
36
+ const telegramAskBridge = telegramAskAllowed
37
+ ? resolveAskUserToolSettings(input.env, options.interaction)
38
+ : undefined;
33
39
  const [slack, telegram] = await Promise.all([
34
40
  isAdapterToolAllowed("SlackSendMessage", options)
35
41
  ? resolveSlackSendToolSettings(input, options)
@@ -42,8 +48,8 @@ export async function resolveAdapterSendToolsSettings(input, options = {}) {
42
48
  }, telegramAskBridge)
43
49
  : undefined,
44
50
  ]);
45
- const askUser = isAdapterToolAllowed("AskUser", options)
46
- ? resolveAskUserToolSettings(input.env)
51
+ const askUser = options.suppressInteractionTools !== true && isAdapterToolAllowed("AskUser", options)
52
+ ? resolveAskUserToolSettings(input.env, options.interaction)
47
53
  : undefined;
48
54
  if (slack === undefined && telegram === undefined && askUser === undefined) {
49
55
  return undefined;
@@ -59,20 +65,23 @@ export async function resolveAdapterSendToolsSettings(input, options = {}) {
59
65
  * into the environment (URL + bearer token). The producing conversation id is
60
66
  * per-request env, present in the spawned child.
61
67
  */
62
- function resolveAskUserToolSettings(env) {
63
- const bridgeUrl = optionalString(env.MONO_AGENT_INTERACTION_BRIDGE_URL);
64
- const bridgeToken = optionalString(env.MONO_AGENT_INTERACTION_BRIDGE_TOKEN);
68
+ function resolveAskUserToolSettings(env, interaction) {
69
+ const bridgeUrl = interaction?.bridgeUrl ?? optionalString(env.MONO_AGENT_INTERACTION_BRIDGE_URL);
70
+ const bridgeToken = interaction?.bridgeToken ?? optionalString(env.MONO_AGENT_INTERACTION_BRIDGE_TOKEN);
65
71
  if (bridgeUrl === undefined || bridgeToken === undefined) {
66
72
  return undefined;
67
73
  }
68
74
  const timeoutRaw = Number(optionalString(env.MONO_AGENT_ASK_USER_TIMEOUT_MS));
69
- const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw >= 1000 ? timeoutRaw : 600_000;
75
+ const timeoutMs = interaction?.timeoutMs
76
+ ?? (Number.isFinite(timeoutRaw) && timeoutRaw >= 1000 ? timeoutRaw : 600_000);
70
77
  const conversationId = optionalString(env.MONO_AGENT_ADAPTER_TOOLS_PRODUCING_CONVERSATION_ID);
78
+ const runId = optionalString(env.MONO_AGENT_ADAPTER_TOOLS_PRODUCING_RUN_ID);
71
79
  return {
72
80
  bridgeUrl,
73
81
  bridgeToken,
74
82
  timeoutMs,
75
83
  ...(conversationId === undefined ? {} : { conversationId }),
84
+ ...(runId === undefined ? {} : { runId }),
76
85
  };
77
86
  }
78
87
  export function adapterSendToolNames(settings) {
@@ -109,9 +118,21 @@ export function adapterSendToolsMcpEnv(configPath, allowedTools, context, intera
109
118
  ...(context?.conversationId === undefined
110
119
  ? {}
111
120
  : { MONO_AGENT_ADAPTER_TOOLS_PRODUCING_CONVERSATION_ID: context.conversationId }),
121
+ ...(context?.runId === undefined
122
+ ? {}
123
+ : { MONO_AGENT_ADAPTER_TOOLS_PRODUCING_RUN_ID: context.runId }),
112
124
  ...(context?.indexPath === undefined
113
125
  ? {}
114
126
  : { MONO_AGENT_ADAPTER_TOOLS_POST_INDEX_PATH: context.indexPath }),
127
+ ...(context?.runOutputDir === undefined
128
+ ? {}
129
+ : { MONO_AGENT_ADAPTER_TOOLS_RUN_OUTPUT_DIR: context.runOutputDir }),
130
+ ...(context?.runOutputIdentity === undefined
131
+ ? {}
132
+ : {
133
+ MONO_AGENT_ADAPTER_TOOLS_RUN_OUTPUT_DEV: String(context.runOutputIdentity.dev),
134
+ MONO_AGENT_ADAPTER_TOOLS_RUN_OUTPUT_INO: String(context.runOutputIdentity.ino),
135
+ }),
115
136
  ...(interaction === undefined
116
137
  ? {}
117
138
  : {
@@ -137,13 +158,26 @@ export function adapterSendToolsChildConfigFromEnv(env, cwd) {
137
158
  };
138
159
  }
139
160
  export function adapterSendToolsMcpServerSpec(configPath, cwd, allowedTools, context, interaction) {
140
- return {
161
+ const spec = {
141
162
  type: "stdio",
142
163
  command: process.execPath,
143
- args: [fileURLToPath(new URL("./adapter-send-tools-main.js", import.meta.url))],
164
+ // Node 24 on macOS defaults to the system trust store, whose trustd access
165
+ // is intentionally unavailable inside strict SRT. Force Node's bundled CA
166
+ // set for this child while still honoring NODE_EXTRA_CA_CERTS when SRT TLS
167
+ // termination or an operator supplies an additional root.
168
+ args: ["--use-bundled-ca", fileURLToPath(new URL("./adapter-send-tools-main.js", import.meta.url))],
144
169
  cwd,
145
170
  env: adapterSendToolsMcpEnv(configPath, allowedTools, context, interaction),
146
171
  };
172
+ // Only the trusted app-owned adapter child receives SRT's coarse loopback
173
+ // capability. A symbol cannot be supplied by JSON MCP config and disappears
174
+ // from serialized provider/tool metadata, so ordinary Bash/project MCP
175
+ // processes keep the stricter no-bind allowlist policy.
176
+ Object.defineProperty(spec, Symbol.for("@mono-agent/app-owned-local-binding"), {
177
+ value: true,
178
+ enumerable: false,
179
+ });
180
+ return spec;
147
181
  }
148
182
  /**
149
183
  * Per-request runtime extension that injects the adapter-send stdio MCP server. It
@@ -151,14 +185,26 @@ export function adapterSendToolsMcpServerSpec(configPath, cwd, allowedTools, con
151
185
  * configured, forwards both to the child so a `SlackSendMessage` post is linked
152
186
  * back to this conversation (so a later in-thread reply resumes it).
153
187
  */
154
- export function createAdapterSendToolsRuntimeExtension(configPath, cwd, allowedTools, indexPath, interaction) {
188
+ export function createAdapterSendToolsRuntimeExtension(configPath, cwd, allowedTools, indexPath, interaction, runOutputRoot) {
155
189
  return async (input) => {
156
190
  const conversationId = input?.request?.conversationId;
191
+ const runId = input?.runId;
157
192
  const hasConversation = typeof conversationId === "string" && conversationId.trim().length > 0;
193
+ const hasRunId = typeof runId === "string" && runId.trim().length > 0;
194
+ const runOutput = runOutputRoot === undefined || !hasRunId
195
+ ? undefined
196
+ : await ensureAdapterRunOutputDir(runOutputRoot, runId);
158
197
  // The conversation id is forwarded whenever known — AskUser targets it even
159
198
  // without indexing; the index path additionally enables posted-message links.
160
- const context = hasConversation
161
- ? { conversationId, ...(indexPath === undefined ? {} : { indexPath }) }
199
+ const context = hasConversation || hasRunId
200
+ ? {
201
+ ...(hasConversation ? { conversationId } : {}),
202
+ ...(hasRunId ? { runId } : {}),
203
+ ...(indexPath === undefined ? {} : { indexPath }),
204
+ ...(runOutput === undefined
205
+ ? {}
206
+ : { runOutputDir: runOutput.path, runOutputIdentity: runOutput.identity }),
207
+ }
162
208
  : undefined;
163
209
  return {
164
210
  runtimeOptions: {
@@ -167,27 +213,37 @@ export function createAdapterSendToolsRuntimeExtension(configPath, cwd, allowedT
167
213
  },
168
214
  },
169
215
  cleanup: async () => { },
216
+ settleCleanup: async () => {
217
+ if (runOutput !== undefined) {
218
+ await removeOwnedDirectory(runOutput.path, runOutput.identity);
219
+ }
220
+ },
170
221
  };
171
222
  };
172
223
  }
173
- export async function createAdapterSendToolsClients(settings) {
224
+ export async function createAdapterSendToolsClients(settings, options = {}) {
174
225
  const slack = settings.slack === undefined
175
226
  ? undefined
176
- : new (await loadSlackModule()).SlackWebApiClient({ botToken: settings.slack.botToken });
227
+ : new (await loadSlackModule()).SlackWebApiClient({
228
+ botToken: settings.slack.botToken,
229
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
230
+ });
177
231
  const telegram = settings.telegram === undefined
178
232
  ? undefined
179
233
  : (await loadTelegramModule()).createTelegramMessageSender(settings.telegram.botToken, {
180
234
  ...(settings.telegram.apiRoot === undefined ? {} : { apiRoot: settings.telegram.apiRoot }),
235
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
181
236
  });
182
237
  return {
183
238
  ...(slack === undefined ? {} : { slack }),
184
239
  ...(telegram === undefined ? {} : { telegram }),
185
240
  };
186
241
  }
187
- export async function createAdapterSendToolsServer(settings, clients, indexing) {
242
+ export async function createAdapterSendToolsServer(settings, clients, indexing, options = {}) {
188
243
  const server = new McpServer({ name: "agent-adapter-send-tools", version: "0.3.0" });
189
244
  if (settings.slack !== undefined && clients.slack !== undefined) {
190
- registerSlackSendTool(server, settings.slack, clients.slack, indexing);
245
+ const adapter = await loadSlackModule();
246
+ registerSlackSendTool(server, settings.slack, clients.slack, adapter.formatMarkdownForSlack, indexing);
191
247
  }
192
248
  if (settings.telegram !== undefined && clients.telegram !== undefined) {
193
249
  // Loaded once here (not per-register-call) because registerTelegramAskTool
@@ -198,7 +254,7 @@ export async function createAdapterSendToolsServer(settings, clients, indexing)
198
254
  registerTelegramSendTool(server, settings.telegram, clients.telegram);
199
255
  }
200
256
  if (settings.telegram.tools.ask) {
201
- registerTelegramAskTool(server, settings.telegram, clients.telegram, adapter);
257
+ registerTelegramAskTool(server, settings.telegram, clients.telegram, adapter, options.fetchImpl ?? globalThis.fetch);
202
258
  }
203
259
  if (settings.telegram.tools.file) {
204
260
  registerTelegramSendFileTool(server, settings.telegram, clients.telegram, adapter);
@@ -207,13 +263,13 @@ export async function createAdapterSendToolsServer(settings, clients, indexing)
207
263
  // AskUser needs a target conversation; the parent app process resolves the
208
264
  // settings without one (for tool-name gating) and must not register the tool.
209
265
  if (settings.askUser?.conversationId !== undefined) {
210
- registerAskUserTool(server, { ...settings.askUser, conversationId: settings.askUser.conversationId });
266
+ registerAskUserTool(server, { ...settings.askUser, conversationId: settings.askUser.conversationId }, options.fetchImpl ?? globalThis.fetch);
211
267
  }
212
268
  return server;
213
269
  }
214
270
  /** Long-poll wait per bridge request; the overall wait is bounded server-side by the ask's timeout. */
215
271
  const ASK_USER_POLL_WAIT_MS = 20_000;
216
- function registerAskUserTool(server, settings) {
272
+ function registerAskUserTool(server, settings, fetchImpl) {
217
273
  server.registerTool("AskUser", {
218
274
  title: "Ask the user and wait",
219
275
  description: "Ask the user ONE consolidated free-text question on the current conversation and WAIT for their reply (blocking, up to the configured timeout — default 10 minutes). Returns the user's answer text. Consolidate everything you need into a single question — a second concurrent ask fails. If the wait times out, the tool returns without an answer and the user's late reply will arrive as their next message: finish the turn gracefully (proceed with sensible defaults and say what you assumed).",
@@ -221,11 +277,14 @@ function registerAskUserTool(server, settings) {
221
277
  question: z.string().min(1).describe("The full question to show the user (plain text, no markdown)."),
222
278
  },
223
279
  }, async (args, extra) => {
224
- const created = await askBridgeRequest(settings, "POST", "/v1/asks", {
280
+ const created = await askBridgeRequest(settings, fetchImpl, "POST", "/v1/asks", {
225
281
  conversationId: settings.conversationId,
282
+ producerConversationId: settings.conversationId,
283
+ runId: settings.runId,
284
+ toolName: "AskUser",
226
285
  question: args.question,
227
286
  timeoutMs: settings.timeoutMs,
228
- });
287
+ }, extra.signal);
229
288
  if (created.status === 409) {
230
289
  return askToolResult("A question is already pending for the user. Wait for its answer instead of asking again.", { answered: false, reason: "already_pending" });
231
290
  }
@@ -235,7 +294,7 @@ function registerAskUserTool(server, settings) {
235
294
  if (created.status !== 201 || typeof created.body.askId !== "string") {
236
295
  throw new Error(`AskUser: the interaction bridge rejected the ask (HTTP ${String(created.status)}).`);
237
296
  }
238
- return await awaitBridgeAsk(settings, created.body.askId, "AskUser", extra);
297
+ return await awaitBridgeAsk(settings, created.body.askId, "AskUser", extra, fetchImpl, extra.signal);
239
298
  });
240
299
  }
241
300
  function askToolResult(text, structured, extraStructured = {}) {
@@ -244,42 +303,74 @@ function askToolResult(text, structured, extraStructured = {}) {
244
303
  structuredContent: { ok: true, ...extraStructured, ...structured },
245
304
  };
246
305
  }
247
- async function awaitBridgeAsk(settings, askId, toolName, extra, extraStructured = {}) {
306
+ async function awaitBridgeAsk(settings, askId, toolName, extra, fetchImpl, signal, extraStructured = {}) {
248
307
  const startedMs = Date.now();
249
- for (;;) {
250
- const poll = await askBridgeRequest(settings, "GET", `/v1/asks/${encodeURIComponent(askId)}?waitMs=${String(ASK_USER_POLL_WAIT_MS)}`);
251
- if (poll.status !== 200) {
252
- throw new Error(`${toolName}: lost the pending ask (HTTP ${String(poll.status)}).`);
253
- }
254
- // Keep-alive: progress notifications reset the runtime's MCP inactivity
255
- // timeout so a long human wait cannot kill the tool call.
256
- await sendAskProgress(extra, Math.round((Date.now() - startedMs) / 1000));
257
- const status = poll.body.status;
258
- if (status === "answered" && typeof poll.body.answer === "string") {
259
- const answeredText = toolName === "TelegramAskButtons"
260
- ? `The user tapped:\n${poll.body.answer}`
261
- : `The user answered:\n${poll.body.answer}`;
262
- return askToolResult(answeredText, { answered: true, answer: poll.body.answer }, extraStructured);
263
- }
264
- if (status === "expired") {
265
- return askToolResult("The user did not answer within the wait window. Their reply will arrive as their next message — wrap up this turn gracefully (proceed with sensible defaults and say what you assumed).", { answered: false, reason: "timeout" }, extraStructured);
266
- }
267
- if (status === "cancelled") {
268
- return askToolResult("The user cancelled the current run. Stop this task.", {
269
- answered: false,
270
- reason: "cancelled",
271
- }, extraStructured);
308
+ try {
309
+ for (;;) {
310
+ const poll = await askBridgeRequest(settings, fetchImpl, "GET", `/v1/asks/${encodeURIComponent(askId)}?waitMs=${String(ASK_USER_POLL_WAIT_MS)}`, undefined, signal);
311
+ if (poll.status !== 200) {
312
+ throw new Error(`${toolName}: lost the pending ask (HTTP ${String(poll.status)}).`);
313
+ }
314
+ // Keep-alive: progress notifications reset the runtime's MCP inactivity
315
+ // timeout so a long human wait cannot kill the tool call.
316
+ await sendAskProgress(extra, Math.round((Date.now() - startedMs) / 1000));
317
+ const status = poll.body.status;
318
+ if (status === "answered" && typeof poll.body.answer === "string") {
319
+ const answeredText = toolName === "TelegramAskButtons"
320
+ ? `The user tapped:\n${poll.body.answer}`
321
+ : `The user answered:\n${poll.body.answer}`;
322
+ return askToolResult(answeredText, { answered: true, answer: poll.body.answer }, extraStructured);
323
+ }
324
+ if (status === "expired") {
325
+ return askToolResult("The user did not answer within the wait window. Their reply will arrive as their next message — wrap up this turn gracefully (proceed with sensible defaults and say what you assumed).", { answered: false, reason: "timeout" }, extraStructured);
326
+ }
327
+ if (status === "cancelled") {
328
+ return askToolResult("The user cancelled the current run. Stop this task.", {
329
+ answered: false,
330
+ reason: "cancelled",
331
+ }, extraStructured);
332
+ }
272
333
  }
273
334
  }
335
+ catch (error) {
336
+ // A cancelled MCP call must not leave a bridge ask pending until its full
337
+ // human timeout. Cleanup gets its own bounded signal (not the aborted call
338
+ // signal), and its failure must never replace the primary tool failure.
339
+ await cleanupBridgeAskBestEffort(settings, fetchImpl, askId);
340
+ throw error;
341
+ }
274
342
  }
275
- async function askBridgeRequest(settings, method, path, body) {
276
- const response = await fetch(new URL(path, settings.bridgeUrl), {
343
+ async function cleanupBridgeAskBestEffort(settings, fetchImpl, askId) {
344
+ const controller = new AbortController();
345
+ let deadlineTimer;
346
+ const deadline = new Promise((resolve) => {
347
+ deadlineTimer = setTimeout(() => {
348
+ controller.abort(new Error("interaction bridge ask cleanup timed out"));
349
+ resolve();
350
+ }, ASK_BRIDGE_CLEANUP_TIMEOUT_MS);
351
+ deadlineTimer.unref?.();
352
+ });
353
+ // Attach the rejection handler before racing. If a test seam or nonstandard
354
+ // fetch ignores abort and rejects later, it cannot become an unhandled error.
355
+ const cleanup = askBridgeRequest(settings, fetchImpl, "DELETE", `/v1/asks/${encodeURIComponent(askId)}`, undefined, controller.signal).then(() => undefined, () => undefined);
356
+ try {
357
+ await Promise.race([cleanup, deadline]);
358
+ }
359
+ finally {
360
+ if (deadlineTimer !== undefined) {
361
+ clearTimeout(deadlineTimer);
362
+ }
363
+ }
364
+ }
365
+ async function askBridgeRequest(settings, fetchImpl, method, path, body, signal) {
366
+ const response = await fetchImpl(new URL(path, settings.bridgeUrl), {
277
367
  method,
278
368
  headers: {
279
369
  authorization: `Bearer ${settings.bridgeToken}`,
280
370
  ...(body === undefined ? {} : { "content-type": "application/json" }),
281
371
  },
282
372
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
373
+ ...(signal === undefined ? {} : { signal }),
283
374
  });
284
375
  let parsed;
285
376
  try {
@@ -313,31 +404,33 @@ async function sendAskProgress(extra, elapsedSeconds) {
313
404
  // Keep-alive only; a lost notification must never fail the ask.
314
405
  }
315
406
  }
316
- function registerSlackSendTool(server, settings, client, indexing) {
407
+ function registerSlackSendTool(server, settings, client, formatMarkdownForSlack, indexing) {
317
408
  server.registerTool("SlackSendMessage", {
318
409
  title: "Send Slack message",
319
410
  description: "Send a message to an allowed Slack channel or DM ID using the configured Slack adapter bot token.",
320
411
  inputSchema: {
321
412
  channel: z.string().min(1).describe("Slack channel or DM ID, e.g. C123456 or D123456."),
322
- text: z.string().min(1).describe("Message text to send."),
413
+ text: z.string().min(1).describe("Message text to send. Defaults to standard Markdown converted to Slack mrkdwn."),
323
414
  thread_ts: z.string().min(1).optional().describe("Optional Slack thread timestamp to reply in."),
324
- mrkdwn: z.boolean().optional().describe("Whether Slack mrkdwn formatting is enabled."),
415
+ mrkdwn: z.boolean().optional().describe("Whether Slack mrkdwn formatting is enabled. Defaults to true; set false to send plain text unchanged."),
325
416
  unfurl_links: z.boolean().optional().describe("Whether Slack should unfurl links."),
326
417
  unfurl_media: z.boolean().optional().describe("Whether Slack should unfurl media."),
327
418
  },
328
- }, async (args) => {
419
+ }, async (args, extra) => {
329
420
  assertSlackChannelAllowed(settings, args.channel);
330
- const chunks = splitTextByCodePoints(args.text, SLACK_SEND_MESSAGE_MAX_CHARS);
421
+ const mrkdwn = args.mrkdwn ?? true;
422
+ const text = mrkdwn ? formatMarkdownForSlack(args.text) : args.text;
423
+ const chunks = splitTextByCodePoints(text, SLACK_SEND_MESSAGE_MAX_CHARS);
331
424
  const results = [];
332
425
  for (const chunk of chunks) {
333
426
  const result = await client.chatPostMessage({
334
427
  channel: args.channel.trim(),
335
428
  text: chunk,
336
429
  ...(args.thread_ts === undefined ? {} : { thread_ts: args.thread_ts }),
337
- ...(args.mrkdwn === undefined ? {} : { mrkdwn: args.mrkdwn }),
430
+ mrkdwn,
338
431
  ...(args.unfurl_links === undefined ? {} : { unfurl_links: args.unfurl_links }),
339
432
  ...(args.unfurl_media === undefined ? {} : { unfurl_media: args.unfurl_media }),
340
- });
433
+ }, { signal: extra.signal });
341
434
  results.push(result);
342
435
  // Link every posted chunk back to this conversation so an in-thread reply can
343
436
  // resume it. Best-effort: appendPostedMessage never throws, so a failed index
@@ -383,7 +476,7 @@ function registerTelegramSendTool(server, settings, client) {
383
476
  reply_to_message_id: z.number().int().optional().describe("Optional message id to reply to."),
384
477
  disable_web_page_preview: z.boolean().optional().describe("Disable Telegram link previews."),
385
478
  },
386
- }, async (args) => {
479
+ }, async (args, extra) => {
387
480
  assertTelegramChatAllowed(settings, args.chat_id);
388
481
  const result = await client.sendMessage({
389
482
  chat_id: args.chat_id,
@@ -391,17 +484,89 @@ function registerTelegramSendTool(server, settings, client) {
391
484
  ...(args.parse_mode === undefined ? {} : { parse_mode: args.parse_mode }),
392
485
  ...(args.reply_to_message_id === undefined ? {} : { reply_to_message_id: args.reply_to_message_id }),
393
486
  ...(args.disable_web_page_preview === undefined ? {} : { disable_web_page_preview: args.disable_web_page_preview }),
394
- });
487
+ }, { signal: extra.signal });
395
488
  return {
396
489
  content: [{ type: "text", text: `Sent Telegram message ${result.message_id} to ${String(result.chat.id)}.` }],
397
490
  structuredContent: { ok: true, chat_id: result.chat.id, message_id: result.message_id },
398
491
  };
399
492
  });
400
493
  }
401
- function registerTelegramAskTool(server, settings, client, adapter) {
494
+ /** Marker appended (buttons kept) when a Telegram ask times out. */
495
+ const TELEGRAM_ASK_EXPIRED_MARKER = "⌛ Expired — tapping a button still reaches me and starts a new turn.";
496
+ /** Marker appended (buttons kept) when a Telegram ask is cancelled. */
497
+ const TELEGRAM_ASK_CANCELLED_MARKER = "🚫 Cancelled — tapping a button still reaches me and starts a new turn.";
498
+ /** Bounded own-signal budget for the best-effort expiry edit — never the aborted call signal. */
499
+ const TELEGRAM_ASK_EXPIRY_EDIT_TIMEOUT_MS = 2_000;
500
+ /** A Telegram 400 is the parse-entity rejection we fall back from (raw text, no parse_mode). */
501
+ function isTelegramParseError(error, adapter) {
502
+ return error instanceof adapter.TelegramApiError && error.errorCode === 400;
503
+ }
504
+ /**
505
+ * Send the ask keyboard rendered as MarkdownV2, retrying ONCE with the raw source and
506
+ * no parse_mode when Telegram rejects the entities with a 400 parse error. Any other
507
+ * failure propagates so the caller can clean up a pending bridge ask.
508
+ */
509
+ async function sendTelegramAskKeyboard(client, adapter, params) {
510
+ try {
511
+ return await client.sendMessage({
512
+ chat_id: params.chat_id,
513
+ text: params.rendered,
514
+ parse_mode: "MarkdownV2",
515
+ reply_markup: params.replyMarkup,
516
+ }, { signal: params.signal });
517
+ }
518
+ catch (error) {
519
+ if (!isTelegramParseError(error, adapter)) {
520
+ throw error;
521
+ }
522
+ return await client.sendMessage({
523
+ chat_id: params.chat_id,
524
+ text: params.rawSource,
525
+ reply_markup: params.replyMarkup,
526
+ }, { signal: params.signal });
527
+ }
528
+ }
529
+ /**
530
+ * Best-effort: append an expiry/cancel note to the buttons message while KEEPING the
531
+ * inline keyboard (Telegram strips the keyboard on `editMessageText` unless reply_markup
532
+ * is re-sent). Renders through the same MarkdownV2-with-plain-fallback path. Uses its own
533
+ * bounded signal — never the aborted call signal — and swallows every failure so a failed
534
+ * edit can never replace the primary ask result.
535
+ */
536
+ async function editExpiredTelegramAskMessage(client, adapter, params) {
537
+ try {
538
+ const source = `${params.rawSource}\n\n${params.marker}`;
539
+ const rendered = adapter.renderTelegramMarkdown(source);
540
+ const signal = AbortSignal.timeout(TELEGRAM_ASK_EXPIRY_EDIT_TIMEOUT_MS);
541
+ try {
542
+ await client.editMessageText({
543
+ chat_id: params.chat_id,
544
+ message_id: params.message_id,
545
+ text: rendered,
546
+ parse_mode: "MarkdownV2",
547
+ reply_markup: params.replyMarkup,
548
+ }, { signal });
549
+ }
550
+ catch (error) {
551
+ if (!isTelegramParseError(error, adapter)) {
552
+ throw error;
553
+ }
554
+ await client.editMessageText({
555
+ chat_id: params.chat_id,
556
+ message_id: params.message_id,
557
+ text: source,
558
+ reply_markup: params.replyMarkup,
559
+ }, { signal });
560
+ }
561
+ }
562
+ catch {
563
+ // Best-effort: a failed expiry edit must never replace the ask result.
564
+ }
565
+ }
566
+ function registerTelegramAskTool(server, settings, client, adapter, fetchImpl) {
402
567
  server.registerTool("TelegramAskButtons", {
403
568
  title: "Ask via Telegram buttons",
404
- description: "Ask the owner a question in an allowed Telegram chat with tappable inline-keyboard buttons (e.g. a confirmation or a multiple-choice) and WAIT for their tap. Returns the tapped label. A second concurrent ask in the same chat fails.",
569
+ description: "Ask the owner a question in an allowed Telegram chat with tappable inline-keyboard buttons (e.g. a confirmation or a multiple-choice). By default WAITS for their tap (up to the ask timeout) and returns the tapped label; a second concurrent ask in the same chat fails. On a proactive or scheduled turn (cron/heartbeat) set `wait:false`: the buttons are sent and the tool returns immediately, and the tap arrives later as a new message quoting the question.",
405
570
  inputSchema: {
406
571
  chat_id: z.union([z.string().min(1), z.number().int()]).describe("Telegram chat id from the adapter allowlist."),
407
572
  question: z.string().min(1).describe("The question to show above the buttons."),
@@ -411,9 +576,34 @@ function registerTelegramAskTool(server, settings, client, adapter) {
411
576
  .max(adapter.TELEGRAM_ASK_MAX_OPTIONS)
412
577
  .describe("Button labels (2–8). The label the user taps is echoed back as a new message."),
413
578
  note: z.string().min(1).optional().describe("Optional extra context shown beneath the question."),
579
+ wait: z
580
+ .boolean()
581
+ .optional()
582
+ .describe("Default true: block until the user taps (up to the ask timeout). Set false on proactive/scheduled (cron/heartbeat) turns — send the buttons and return immediately; the tapped answer arrives later as a new message quoting the question."),
414
583
  },
415
584
  }, async (args, extra) => {
416
585
  assertTelegramChatAllowed(settings, args.chat_id);
586
+ const inlineKeyboard = args.options.map((label, index) => [
587
+ { text: label, callback_data: adapter.telegramAskCallbackData(index) },
588
+ ]);
589
+ const replyMarkup = { inline_keyboard: inlineKeyboard };
590
+ // Raw source is what the bridge records and what the plain-text fallback (and the
591
+ // expiry edit) re-send; the rendered form is only for the MarkdownV2 send.
592
+ const source = args.note === undefined ? args.question : `${args.question}\n\n${args.note}`;
593
+ const rendered = adapter.renderTelegramMarkdown(source);
594
+ // Non-blocking mode (proactive/scheduled turns): send the buttons and return.
595
+ // No bridge is consulted — this must work even with no bridge configured.
596
+ if (args.wait === false) {
597
+ const sent = await sendTelegramAskKeyboard(client, adapter, {
598
+ chat_id: args.chat_id,
599
+ rendered,
600
+ rawSource: source,
601
+ replyMarkup,
602
+ signal: extra.signal,
603
+ });
604
+ return askToolResult(`Sent the question with ${String(args.options.length)} buttons to ${String(sent.chat.id)} (message ${String(sent.message_id)}). Not waiting — the tapped answer will arrive as a new message.`, { answered: false, reason: "not_waiting" }, { chat_id: sent.chat.id, message_id: sent.message_id, options: args.options });
605
+ }
606
+ // Blocking mode requires the interaction bridge to correlate the tap.
417
607
  const bridge = settings.askBridge;
418
608
  if (bridge === undefined) {
419
609
  return askToolResult("TelegramAskButtons requires a live interaction bridge, but none is configured.", {
@@ -421,18 +611,18 @@ function registerTelegramAskTool(server, settings, client, adapter) {
421
611
  reason: "unsupported_channel",
422
612
  });
423
613
  }
424
- const inlineKeyboard = args.options.map((label, index) => [
425
- { text: label, callback_data: adapter.telegramAskCallbackData(index) },
426
- ]);
427
- const text = args.note === undefined ? args.question : `${args.question}\n\n${args.note}`;
428
614
  const conversationId = `telegram:${String(args.chat_id)}`;
429
- const created = await askBridgeRequest(bridge, "POST", "/v1/asks", {
615
+ const created = await askBridgeRequest(bridge, fetchImpl, "POST", "/v1/asks", {
430
616
  conversationId,
431
- question: text,
617
+ producerConversationId: bridge.conversationId,
618
+ runId: bridge.runId,
619
+ toolName: "TelegramAskButtons",
620
+ question: source,
621
+ options: args.options,
432
622
  timeoutMs: bridge.timeoutMs,
433
623
  postQuestion: false,
434
624
  answerKind: "callback",
435
- });
625
+ }, extra.signal);
436
626
  if (created.status === 409) {
437
627
  return askToolResult("A question is already pending for this Telegram chat. Wait for its answer instead of asking again.", { answered: false, reason: "already_pending" }, { chat_id: args.chat_id });
438
628
  }
@@ -445,21 +635,36 @@ function registerTelegramAskTool(server, settings, client, adapter) {
445
635
  const askId = created.body.askId;
446
636
  let result;
447
637
  try {
448
- result = await client.sendMessage({
638
+ result = await sendTelegramAskKeyboard(client, adapter, {
449
639
  chat_id: args.chat_id,
450
- text,
451
- reply_markup: { inline_keyboard: inlineKeyboard },
640
+ rendered,
641
+ rawSource: source,
642
+ replyMarkup,
643
+ signal: extra.signal,
452
644
  });
453
645
  }
454
646
  catch (error) {
455
- await askBridgeRequest(bridge, "DELETE", `/v1/asks/${encodeURIComponent(askId)}`);
647
+ await cleanupBridgeAskBestEffort(bridge, fetchImpl, askId);
456
648
  throw error;
457
649
  }
458
- return await awaitBridgeAsk(bridge, askId, "TelegramAskButtons", extra, {
650
+ const askResult = await awaitBridgeAsk(bridge, askId, "TelegramAskButtons", extra, fetchImpl, extra.signal, {
459
651
  chat_id: result.chat.id,
460
652
  message_id: result.message_id,
461
653
  options: args.options,
462
654
  });
655
+ // An expired/cancelled ask leaves a live-looking keyboard forever; append a note
656
+ // while keeping the buttons (a tap still starts a fresh turn). Best-effort only.
657
+ const reason = askResult.structuredContent.reason;
658
+ if (reason === "timeout" || reason === "cancelled") {
659
+ await editExpiredTelegramAskMessage(client, adapter, {
660
+ chat_id: result.chat.id,
661
+ message_id: result.message_id,
662
+ rawSource: source,
663
+ marker: reason === "timeout" ? TELEGRAM_ASK_EXPIRED_MARKER : TELEGRAM_ASK_CANCELLED_MARKER,
664
+ replyMarkup,
665
+ });
666
+ }
667
+ return askResult;
463
668
  });
464
669
  }
465
670
  /**
@@ -473,7 +678,7 @@ function registerTelegramAskTool(server, settings, client, adapter) {
473
678
  function registerTelegramSendFileTool(server, settings, client, adapter) {
474
679
  server.registerTool("TelegramSendFile", {
475
680
  title: "Send Telegram file",
476
- description: "Upload and send a file to an allowed Telegram chat. Set `kind:\"document\"` to send any file (shown as a downloadable document) or `kind:\"photo\"` to send an image inline. Provide the bytes as base64 `data` (with a `filename` — required for a document), or a workspace `path` (preferred — with a self-hosted Bot API server a `path` upload streams from disk with no size buffering, up to the configured cap).",
681
+ description: "Upload and send a file to an allowed Telegram chat. Set `kind:\"document\"` to send any file (shown as a downloadable document) or `kind:\"photo\"` to send an image inline. Provide the bytes as base64 `data` (with a `filename` — required for a document), or a workspace `path`. A self-hosted Bot API can stream legacy path uploads; strict run-output paths are always read through a pinned descriptor.",
477
682
  inputSchema: {
478
683
  kind: z.enum(["document", "photo"]).describe("`document` for any file (downloadable), `photo` for an image shown inline."),
479
684
  chat_id: z.union([z.string().min(1), z.number().int()]).describe("Telegram chat id from the adapter allowlist."),
@@ -482,17 +687,27 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
482
687
  filename: z.string().min(1).optional().describe("Filename to present. Required with `data` for a document; derived from `path` otherwise."),
483
688
  caption: z.string().min(1).optional().describe("Optional caption shown with the file."),
484
689
  },
485
- }, async (args) => {
690
+ }, async (args, extra) => {
691
+ extra.signal.throwIfAborted();
486
692
  const kind = args.kind;
487
693
  assertTelegramChatAllowed(settings, args.chat_id);
694
+ if ((args.data !== undefined) === (args.path !== undefined)) {
695
+ throw new Error("provide exactly one of `data` (base64) or `path`.");
696
+ }
488
697
  const maxUploadBytes = settings.maxUploadBytes ?? adapter.DEFAULT_ATTACHMENT_MAX_BYTES;
698
+ const strictPathUpload = args.path !== undefined && settings.sendTools?.pathScope === "run-output";
699
+ const strictFile = strictPathUpload
700
+ ? await readStrictTelegramFile(settings, args.path, maxUploadBytes, extra.signal, args.filename)
701
+ : undefined;
702
+ const uploadPath = args.path === undefined || strictPathUpload
703
+ ? undefined
704
+ : await resolveTelegramUploadPath(args.path);
489
705
  // file:// fast path: a --local self-hosted server reads the file straight
490
706
  // from disk, so a path upload needs no buffering at any size — only a
491
707
  // stat-level cap check. Falls back once to the buffered path when the
492
708
  // server rejects the URI (e.g. a non---local self-hosted root).
493
- if (kind === "document" && settings.apiRoot !== undefined && args.path !== undefined && args.data === undefined) {
494
- const resolved = resolvePath(process.cwd(), args.path);
495
- const info = await stat(resolved);
709
+ if (kind === "document" && settings.apiRoot !== undefined && uploadPath !== undefined && args.data === undefined) {
710
+ const info = await stat(uploadPath);
496
711
  if (!info.isFile() || info.size === 0) {
497
712
  throw new Error("file is empty or not a regular file.");
498
713
  }
@@ -502,10 +717,10 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
502
717
  try {
503
718
  const sent = await client.sendDocument({
504
719
  chat_id: args.chat_id,
505
- document: pathToFileURL(resolved).href,
720
+ document: pathToFileURL(uploadPath).href,
506
721
  ...(args.caption === undefined ? {} : { caption: args.caption }),
507
- });
508
- const name = basename(resolved);
722
+ }, { signal: extra.signal });
723
+ const name = basename(uploadPath);
509
724
  return {
510
725
  content: [{ type: "text", text: `Sent ${kind} ${sent.message_id} (${name}) to ${String(sent.chat.id)}.` }],
511
726
  structuredContent: { ok: true, chat_id: sent.chat.id, message_id: sent.message_id, filename: name },
@@ -518,12 +733,14 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
518
733
  }
519
734
  }
520
735
  }
521
- const { bytes, filename } = await resolveTelegramFileBytes({
736
+ const { bytes, filename } = strictFile ?? await resolveTelegramFileBytes({
522
737
  data: args.data,
523
738
  path: args.path,
739
+ ...(uploadPath === undefined ? {} : { resolvedPath: uploadPath }),
524
740
  filename: args.filename,
525
741
  requireFilename: kind === "document",
526
742
  maxBytes: maxUploadBytes,
743
+ signal: extra.signal,
527
744
  });
528
745
  const result = kind === "document"
529
746
  ? await client.sendDocument({
@@ -531,13 +748,13 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
531
748
  document: bytes,
532
749
  filename,
533
750
  ...(args.caption === undefined ? {} : { caption: args.caption }),
534
- })
751
+ }, { signal: extra.signal })
535
752
  : await client.sendPhoto({
536
753
  chat_id: args.chat_id,
537
754
  photo: bytes,
538
755
  filename,
539
756
  ...(args.caption === undefined ? {} : { caption: args.caption }),
540
- });
757
+ }, { signal: extra.signal });
541
758
  return {
542
759
  content: [{ type: "text", text: `Sent ${kind} ${result.message_id} (${filename}) to ${String(result.chat.id)}.` }],
543
760
  structuredContent: { ok: true, chat_id: result.chat.id, message_id: result.message_id, filename },
@@ -566,8 +783,8 @@ async function resolveTelegramFileBytes(input) {
566
783
  }
567
784
  }
568
785
  else {
569
- const resolved = resolvePath(process.cwd(), input.path);
570
- bytes = new Uint8Array(await readFile(resolved));
786
+ const resolved = input.resolvedPath ?? resolvePath(process.cwd(), input.path);
787
+ bytes = new Uint8Array(await readFile(resolved, { signal: input.signal }));
571
788
  filename = input.filename ?? basename(resolved);
572
789
  }
573
790
  if (bytes.byteLength === 0) {
@@ -578,6 +795,110 @@ async function resolveTelegramFileBytes(input) {
578
795
  }
579
796
  return { bytes, filename };
580
797
  }
798
+ async function resolveTelegramUploadPath(inputPath) {
799
+ return resolvePath(process.cwd(), inputPath);
800
+ }
801
+ const STRICT_TELEGRAM_PATH_ERROR = "TelegramSendFile: path must be a regular file inside the current run output directory.";
802
+ /**
803
+ * Read a strict upload through one no-follow file descriptor. Every path check
804
+ * deliberately collapses to the same error so callers cannot use the tool as
805
+ * an existence oracle for files outside the run directory.
806
+ */
807
+ async function readStrictTelegramFile(settings, inputPath, maxBytes, signal, requestedFilename) {
808
+ try {
809
+ signal.throwIfAborted();
810
+ if (settings.runOutputDir === undefined || settings.runOutputIdentity === undefined) {
811
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
812
+ }
813
+ const rootStats = await lstat(settings.runOutputDir);
814
+ if (!rootStats.isDirectory()
815
+ || rootStats.isSymbolicLink()
816
+ || !sameFileIdentity(rootStats, settings.runOutputIdentity)) {
817
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
818
+ }
819
+ const rootReal = await realpath(settings.runOutputDir);
820
+ const candidate = resolvePath(process.cwd(), inputPath);
821
+ const candidateLinkStats = await lstat(candidate);
822
+ if (!candidateLinkStats.isFile()
823
+ || candidateLinkStats.isSymbolicLink()
824
+ || candidateLinkStats.nlink !== 1) {
825
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
826
+ }
827
+ const candidateReal = await realpath(candidate);
828
+ if (!pathIsInside(rootReal, candidateReal))
829
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
830
+ const handle = await open(candidateReal, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
831
+ try {
832
+ const openedStats = await handle.stat();
833
+ if (!openedStats.isFile()
834
+ || openedStats.nlink !== 1
835
+ || openedStats.size === 0
836
+ || openedStats.size > maxBytes) {
837
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
838
+ }
839
+ if (!sameFileIdentity(openedStats, fileIdentity(candidateLinkStats))) {
840
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
841
+ }
842
+ await assertPinnedCandidate(candidateReal, openedStats);
843
+ await assertUnchangedRunRoot(settings.runOutputDir, rootReal, settings.runOutputIdentity);
844
+ const bytes = await readPinnedBytes(handle, openedStats.size, signal);
845
+ await assertPinnedCandidate(candidateReal, openedStats);
846
+ await assertUnchangedRunRoot(settings.runOutputDir, rootReal, settings.runOutputIdentity);
847
+ const finalStats = await handle.stat();
848
+ if (!sameFileIdentity(finalStats, fileIdentity(openedStats))
849
+ || finalStats.nlink !== 1
850
+ || finalStats.size !== openedStats.size
851
+ || bytes.byteLength === 0
852
+ || bytes.byteLength > maxBytes) {
853
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
854
+ }
855
+ return { bytes, filename: requestedFilename ?? basename(candidateReal) };
856
+ }
857
+ finally {
858
+ await handle.close();
859
+ }
860
+ }
861
+ catch (error) {
862
+ if (signal.aborted)
863
+ throw signal.reason ?? error;
864
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
865
+ }
866
+ }
867
+ async function readPinnedBytes(handle, size, signal) {
868
+ const bytes = Buffer.allocUnsafe(size);
869
+ let offset = 0;
870
+ while (offset < size) {
871
+ signal.throwIfAborted();
872
+ const read = await handle.read(bytes, offset, size - offset, offset);
873
+ if (read.bytesRead === 0)
874
+ break;
875
+ offset += read.bytesRead;
876
+ }
877
+ if (offset !== size)
878
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
879
+ return new Uint8Array(bytes);
880
+ }
881
+ function pathIsInside(root, candidate) {
882
+ const fromRoot = relative(root, candidate);
883
+ return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
884
+ }
885
+ async function assertPinnedCandidate(path, opened) {
886
+ const current = await lstat(path);
887
+ if (!current.isFile()
888
+ || current.isSymbolicLink()
889
+ || current.nlink !== 1
890
+ || !sameFileIdentity(current, fileIdentity(opened))) {
891
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
892
+ }
893
+ }
894
+ async function assertUnchangedRunRoot(path, expectedRealPath, expectedIdentity) {
895
+ const current = await lstat(path);
896
+ if (!current.isDirectory() || current.isSymbolicLink() || !sameFileIdentity(current, expectedIdentity)) {
897
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
898
+ }
899
+ if (await realpath(path) !== expectedRealPath)
900
+ throw new Error(STRICT_TELEGRAM_PATH_ERROR);
901
+ }
581
902
  async function resolveSlackSendToolSettings(input, options) {
582
903
  try {
583
904
  const adapter = await loadSlackModule();
@@ -605,6 +926,9 @@ async function resolveTelegramSendToolSettings(input, options, tools, askBridge)
605
926
  if (!config.enabled) {
606
927
  return undefined;
607
928
  }
929
+ const producingConversationId = optionalString(input.env.MONO_AGENT_ADAPTER_TOOLS_PRODUCING_CONVERSATION_ID);
930
+ const runOutputDir = optionalString(input.env.MONO_AGENT_ADAPTER_TOOLS_RUN_OUTPUT_DIR);
931
+ const runOutputIdentity = parseFileIdentity(input.env.MONO_AGENT_ADAPTER_TOOLS_RUN_OUTPUT_DEV, input.env.MONO_AGENT_ADAPTER_TOOLS_RUN_OUTPUT_INO);
608
932
  return {
609
933
  botToken: config.botToken,
610
934
  allowedChatIds: config.allowedChatIds,
@@ -613,6 +937,10 @@ async function resolveTelegramSendToolSettings(input, options, tools, askBridge)
613
937
  maxUploadBytes: config.attachments?.maxUploadBytes ?? adapter.DEFAULT_ATTACHMENT_MAX_BYTES,
614
938
  tools,
615
939
  ...(askBridge === undefined ? {} : { askBridge }),
940
+ ...(config.sendTools === undefined ? {} : { sendTools: config.sendTools }),
941
+ ...(producingConversationId === undefined ? {} : { producingConversationId }),
942
+ ...(runOutputDir === undefined ? {} : { runOutputDir }),
943
+ ...(runOutputIdentity === undefined ? {} : { runOutputIdentity }),
616
944
  };
617
945
  }
618
946
  catch (error) {
@@ -630,11 +958,81 @@ function assertSlackChannelAllowed(settings, channel) {
630
958
  }
631
959
  function assertTelegramChatAllowed(settings, chatId) {
632
960
  const normalized = String(chatId);
961
+ if (settings.sendTools?.scope === "producing-conversation") {
962
+ const producingChatId = telegramChatIdFromConversation(settings.producingConversationId);
963
+ if (producingChatId === undefined) {
964
+ throw new Error("TelegramSendMessage: producing Telegram conversation context is unavailable.");
965
+ }
966
+ if (normalized !== producingChatId) {
967
+ throw new Error("TelegramSendMessage: chat_id must match the producing Telegram conversation.");
968
+ }
969
+ }
633
970
  if (settings.allowAllChats || settings.allowedChatIds.includes(normalized)) {
634
971
  return;
635
972
  }
636
973
  throw new Error("TelegramSendMessage: chat_id is not allowed by Telegram adapter config.");
637
974
  }
975
+ function telegramChatIdFromConversation(conversationId) {
976
+ if (conversationId === undefined)
977
+ return undefined;
978
+ const base = conversationId.split("#", 1)[0] ?? conversationId;
979
+ if (!base.startsWith("telegram:"))
980
+ return undefined;
981
+ const chatId = base.slice("telegram:".length);
982
+ return chatId.length === 0 ? undefined : chatId;
983
+ }
984
+ const SAFE_ADAPTER_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/u;
985
+ async function ensureAdapterRunOutputDir(root, runId) {
986
+ if (!SAFE_ADAPTER_RUN_ID.test(runId)) {
987
+ throw new Error("adapter-send-tools: run id is unsafe for the run output directory.");
988
+ }
989
+ const outputRoot = resolvePath(root);
990
+ const runOutputDir = join(outputRoot, runId);
991
+ await mkdir(outputRoot, { recursive: true, mode: 0o700 });
992
+ try {
993
+ await mkdir(runOutputDir, { mode: 0o700 });
994
+ }
995
+ catch (error) {
996
+ if (error.code !== "EEXIST")
997
+ throw error;
998
+ const existing = await lstat(runOutputDir);
999
+ if (!existing.isDirectory() || existing.isSymbolicLink()) {
1000
+ throw new Error("adapter-send-tools: run output path is not a real directory.");
1001
+ }
1002
+ }
1003
+ const current = await lstat(runOutputDir);
1004
+ if (!current.isDirectory() || current.isSymbolicLink()) {
1005
+ throw new Error("adapter-send-tools: run output path is not a real directory.");
1006
+ }
1007
+ return { path: runOutputDir, identity: fileIdentity(current) };
1008
+ }
1009
+ function fileIdentity(stats) {
1010
+ return { dev: stats.dev, ino: stats.ino };
1011
+ }
1012
+ function sameFileIdentity(stats, expected) {
1013
+ return stats.dev === expected.dev && stats.ino === expected.ino;
1014
+ }
1015
+ function parseFileIdentity(devValue, inoValue) {
1016
+ const dev = Number(devValue);
1017
+ const ino = Number(inoValue);
1018
+ return Number.isSafeInteger(dev) && dev >= 0 && Number.isSafeInteger(ino) && ino > 0
1019
+ ? { dev, ino }
1020
+ : undefined;
1021
+ }
1022
+ async function removeOwnedDirectory(path, expected) {
1023
+ let current;
1024
+ try {
1025
+ current = await lstat(path);
1026
+ }
1027
+ catch (error) {
1028
+ if (error.code === "ENOENT")
1029
+ return;
1030
+ throw error;
1031
+ }
1032
+ if (!current.isDirectory() || current.isSymbolicLink() || !sameFileIdentity(current, expected))
1033
+ return;
1034
+ await rm(path, { recursive: true, force: true });
1035
+ }
638
1036
  function optionalString(value) {
639
1037
  if (value === undefined) {
640
1038
  return undefined;