@mono-agent/agent-app 0.6.1 → 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 -17
  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 +41 -8
  9. package/dist/adapter-send-tools.d.ts.map +1 -1
  10. package/dist/adapter-send-tools.js +563 -108
  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 +532 -134
  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 +18 -7
  24. package/dist/channels.js.map +1 -1
  25. package/dist/cli.d.ts +77 -4
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +2106 -99
  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 +1198 -96
  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 +32 -2
  69. package/dist/interaction-bridge.d.ts.map +1 -1
  70. package/dist/interaction-bridge.js +309 -28
  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 +136 -5
  127. package/dist/provider-setup.d.ts.map +1 -1
  128. package/dist/provider-setup.js +1552 -33
  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 +38 -3
  186. package/dist/wizard/model-discovery.d.ts.map +1 -1
  187. package/dist/wizard/model-discovery.js +927 -102
  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 +66 -11
  194. package/dist/wizard/prompts.d.ts.map +1 -1
  195. package/dist/wizard/prompts.js +190 -20
  196. package/dist/wizard/prompts.js.map +1 -1
  197. package/dist/wizard/run.d.ts +60 -3
  198. package/dist/wizard/run.d.ts.map +1 -1
  199. package/dist/wizard/run.js +1161 -145
  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,8 +1,10 @@
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";
7
+ import { splitTextByCodePoints } from "@mono-agent/agent-contracts";
6
8
  import { ALLOW_ALL_TOOLS } from "@mono-agent/config";
7
9
  import * as z from "zod/v4";
8
10
  import { LEGACY_TOOL_ALIASES } from "./modules/known-tools.js";
@@ -11,6 +13,9 @@ let slackModule;
11
13
  let telegramModule;
12
14
  const loadSlackModule = async () => (slackModule ??= await import("@mono-agent/slack-adapter"));
13
15
  const loadTelegramModule = async () => (telegramModule ??= await import("@mono-agent/telegram-adapter"));
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;
14
19
  /**
15
20
  * Model-visible send tools for explicitly allowed, already-enabled communication adapters.
16
21
  *
@@ -24,9 +29,13 @@ const loadTelegramModule = async () => (telegramModule ??= await import("@mono-a
24
29
  export const ADAPTER_SEND_TOOLS_MCP_SERVER_NAME = "mono-agent-adapter-send";
25
30
  export async function resolveAdapterSendToolsSettings(input, options = {}) {
26
31
  const telegramSendAllowed = isAdapterToolAllowed("TelegramSendMessage", options);
27
- const telegramAskAllowed = isAdapterToolAllowed("TelegramAskButtons", options);
32
+ const telegramAskAllowed = options.suppressInteractionTools !== true
33
+ && isAdapterToolAllowed("TelegramAskButtons", options);
28
34
  const telegramFileAllowed = isAdapterToolAllowed("TelegramSendFile", options);
29
35
  const telegramAnyAllowed = telegramSendAllowed || telegramAskAllowed || telegramFileAllowed;
36
+ const telegramAskBridge = telegramAskAllowed
37
+ ? resolveAskUserToolSettings(input.env, options.interaction)
38
+ : undefined;
30
39
  const [slack, telegram] = await Promise.all([
31
40
  isAdapterToolAllowed("SlackSendMessage", options)
32
41
  ? resolveSlackSendToolSettings(input, options)
@@ -36,11 +45,11 @@ export async function resolveAdapterSendToolsSettings(input, options = {}) {
36
45
  send: telegramSendAllowed,
37
46
  ask: telegramAskAllowed,
38
47
  file: telegramFileAllowed,
39
- })
48
+ }, telegramAskBridge)
40
49
  : undefined,
41
50
  ]);
42
- const askUser = isAdapterToolAllowed("AskUser", options)
43
- ? resolveAskUserToolSettings(input.env)
51
+ const askUser = options.suppressInteractionTools !== true && isAdapterToolAllowed("AskUser", options)
52
+ ? resolveAskUserToolSettings(input.env, options.interaction)
44
53
  : undefined;
45
54
  if (slack === undefined && telegram === undefined && askUser === undefined) {
46
55
  return undefined;
@@ -56,20 +65,23 @@ export async function resolveAdapterSendToolsSettings(input, options = {}) {
56
65
  * into the environment (URL + bearer token). The producing conversation id is
57
66
  * per-request env, present in the spawned child.
58
67
  */
59
- function resolveAskUserToolSettings(env) {
60
- const bridgeUrl = optionalString(env.MONO_AGENT_INTERACTION_BRIDGE_URL);
61
- 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);
62
71
  if (bridgeUrl === undefined || bridgeToken === undefined) {
63
72
  return undefined;
64
73
  }
65
74
  const timeoutRaw = Number(optionalString(env.MONO_AGENT_ASK_USER_TIMEOUT_MS));
66
- const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw >= 1000 ? timeoutRaw : 600_000;
75
+ const timeoutMs = interaction?.timeoutMs
76
+ ?? (Number.isFinite(timeoutRaw) && timeoutRaw >= 1000 ? timeoutRaw : 600_000);
67
77
  const conversationId = optionalString(env.MONO_AGENT_ADAPTER_TOOLS_PRODUCING_CONVERSATION_ID);
78
+ const runId = optionalString(env.MONO_AGENT_ADAPTER_TOOLS_PRODUCING_RUN_ID);
68
79
  return {
69
80
  bridgeUrl,
70
81
  bridgeToken,
71
82
  timeoutMs,
72
83
  ...(conversationId === undefined ? {} : { conversationId }),
84
+ ...(runId === undefined ? {} : { runId }),
73
85
  };
74
86
  }
75
87
  export function adapterSendToolNames(settings) {
@@ -99,16 +111,35 @@ export function adapterSendToolNames(settings) {
99
111
  export function isAdapterSendToolAllowed(name, policy) {
100
112
  return isAdapterToolAllowed(name, policy);
101
113
  }
102
- export function adapterSendToolsMcpEnv(configPath, allowedTools, context) {
114
+ export function adapterSendToolsMcpEnv(configPath, allowedTools, context, interaction) {
103
115
  return {
104
116
  MONO_AGENT_ADAPTER_TOOLS_CONFIG_PATH: configPath,
105
117
  MONO_AGENT_ADAPTER_TOOLS_ALLOWED_TOOLS: JSON.stringify(allowedTools),
106
118
  ...(context?.conversationId === undefined
107
119
  ? {}
108
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 }),
109
124
  ...(context?.indexPath === undefined
110
125
  ? {}
111
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
+ }),
136
+ ...(interaction === undefined
137
+ ? {}
138
+ : {
139
+ MONO_AGENT_INTERACTION_BRIDGE_URL: interaction.bridgeUrl,
140
+ MONO_AGENT_INTERACTION_BRIDGE_TOKEN: interaction.bridgeToken,
141
+ MONO_AGENT_ASK_USER_TIMEOUT_MS: String(interaction.timeoutMs),
142
+ }),
112
143
  };
113
144
  }
114
145
  export function adapterSendToolsChildConfigFromEnv(env, cwd) {
@@ -126,14 +157,27 @@ export function adapterSendToolsChildConfigFromEnv(env, cwd) {
126
157
  ...(indexing === undefined ? {} : { indexing }),
127
158
  };
128
159
  }
129
- export function adapterSendToolsMcpServerSpec(configPath, cwd, allowedTools, context) {
130
- return {
160
+ export function adapterSendToolsMcpServerSpec(configPath, cwd, allowedTools, context, interaction) {
161
+ const spec = {
131
162
  type: "stdio",
132
163
  command: process.execPath,
133
- 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))],
134
169
  cwd,
135
- env: adapterSendToolsMcpEnv(configPath, allowedTools, context),
170
+ env: adapterSendToolsMcpEnv(configPath, allowedTools, context, interaction),
136
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;
137
181
  }
138
182
  /**
139
183
  * Per-request runtime extension that injects the adapter-send stdio MCP server. It
@@ -141,43 +185,65 @@ export function adapterSendToolsMcpServerSpec(configPath, cwd, allowedTools, con
141
185
  * configured, forwards both to the child so a `SlackSendMessage` post is linked
142
186
  * back to this conversation (so a later in-thread reply resumes it).
143
187
  */
144
- export function createAdapterSendToolsRuntimeExtension(configPath, cwd, allowedTools, indexPath) {
188
+ export function createAdapterSendToolsRuntimeExtension(configPath, cwd, allowedTools, indexPath, interaction, runOutputRoot) {
145
189
  return async (input) => {
146
190
  const conversationId = input?.request?.conversationId;
191
+ const runId = input?.runId;
147
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);
148
197
  // The conversation id is forwarded whenever known — AskUser targets it even
149
198
  // without indexing; the index path additionally enables posted-message links.
150
- const context = hasConversation
151
- ? { 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
+ }
152
208
  : undefined;
153
209
  return {
154
210
  runtimeOptions: {
155
211
  mcpServers: {
156
- [ADAPTER_SEND_TOOLS_MCP_SERVER_NAME]: adapterSendToolsMcpServerSpec(configPath, cwd, allowedTools, context),
212
+ [ADAPTER_SEND_TOOLS_MCP_SERVER_NAME]: adapterSendToolsMcpServerSpec(configPath, cwd, allowedTools, context, interaction),
157
213
  },
158
214
  },
159
215
  cleanup: async () => { },
216
+ settleCleanup: async () => {
217
+ if (runOutput !== undefined) {
218
+ await removeOwnedDirectory(runOutput.path, runOutput.identity);
219
+ }
220
+ },
160
221
  };
161
222
  };
162
223
  }
163
- export async function createAdapterSendToolsClients(settings) {
224
+ export async function createAdapterSendToolsClients(settings, options = {}) {
164
225
  const slack = settings.slack === undefined
165
226
  ? undefined
166
- : 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
+ });
167
231
  const telegram = settings.telegram === undefined
168
232
  ? undefined
169
233
  : (await loadTelegramModule()).createTelegramMessageSender(settings.telegram.botToken, {
170
234
  ...(settings.telegram.apiRoot === undefined ? {} : { apiRoot: settings.telegram.apiRoot }),
235
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
171
236
  });
172
237
  return {
173
238
  ...(slack === undefined ? {} : { slack }),
174
239
  ...(telegram === undefined ? {} : { telegram }),
175
240
  };
176
241
  }
177
- export async function createAdapterSendToolsServer(settings, clients, indexing) {
242
+ export async function createAdapterSendToolsServer(settings, clients, indexing, options = {}) {
178
243
  const server = new McpServer({ name: "agent-adapter-send-tools", version: "0.3.0" });
179
244
  if (settings.slack !== undefined && clients.slack !== undefined) {
180
- registerSlackSendTool(server, settings.slack, clients.slack, indexing);
245
+ const adapter = await loadSlackModule();
246
+ registerSlackSendTool(server, settings.slack, clients.slack, adapter.formatMarkdownForSlack, indexing);
181
247
  }
182
248
  if (settings.telegram !== undefined && clients.telegram !== undefined) {
183
249
  // Loaded once here (not per-register-call) because registerTelegramAskTool
@@ -188,7 +254,7 @@ export async function createAdapterSendToolsServer(settings, clients, indexing)
188
254
  registerTelegramSendTool(server, settings.telegram, clients.telegram);
189
255
  }
190
256
  if (settings.telegram.tools.ask) {
191
- registerTelegramAskTool(server, settings.telegram, clients.telegram, adapter);
257
+ registerTelegramAskTool(server, settings.telegram, clients.telegram, adapter, options.fetchImpl ?? globalThis.fetch);
192
258
  }
193
259
  if (settings.telegram.tools.file) {
194
260
  registerTelegramSendFileTool(server, settings.telegram, clients.telegram, adapter);
@@ -197,13 +263,13 @@ export async function createAdapterSendToolsServer(settings, clients, indexing)
197
263
  // AskUser needs a target conversation; the parent app process resolves the
198
264
  // settings without one (for tool-name gating) and must not register the tool.
199
265
  if (settings.askUser?.conversationId !== undefined) {
200
- registerAskUserTool(server, { ...settings.askUser, conversationId: settings.askUser.conversationId });
266
+ registerAskUserTool(server, { ...settings.askUser, conversationId: settings.askUser.conversationId }, options.fetchImpl ?? globalThis.fetch);
201
267
  }
202
268
  return server;
203
269
  }
204
270
  /** Long-poll wait per bridge request; the overall wait is bounded server-side by the ask's timeout. */
205
271
  const ASK_USER_POLL_WAIT_MS = 20_000;
206
- function registerAskUserTool(server, settings) {
272
+ function registerAskUserTool(server, settings, fetchImpl) {
207
273
  server.registerTool("AskUser", {
208
274
  title: "Ask the user and wait",
209
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).",
@@ -211,63 +277,100 @@ function registerAskUserTool(server, settings) {
211
277
  question: z.string().min(1).describe("The full question to show the user (plain text, no markdown)."),
212
278
  },
213
279
  }, async (args, extra) => {
214
- const created = await askBridgeRequest(settings, "POST", "/v1/asks", {
280
+ const created = await askBridgeRequest(settings, fetchImpl, "POST", "/v1/asks", {
215
281
  conversationId: settings.conversationId,
282
+ producerConversationId: settings.conversationId,
283
+ runId: settings.runId,
284
+ toolName: "AskUser",
216
285
  question: args.question,
217
286
  timeoutMs: settings.timeoutMs,
218
- });
287
+ }, extra.signal);
219
288
  if (created.status === 409) {
220
- return askUserResult("A question is already pending for the user. Wait for its answer instead of asking again.", { answered: false, reason: "already_pending" });
289
+ return askToolResult("A question is already pending for the user. Wait for its answer instead of asking again.", { answered: false, reason: "already_pending" });
221
290
  }
222
291
  if (created.status === 501) {
223
- return askUserResult("This conversation's channel does not support interactive asks. Ask your question in your final reply instead.", { answered: false, reason: "unsupported_channel" });
292
+ return askToolResult("This conversation's channel does not support interactive asks. Ask your question in your final reply instead.", { answered: false, reason: "unsupported_channel" });
224
293
  }
225
294
  if (created.status !== 201 || typeof created.body.askId !== "string") {
226
295
  throw new Error(`AskUser: the interaction bridge rejected the ask (HTTP ${String(created.status)}).`);
227
296
  }
228
- const askId = created.body.askId;
229
- const startedMs = Date.now();
297
+ return await awaitBridgeAsk(settings, created.body.askId, "AskUser", extra, fetchImpl, extra.signal);
298
+ });
299
+ }
300
+ function askToolResult(text, structured, extraStructured = {}) {
301
+ return {
302
+ content: [{ type: "text", text }],
303
+ structuredContent: { ok: true, ...extraStructured, ...structured },
304
+ };
305
+ }
306
+ async function awaitBridgeAsk(settings, askId, toolName, extra, fetchImpl, signal, extraStructured = {}) {
307
+ const startedMs = Date.now();
308
+ try {
230
309
  for (;;) {
231
- const poll = await askBridgeRequest(settings, "GET", `/v1/asks/${encodeURIComponent(askId)}?waitMs=${String(ASK_USER_POLL_WAIT_MS)}`);
310
+ const poll = await askBridgeRequest(settings, fetchImpl, "GET", `/v1/asks/${encodeURIComponent(askId)}?waitMs=${String(ASK_USER_POLL_WAIT_MS)}`, undefined, signal);
232
311
  if (poll.status !== 200) {
233
- throw new Error(`AskUser: lost the pending ask (HTTP ${String(poll.status)}).`);
312
+ throw new Error(`${toolName}: lost the pending ask (HTTP ${String(poll.status)}).`);
234
313
  }
235
314
  // Keep-alive: progress notifications reset the runtime's MCP inactivity
236
315
  // timeout so a long human wait cannot kill the tool call.
237
316
  await sendAskProgress(extra, Math.round((Date.now() - startedMs) / 1000));
238
317
  const status = poll.body.status;
239
318
  if (status === "answered" && typeof poll.body.answer === "string") {
240
- return askUserResult(`The user answered:\n${poll.body.answer}`, {
241
- answered: true,
242
- answer: poll.body.answer,
243
- });
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);
244
323
  }
245
324
  if (status === "expired") {
246
- return askUserResult("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" });
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);
247
326
  }
248
327
  if (status === "cancelled") {
249
- return askUserResult("The user cancelled the current run. Stop this task.", {
328
+ return askToolResult("The user cancelled the current run. Stop this task.", {
250
329
  answered: false,
251
330
  reason: "cancelled",
252
- });
331
+ }, extraStructured);
253
332
  }
254
333
  }
255
- });
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
+ }
256
342
  }
257
- function askUserResult(text, structured) {
258
- return {
259
- content: [{ type: "text", text }],
260
- structuredContent: { ok: true, ...structured },
261
- };
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
+ }
262
364
  }
263
- async function askBridgeRequest(settings, method, path, body) {
264
- const response = await fetch(new URL(path, settings.bridgeUrl), {
365
+ async function askBridgeRequest(settings, fetchImpl, method, path, body, signal) {
366
+ const response = await fetchImpl(new URL(path, settings.bridgeUrl), {
265
367
  method,
266
368
  headers: {
267
369
  authorization: `Bearer ${settings.bridgeToken}`,
268
370
  ...(body === undefined ? {} : { "content-type": "application/json" }),
269
371
  },
270
372
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
373
+ ...(signal === undefined ? {} : { signal }),
271
374
  });
272
375
  let parsed;
273
376
  try {
@@ -301,41 +404,64 @@ async function sendAskProgress(extra, elapsedSeconds) {
301
404
  // Keep-alive only; a lost notification must never fail the ask.
302
405
  }
303
406
  }
304
- function registerSlackSendTool(server, settings, client, indexing) {
407
+ function registerSlackSendTool(server, settings, client, formatMarkdownForSlack, indexing) {
305
408
  server.registerTool("SlackSendMessage", {
306
409
  title: "Send Slack message",
307
410
  description: "Send a message to an allowed Slack channel or DM ID using the configured Slack adapter bot token.",
308
411
  inputSchema: {
309
412
  channel: z.string().min(1).describe("Slack channel or DM ID, e.g. C123456 or D123456."),
310
- 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."),
311
414
  thread_ts: z.string().min(1).optional().describe("Optional Slack thread timestamp to reply in."),
312
- 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."),
313
416
  unfurl_links: z.boolean().optional().describe("Whether Slack should unfurl links."),
314
417
  unfurl_media: z.boolean().optional().describe("Whether Slack should unfurl media."),
315
418
  },
316
- }, async (args) => {
419
+ }, async (args, extra) => {
317
420
  assertSlackChannelAllowed(settings, args.channel);
318
- const result = await client.chatPostMessage({
319
- channel: args.channel.trim(),
320
- text: args.text,
321
- ...(args.thread_ts === undefined ? {} : { thread_ts: args.thread_ts }),
322
- ...(args.mrkdwn === undefined ? {} : { mrkdwn: args.mrkdwn }),
323
- ...(args.unfurl_links === undefined ? {} : { unfurl_links: args.unfurl_links }),
324
- ...(args.unfurl_media === undefined ? {} : { unfurl_media: args.unfurl_media }),
325
- });
326
- // Link the posted message back to this conversation so an in-thread reply can
327
- // resume it. Best-effort: appendPostedMessage never throws, so a failed index
328
- // write can never fail the send.
329
- if (indexing !== undefined) {
330
- await appendPostedMessage(indexing.indexPath, {
331
- channelId: result.channel,
332
- ts: result.ts,
333
- conversationId: indexing.conversationId,
334
- });
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);
424
+ const results = [];
425
+ for (const chunk of chunks) {
426
+ const result = await client.chatPostMessage({
427
+ channel: args.channel.trim(),
428
+ text: chunk,
429
+ ...(args.thread_ts === undefined ? {} : { thread_ts: args.thread_ts }),
430
+ mrkdwn,
431
+ ...(args.unfurl_links === undefined ? {} : { unfurl_links: args.unfurl_links }),
432
+ ...(args.unfurl_media === undefined ? {} : { unfurl_media: args.unfurl_media }),
433
+ }, { signal: extra.signal });
434
+ results.push(result);
435
+ // Link every posted chunk back to this conversation so an in-thread reply can
436
+ // resume it. Best-effort: appendPostedMessage never throws, so a failed index
437
+ // write can never fail the send.
438
+ if (indexing !== undefined) {
439
+ await appendPostedMessage(indexing.indexPath, {
440
+ channelId: result.channel,
441
+ ts: result.ts,
442
+ conversationId: indexing.conversationId,
443
+ });
444
+ }
445
+ }
446
+ const [firstResult] = results;
447
+ if (firstResult === undefined) {
448
+ throw new Error("SlackSendMessage: no message chunks were produced.");
335
449
  }
450
+ const chunkRefs = results.map((result) => ({ channel: result.channel, ts: result.ts }));
451
+ const message = results.length === 1
452
+ ? `Sent Slack message to ${firstResult.channel} at ${firstResult.ts}.`
453
+ : `Sent ${String(results.length)} Slack messages to ${firstResult.channel} starting at ${firstResult.ts}.`;
336
454
  return {
337
- content: [{ type: "text", text: `Sent Slack message to ${result.channel} at ${result.ts}.` }],
338
- structuredContent: { ok: true, channel: result.channel, ts: result.ts },
455
+ content: [{ type: "text", text: message }],
456
+ structuredContent: results.length === 1
457
+ ? { ok: true, channel: firstResult.channel, ts: firstResult.ts }
458
+ : {
459
+ ok: true,
460
+ channel: firstResult.channel,
461
+ ts: firstResult.ts,
462
+ chunkCount: results.length,
463
+ chunks: chunkRefs,
464
+ },
339
465
  };
340
466
  });
341
467
  }
@@ -350,7 +476,7 @@ function registerTelegramSendTool(server, settings, client) {
350
476
  reply_to_message_id: z.number().int().optional().describe("Optional message id to reply to."),
351
477
  disable_web_page_preview: z.boolean().optional().describe("Disable Telegram link previews."),
352
478
  },
353
- }, async (args) => {
479
+ }, async (args, extra) => {
354
480
  assertTelegramChatAllowed(settings, args.chat_id);
355
481
  const result = await client.sendMessage({
356
482
  chat_id: args.chat_id,
@@ -358,17 +484,89 @@ function registerTelegramSendTool(server, settings, client) {
358
484
  ...(args.parse_mode === undefined ? {} : { parse_mode: args.parse_mode }),
359
485
  ...(args.reply_to_message_id === undefined ? {} : { reply_to_message_id: args.reply_to_message_id }),
360
486
  ...(args.disable_web_page_preview === undefined ? {} : { disable_web_page_preview: args.disable_web_page_preview }),
361
- });
487
+ }, { signal: extra.signal });
362
488
  return {
363
489
  content: [{ type: "text", text: `Sent Telegram message ${result.message_id} to ${String(result.chat.id)}.` }],
364
490
  structuredContent: { ok: true, chat_id: result.chat.id, message_id: result.message_id },
365
491
  };
366
492
  });
367
493
  }
368
- 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) {
369
567
  server.registerTool("TelegramAskButtons", {
370
568
  title: "Ask via Telegram buttons",
371
- description: "Ask the owner a question in an allowed Telegram chat with tappable inline-keyboard buttons (e.g. a confirmation or a multiple-choice). This tool returns immediately after posting the question; it does NOT wait for the answer. When the user taps a button, their choice arrives as a new message on the same conversation, so continue on that next turn.",
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.",
372
570
  inputSchema: {
373
571
  chat_id: z.union([z.string().min(1), z.number().int()]).describe("Telegram chat id from the adapter allowlist."),
374
572
  question: z.string().min(1).describe("The question to show above the buttons."),
@@ -378,32 +576,95 @@ function registerTelegramAskTool(server, settings, client, adapter) {
378
576
  .max(adapter.TELEGRAM_ASK_MAX_OPTIONS)
379
577
  .describe("Button labels (2–8). The label the user taps is echoed back as a new message."),
380
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."),
381
583
  },
382
- }, async (args) => {
584
+ }, async (args, extra) => {
383
585
  assertTelegramChatAllowed(settings, args.chat_id);
384
586
  const inlineKeyboard = args.options.map((label, index) => [
385
587
  { text: label, callback_data: adapter.telegramAskCallbackData(index) },
386
588
  ]);
387
- const text = args.note === undefined ? args.question : `${args.question}\n\n${args.note}`;
388
- const result = await client.sendMessage({
389
- chat_id: args.chat_id,
390
- text,
391
- reply_markup: { inline_keyboard: inlineKeyboard },
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.
607
+ const bridge = settings.askBridge;
608
+ if (bridge === undefined) {
609
+ return askToolResult("TelegramAskButtons requires a live interaction bridge, but none is configured.", {
610
+ answered: false,
611
+ reason: "unsupported_channel",
612
+ });
613
+ }
614
+ const conversationId = `telegram:${String(args.chat_id)}`;
615
+ const created = await askBridgeRequest(bridge, fetchImpl, "POST", "/v1/asks", {
616
+ conversationId,
617
+ producerConversationId: bridge.conversationId,
618
+ runId: bridge.runId,
619
+ toolName: "TelegramAskButtons",
620
+ question: source,
621
+ options: args.options,
622
+ timeoutMs: bridge.timeoutMs,
623
+ postQuestion: false,
624
+ answerKind: "callback",
625
+ }, extra.signal);
626
+ if (created.status === 409) {
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 });
628
+ }
629
+ if (created.status === 501) {
630
+ return askToolResult("This Telegram chat is not registered with the interaction bridge. Ask your question in your final reply instead.", { answered: false, reason: "unsupported_channel" }, { chat_id: args.chat_id });
631
+ }
632
+ if (created.status !== 201 || typeof created.body.askId !== "string") {
633
+ throw new Error(`TelegramAskButtons: the interaction bridge rejected the ask (HTTP ${String(created.status)}).`);
634
+ }
635
+ const askId = created.body.askId;
636
+ let result;
637
+ try {
638
+ result = await sendTelegramAskKeyboard(client, adapter, {
639
+ chat_id: args.chat_id,
640
+ rendered,
641
+ rawSource: source,
642
+ replyMarkup,
643
+ signal: extra.signal,
644
+ });
645
+ }
646
+ catch (error) {
647
+ await cleanupBridgeAskBestEffort(bridge, fetchImpl, askId);
648
+ throw error;
649
+ }
650
+ const askResult = await awaitBridgeAsk(bridge, askId, "TelegramAskButtons", extra, fetchImpl, extra.signal, {
651
+ chat_id: result.chat.id,
652
+ message_id: result.message_id,
653
+ options: args.options,
392
654
  });
393
- return {
394
- content: [
395
- {
396
- type: "text",
397
- text: `Posted question ${result.message_id} to ${String(result.chat.id)} with ${String(args.options.length)} options. The user's choice will arrive as a new message.`,
398
- },
399
- ],
400
- structuredContent: {
401
- ok: true,
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, {
402
660
  chat_id: result.chat.id,
403
661
  message_id: result.message_id,
404
- options: args.options,
405
- },
406
- };
662
+ rawSource: source,
663
+ marker: reason === "timeout" ? TELEGRAM_ASK_EXPIRED_MARKER : TELEGRAM_ASK_CANCELLED_MARKER,
664
+ replyMarkup,
665
+ });
666
+ }
667
+ return askResult;
407
668
  });
408
669
  }
409
670
  /**
@@ -417,7 +678,7 @@ function registerTelegramAskTool(server, settings, client, adapter) {
417
678
  function registerTelegramSendFileTool(server, settings, client, adapter) {
418
679
  server.registerTool("TelegramSendFile", {
419
680
  title: "Send Telegram file",
420
- 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.",
421
682
  inputSchema: {
422
683
  kind: z.enum(["document", "photo"]).describe("`document` for any file (downloadable), `photo` for an image shown inline."),
423
684
  chat_id: z.union([z.string().min(1), z.number().int()]).describe("Telegram chat id from the adapter allowlist."),
@@ -426,17 +687,27 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
426
687
  filename: z.string().min(1).optional().describe("Filename to present. Required with `data` for a document; derived from `path` otherwise."),
427
688
  caption: z.string().min(1).optional().describe("Optional caption shown with the file."),
428
689
  },
429
- }, async (args) => {
690
+ }, async (args, extra) => {
691
+ extra.signal.throwIfAborted();
430
692
  const kind = args.kind;
431
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
+ }
432
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);
433
705
  // file:// fast path: a --local self-hosted server reads the file straight
434
706
  // from disk, so a path upload needs no buffering at any size — only a
435
707
  // stat-level cap check. Falls back once to the buffered path when the
436
708
  // server rejects the URI (e.g. a non---local self-hosted root).
437
- if (kind === "document" && settings.apiRoot !== undefined && args.path !== undefined && args.data === undefined) {
438
- const resolved = resolvePath(process.cwd(), args.path);
439
- const info = await stat(resolved);
709
+ if (kind === "document" && settings.apiRoot !== undefined && uploadPath !== undefined && args.data === undefined) {
710
+ const info = await stat(uploadPath);
440
711
  if (!info.isFile() || info.size === 0) {
441
712
  throw new Error("file is empty or not a regular file.");
442
713
  }
@@ -446,10 +717,10 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
446
717
  try {
447
718
  const sent = await client.sendDocument({
448
719
  chat_id: args.chat_id,
449
- document: pathToFileURL(resolved).href,
720
+ document: pathToFileURL(uploadPath).href,
450
721
  ...(args.caption === undefined ? {} : { caption: args.caption }),
451
- });
452
- const name = basename(resolved);
722
+ }, { signal: extra.signal });
723
+ const name = basename(uploadPath);
453
724
  return {
454
725
  content: [{ type: "text", text: `Sent ${kind} ${sent.message_id} (${name}) to ${String(sent.chat.id)}.` }],
455
726
  structuredContent: { ok: true, chat_id: sent.chat.id, message_id: sent.message_id, filename: name },
@@ -462,12 +733,14 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
462
733
  }
463
734
  }
464
735
  }
465
- const { bytes, filename } = await resolveTelegramFileBytes({
736
+ const { bytes, filename } = strictFile ?? await resolveTelegramFileBytes({
466
737
  data: args.data,
467
738
  path: args.path,
739
+ ...(uploadPath === undefined ? {} : { resolvedPath: uploadPath }),
468
740
  filename: args.filename,
469
741
  requireFilename: kind === "document",
470
742
  maxBytes: maxUploadBytes,
743
+ signal: extra.signal,
471
744
  });
472
745
  const result = kind === "document"
473
746
  ? await client.sendDocument({
@@ -475,13 +748,13 @@ function registerTelegramSendFileTool(server, settings, client, adapter) {
475
748
  document: bytes,
476
749
  filename,
477
750
  ...(args.caption === undefined ? {} : { caption: args.caption }),
478
- })
751
+ }, { signal: extra.signal })
479
752
  : await client.sendPhoto({
480
753
  chat_id: args.chat_id,
481
754
  photo: bytes,
482
755
  filename,
483
756
  ...(args.caption === undefined ? {} : { caption: args.caption }),
484
- });
757
+ }, { signal: extra.signal });
485
758
  return {
486
759
  content: [{ type: "text", text: `Sent ${kind} ${result.message_id} (${filename}) to ${String(result.chat.id)}.` }],
487
760
  structuredContent: { ok: true, chat_id: result.chat.id, message_id: result.message_id, filename },
@@ -510,8 +783,8 @@ async function resolveTelegramFileBytes(input) {
510
783
  }
511
784
  }
512
785
  else {
513
- const resolved = resolvePath(process.cwd(), input.path);
514
- 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 }));
515
788
  filename = input.filename ?? basename(resolved);
516
789
  }
517
790
  if (bytes.byteLength === 0) {
@@ -522,6 +795,110 @@ async function resolveTelegramFileBytes(input) {
522
795
  }
523
796
  return { bytes, filename };
524
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
+ }
525
902
  async function resolveSlackSendToolSettings(input, options) {
526
903
  try {
527
904
  const adapter = await loadSlackModule();
@@ -542,13 +919,16 @@ async function resolveSlackSendToolSettings(input, options) {
542
919
  return undefined;
543
920
  }
544
921
  }
545
- async function resolveTelegramSendToolSettings(input, options, tools) {
922
+ async function resolveTelegramSendToolSettings(input, options, tools, askBridge) {
546
923
  try {
547
924
  const adapter = await loadTelegramModule();
548
925
  const config = await adapter.loadTelegramAdapterConfig({ env: input.env, jsonPath: input.configPath });
549
926
  if (!config.enabled) {
550
927
  return undefined;
551
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);
552
932
  return {
553
933
  botToken: config.botToken,
554
934
  allowedChatIds: config.allowedChatIds,
@@ -556,6 +936,11 @@ async function resolveTelegramSendToolSettings(input, options, tools) {
556
936
  ...(config.apiRoot === undefined ? {} : { apiRoot: config.apiRoot }),
557
937
  maxUploadBytes: config.attachments?.maxUploadBytes ?? adapter.DEFAULT_ATTACHMENT_MAX_BYTES,
558
938
  tools,
939
+ ...(askBridge === undefined ? {} : { askBridge }),
940
+ ...(config.sendTools === undefined ? {} : { sendTools: config.sendTools }),
941
+ ...(producingConversationId === undefined ? {} : { producingConversationId }),
942
+ ...(runOutputDir === undefined ? {} : { runOutputDir }),
943
+ ...(runOutputIdentity === undefined ? {} : { runOutputIdentity }),
559
944
  };
560
945
  }
561
946
  catch (error) {
@@ -573,11 +958,81 @@ function assertSlackChannelAllowed(settings, channel) {
573
958
  }
574
959
  function assertTelegramChatAllowed(settings, chatId) {
575
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
+ }
576
970
  if (settings.allowAllChats || settings.allowedChatIds.includes(normalized)) {
577
971
  return;
578
972
  }
579
973
  throw new Error("TelegramSendMessage: chat_id is not allowed by Telegram adapter config.");
580
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
+ }
581
1036
  function optionalString(value) {
582
1037
  if (value === undefined) {
583
1038
  return undefined;