@mono-agent/agent-app 0.3.0 → 0.4.1

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 (186) hide show
  1. package/README.md +24 -10
  2. package/dist/adapter-send-tools-main.d.ts +3 -0
  3. package/dist/adapter-send-tools-main.d.ts.map +1 -0
  4. package/dist/adapter-send-tools-main.js +20 -0
  5. package/dist/adapter-send-tools-main.js.map +1 -0
  6. package/dist/adapter-send-tools.d.ts +125 -0
  7. package/dist/adapter-send-tools.d.ts.map +1 -0
  8. package/dist/adapter-send-tools.js +629 -0
  9. package/dist/adapter-send-tools.js.map +1 -0
  10. package/dist/app-config.d.ts +86 -26
  11. package/dist/app-config.d.ts.map +1 -1
  12. package/dist/app-config.js +284 -90
  13. package/dist/app-config.js.map +1 -1
  14. package/dist/app.d.ts +51 -20
  15. package/dist/app.d.ts.map +1 -1
  16. package/dist/app.js +889 -119
  17. package/dist/app.js.map +1 -1
  18. package/dist/artifact-retention.d.ts +32 -0
  19. package/dist/artifact-retention.d.ts.map +1 -0
  20. package/dist/artifact-retention.js +111 -0
  21. package/dist/artifact-retention.js.map +1 -0
  22. package/dist/audit-runs.d.ts +12 -0
  23. package/dist/audit-runs.d.ts.map +1 -0
  24. package/dist/audit-runs.js +68 -0
  25. package/dist/audit-runs.js.map +1 -0
  26. package/dist/backfill.d.ts +75 -0
  27. package/dist/backfill.d.ts.map +1 -0
  28. package/dist/backfill.js +350 -0
  29. package/dist/backfill.js.map +1 -0
  30. package/dist/background.d.ts +8 -5
  31. package/dist/background.d.ts.map +1 -1
  32. package/dist/background.js +279 -53
  33. package/dist/background.js.map +1 -1
  34. package/dist/broadcast-recorder.d.ts +27 -0
  35. package/dist/broadcast-recorder.d.ts.map +1 -0
  36. package/dist/broadcast-recorder.js +82 -0
  37. package/dist/broadcast-recorder.js.map +1 -0
  38. package/dist/channel-config-view.d.ts +28 -0
  39. package/dist/channel-config-view.d.ts.map +1 -0
  40. package/dist/channel-config-view.js +79 -0
  41. package/dist/channel-config-view.js.map +1 -0
  42. package/dist/channel-gate.d.ts +22 -0
  43. package/dist/channel-gate.d.ts.map +1 -0
  44. package/dist/channel-gate.js +35 -0
  45. package/dist/channel-gate.js.map +1 -0
  46. package/dist/channel-plugins.d.ts +39 -0
  47. package/dist/channel-plugins.d.ts.map +1 -0
  48. package/dist/channel-plugins.js +346 -0
  49. package/dist/channel-plugins.js.map +1 -0
  50. package/dist/channels.d.ts +62 -66
  51. package/dist/channels.d.ts.map +1 -1
  52. package/dist/channels.js +850 -120
  53. package/dist/channels.js.map +1 -1
  54. package/dist/cli.d.ts +103 -4
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +984 -112
  57. package/dist/cli.js.map +1 -1
  58. package/dist/config-reference.d.ts +24 -0
  59. package/dist/config-reference.d.ts.map +1 -0
  60. package/dist/config-reference.js +580 -0
  61. package/dist/config-reference.js.map +1 -0
  62. package/dist/configured-agent.d.ts +104 -0
  63. package/dist/configured-agent.d.ts.map +1 -0
  64. package/dist/configured-agent.js +578 -0
  65. package/dist/configured-agent.js.map +1 -0
  66. package/dist/consumer-contract.d.ts +36 -0
  67. package/dist/consumer-contract.d.ts.map +1 -0
  68. package/dist/consumer-contract.js +290 -0
  69. package/dist/consumer-contract.js.map +1 -0
  70. package/dist/doctor.d.ts +17 -0
  71. package/dist/doctor.d.ts.map +1 -1
  72. package/dist/doctor.js +392 -95
  73. package/dist/doctor.js.map +1 -1
  74. package/dist/index.d.ts +14 -6
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +7 -3
  77. package/dist/index.js.map +1 -1
  78. package/dist/init.d.ts +23 -7
  79. package/dist/init.d.ts.map +1 -1
  80. package/dist/init.js +91 -26
  81. package/dist/init.js.map +1 -1
  82. package/dist/install-skill.d.ts +1 -1
  83. package/dist/install-skill.js +2 -2
  84. package/dist/install-skill.js.map +1 -1
  85. package/dist/interaction-bridge.d.ts +72 -0
  86. package/dist/interaction-bridge.d.ts.map +1 -0
  87. package/dist/interaction-bridge.js +354 -0
  88. package/dist/interaction-bridge.js.map +1 -0
  89. package/dist/launchd.d.ts +0 -2
  90. package/dist/launchd.d.ts.map +1 -1
  91. package/dist/launchd.js +0 -2
  92. package/dist/launchd.js.map +1 -1
  93. package/dist/memory-command.d.ts +10 -0
  94. package/dist/memory-command.d.ts.map +1 -0
  95. package/dist/memory-command.js +649 -0
  96. package/dist/memory-command.js.map +1 -0
  97. package/dist/memory-recall-main.d.ts +3 -0
  98. package/dist/memory-recall-main.d.ts.map +1 -0
  99. package/dist/memory-recall-main.js +30 -0
  100. package/dist/memory-recall-main.js.map +1 -0
  101. package/dist/memory-recall.d.ts +147 -0
  102. package/dist/memory-recall.d.ts.map +1 -0
  103. package/dist/memory-recall.js +345 -0
  104. package/dist/memory-recall.js.map +1 -0
  105. package/dist/memory-rituals.d.ts +8 -11
  106. package/dist/memory-rituals.d.ts.map +1 -1
  107. package/dist/memory-rituals.js +26 -25
  108. package/dist/memory-rituals.js.map +1 -1
  109. package/dist/metrics.d.ts +13 -0
  110. package/dist/metrics.d.ts.map +1 -0
  111. package/dist/metrics.js +105 -0
  112. package/dist/metrics.js.map +1 -0
  113. package/dist/notify-destinations.d.ts +23 -0
  114. package/dist/notify-destinations.d.ts.map +1 -0
  115. package/dist/notify-destinations.js +79 -0
  116. package/dist/notify-destinations.js.map +1 -0
  117. package/dist/posted-message-index.d.ts +61 -0
  118. package/dist/posted-message-index.d.ts.map +1 -0
  119. package/dist/posted-message-index.js +178 -0
  120. package/dist/posted-message-index.js.map +1 -0
  121. package/dist/proactive-notify.d.ts +32 -0
  122. package/dist/proactive-notify.d.ts.map +1 -0
  123. package/dist/proactive-notify.js +62 -0
  124. package/dist/proactive-notify.js.map +1 -0
  125. package/dist/recipes/base.d.ts +15 -0
  126. package/dist/recipes/base.d.ts.map +1 -0
  127. package/dist/recipes/base.js +51 -0
  128. package/dist/recipes/base.js.map +1 -0
  129. package/dist/recipes/catalog.d.ts +4 -0
  130. package/dist/recipes/catalog.d.ts.map +1 -0
  131. package/dist/recipes/catalog.js +509 -0
  132. package/dist/recipes/catalog.js.map +1 -0
  133. package/dist/recipes/index.d.ts +11 -0
  134. package/dist/recipes/index.d.ts.map +1 -0
  135. package/dist/recipes/index.js +14 -0
  136. package/dist/recipes/index.js.map +1 -0
  137. package/dist/recipes/types.d.ts +70 -0
  138. package/dist/recipes/types.d.ts.map +1 -0
  139. package/dist/recipes/types.js +15 -0
  140. package/dist/recipes/types.js.map +1 -0
  141. package/dist/request-model-override.d.ts +67 -0
  142. package/dist/request-model-override.d.ts.map +1 -0
  143. package/dist/request-model-override.js +96 -0
  144. package/dist/request-model-override.js.map +1 -0
  145. package/dist/runs-health.d.ts +22 -0
  146. package/dist/runs-health.d.ts.map +1 -0
  147. package/dist/runs-health.js +144 -0
  148. package/dist/runs-health.js.map +1 -0
  149. package/dist/seen-conversations.d.ts +22 -0
  150. package/dist/seen-conversations.d.ts.map +1 -0
  151. package/dist/seen-conversations.js +120 -0
  152. package/dist/seen-conversations.js.map +1 -0
  153. package/dist/sessions.d.ts +21 -0
  154. package/dist/sessions.d.ts.map +1 -0
  155. package/dist/sessions.js +45 -0
  156. package/dist/sessions.js.map +1 -0
  157. package/dist/setup.d.ts +29 -0
  158. package/dist/setup.d.ts.map +1 -0
  159. package/dist/setup.js +97 -0
  160. package/dist/setup.js.map +1 -0
  161. package/dist/trigger-overrides.d.ts +17 -0
  162. package/dist/trigger-overrides.d.ts.map +1 -0
  163. package/dist/trigger-overrides.js +30 -0
  164. package/dist/trigger-overrides.js.map +1 -0
  165. package/dist/tui-command.d.ts +56 -0
  166. package/dist/tui-command.d.ts.map +1 -0
  167. package/dist/tui-command.js +160 -0
  168. package/dist/tui-command.js.map +1 -0
  169. package/dist/ui.d.ts +54 -0
  170. package/dist/ui.d.ts.map +1 -0
  171. package/dist/ui.js +157 -0
  172. package/dist/ui.js.map +1 -0
  173. package/dist/web-command.d.ts +39 -0
  174. package/dist/web-command.d.ts.map +1 -0
  175. package/dist/web-command.js +151 -0
  176. package/dist/web-command.js.map +1 -0
  177. package/package.json +22 -17
  178. package/schema/mono-agent.config.schema.json +1686 -0
  179. package/skills/mono-agent-composer/SKILL.md +38 -12
  180. package/skills/mono-agent-composer/agents/openai.yaml +4 -0
  181. package/skills/mono-agent-composer/references/config-blueprint.md +84 -31
  182. package/skills/mono-agent-composer/references/discovery-questions.md +49 -36
  183. package/skills/mono-agent-composer/references/feature-coverage.md +26 -19
  184. package/skills/mono-agent-composer/references/package-map.md +20 -22
  185. package/skills/mono-agent-composer/references/playbooks.md +209 -0
  186. package/skills/mono-agent-composer/references/validation.md +14 -5
package/dist/channels.js CHANGED
@@ -1,50 +1,241 @@
1
- import { resolve } from "node:path";
2
- import { A2AConsumerError, A2AProviderError, startA2AProvider, } from "@mono-agent/a2a-adapter";
3
- import { loadA2AAdapterConfig } from "@mono-agent/a2a-adapter";
4
- import { CronAdapterError, loadCronAdapterConfig, startCronAdapter } from "@mono-agent/cron-adapter";
5
- import { loadOpenAIApiAdapterConfig, OpenAIApiAdapterError, startOpenAIApiAdapter, } from "@mono-agent/openai-api-adapter";
6
- import { loadSlackAdapterConfig, SlackAdapterConfigError, startSlackAdapter, } from "@mono-agent/slack-adapter";
7
- import { loadTelegramAdapterConfig, startTelegramAdapter, TelegramAdapterConfigError, } from "@mono-agent/telegram-adapter";
8
- import { loadWebhookAdapterConfig, startWebhookAdapter, WebhookAdapterError, } from "@mono-agent/webhook-adapter";
9
- import { loadWhatsAppAdapterConfig, startWhatsAppAdapter, WhatsAppAdapterConfigError, } from "@mono-agent/whatsapp-adapter";
1
+ import { NOTHING_TO_REPORT_SENTINEL } from "@mono-agent/agent-contracts";
2
+ import { describeRunFailureKind } from "@mono-agent/observability";
3
+ import { discoverLocalProviderModels, modelReferenceKey, parseMonoRuntimeModelReference, resolveModelEffortLevels, } from "@mono-agent/runtime-adapter";
4
+ import { isAdapterSendToolAllowed } from "./adapter-send-tools.js";
5
+ import { isChannelConfigured } from "./channel-gate.js";
6
+ import { buildChannelConfigView } from "./channel-config-view.js";
7
+ import { resolveConfiguredChannelPlugins, } from "./channel-plugins.js";
8
+ import { findTriggerOverrideIssues } from "./trigger-overrides.js";
9
+ import { appendPostedMessage, lookupProducingConversation } from "./posted-message-index.js";
10
+ /** The channel ids this app can drive from config (third-party drivers add their own). */
11
+ export const BUILTIN_CHANNEL_IDS = [
12
+ "telegram",
13
+ "slack",
14
+ "webhook",
15
+ "openai-api",
16
+ "cron",
17
+ "tui",
18
+ "live",
19
+ ];
20
+ let telegramModule;
21
+ let slackModule;
22
+ let webhookModule;
23
+ let openaiApiModule;
24
+ let cronModule;
25
+ let tuiModule;
26
+ const loadTelegramModule = async () => (telegramModule ??= await import("@mono-agent/telegram-adapter"));
27
+ const loadSlackModule = async () => (slackModule ??= await import("@mono-agent/slack-adapter"));
28
+ const loadWebhookModule = async () => (webhookModule ??= await import("@mono-agent/webhook-adapter"));
29
+ const loadOpenAIApiModule = async () => (openaiApiModule ??= await import("@mono-agent/openai-api-adapter"));
30
+ const loadCronModule = async () => (cronModule ??= await import("@mono-agent/cron-adapter"));
31
+ const loadTuiModule = async () => (tuiModule ??= await import("@mono-agent/operator-adapter"));
32
+ let liveModule;
33
+ const loadLiveModule = async () => (liveModule ??= await import("@mono-agent/operator-adapter"));
34
+ const TELEGRAM_GATE = { jsonKey: "telegram", envPrefix: "MONO_AGENT_TELEGRAM_" };
35
+ const SLACK_GATE = { jsonKey: "slack", envPrefix: "MONO_AGENT_SLACK_" };
36
+ const WEBHOOK_GATE = { jsonKey: "webhook", envPrefix: "MONO_AGENT_WEBHOOK_", dir: "webhook" };
37
+ const OPENAI_API_GATE = { jsonKey: "openaiApi", envPrefix: "MONO_AGENT_OPENAI_API_" };
38
+ const CRON_GATE = { jsonKey: "cron", envPrefix: "MONO_AGENT_CRON_", dir: "cron" };
39
+ const UNCONFIGURED_TELEGRAM_CONFIG = {
40
+ enabled: false,
41
+ botToken: "",
42
+ allowedChatIds: [],
43
+ allowAllChats: false,
44
+ };
45
+ const UNCONFIGURED_SLACK_CONFIG = {
46
+ enabled: false,
47
+ botToken: "",
48
+ appToken: "",
49
+ allowedChannelIds: [],
50
+ allowAllChannels: false,
51
+ botUserIds: [],
52
+ mentionTextAliases: [],
53
+ stripMentionText: false,
54
+ shortcuts: [],
55
+ homeTab: { enabled: false, buttons: [] },
56
+ };
57
+ const UNCONFIGURED_WEBHOOK_CONFIG = {
58
+ enabled: false,
59
+ host: "127.0.0.1",
60
+ port: 0,
61
+ allowNonLoopback: false,
62
+ retentionMs: 300_000,
63
+ maxStoredRequests: 100,
64
+ endpoints: [{ name: "default", path: "/webhook/invoke", mode: "sync", enabled: true }],
65
+ path: "/webhook/invoke",
66
+ defaultMode: "sync",
67
+ };
68
+ const UNCONFIGURED_OPENAI_API_CONFIG = {
69
+ enabled: false,
70
+ host: "127.0.0.1",
71
+ port: 0,
72
+ basePath: "/v1",
73
+ allowNonLoopback: false,
74
+ modelId: "agent",
75
+ };
76
+ const UNCONFIGURED_CRON_CONFIG = { jobs: [] };
77
+ /** Config-view section for a channel the gate found no intent for (no fields to annotate). */
78
+ function unconfiguredChannelView(id, label) {
79
+ return { id, label, status: "disabled", fields: [] };
80
+ }
10
81
  export function createTelegramChannelDriver(overrides = {}) {
11
82
  return {
12
83
  id: "telegram",
13
84
  label: "Telegram",
85
+ async configView(input) {
86
+ if (!(await isChannelConfigured(input, TELEGRAM_GATE))) {
87
+ return unconfiguredChannelView("telegram", "Telegram");
88
+ }
89
+ const adapter = await loadTelegramModule();
90
+ return await buildChannelConfigView(this, adapter.TELEGRAM_CONFIG_FIELDS, input);
91
+ },
14
92
  async loadConfig(input) {
15
- return await loadTelegramAdapterConfig({ env: input.env, jsonPath: input.configPath });
93
+ if (!(await isChannelConfigured(input, TELEGRAM_GATE))) {
94
+ return UNCONFIGURED_TELEGRAM_CONFIG;
95
+ }
96
+ const adapter = await loadTelegramModule();
97
+ return await adapter.loadTelegramAdapterConfig({ env: input.env, jsonPath: input.configPath });
16
98
  },
17
99
  isConfigError(error) {
18
- return error instanceof TelegramAdapterConfigError;
100
+ return telegramModule !== undefined && error instanceof telegramModule.TelegramAdapterConfigError;
19
101
  },
20
102
  disabledReason(config) {
21
103
  return config.enabled ? undefined : "Telegram is disabled.";
22
104
  },
23
105
  async start(input) {
24
- const startAdapter = overrides.startAdapter ?? startTelegramAdapter;
106
+ const adapter = await loadTelegramModule();
107
+ const startAdapter = overrides.startAdapter ?? adapter.startTelegramAdapter;
25
108
  const result = await startAdapter(telegramStartOptions(input, overrides));
109
+ // Register this channel's interaction sink so the host bridge can post
110
+ // ask_user questions and tool-progress status lines into Telegram chats.
111
+ // The sink enforces the adapter allowlist: a tool-supplied conversation
112
+ // can never reach a chat the operator never allowlisted.
113
+ input.interaction?.registerSink("telegram", {
114
+ postQuestion: async (conversationId, text) => {
115
+ await result.post(requireAllowedTelegramChat(conversationId, input), text);
116
+ },
117
+ postStatus: async (conversationId, text, statusOptions) => {
118
+ await result.postStatus(requireAllowedTelegramChat(conversationId, input), text, statusOptions);
119
+ },
120
+ });
26
121
  return {
27
122
  summary: {},
28
123
  stop: () => result.stop(),
124
+ // Push delivery: a proactive nudge to telegram:<chat> runs as a turn on
125
+ // this chat's own harness and is delivered through the normal stream.
126
+ // Enforces the adapter allowlist so a payload-supplied destination cannot
127
+ // reach a chat the operator never allowlisted.
128
+ notify: async ({ conversationId, text, verbatim }) => {
129
+ const chatId = telegramChatIdFromConversation(conversationId);
130
+ if (chatId === undefined) {
131
+ input.logger?.warn?.("Telegram proactive notify skipped: unparseable destination.", { conversationId });
132
+ return { delivered: false, reason: "unparseable telegram destination" };
133
+ }
134
+ if (!input.config.allowAllChats && !input.config.allowedChatIds.includes(String(chatId))) {
135
+ input.logger?.warn?.("Telegram proactive notify skipped: destination not in allowlist.", { conversationId });
136
+ return { delivered: false, reason: "telegram chat is not in the adapter allowlist" };
137
+ }
138
+ // During configured quiet hours, deliver silently (disable_notification)
139
+ // so an overnight cron/webhook result lands without a push sound.
140
+ const silent = input.config.quietHours !== undefined &&
141
+ adapter.isWithinQuietHours(new Date(), input.config.quietHours);
142
+ const notifyOptions = verbatim === undefined && !silent
143
+ ? undefined
144
+ : {
145
+ ...(verbatim === undefined ? {} : { verbatim }),
146
+ ...(silent ? { silent: true } : {}),
147
+ };
148
+ return await result.notify(chatId, text, notifyOptions);
149
+ },
29
150
  };
30
151
  },
31
152
  };
32
153
  }
154
+ /**
155
+ * Download-path attachment options from config (`maxUploadBytes` stays with the
156
+ * send tools, which reload the config themselves).
157
+ */
158
+ function telegramAttachmentOptions(config) {
159
+ const attachments = config.attachments;
160
+ if (attachments?.maxBytes === undefined && attachments?.downloadTimeoutMs === undefined) {
161
+ return undefined;
162
+ }
163
+ return {
164
+ ...(attachments.maxBytes === undefined ? {} : { maxBytes: attachments.maxBytes }),
165
+ ...(attachments.downloadTimeoutMs === undefined ? {} : { downloadTimeoutMs: attachments.downloadTimeoutMs }),
166
+ };
167
+ }
168
+ /** Resolve + allowlist-check a `telegram:<chat>` destination for interaction-sink posts. */
169
+ function requireAllowedTelegramChat(conversationId, input) {
170
+ const chatId = telegramChatIdFromConversation(conversationId);
171
+ if (chatId === undefined) {
172
+ throw new Error(`unparseable telegram destination: ${conversationId}`);
173
+ }
174
+ if (!input.config.allowAllChats && !input.config.allowedChatIds.includes(String(chatId))) {
175
+ throw new Error("telegram chat is not in the adapter allowlist.");
176
+ }
177
+ return chatId;
178
+ }
179
+ /** Extract the Telegram chat id from a `telegram:<chat>` conversationId (numeric ids become numbers; a rollover #bucket suffix is stripped). */
180
+ export function telegramChatIdFromConversation(conversationId) {
181
+ const prefix = "telegram:";
182
+ if (!conversationId.startsWith(prefix)) {
183
+ return undefined;
184
+ }
185
+ const raw = conversationId.slice(prefix.length).split("#", 1)[0]?.trim();
186
+ if (raw === undefined || raw.length === 0) {
187
+ return undefined;
188
+ }
189
+ return /^-?\d+$/u.test(raw) ? Number(raw) : raw;
190
+ }
33
191
  export function createSlackChannelDriver(overrides = {}) {
34
192
  return {
35
193
  id: "slack",
36
194
  label: "Slack",
195
+ async configView(input) {
196
+ if (!(await isChannelConfigured(input, SLACK_GATE))) {
197
+ return unconfiguredChannelView("slack", "Slack");
198
+ }
199
+ const adapter = await loadSlackModule();
200
+ return await buildChannelConfigView(this, adapter.SLACK_CONFIG_FIELDS, input);
201
+ },
37
202
  async loadConfig(input) {
38
- return await loadSlackAdapterConfig({ env: input.env, jsonPath: input.configPath });
203
+ if (!(await isChannelConfigured(input, SLACK_GATE))) {
204
+ return UNCONFIGURED_SLACK_CONFIG;
205
+ }
206
+ const adapter = await loadSlackModule();
207
+ return await adapter.loadSlackAdapterConfig({ env: input.env, jsonPath: input.configPath });
39
208
  },
40
209
  isConfigError(error) {
41
- return error instanceof SlackAdapterConfigError;
210
+ return slackModule !== undefined && error instanceof slackModule.SlackAdapterConfigError;
42
211
  },
43
212
  disabledReason(config) {
44
213
  return config.enabled ? undefined : "Slack is disabled.";
45
214
  },
46
215
  async start(input) {
47
- const startAdapter = overrides.startAdapter ?? startSlackAdapter;
216
+ const adapter = await loadSlackModule();
217
+ const startAdapter = overrides.startAdapter ?? adapter.startSlackAdapter;
218
+ // Link posted messages to their producing conversation so an in-thread reply
219
+ // resumes that conversation instead of a fresh, history-less slack: thread.
220
+ const indexPath = input.postedMessageIndexPath;
221
+ // Forward the operator's optional Socket Mode resilience tuning; omitting a
222
+ // field lets the runner apply its own default.
223
+ const reconnect = {};
224
+ if (input.config.reconnectInitialBackoffMs !== undefined)
225
+ reconnect.initialMs = input.config.reconnectInitialBackoffMs;
226
+ if (input.config.reconnectMaxBackoffMs !== undefined)
227
+ reconnect.maxMs = input.config.reconnectMaxBackoffMs;
228
+ if (input.config.reconnectStabilityMs !== undefined)
229
+ reconnect.stabilityMs = input.config.reconnectStabilityMs;
230
+ if (input.config.reconnectStartupGraceMs !== undefined)
231
+ reconnect.startupGraceMs = input.config.reconnectStartupGraceMs;
232
+ if (input.config.drainDeadlineMs !== undefined)
233
+ reconnect.drainDeadlineMs = input.config.drainDeadlineMs;
234
+ const heartbeat = {};
235
+ if (input.config.heartbeatIntervalMs !== undefined)
236
+ heartbeat.intervalMs = input.config.heartbeatIntervalMs;
237
+ if (input.config.heartbeatTimeoutMs !== undefined)
238
+ heartbeat.timeoutMs = input.config.heartbeatTimeoutMs;
48
239
  const result = await startAdapter({
49
240
  botToken: input.config.botToken,
50
241
  appToken: input.config.appToken,
@@ -53,130 +244,389 @@ export function createSlackChannelDriver(overrides = {}) {
53
244
  botUserIds: input.config.botUserIds,
54
245
  mentionTextAliases: input.config.mentionTextAliases,
55
246
  stripMentionText: input.config.stripMentionText,
247
+ shortcuts: input.config.shortcuts,
248
+ homeTab: input.config.homeTab,
56
249
  responder: input.responder,
250
+ // A Socket Mode drop is recoverable by construction (the runner always
251
+ // reconnects with backoff), so report it as DEGRADED — keep the responder/
252
+ // harness alive — rather than fatal. onConnectionRestored flips the channel
253
+ // back to running once a reconnect stays up past the stability window.
254
+ onConnectionLost: (reason) => input.onDegraded?.(reason),
255
+ onConnectionRestored: () => input.onRecovered?.(),
256
+ ...(Object.keys(reconnect).length === 0 ? {} : { reconnect }),
257
+ ...(Object.keys(heartbeat).length === 0 ? {} : { heartbeat }),
57
258
  ...(input.logger === undefined ? {} : { logger: input.logger }),
259
+ ...(indexPath === undefined
260
+ ? {}
261
+ : {
262
+ resolvePostIndex: (channelId, ts) => lookupProducingConversation(indexPath, channelId, ts),
263
+ recordPostedMessage: (channelId, ts, conversationId) => {
264
+ void appendPostedMessage(indexPath, { channelId, ts, conversationId });
265
+ },
266
+ }),
58
267
  ...(overrides.createApi === undefined ? {} : { createApi: overrides.createApi }),
59
268
  ...(overrides.webSocketFactory === undefined ? {} : { webSocketFactory: overrides.webSocketFactory }),
60
269
  });
61
270
  return {
62
271
  summary: {},
63
272
  stop: () => result.stop(),
273
+ // Push delivery: a proactive nudge to slack:<ch>[:<thread>] runs as a turn
274
+ // on that conversation's own harness and is delivered through the stream.
275
+ // Enforces the adapter allowlist so a payload-supplied destination cannot
276
+ // reach a channel the operator never allowlisted.
277
+ notify: async ({ conversationId, text, verbatim }) => {
278
+ const target = slackTargetFromConversation(conversationId);
279
+ if (target === undefined) {
280
+ input.logger?.warn?.("Slack proactive notify skipped: unparseable destination.", { conversationId });
281
+ return { delivered: false, reason: "unparseable slack destination" };
282
+ }
283
+ const normalized = target.channelId.trim().toLowerCase();
284
+ const allowed = input.config.allowAllChannels ||
285
+ input.config.allowedChannelIds.some((id) => id.trim().toLowerCase() === normalized);
286
+ if (!allowed) {
287
+ input.logger?.warn?.("Slack proactive notify skipped: destination not in allowlist.", { conversationId });
288
+ return { delivered: false, reason: "slack channel is not in the adapter allowlist" };
289
+ }
290
+ return await result.adapter.notify(target.channelId, target.threadTs, text, verbatim === undefined ? undefined : { verbatim });
291
+ },
64
292
  };
65
293
  },
66
294
  };
67
295
  }
68
- export function createA2AChannelDriver(overrides = {}) {
296
+ /** Extract `{channelId, threadTs?}` from a `slack:<ch>[:<thread>]` conversationId (a rollover #bucket suffix is stripped). */
297
+ export function slackTargetFromConversation(conversationId) {
298
+ const prefix = "slack:";
299
+ if (!conversationId.startsWith(prefix)) {
300
+ return undefined;
301
+ }
302
+ const rest = conversationId.slice(prefix.length).split("#", 1)[0];
303
+ if (rest === undefined || rest.length === 0) {
304
+ return undefined;
305
+ }
306
+ const colon = rest.indexOf(":");
307
+ if (colon < 0) {
308
+ const channelId = rest.trim();
309
+ if (channelId.length === 0) {
310
+ return undefined;
311
+ }
312
+ return { channelId };
313
+ }
314
+ const channelId = rest.slice(0, colon).trim();
315
+ const threadTs = rest.slice(colon + 1).trim();
316
+ if (channelId.length === 0) {
317
+ return undefined;
318
+ }
319
+ if (threadTs.length === 0) {
320
+ return { channelId };
321
+ }
322
+ // A canonical Slack threadTs (e.g. 1718800000.123456) never contains a colon, so a
323
+ // stray/double colon is an operator typo — reject it so the driver warns + skips
324
+ // cleanly rather than posting to the Slack API with a malformed thread_ts.
325
+ if (threadTs.includes(":")) {
326
+ return undefined;
327
+ }
328
+ return { channelId, threadTs };
329
+ }
330
+ // Default wall-clock bound for a webhook run, reclaiming a hung slot. Mirrors
331
+ // {@link DEFAULT_CRON_MAX_RUN_MS}; an operator can override via webhook.maxRunMs.
332
+ const DEFAULT_WEBHOOK_MAX_RUN_MS = 20 * 60 * 1000;
333
+ export function createWebhookChannelDriver(overrides = {}) {
69
334
  return {
70
- id: "a2a",
71
- label: "A2A",
335
+ id: "webhook",
336
+ label: "Webhook",
337
+ async configView(input) {
338
+ if (!(await isChannelConfigured(input, WEBHOOK_GATE))) {
339
+ return unconfiguredChannelView("webhook", "Webhook");
340
+ }
341
+ const adapter = await loadWebhookModule();
342
+ return await buildChannelConfigView(this, adapter.WEBHOOK_CONFIG_FIELDS, input);
343
+ },
344
+ configIssues(config) {
345
+ return findTriggerOverrideIssues(config.endpoints
346
+ .filter((endpoint) => endpoint.enabled)
347
+ .map((endpoint) => ({
348
+ name: `webhook endpoint "${endpoint.name}"`,
349
+ ...(endpoint.model === undefined ? {} : { model: endpoint.model }),
350
+ ...(endpoint.effort === undefined ? {} : { effort: endpoint.effort }),
351
+ })));
352
+ },
72
353
  async loadConfig(input) {
73
- return await loadA2AAdapterConfig({ env: input.env, jsonPath: input.configPath });
354
+ if (!(await isChannelConfigured(input, WEBHOOK_GATE))) {
355
+ return UNCONFIGURED_WEBHOOK_CONFIG;
356
+ }
357
+ const adapter = await loadWebhookModule();
358
+ // cwd is required so `webhook/*.md` endpoint files are discovered.
359
+ return await adapter.loadWebhookAdapterConfig({ env: input.env, jsonPath: input.configPath, cwd: input.cwd });
74
360
  },
75
361
  isConfigError(error) {
76
- return error instanceof A2AProviderError || error instanceof A2AConsumerError;
362
+ return webhookModule !== undefined && error instanceof webhookModule.WebhookAdapterError;
77
363
  },
78
364
  disabledReason(config) {
79
- return config.provider.enabled ? undefined : "A2A provider is disabled.";
365
+ return config.enabled ? undefined : "Webhook adapter is disabled.";
80
366
  },
81
367
  waitingReason(config) {
82
- if (config.agent === undefined || config.skill === undefined) {
83
- return "A2A provider requires agent and skill configuration.";
84
- }
85
- return undefined;
368
+ return config.endpoints.some((endpoint) => endpoint.enabled)
369
+ ? undefined
370
+ : "Webhook adapter has no enabled endpoints.";
86
371
  },
87
372
  async start(input) {
88
- const config = input.config;
89
- if (config.agent === undefined || config.skill === undefined) {
90
- throw new A2AProviderError("missing_required_config", "A2A provider requires agent and skill configuration.");
91
- }
92
- const providerFactory = overrides.providerFactory ?? startA2AProvider;
93
- const provider = await providerFactory({
94
- host: config.provider.host,
95
- port: config.provider.port,
96
- ...(config.provider.publicBaseUrl === undefined ? {} : { publicBaseUrl: config.provider.publicBaseUrl }),
97
- allowNonLoopback: config.provider.allowNonLoopback,
98
- requireBearer: config.provider.requireBearer,
99
- ...(config.provider.bearerToken === undefined ? {} : { bearerToken: config.provider.bearerToken }),
373
+ const endpoints = input.config.endpoints.filter((endpoint) => endpoint.enabled);
374
+ const endpointByName = new Map(endpoints.map((endpoint) => [endpoint.name, endpoint]));
375
+ const adapterModule = await loadWebhookModule();
376
+ const adapterFactory = overrides.adapterFactory ?? adapterModule.startWebhookAdapter;
377
+ const adapter = await adapterFactory({
378
+ host: input.config.host,
379
+ port: input.config.port,
380
+ allowNonLoopback: input.config.allowNonLoopback,
381
+ defaultMode: input.config.defaultMode,
382
+ retentionMs: input.config.retentionMs,
383
+ maxStoredRequests: input.config.maxStoredRequests,
384
+ // Default-on max-run bound so a hung (esp. async) webhook can't hold a
385
+ // conversation slot forever; mirrors the cron adapter's 20-min default.
386
+ maxRunMs: input.config.maxRunMs ?? DEFAULT_WEBHOOK_MAX_RUN_MS,
387
+ endpoints: endpoints
388
+ .map((endpoint) => ({
389
+ name: endpoint.name,
390
+ path: endpoint.path,
391
+ mode: endpoint.mode,
392
+ ...(endpoint.prompt === undefined ? {} : { prompt: endpoint.prompt }),
393
+ ...(endpoint.notify === undefined ? {} : { notify: endpoint.notify }),
394
+ ...(endpoint.notifyConversationId === undefined ? {} : { notifyConversationId: endpoint.notifyConversationId }),
395
+ ...(endpoint.model === undefined ? {} : { model: endpoint.model }),
396
+ ...(endpoint.effort === undefined ? {} : { effort: endpoint.effort }),
397
+ })),
100
398
  responder: input.responder,
101
- agent: {
102
- name: config.agent.name,
103
- description: config.agent.description,
104
- version: config.agent.version,
105
- ...(config.agent.providerOrganization === undefined || config.agent.providerUrl === undefined
106
- ? {}
107
- : {
108
- provider: {
109
- organization: config.agent.providerOrganization,
110
- url: config.agent.providerUrl,
111
- },
112
- }),
399
+ onResult: (status, request) => {
400
+ void deliverNativeWebhookNotification({
401
+ endpoint: endpointByName.get(request.metadata.webhook.endpointName),
402
+ status,
403
+ request,
404
+ ...(input.notifyDestination === undefined ? {} : { notifyDestination: input.notifyDestination }),
405
+ ...(input.listNotifyDestinations === undefined ? {} : { listNotifyDestinations: input.listNotifyDestinations }),
406
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
407
+ });
113
408
  },
114
- skill: config.skill,
115
409
  ...(input.logger === undefined ? {} : { logger: input.logger }),
116
410
  });
117
411
  return {
118
- summary: { agentCardUrl: provider.agentCardUrl },
119
- stop: () => provider.stop(),
412
+ summary: {
413
+ invokeUrl: adapter.invokeUrl,
414
+ port: adapter.port,
415
+ invokeUrls: Object.fromEntries((adapter.endpoints ?? []).map((endpoint) => [endpoint.name, endpoint.invokeUrl])),
416
+ },
417
+ stop: () => adapter.stop(),
120
418
  };
121
419
  },
122
420
  };
123
421
  }
124
- export function createWebhookChannelDriver(overrides = {}) {
422
+ export function createOpenAIApiChannelDriver(overrides = {}) {
125
423
  return {
126
- id: "webhook",
127
- label: "Webhook",
424
+ id: "openai-api",
425
+ label: "OpenAI API",
426
+ async configView(input) {
427
+ if (!(await isChannelConfigured(input, OPENAI_API_GATE))) {
428
+ return unconfiguredChannelView("openai-api", "OpenAI API");
429
+ }
430
+ const adapter = await loadOpenAIApiModule();
431
+ return await buildChannelConfigView(this, adapter.OPENAI_API_CONFIG_FIELDS, input, { jsonKey: "openaiApi" });
432
+ },
128
433
  async loadConfig(input) {
129
- return await loadWebhookAdapterConfig({ env: input.env, jsonPath: input.configPath });
434
+ if (!(await isChannelConfigured(input, OPENAI_API_GATE))) {
435
+ return UNCONFIGURED_OPENAI_API_CONFIG;
436
+ }
437
+ const adapter = await loadOpenAIApiModule();
438
+ return await adapter.loadOpenAIApiAdapterConfig({ env: input.env, jsonPath: input.configPath });
130
439
  },
131
440
  isConfigError(error) {
132
- return error instanceof WebhookAdapterError;
441
+ return openaiApiModule !== undefined && error instanceof openaiApiModule.OpenAIApiAdapterError;
133
442
  },
134
443
  disabledReason(config) {
135
- return config.enabled ? undefined : "Webhook adapter is disabled.";
444
+ return config.enabled ? undefined : "OpenAI API adapter is disabled.";
136
445
  },
137
446
  async start(input) {
138
- const adapterFactory = overrides.adapterFactory ?? startWebhookAdapter;
447
+ const adapterModule = await loadOpenAIApiModule();
448
+ const adapterFactory = overrides.adapterFactory ?? adapterModule.startOpenAIApiAdapter;
139
449
  const adapter = await adapterFactory({
140
450
  host: input.config.host,
141
451
  port: input.config.port,
142
- path: input.config.path,
452
+ basePath: input.config.basePath,
143
453
  allowNonLoopback: input.config.allowNonLoopback,
144
- defaultMode: input.config.defaultMode,
145
- retentionMs: input.config.retentionMs,
146
- maxStoredRequests: input.config.maxStoredRequests,
454
+ ...(input.config.apiKey === undefined ? {} : { apiKey: input.config.apiKey }),
455
+ modelId: input.config.modelId,
147
456
  responder: input.responder,
148
457
  ...(input.logger === undefined ? {} : { logger: input.logger }),
149
458
  });
150
459
  return {
151
- summary: { invokeUrl: adapter.invokeUrl },
460
+ summary: { baseUrl: adapter.baseUrl },
152
461
  stop: () => adapter.stop(),
153
462
  };
154
463
  },
155
464
  };
156
465
  }
157
- export function createOpenAIApiChannelDriver(overrides = {}) {
466
+ /**
467
+ * `/v1/info` re-runs local-provider discovery at most this often; the TUI
468
+ * fetches info on connect + agent switch (not per turn), so this just bounds
469
+ * how often a flaky/slow local endpoint gets probed rather than affecting
470
+ * perceived freshness in practice.
471
+ */
472
+ const LOCAL_MODEL_DISCOVERY_TTL_MS = 30_000;
473
+ /**
474
+ * The TUI stream endpoint deviates from the channels-off convention: with no
475
+ * `tui` section and no `MONO_AGENT_TUI_*` env it is ENABLED on loopback with
476
+ * an ephemeral port, so `mono-agent tui` can reach any running agent without a
477
+ * per-agent config edit. There is consequently no isChannelConfigured gate and
478
+ * no synthetic UNCONFIGURED constant — the real loader always answers (its
479
+ * empty-input output is `enabled: true` + defaults). `"tui": {"enabled": false}`
480
+ * opts out.
481
+ */
482
+ export function createTuiChannelDriver(overrides = {}) {
158
483
  return {
159
- id: "openai-api",
160
- label: "OpenAI API",
484
+ id: "tui",
485
+ label: "TUI",
486
+ async configView(input) {
487
+ const adapter = await loadTuiModule();
488
+ return await buildChannelConfigView(this, adapter.TUI_CONFIG_FIELDS, input, { jsonKey: "tui" });
489
+ },
161
490
  async loadConfig(input) {
162
- return await loadOpenAIApiAdapterConfig({ env: input.env, jsonPath: input.configPath });
491
+ const adapter = await loadTuiModule();
492
+ return await adapter.loadTuiAdapterConfig({ env: input.env, jsonPath: input.configPath });
163
493
  },
164
494
  isConfigError(error) {
165
- return error instanceof OpenAIApiAdapterError;
495
+ return tuiModule !== undefined && error instanceof tuiModule.TuiAdapterError;
166
496
  },
167
497
  disabledReason(config) {
168
- return config.enabled ? undefined : "OpenAI API adapter is disabled.";
498
+ return config.enabled ? undefined : "TUI stream endpoint is disabled.";
169
499
  },
170
500
  async start(input) {
171
- const adapterFactory = overrides.adapterFactory ?? startOpenAIApiAdapter;
501
+ const adapterModule = await loadTuiModule();
502
+ const adapterFactory = overrides.adapterFactory ?? adapterModule.startTuiAdapter;
503
+ const discoverModels = overrides.discoverModels ?? discoverLocalProviderModels;
504
+ const localProviders = input.coreConfig.providers?.local;
505
+ // The statically configured candidate models: the primary first, then
506
+ // each configured fallback, as canonical `modelReferenceKey` strings,
507
+ // de-duplicated (a fallback redundantly naming the primary collapses away).
508
+ const configModelKeys = [];
509
+ for (const ref of [input.coreConfig.runtime.model, ...(input.coreConfig.runtime.fallbackModels ?? [])]) {
510
+ const key = modelReferenceKey(ref);
511
+ if (!configModelKeys.includes(key)) {
512
+ configModelKeys.push(key);
513
+ }
514
+ }
515
+ // Local-provider discovery is async and its result can change after
516
+ // startup (an endpoint started later, a model loaded/unloaded) — so
517
+ // `info` is a PROVIDER the adapter re-invokes on every GET /v1/info,
518
+ // rather than a value snapshotted once at channel composition. A short
519
+ // TTL cache keeps a flaky/slow endpoint from being hammered; the TUI
520
+ // only calls /v1/info on connect + agent switch, not per turn, so 30s
521
+ // of staleness there is unnoticeable in practice.
522
+ let discoveryCache;
523
+ const discoverModelsCached = async () => {
524
+ const now = Date.now();
525
+ if (discoveryCache !== undefined && now < discoveryCache.expiresAt) {
526
+ return discoveryCache.models;
527
+ }
528
+ const models = await discoverModels(localProviders);
529
+ discoveryCache = { expiresAt: now + LOCAL_MODEL_DISCOVERY_TTL_MS, models };
530
+ return models;
531
+ };
532
+ const buildInfo = async () => {
533
+ const discovered = await discoverModelsCached();
534
+ const labelByRef = new Map(discovered.map((model) => [model.ref, model.label]));
535
+ const models = [...configModelKeys];
536
+ for (const model of discovered) {
537
+ if (!models.includes(model.ref)) {
538
+ models.push(model.ref);
539
+ }
540
+ }
541
+ const modelOptions = {};
542
+ for (const ref of models) {
543
+ let parsedRef;
544
+ try {
545
+ parsedRef = parseMonoRuntimeModelReference(ref);
546
+ }
547
+ catch {
548
+ continue;
549
+ }
550
+ const resolved = resolveModelEffortLevels(parsedRef, localProviders);
551
+ const label = labelByRef.get(ref);
552
+ const entry = {
553
+ ...(resolved.effortLevels === undefined ? {} : { effortLevels: resolved.effortLevels }),
554
+ reasoning: resolved.reasoning,
555
+ ...(resolved.reasoningMode === undefined ? {} : { reasoningMode: resolved.reasoningMode }),
556
+ ...(label === undefined ? {} : { label }),
557
+ };
558
+ if (Object.keys(entry).length > 0) {
559
+ modelOptions[ref] = entry;
560
+ }
561
+ }
562
+ return {
563
+ model: modelReferenceKey(input.coreConfig.runtime.model),
564
+ ...(input.coreConfig.runtime.effort === undefined ? {} : { effort: input.coreConfig.runtime.effort }),
565
+ models,
566
+ ...(Object.keys(modelOptions).length === 0 ? {} : { modelOptions }),
567
+ };
568
+ };
172
569
  const adapter = await adapterFactory({
173
570
  host: input.config.host,
174
571
  port: input.config.port,
175
572
  basePath: input.config.basePath,
176
573
  allowNonLoopback: input.config.allowNonLoopback,
177
574
  ...(input.config.apiKey === undefined ? {} : { apiKey: input.config.apiKey }),
178
- modelId: input.config.modelId,
179
575
  responder: input.responder,
576
+ info: buildInfo,
577
+ // A dead endpoint must flip the channel to failed, not serve nothing
578
+ // silently — the TUI's only discovery signal is this channel's status.
579
+ onServerError: (reason) => input.onFailure(reason),
580
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
581
+ });
582
+ return {
583
+ summary: { baseUrl: adapter.baseUrl },
584
+ stop: () => adapter.stop(),
585
+ };
586
+ },
587
+ };
588
+ }
589
+ /**
590
+ * The live event relay, like the TUI endpoint, deviates from the channels-off
591
+ * convention: it is ENABLED by default on loopback with an ephemeral port so
592
+ * `mono-agent web` can observe any running agent without a per-agent config edit.
593
+ * It is passive and read-only — it relays the host's in-process run-event bus over
594
+ * SSE and never drives a turn. `"live": {"enabled": false}` opts out.
595
+ */
596
+ export function createLiveChannelDriver(overrides = {}) {
597
+ return {
598
+ id: "live",
599
+ label: "Live",
600
+ async configView(input) {
601
+ const adapter = await loadLiveModule();
602
+ return await buildChannelConfigView(this, adapter.LIVE_CONFIG_FIELDS, input, { jsonKey: "live" });
603
+ },
604
+ async loadConfig(input) {
605
+ const adapter = await loadLiveModule();
606
+ return await adapter.loadLiveAdapterConfig({ env: input.env, jsonPath: input.configPath });
607
+ },
608
+ isConfigError(error) {
609
+ return liveModule !== undefined && error instanceof liveModule.LiveAdapterError;
610
+ },
611
+ disabledReason(config) {
612
+ return config.enabled ? undefined : "Live event relay is disabled.";
613
+ },
614
+ async start(input) {
615
+ const adapterModule = await loadLiveModule();
616
+ const adapterFactory = overrides.adapterFactory ?? adapterModule.startLiveAdapter;
617
+ // The host feeds the shared run-event bus (via the broadcast recorder). If it
618
+ // has none, still serve an (empty) stream rather than failing the channel.
619
+ const bus = input.liveEventBus ?? adapterModule.createLiveEventBus();
620
+ const adapter = await adapterFactory({
621
+ bus,
622
+ host: input.config.host,
623
+ port: input.config.port,
624
+ basePath: input.config.basePath,
625
+ allowNonLoopback: input.config.allowNonLoopback,
626
+ ...(input.config.apiKey === undefined ? {} : { apiKey: input.config.apiKey }),
627
+ // A dead endpoint must flip the channel to failed, not serve nothing
628
+ // silently — the web surface's only per-agent live signal is this status.
629
+ onServerError: (reason) => input.onFailure(reason),
180
630
  ...(input.logger === undefined ? {} : { logger: input.logger }),
181
631
  });
182
632
  return {
@@ -186,15 +636,58 @@ export function createOpenAIApiChannelDriver(overrides = {}) {
186
636
  },
187
637
  };
188
638
  }
639
+ /**
640
+ * 20 minutes — comfortably above any real briefing/scan (observed max ~6.5 min) yet bounded, so a
641
+ * hung run is reclaimed rather than blocking the job indefinitely.
642
+ */
643
+ const DEFAULT_CRON_MAX_RUN_MS = 20 * 60 * 1000;
644
+ const DEFAULT_CRON_FAILURE_NOTICE_COOLDOWN_HOURS = 6;
645
+ const MAX_CRON_FAILURE_NOTICE_ERROR_CHARS = 180;
646
+ /**
647
+ * Whether a notify-enabled turn's final text should suppress delivery: empty /
648
+ * whitespace-only (the agent chose to stay silent) or exactly the
649
+ * {@link NOTHING_TO_REPORT_SENTINEL} token. Matched trimmed and
650
+ * case-insensitively; never substring-matched, so a digest that merely mentions
651
+ * the phrase is still delivered.
652
+ */
653
+ function suppressesNotification(text) {
654
+ const trimmed = text?.trim() ?? "";
655
+ return trimmed.length === 0 || trimmed.toUpperCase() === NOTHING_TO_REPORT_SENTINEL;
656
+ }
657
+ /** A conversationId a verbatim notification can be delivered to (a push channel with a notify hook). */
658
+ function isDeliverableConversation(conversationId) {
659
+ return conversationId.startsWith("telegram:") || conversationId.startsWith("slack:");
660
+ }
189
661
  export function createCronChannelDriver(overrides = {}) {
662
+ const failureNoticeLastSentMsByJobId = new Map();
190
663
  return {
191
664
  id: "cron",
192
665
  label: "Cron",
666
+ async configView(input) {
667
+ if (!(await isChannelConfigured(input, CRON_GATE))) {
668
+ return unconfiguredChannelView("cron", "Cron");
669
+ }
670
+ const adapter = await loadCronModule();
671
+ return await buildChannelConfigView(this, adapter.CRON_CONFIG_FIELDS, input);
672
+ },
673
+ configIssues(config) {
674
+ return findTriggerOverrideIssues(config.jobs
675
+ .filter((job) => job.enabled)
676
+ .map((job) => ({
677
+ name: `cron job "${job.id}"`,
678
+ ...(job.model === undefined ? {} : { model: job.model }),
679
+ ...(job.effort === undefined ? {} : { effort: job.effort }),
680
+ })));
681
+ },
193
682
  async loadConfig(input) {
194
- return await loadCronAdapterConfig({ env: input.env, jsonPath: input.configPath, cwd: input.cwd });
683
+ if (!(await isChannelConfigured(input, CRON_GATE))) {
684
+ return UNCONFIGURED_CRON_CONFIG;
685
+ }
686
+ const adapter = await loadCronModule();
687
+ return await adapter.loadCronAdapterConfig({ env: input.env, jsonPath: input.configPath, cwd: input.cwd });
195
688
  },
196
689
  isConfigError(error) {
197
- return error instanceof CronAdapterError;
690
+ return cronModule !== undefined && error instanceof cronModule.CronAdapterError;
198
691
  },
199
692
  disabledReason(config) {
200
693
  const enabledJobs = config.jobs.filter((job) => job.enabled);
@@ -202,19 +695,51 @@ export function createCronChannelDriver(overrides = {}) {
202
695
  },
203
696
  async start(input) {
204
697
  const jobs = input.config.jobs.filter((job) => job.enabled);
205
- const adapterFactory = overrides.adapterFactory ?? startCronAdapter;
698
+ const jobById = new Map(jobs.map((job) => [job.id, job]));
699
+ const adapterModule = await loadCronModule();
700
+ const adapterFactory = overrides.adapterFactory ?? adapterModule.startCronAdapter;
206
701
  const adapter = adapterFactory({
207
702
  responder: input.responder,
703
+ // Skip overlapping firings (a job still running when its next tick fires)
704
+ // — the legacy/default app behavior. This avoids the scheduler's
705
+ // unbounded "queue" default retaining stale ticks in memory when a job
706
+ // runs longer than its interval. (Queue/replace remain available to
707
+ // programmatic callers of startCronAdapter.) The watchdog below is what
708
+ // keeps "skip" safe: a hung run no longer pins the slot forever.
709
+ overlap: "skip",
710
+ // Reclaim a run whose responder never settles, so a single wedged run can't
711
+ // permanently skip every future firing of the job.
712
+ maxRunMs: overrides.maxRunMs ?? DEFAULT_CRON_MAX_RUN_MS,
208
713
  jobs: jobs.map((job) => ({
209
714
  id: job.id,
210
715
  expression: job.expression,
211
716
  timezone: job.timezone,
212
717
  prompt: job.prompt,
213
718
  ...(job.conversationId === undefined ? {} : { conversationId: job.conversationId }),
719
+ ...(job.maxRunMs === undefined ? {} : { maxRunMs: job.maxRunMs }),
720
+ ...(job.notify === undefined ? {} : { notify: job.notify }),
721
+ ...(job.notifyConversationId === undefined ? {} : { notifyConversationId: job.notifyConversationId }),
722
+ ...(job.model === undefined ? {} : { model: job.model }),
723
+ ...(job.effort === undefined ? {} : { effort: job.effort }),
214
724
  })),
215
725
  onResult: (result) => {
216
726
  const level = result.kind === "failed" ? "error" : result.kind === "skipped" ? "warn" : "info";
217
727
  input.logger?.[level]?.("Cron job finished.", { result });
728
+ void deliverCronModelExhaustionFailureNotice({
729
+ job: jobById.get(result.jobId),
730
+ result,
731
+ cooldowns: failureNoticeLastSentMsByJobId,
732
+ now: overrides.now ?? (() => new Date()),
733
+ ...(input.notifyDestination === undefined ? {} : { notifyDestination: input.notifyDestination }),
734
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
735
+ });
736
+ void deliverNativeCronNotification({
737
+ job: jobById.get(result.jobId),
738
+ result,
739
+ ...(input.notifyDestination === undefined ? {} : { notifyDestination: input.notifyDestination }),
740
+ ...(input.listNotifyDestinations === undefined ? {} : { listNotifyDestinations: input.listNotifyDestinations }),
741
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
742
+ });
218
743
  },
219
744
  ...(input.logger === undefined ? {} : { logger: input.logger }),
220
745
  });
@@ -227,57 +752,238 @@ export function createCronChannelDriver(overrides = {}) {
227
752
  },
228
753
  };
229
754
  }
230
- export function createWhatsAppChannelDriver(overrides = {}) {
231
- return {
232
- id: "whatsapp",
233
- label: "WhatsApp",
234
- async loadConfig(input) {
235
- return await loadWhatsAppAdapterConfig({ env: input.env, jsonPath: input.configPath });
236
- },
237
- isConfigError(error) {
238
- return error instanceof WhatsAppAdapterConfigError;
239
- },
240
- disabledReason(config) {
241
- return config.enabled ? undefined : "WhatsApp is disabled.";
242
- },
243
- async start(input) {
244
- const startAdapter = overrides.startAdapter ?? startWhatsAppAdapter;
245
- const result = await startAdapter({
246
- authDir: overrides.authDir ?? resolve(input.cwd, ".mono-agent", "whatsapp-auth"),
247
- config: input.config,
248
- responder: input.responder,
249
- ...(input.logger === undefined ? {} : { logger: input.logger }),
250
- onQr: (qr) => {
251
- input.logger?.info?.("WhatsApp login QR code received; scan it with the WhatsApp app.", { qr });
252
- },
253
- ...(overrides.socketFactory === undefined ? {} : { createSocket: overrides.socketFactory }),
755
+ async function deliverCronModelExhaustionFailureNotice(input) {
756
+ const job = input.job;
757
+ if (job?.notify !== true ||
758
+ input.result.kind !== "failed" ||
759
+ input.result.failureKind !== "provider_unavailable_exhausted") {
760
+ return;
761
+ }
762
+ if (job.notifyConversationId === undefined) {
763
+ input.logger?.warn?.("Cron failure notice skipped: notifyConversationId is required.", { jobId: job.id });
764
+ return;
765
+ }
766
+ if (input.notifyDestination === undefined) {
767
+ input.logger?.warn?.("Cron failure notice skipped: no delivery hook is available.", { jobId: job.id });
768
+ return;
769
+ }
770
+ const nowMs = input.now().getTime();
771
+ const cooldownHours = job.notifyFailureCooldownHours ?? DEFAULT_CRON_FAILURE_NOTICE_COOLDOWN_HOURS;
772
+ const cooldownMs = cooldownHours * 60 * 60 * 1000;
773
+ const lastSentMs = input.cooldowns.get(job.id);
774
+ if (lastSentMs !== undefined && nowMs - lastSentMs < cooldownMs) {
775
+ input.logger?.info?.("Cron failure notice skipped: cooldown is active.", {
776
+ jobId: job.id,
777
+ cooldownHours,
778
+ });
779
+ return;
780
+ }
781
+ const destination = job.notifyConversationId;
782
+ const text = buildCronModelExhaustionFailureNotice(job, input.result);
783
+ input.cooldowns.set(job.id, nowMs);
784
+ try {
785
+ const delivery = await input.notifyDestination(destination, text, { verbatim: true });
786
+ if (!delivery.delivered) {
787
+ input.logger?.warn?.("Cron failure notice was not delivered.", {
788
+ jobId: job.id,
789
+ conversationId: destination,
790
+ ...(delivery.reason === undefined ? {} : { reason: delivery.reason }),
254
791
  });
255
- return {
256
- summary: {},
257
- stop: () => result.stop(),
258
- };
259
- },
260
- };
792
+ return;
793
+ }
794
+ input.logger?.info?.("Cron failure notice delivered.", { jobId: job.id, conversationId: destination });
795
+ }
796
+ catch (error) {
797
+ input.logger?.warn?.("Cron failure notice failed.", {
798
+ jobId: job.id,
799
+ conversationId: destination,
800
+ reason: error instanceof Error ? error.message : String(error),
801
+ });
802
+ }
803
+ }
804
+ function buildCronModelExhaustionFailureNotice(job, result) {
805
+ const jobId = oneLine(job.id);
806
+ const latestError = truncateOneLine(result.error, MAX_CRON_FAILURE_NOTICE_ERROR_CHARS);
807
+ const prefix = `Cron job "${jobId}" failed: all configured models failed.`;
808
+ return latestError.length === 0 ? prefix : `${prefix} Latest error: ${latestError}`;
809
+ }
810
+ function truncateOneLine(value, maxChars) {
811
+ const collapsed = oneLine(value);
812
+ if (collapsed.length <= maxChars) {
813
+ return collapsed;
814
+ }
815
+ return `${collapsed.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
816
+ }
817
+ function oneLine(value) {
818
+ return value.replace(/\s+/gu, " ").trim();
819
+ }
820
+ async function deliverNativeCronNotification(input) {
821
+ const job = input.job;
822
+ if (job?.notify !== true || input.result.kind !== "succeeded") {
823
+ return;
824
+ }
825
+ const text = input.result.text;
826
+ // The agent stays silent by producing no final answer or the explicit sentinel.
827
+ if (text === undefined || suppressesNotification(text)) {
828
+ return;
829
+ }
830
+ try {
831
+ if (input.notifyDestination === undefined) {
832
+ input.logger?.warn?.("Native cron notification skipped: no delivery hook is available.", { jobId: job.id });
833
+ return;
834
+ }
835
+ const destination = await resolveNativeCronNotifyDestination({
836
+ job,
837
+ ...(input.listNotifyDestinations === undefined ? {} : { listNotifyDestinations: input.listNotifyDestinations }),
838
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
839
+ });
840
+ if (destination === undefined) {
841
+ return;
842
+ }
843
+ const delivery = await input.notifyDestination(destination, text, { verbatim: true });
844
+ if (!delivery.delivered) {
845
+ input.logger?.warn?.("Native cron notification was not delivered.", {
846
+ jobId: job.id,
847
+ conversationId: destination,
848
+ ...(delivery.reason === undefined ? {} : { reason: delivery.reason }),
849
+ });
850
+ }
851
+ }
852
+ catch (error) {
853
+ input.logger?.warn?.("Native cron notification failed.", {
854
+ jobId: job.id,
855
+ reason: error instanceof Error ? error.message : String(error),
856
+ });
857
+ }
858
+ }
859
+ async function resolveNativeCronNotifyDestination(input) {
860
+ if (input.job.notifyConversationId !== undefined) {
861
+ return input.job.notifyConversationId;
862
+ }
863
+ if (input.listNotifyDestinations === undefined) {
864
+ input.logger?.warn?.("Native cron notification skipped: no destination is configured and no destination resolver is available.", {
865
+ jobId: input.job.id,
866
+ });
867
+ return undefined;
868
+ }
869
+ const destinations = await input.listNotifyDestinations();
870
+ if (destinations.length !== 1) {
871
+ input.logger?.warn?.("Native cron notification skipped: destination inference requires exactly one candidate.", {
872
+ jobId: input.job.id,
873
+ destinationCount: destinations.length,
874
+ });
875
+ return undefined;
876
+ }
877
+ return destinations[0]?.conversationId;
878
+ }
879
+ async function deliverNativeWebhookNotification(input) {
880
+ const endpoint = input.endpoint;
881
+ if (endpoint?.notify !== true || input.status.status !== "succeeded") {
882
+ return;
883
+ }
884
+ const text = input.status.text;
885
+ // The agent stays silent by producing no final answer or the explicit sentinel.
886
+ if (text === undefined || suppressesNotification(text)) {
887
+ return;
888
+ }
889
+ const source = { endpointName: input.request.metadata.webhook.endpointName };
890
+ try {
891
+ if (input.notifyDestination === undefined) {
892
+ input.logger?.warn?.("Native webhook notification skipped: no delivery hook is available.", source);
893
+ return;
894
+ }
895
+ const destination = await resolveNativeWebhookNotifyDestination({
896
+ endpoint,
897
+ source,
898
+ requestConversationId: input.request.conversationId,
899
+ ...(input.listNotifyDestinations === undefined ? {} : { listNotifyDestinations: input.listNotifyDestinations }),
900
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
901
+ });
902
+ if (destination === undefined) {
903
+ return;
904
+ }
905
+ const delivery = await input.notifyDestination(destination, text, { verbatim: true });
906
+ if (!delivery.delivered) {
907
+ input.logger?.warn?.("Native webhook notification was not delivered.", {
908
+ ...source,
909
+ conversationId: destination,
910
+ ...(delivery.reason === undefined ? {} : { reason: delivery.reason }),
911
+ });
912
+ }
913
+ }
914
+ catch (error) {
915
+ input.logger?.warn?.("Native webhook notification failed.", {
916
+ ...source,
917
+ reason: error instanceof Error ? error.message : String(error),
918
+ });
919
+ }
920
+ }
921
+ async function resolveNativeWebhookNotifyDestination(input) {
922
+ if (input.endpoint.notifyConversationId !== undefined) {
923
+ return input.endpoint.notifyConversationId;
924
+ }
925
+ // Async-callback pattern: when the inbound payload names a deliverable chat
926
+ // (e.g. the service posts back the originating `telegram:`/`slack:` id), deliver
927
+ // the answer there. The owning channel's allowlist still bounds it.
928
+ if (input.requestConversationId !== undefined && isDeliverableConversation(input.requestConversationId)) {
929
+ return input.requestConversationId;
930
+ }
931
+ if (input.listNotifyDestinations === undefined) {
932
+ input.logger?.warn?.("Native webhook notification skipped: no destination is configured and no destination resolver is available.", input.source);
933
+ return undefined;
934
+ }
935
+ const destinations = await input.listNotifyDestinations();
936
+ if (destinations.length !== 1) {
937
+ input.logger?.warn?.("Native webhook notification skipped: destination inference requires exactly one candidate.", {
938
+ ...input.source,
939
+ destinationCount: destinations.length,
940
+ });
941
+ return undefined;
942
+ }
943
+ return destinations[0]?.conversationId;
261
944
  }
262
945
  /** Every channel the app can drive, in startup/status display order. */
263
946
  export function defaultChannelDrivers(overrides = {}) {
264
947
  return [
265
948
  createTelegramChannelDriver(overrides.telegram),
266
949
  createSlackChannelDriver(overrides.slack),
267
- createA2AChannelDriver(overrides.a2a),
268
950
  createWebhookChannelDriver(overrides.webhook),
269
951
  createOpenAIApiChannelDriver(overrides.openaiApi),
270
952
  createCronChannelDriver(overrides.cron),
271
- createWhatsAppChannelDriver(overrides.whatsapp),
953
+ createTuiChannelDriver(overrides.tui),
954
+ createLiveChannelDriver(overrides.live),
272
955
  ];
273
956
  }
957
+ export async function resolveChannelDrivers(input, overrides = {}) {
958
+ const drivers = [...defaultChannelDrivers(overrides)];
959
+ const plugins = await resolveConfiguredChannelPlugins(input, { reservedIds: BUILTIN_CHANNEL_IDS });
960
+ for (const plugin of plugins) {
961
+ const existing = drivers.findIndex((driver) => driver.id === plugin.id);
962
+ if (existing >= 0) {
963
+ drivers[existing] = plugin;
964
+ }
965
+ else {
966
+ drivers.push(plugin);
967
+ }
968
+ }
969
+ return drivers;
970
+ }
274
971
  function telegramStartOptions(input, overrides) {
972
+ // Subscribe to (and handle) inline-keyboard taps only when the `telegram_ask`
973
+ // tool is permitted by the tool policy — so enabling the tool is the single
974
+ // switch that turns on the whole ask/answer round-trip. Without it the bot
975
+ // stays message-only and never registers the callback handler.
976
+ const callbacksEnabled = isAdapterSendToolAllowed("telegram_ask", {
977
+ allowedTools: input.coreConfig.tools.allowedTools,
978
+ disallowedTools: input.coreConfig.tools.disallowedTools,
979
+ });
275
980
  return {
276
981
  botToken: input.config.botToken,
277
982
  allowedChatIds: [...input.config.allowedChatIds],
278
983
  allowAllChats: input.config.allowAllChats,
279
984
  responder: input.responder,
280
- allowedUpdates: ["message"],
985
+ allowedUpdates: callbacksEnabled ? ["message", "callback_query"] : ["message"],
986
+ ...(callbacksEnabled ? { callbacksEnabled: true } : {}),
281
987
  deleteWebhookOnStart: true,
282
988
  stream: {
283
989
  initialStatusText: "Agent is thinking...",
@@ -293,9 +999,30 @@ function telegramStartOptions(input, overrides) {
293
999
  unauthorizedText: "This chat is not allowlisted for this agent.",
294
1000
  errorText: telegramErrorText,
295
1001
  },
296
- // A polling crash after a successful start must flip the channel to failed,
297
- // not leave it reported as running.
298
- onPollingError: (error) => input.onFailure(error instanceof Error ? error.message : String(error)),
1002
+ // A polling crash is recoverable by construction: the adapter always schedules
1003
+ // a backoff restart (it never gives up on its own), so report it as DEGRADED
1004
+ // (keep the responder/harness alive) rather than fatal. onPollingRecovered flips
1005
+ // the channel back to running once a restarted runner stays up.
1006
+ onPollingError: (error) => input.onDegraded?.(error instanceof Error ? error.message : String(error)),
1007
+ onPollingRecovered: () => input.onRecovered?.(),
1008
+ ...(input.config.apiRoot === undefined ? {} : { apiRoot: input.config.apiRoot }),
1009
+ ...(telegramAttachmentOptions(input.config) === undefined
1010
+ ? {}
1011
+ : { attachments: telegramAttachmentOptions(input.config) }),
1012
+ ...(input.interaction === undefined
1013
+ ? {}
1014
+ : {
1015
+ pendingAsks: {
1016
+ tryResolve: (conversationId, answer) => input.interaction.tryResolveAsk(conversationId, answer),
1017
+ cancel: (conversationId) => {
1018
+ input.interaction.cancelAsks(conversationId);
1019
+ },
1020
+ },
1021
+ }),
1022
+ ...(input.config.ipFamily === undefined ? {} : { transport: { ipFamily: input.config.ipFamily } }),
1023
+ ...(input.config.pollWatchdogMs === undefined ? {} : { pollWatchdogMs: input.config.pollWatchdogMs }),
1024
+ ...(input.config.commands === undefined ? {} : { commands: [...input.config.commands] }),
1025
+ ...(input.config.reactions === undefined ? {} : { reactions: input.config.reactions }),
299
1026
  ...(input.logger === undefined ? {} : { logger: input.logger }),
300
1027
  ...(overrides.botFactory === undefined ? {} : { botFactory: overrides.botFactory }),
301
1028
  ...(overrides.runnerFactory === undefined ? {} : { runnerFactory: overrides.runnerFactory }),
@@ -303,21 +1030,24 @@ function telegramStartOptions(input, overrides) {
303
1030
  }
304
1031
  function telegramErrorText(input) {
305
1032
  const failure = failureFromUnknown(input.error);
306
- if (failure?.kind === "usage_limit") {
307
- const maxTurns = nestedNumber(failure.details, ["diagnostics", "max_turns"]);
308
- return [
309
- `I hit the runtime turn limit${maxTurns === undefined ? "" : ` (${maxTurns} turns)`} before I could finish.`,
310
- "Send a narrower follow-up, or check the local artifact summary for the incomplete run.",
311
- ].join(" ");
312
- }
313
- if (failure?.kind === "cancelled") {
314
- return "Cancelled.";
1033
+ if (failure?.kind !== undefined) {
1034
+ const description = describeRunFailureKind({ failureKind: failure.kind });
1035
+ if (description.known || failure.message === undefined || failure.message.trim().length === 0) {
1036
+ const explanation = failure.kind === "usage_limit"
1037
+ ? usageLimitExplanation(description.explanation, failure.details)
1038
+ : description.explanation;
1039
+ return `${explanation} ${description.nextStep}`;
1040
+ }
315
1041
  }
316
1042
  if (failure?.message !== undefined && failure.message.trim().length > 0) {
317
1043
  return `I could not complete that message: ${failure.message}`;
318
1044
  }
319
1045
  return "I could not complete that message. Check the local artifact summary for details.";
320
1046
  }
1047
+ function usageLimitExplanation(explanation, details) {
1048
+ const maxTurns = nestedNumber(details, ["diagnostics", "max_turns"]);
1049
+ return maxTurns === undefined ? explanation : `${explanation} Configured turn cap: ${maxTurns} turns.`;
1050
+ }
321
1051
  function failureFromUnknown(error) {
322
1052
  if (!isRecord(error)) {
323
1053
  return undefined;