@mono-agent/agent-app 0.4.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 (173) hide show
  1. package/README.md +15 -5
  2. package/dist/adapter-send-tools-main.js +2 -2
  3. package/dist/adapter-send-tools-main.js.map +1 -1
  4. package/dist/adapter-send-tools.d.ts +72 -6
  5. package/dist/adapter-send-tools.d.ts.map +1 -1
  6. package/dist/adapter-send-tools.js +413 -38
  7. package/dist/adapter-send-tools.js.map +1 -1
  8. package/dist/app-config.d.ts +48 -11
  9. package/dist/app-config.d.ts.map +1 -1
  10. package/dist/app-config.js +98 -24
  11. package/dist/app-config.js.map +1 -1
  12. package/dist/app.d.ts +10 -0
  13. package/dist/app.d.ts.map +1 -1
  14. package/dist/app.js +659 -48
  15. package/dist/app.js.map +1 -1
  16. package/dist/artifact-retention.d.ts +32 -0
  17. package/dist/artifact-retention.d.ts.map +1 -0
  18. package/dist/artifact-retention.js +111 -0
  19. package/dist/artifact-retention.js.map +1 -0
  20. package/dist/audit-runs.d.ts +12 -0
  21. package/dist/audit-runs.d.ts.map +1 -0
  22. package/dist/audit-runs.js +68 -0
  23. package/dist/audit-runs.js.map +1 -0
  24. package/dist/backfill.d.ts +3 -7
  25. package/dist/backfill.d.ts.map +1 -1
  26. package/dist/backfill.js +115 -11
  27. package/dist/backfill.js.map +1 -1
  28. package/dist/background.d.ts +8 -1
  29. package/dist/background.d.ts.map +1 -1
  30. package/dist/background.js +202 -4
  31. package/dist/background.js.map +1 -1
  32. package/dist/broadcast-recorder.d.ts +27 -0
  33. package/dist/broadcast-recorder.d.ts.map +1 -0
  34. package/dist/broadcast-recorder.js +82 -0
  35. package/dist/broadcast-recorder.js.map +1 -0
  36. package/dist/channel-config-view.d.ts +28 -0
  37. package/dist/channel-config-view.d.ts.map +1 -0
  38. package/dist/channel-config-view.js +79 -0
  39. package/dist/channel-config-view.js.map +1 -0
  40. package/dist/channel-gate.d.ts +22 -0
  41. package/dist/channel-gate.d.ts.map +1 -0
  42. package/dist/channel-gate.js +35 -0
  43. package/dist/channel-gate.js.map +1 -0
  44. package/dist/channel-plugins.d.ts +39 -0
  45. package/dist/channel-plugins.d.ts.map +1 -0
  46. package/dist/channel-plugins.js +346 -0
  47. package/dist/channel-plugins.js.map +1 -0
  48. package/dist/channels.d.ts +62 -73
  49. package/dist/channels.d.ts.map +1 -1
  50. package/dist/channels.js +833 -126
  51. package/dist/channels.js.map +1 -1
  52. package/dist/cli.d.ts +53 -3
  53. package/dist/cli.d.ts.map +1 -1
  54. package/dist/cli.js +696 -28
  55. package/dist/cli.js.map +1 -1
  56. package/dist/config-reference.d.ts +24 -0
  57. package/dist/config-reference.d.ts.map +1 -0
  58. package/dist/config-reference.js +580 -0
  59. package/dist/config-reference.js.map +1 -0
  60. package/dist/configured-agent.d.ts +104 -0
  61. package/dist/configured-agent.d.ts.map +1 -0
  62. package/dist/configured-agent.js +578 -0
  63. package/dist/configured-agent.js.map +1 -0
  64. package/dist/consumer-contract.d.ts +36 -0
  65. package/dist/consumer-contract.d.ts.map +1 -0
  66. package/dist/consumer-contract.js +290 -0
  67. package/dist/consumer-contract.js.map +1 -0
  68. package/dist/doctor.d.ts +9 -0
  69. package/dist/doctor.d.ts.map +1 -1
  70. package/dist/doctor.js +252 -55
  71. package/dist/doctor.js.map +1 -1
  72. package/dist/index.d.ts +9 -3
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +5 -2
  75. package/dist/index.js.map +1 -1
  76. package/dist/init.d.ts +23 -7
  77. package/dist/init.d.ts.map +1 -1
  78. package/dist/init.js +91 -26
  79. package/dist/init.js.map +1 -1
  80. package/dist/install-skill.d.ts +1 -1
  81. package/dist/install-skill.js +2 -2
  82. package/dist/install-skill.js.map +1 -1
  83. package/dist/interaction-bridge.d.ts +72 -0
  84. package/dist/interaction-bridge.d.ts.map +1 -0
  85. package/dist/interaction-bridge.js +354 -0
  86. package/dist/interaction-bridge.js.map +1 -0
  87. package/dist/memory-command.d.ts +10 -0
  88. package/dist/memory-command.d.ts.map +1 -0
  89. package/dist/memory-command.js +649 -0
  90. package/dist/memory-command.js.map +1 -0
  91. package/dist/memory-recall-main.js +1 -1
  92. package/dist/memory-recall-main.js.map +1 -1
  93. package/dist/memory-recall.d.ts +41 -5
  94. package/dist/memory-recall.d.ts.map +1 -1
  95. package/dist/memory-recall.js +75 -5
  96. package/dist/memory-recall.js.map +1 -1
  97. package/dist/memory-rituals.d.ts +8 -11
  98. package/dist/memory-rituals.d.ts.map +1 -1
  99. package/dist/memory-rituals.js +26 -25
  100. package/dist/memory-rituals.js.map +1 -1
  101. package/dist/metrics.d.ts +13 -0
  102. package/dist/metrics.d.ts.map +1 -0
  103. package/dist/metrics.js +105 -0
  104. package/dist/metrics.js.map +1 -0
  105. package/dist/notify-destinations.d.ts +23 -0
  106. package/dist/notify-destinations.d.ts.map +1 -0
  107. package/dist/notify-destinations.js +79 -0
  108. package/dist/notify-destinations.js.map +1 -0
  109. package/dist/posted-message-index.d.ts +61 -0
  110. package/dist/posted-message-index.d.ts.map +1 -0
  111. package/dist/posted-message-index.js +178 -0
  112. package/dist/posted-message-index.js.map +1 -0
  113. package/dist/proactive-notify.d.ts +32 -0
  114. package/dist/proactive-notify.d.ts.map +1 -0
  115. package/dist/proactive-notify.js +62 -0
  116. package/dist/proactive-notify.js.map +1 -0
  117. package/dist/recipes/base.d.ts +15 -0
  118. package/dist/recipes/base.d.ts.map +1 -0
  119. package/dist/recipes/base.js +51 -0
  120. package/dist/recipes/base.js.map +1 -0
  121. package/dist/recipes/catalog.d.ts +4 -0
  122. package/dist/recipes/catalog.d.ts.map +1 -0
  123. package/dist/recipes/catalog.js +509 -0
  124. package/dist/recipes/catalog.js.map +1 -0
  125. package/dist/recipes/index.d.ts +11 -0
  126. package/dist/recipes/index.d.ts.map +1 -0
  127. package/dist/recipes/index.js +14 -0
  128. package/dist/recipes/index.js.map +1 -0
  129. package/dist/recipes/types.d.ts +70 -0
  130. package/dist/recipes/types.d.ts.map +1 -0
  131. package/dist/recipes/types.js +15 -0
  132. package/dist/recipes/types.js.map +1 -0
  133. package/dist/request-model-override.d.ts +67 -0
  134. package/dist/request-model-override.d.ts.map +1 -0
  135. package/dist/request-model-override.js +96 -0
  136. package/dist/request-model-override.js.map +1 -0
  137. package/dist/runs-health.d.ts +22 -0
  138. package/dist/runs-health.d.ts.map +1 -0
  139. package/dist/runs-health.js +144 -0
  140. package/dist/runs-health.js.map +1 -0
  141. package/dist/seen-conversations.d.ts +22 -0
  142. package/dist/seen-conversations.d.ts.map +1 -0
  143. package/dist/seen-conversations.js +120 -0
  144. package/dist/seen-conversations.js.map +1 -0
  145. package/dist/setup.d.ts +29 -0
  146. package/dist/setup.d.ts.map +1 -0
  147. package/dist/setup.js +97 -0
  148. package/dist/setup.js.map +1 -0
  149. package/dist/trigger-overrides.d.ts +17 -0
  150. package/dist/trigger-overrides.d.ts.map +1 -0
  151. package/dist/trigger-overrides.js +30 -0
  152. package/dist/trigger-overrides.js.map +1 -0
  153. package/dist/tui-command.d.ts +56 -0
  154. package/dist/tui-command.d.ts.map +1 -0
  155. package/dist/tui-command.js +160 -0
  156. package/dist/tui-command.js.map +1 -0
  157. package/dist/ui.d.ts.map +1 -1
  158. package/dist/ui.js +4 -0
  159. package/dist/ui.js.map +1 -1
  160. package/dist/web-command.d.ts +39 -0
  161. package/dist/web-command.d.ts.map +1 -0
  162. package/dist/web-command.js +151 -0
  163. package/dist/web-command.js.map +1 -0
  164. package/package.json +17 -17
  165. package/schema/mono-agent.config.schema.json +1686 -0
  166. package/skills/mono-agent-composer/SKILL.md +34 -9
  167. package/skills/mono-agent-composer/agents/openai.yaml +4 -0
  168. package/skills/mono-agent-composer/references/config-blueprint.md +52 -18
  169. package/skills/mono-agent-composer/references/discovery-questions.md +14 -16
  170. package/skills/mono-agent-composer/references/feature-coverage.md +19 -15
  171. package/skills/mono-agent-composer/references/package-map.md +17 -19
  172. package/skills/mono-agent-composer/references/playbooks.md +209 -0
  173. package/skills/mono-agent-composer/references/validation.md +9 -1
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,84 +244,122 @@ 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 = {}) {
69
- return {
70
- id: "a2a",
71
- label: "A2A",
72
- async loadConfig(input) {
73
- return await loadA2AAdapterConfig({ env: input.env, jsonPath: input.configPath });
74
- },
75
- isConfigError(error) {
76
- return error instanceof A2AProviderError || error instanceof A2AConsumerError;
77
- },
78
- disabledReason(config) {
79
- return config.provider.enabled ? undefined : "A2A provider is disabled.";
80
- },
81
- waitingReason(config) {
82
- if (config.agent === undefined || config.skill === undefined) {
83
- return "A2A provider requires agent and skill configuration.";
84
- }
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) {
85
310
  return undefined;
86
- },
87
- 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 }),
100
- 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
- }),
113
- },
114
- skill: config.skill,
115
- ...(input.logger === undefined ? {} : { logger: input.logger }),
116
- });
117
- return {
118
- summary: { agentCardUrl: provider.agentCardUrl },
119
- stop: () => provider.stop(),
120
- };
121
- },
122
- };
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 };
123
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;
124
333
  export function createWebhookChannelDriver(overrides = {}) {
125
334
  return {
126
335
  id: "webhook",
127
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
+ },
128
353
  async loadConfig(input) {
354
+ if (!(await isChannelConfigured(input, WEBHOOK_GATE))) {
355
+ return UNCONFIGURED_WEBHOOK_CONFIG;
356
+ }
357
+ const adapter = await loadWebhookModule();
129
358
  // cwd is required so `webhook/*.md` endpoint files are discovered.
130
- return await loadWebhookAdapterConfig({ env: input.env, jsonPath: input.configPath, cwd: input.cwd });
359
+ return await adapter.loadWebhookAdapterConfig({ env: input.env, jsonPath: input.configPath, cwd: input.cwd });
131
360
  },
132
361
  isConfigError(error) {
133
- return error instanceof WebhookAdapterError;
362
+ return webhookModule !== undefined && error instanceof webhookModule.WebhookAdapterError;
134
363
  },
135
364
  disabledReason(config) {
136
365
  return config.enabled ? undefined : "Webhook adapter is disabled.";
@@ -141,7 +370,10 @@ export function createWebhookChannelDriver(overrides = {}) {
141
370
  : "Webhook adapter has no enabled endpoints.";
142
371
  },
143
372
  async start(input) {
144
- const adapterFactory = overrides.adapterFactory ?? startWebhookAdapter;
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;
145
377
  const adapter = await adapterFactory({
146
378
  host: input.config.host,
147
379
  port: input.config.port,
@@ -149,15 +381,31 @@ export function createWebhookChannelDriver(overrides = {}) {
149
381
  defaultMode: input.config.defaultMode,
150
382
  retentionMs: input.config.retentionMs,
151
383
  maxStoredRequests: input.config.maxStoredRequests,
152
- endpoints: input.config.endpoints
153
- .filter((endpoint) => endpoint.enabled)
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
154
388
  .map((endpoint) => ({
155
389
  name: endpoint.name,
156
390
  path: endpoint.path,
157
391
  mode: endpoint.mode,
158
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 }),
159
397
  })),
160
398
  responder: input.responder,
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
+ });
408
+ },
161
409
  ...(input.logger === undefined ? {} : { logger: input.logger }),
162
410
  });
163
411
  return {
@@ -175,17 +423,29 @@ export function createOpenAIApiChannelDriver(overrides = {}) {
175
423
  return {
176
424
  id: "openai-api",
177
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
+ },
178
433
  async loadConfig(input) {
179
- return await loadOpenAIApiAdapterConfig({ 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 });
180
439
  },
181
440
  isConfigError(error) {
182
- return error instanceof OpenAIApiAdapterError;
441
+ return openaiApiModule !== undefined && error instanceof openaiApiModule.OpenAIApiAdapterError;
183
442
  },
184
443
  disabledReason(config) {
185
444
  return config.enabled ? undefined : "OpenAI API adapter is disabled.";
186
445
  },
187
446
  async start(input) {
188
- const adapterFactory = overrides.adapterFactory ?? startOpenAIApiAdapter;
447
+ const adapterModule = await loadOpenAIApiModule();
448
+ const adapterFactory = overrides.adapterFactory ?? adapterModule.startOpenAIApiAdapter;
189
449
  const adapter = await adapterFactory({
190
450
  host: input.config.host,
191
451
  port: input.config.port,
@@ -203,15 +463,231 @@ export function createOpenAIApiChannelDriver(overrides = {}) {
203
463
  },
204
464
  };
205
465
  }
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 = {}) {
483
+ return {
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
+ },
490
+ async loadConfig(input) {
491
+ const adapter = await loadTuiModule();
492
+ return await adapter.loadTuiAdapterConfig({ env: input.env, jsonPath: input.configPath });
493
+ },
494
+ isConfigError(error) {
495
+ return tuiModule !== undefined && error instanceof tuiModule.TuiAdapterError;
496
+ },
497
+ disabledReason(config) {
498
+ return config.enabled ? undefined : "TUI stream endpoint is disabled.";
499
+ },
500
+ async start(input) {
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
+ };
569
+ const adapter = await adapterFactory({
570
+ host: input.config.host,
571
+ port: input.config.port,
572
+ basePath: input.config.basePath,
573
+ allowNonLoopback: input.config.allowNonLoopback,
574
+ ...(input.config.apiKey === undefined ? {} : { apiKey: input.config.apiKey }),
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),
630
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
631
+ });
632
+ return {
633
+ summary: { baseUrl: adapter.baseUrl },
634
+ stop: () => adapter.stop(),
635
+ };
636
+ },
637
+ };
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
+ }
206
661
  export function createCronChannelDriver(overrides = {}) {
662
+ const failureNoticeLastSentMsByJobId = new Map();
207
663
  return {
208
664
  id: "cron",
209
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
+ },
210
682
  async loadConfig(input) {
211
- 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 });
212
688
  },
213
689
  isConfigError(error) {
214
- return error instanceof CronAdapterError;
690
+ return cronModule !== undefined && error instanceof cronModule.CronAdapterError;
215
691
  },
216
692
  disabledReason(config) {
217
693
  const enabledJobs = config.jobs.filter((job) => job.enabled);
@@ -219,25 +695,51 @@ export function createCronChannelDriver(overrides = {}) {
219
695
  },
220
696
  async start(input) {
221
697
  const jobs = input.config.jobs.filter((job) => job.enabled);
222
- 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;
223
701
  const adapter = adapterFactory({
224
702
  responder: input.responder,
225
703
  // Skip overlapping firings (a job still running when its next tick fires)
226
704
  // — the legacy/default app behavior. This avoids the scheduler's
227
705
  // unbounded "queue" default retaining stale ticks in memory when a job
228
706
  // runs longer than its interval. (Queue/replace remain available to
229
- // programmatic callers of startCronAdapter.)
707
+ // programmatic callers of startCronAdapter.) The watchdog below is what
708
+ // keeps "skip" safe: a hung run no longer pins the slot forever.
230
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,
231
713
  jobs: jobs.map((job) => ({
232
714
  id: job.id,
233
715
  expression: job.expression,
234
716
  timezone: job.timezone,
235
717
  prompt: job.prompt,
236
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 }),
237
724
  })),
238
725
  onResult: (result) => {
239
726
  const level = result.kind === "failed" ? "error" : result.kind === "skipped" ? "warn" : "info";
240
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
+ });
241
743
  },
242
744
  ...(input.logger === undefined ? {} : { logger: input.logger }),
243
745
  });
@@ -250,57 +752,238 @@ export function createCronChannelDriver(overrides = {}) {
250
752
  },
251
753
  };
252
754
  }
253
- export function createWhatsAppChannelDriver(overrides = {}) {
254
- return {
255
- id: "whatsapp",
256
- label: "WhatsApp",
257
- async loadConfig(input) {
258
- return await loadWhatsAppAdapterConfig({ env: input.env, jsonPath: input.configPath });
259
- },
260
- isConfigError(error) {
261
- return error instanceof WhatsAppAdapterConfigError;
262
- },
263
- disabledReason(config) {
264
- return config.enabled ? undefined : "WhatsApp is disabled.";
265
- },
266
- async start(input) {
267
- const startAdapter = overrides.startAdapter ?? startWhatsAppAdapter;
268
- const result = await startAdapter({
269
- authDir: overrides.authDir ?? resolve(input.cwd, ".mono-agent", "whatsapp-auth"),
270
- config: input.config,
271
- responder: input.responder,
272
- ...(input.logger === undefined ? {} : { logger: input.logger }),
273
- onQr: (qr) => {
274
- input.logger?.info?.("WhatsApp login QR code received; scan it with the WhatsApp app.", { qr });
275
- },
276
- ...(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 }),
277
791
  });
278
- return {
279
- summary: {},
280
- stop: () => result.stop(),
281
- };
282
- },
283
- };
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;
284
944
  }
285
945
  /** Every channel the app can drive, in startup/status display order. */
286
946
  export function defaultChannelDrivers(overrides = {}) {
287
947
  return [
288
948
  createTelegramChannelDriver(overrides.telegram),
289
949
  createSlackChannelDriver(overrides.slack),
290
- createA2AChannelDriver(overrides.a2a),
291
950
  createWebhookChannelDriver(overrides.webhook),
292
951
  createOpenAIApiChannelDriver(overrides.openaiApi),
293
952
  createCronChannelDriver(overrides.cron),
294
- createWhatsAppChannelDriver(overrides.whatsapp),
953
+ createTuiChannelDriver(overrides.tui),
954
+ createLiveChannelDriver(overrides.live),
295
955
  ];
296
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
+ }
297
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
+ });
298
980
  return {
299
981
  botToken: input.config.botToken,
300
982
  allowedChatIds: [...input.config.allowedChatIds],
301
983
  allowAllChats: input.config.allowAllChats,
302
984
  responder: input.responder,
303
- allowedUpdates: ["message"],
985
+ allowedUpdates: callbacksEnabled ? ["message", "callback_query"] : ["message"],
986
+ ...(callbacksEnabled ? { callbacksEnabled: true } : {}),
304
987
  deleteWebhookOnStart: true,
305
988
  stream: {
306
989
  initialStatusText: "Agent is thinking...",
@@ -316,9 +999,30 @@ function telegramStartOptions(input, overrides) {
316
999
  unauthorizedText: "This chat is not allowlisted for this agent.",
317
1000
  errorText: telegramErrorText,
318
1001
  },
319
- // A polling crash after a successful start must flip the channel to failed,
320
- // not leave it reported as running.
321
- 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 }),
322
1026
  ...(input.logger === undefined ? {} : { logger: input.logger }),
323
1027
  ...(overrides.botFactory === undefined ? {} : { botFactory: overrides.botFactory }),
324
1028
  ...(overrides.runnerFactory === undefined ? {} : { runnerFactory: overrides.runnerFactory }),
@@ -326,21 +1030,24 @@ function telegramStartOptions(input, overrides) {
326
1030
  }
327
1031
  function telegramErrorText(input) {
328
1032
  const failure = failureFromUnknown(input.error);
329
- if (failure?.kind === "usage_limit") {
330
- const maxTurns = nestedNumber(failure.details, ["diagnostics", "max_turns"]);
331
- return [
332
- `I hit the runtime turn limit${maxTurns === undefined ? "" : ` (${maxTurns} turns)`} before I could finish.`,
333
- "Send a narrower follow-up, or check the local artifact summary for the incomplete run.",
334
- ].join(" ");
335
- }
336
- if (failure?.kind === "cancelled") {
337
- 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
+ }
338
1041
  }
339
1042
  if (failure?.message !== undefined && failure.message.trim().length > 0) {
340
1043
  return `I could not complete that message: ${failure.message}`;
341
1044
  }
342
1045
  return "I could not complete that message. Check the local artifact summary for details.";
343
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
+ }
344
1051
  function failureFromUnknown(error) {
345
1052
  if (!isRecord(error)) {
346
1053
  return undefined;