loopengine 0.1.94 → 0.1.97

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 (68) hide show
  1. package/README.md +21 -2
  2. package/dist/bin/ability-manager.d.ts +4 -0
  3. package/dist/bin/ability-manager.js +34 -2
  4. package/dist/bin/cli.d.ts +1 -1
  5. package/dist/bin/cli.js +3 -0
  6. package/dist/core/ability-details.d.ts +31 -0
  7. package/dist/core/ability-details.js +140 -0
  8. package/dist/core/ability-sources.d.ts +59 -0
  9. package/dist/core/ability-sources.js +312 -0
  10. package/dist/core/agent-config.d.ts +21 -1
  11. package/dist/core/discover-agents.js +11 -1
  12. package/dist/core/http-notifier.d.ts +7 -1
  13. package/dist/core/http-notifier.js +68 -9
  14. package/dist/core/http-notify-triggers/email.d.ts +2 -2
  15. package/dist/core/http-notify-triggers/email.js +2 -4
  16. package/dist/core/http-notify-triggers/lark.d.ts +2 -2
  17. package/dist/core/http-notify-triggers/lark.js +2 -4
  18. package/dist/core/http-notify-triggers/slack.d.ts +2 -2
  19. package/dist/core/http-notify-triggers/slack.js +2 -4
  20. package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
  21. package/dist/core/http-notify-triggers/telegram.js +71 -0
  22. package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
  23. package/dist/core/http-notify-triggers/webhook.js +2 -4
  24. package/dist/core/model-calls/meta-model-call.d.ts +15 -0
  25. package/dist/core/model-calls/meta-model-call.js +48 -0
  26. package/dist/core/model-calls/openai-model-call.d.ts +5 -0
  27. package/dist/core/model-calls/openai-model-call.js +47 -1
  28. package/dist/core/model-calls/xai-model-call.d.ts +15 -0
  29. package/dist/core/model-calls/xai-model-call.js +45 -0
  30. package/dist/core/notifier-file.d.ts +40 -6
  31. package/dist/core/notifier-file.js +119 -25
  32. package/dist/core/system-agents/workflow-planner.d.ts +100 -0
  33. package/dist/core/system-agents/workflow-planner.js +278 -0
  34. package/dist/core/workflows.d.ts +26 -0
  35. package/dist/core/workflows.js +39 -0
  36. package/dist/core/workspace-abilities.d.ts +31 -0
  37. package/dist/core/workspace-abilities.js +110 -0
  38. package/dist/index.d.ts +18 -4
  39. package/dist/index.js +17 -3
  40. package/dist/web/ability-details-admin.d.ts +17 -0
  41. package/dist/web/ability-details-admin.js +45 -0
  42. package/dist/web/ability-sources-admin.d.ts +33 -0
  43. package/dist/web/ability-sources-admin.js +65 -0
  44. package/dist/web/agent-file-admin.d.ts +1 -1
  45. package/dist/web/agent-file-admin.js +3 -0
  46. package/dist/web/agents-config-page.js +608 -224
  47. package/dist/web/agents-list-page.js +49 -20
  48. package/dist/web/console-shell.js +248 -7
  49. package/dist/web/dev-ui-styles.js +13 -1
  50. package/dist/web/global-config-page.js +403 -81
  51. package/dist/web/global-config.d.ts +44 -1
  52. package/dist/web/global-config.js +162 -2
  53. package/dist/web/interview-admin.js +2 -2
  54. package/dist/web/messaging-icons.d.ts +2 -0
  55. package/dist/web/messaging-icons.js +7 -0
  56. package/dist/web/notifier-admin.d.ts +33 -15
  57. package/dist/web/notifier-admin.js +109 -65
  58. package/dist/web/provider-icons.d.ts +2 -0
  59. package/dist/web/provider-icons.js +10 -0
  60. package/dist/web/templates-admin.d.ts +3 -0
  61. package/dist/web/templates-admin.js +5 -1
  62. package/dist/web/workflow-admin.d.ts +124 -0
  63. package/dist/web/workflow-admin.js +308 -0
  64. package/dist/web/workflow-page.d.ts +1 -0
  65. package/dist/web/workflow-page.js +226 -0
  66. package/dist/web/workspace-abilities-admin.d.ts +24 -0
  67. package/dist/web/workspace-abilities-admin.js +118 -0
  68. package/package.json +13 -1
@@ -1,6 +1,6 @@
1
1
  import type { AgentConfig } from '../core/agent-config.js';
2
2
  export interface ModelProviderInfo {
3
- provider: 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini';
3
+ provider: 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini' | 'xai' | 'meta';
4
4
  envVar: string;
5
5
  configured: boolean;
6
6
  }
@@ -69,6 +69,49 @@ export declare function modelKeyEnvVar(provider: string): string | undefined;
69
69
  */
70
70
  export declare function setModelProviderKey(provider: string, key: unknown): Promise<void>;
71
71
  export declare function clearModelProviderKey(provider: string): Promise<void>;
72
+ export interface ProviderModelsView {
73
+ provider: string;
74
+ /** Model ids to offer, newest first where the provider says when each
75
+ * was released. Empty when they couldn't be listed (see `error`). */
76
+ models: string[];
77
+ /** Why the list is short or empty: no key saved (only common models,
78
+ * if any, are listed), or the provider refused or timed out. Shown
79
+ * under the dropdown. */
80
+ error?: string;
81
+ }
82
+ export declare const MODELS_FILE_ENV = "LOOPENGINE_MODELS_FILE";
83
+ /** models.yaml in the project root (where .env is), or the file
84
+ * LOOPENGINE_MODELS_FILE names (absolute, or relative to the root). */
85
+ export declare function modelsFilePath(): string;
86
+ /**
87
+ * The hand-edited model lists in models.yaml: provider name to the models
88
+ * its dropdown offers, in order.
89
+ *
90
+ * anthropic:
91
+ * - claude-opus-5-5
92
+ * - claude-sonnet-5-5
93
+ *
94
+ * Read fresh on every call (it's a few lines), so an edit shows the next
95
+ * time a dropdown loads. A missing file is no lists; a malformed one is
96
+ * reported in `error` and ignored as a whole, so a typo can't hide one
97
+ * provider's models while the rest still load.
98
+ */
99
+ export declare function readModelsFile(): {
100
+ models: Partial<Record<ModelProviderInfo['provider'], string[]>>;
101
+ error?: string;
102
+ };
103
+ /**
104
+ * The models a provider offers, for the model dropdown in the New agent
105
+ * and Model forms. A provider listed in models.yaml (readModelsFile)
106
+ * offers exactly that list, with no key needed. Otherwise this asks the
107
+ * provider itself with the saved key (cached for ten minutes per key),
108
+ * so new models show up without a release. Never throws for a provider
109
+ * problem: the view carries an `error` instead, and the form lets the
110
+ * model name be typed. Throws ModelKeyError for an unknown provider.
111
+ */
112
+ export declare function listProviderModels(provider: string, options?: {
113
+ fetch?: typeof fetch;
114
+ }): Promise<ProviderModelsView>;
72
115
  export declare class TimeZoneError extends Error {
73
116
  }
74
117
  export interface TimeZoneView {
@@ -1,3 +1,17 @@
1
+ // Backs the global /config page's Models and Gateways sections (see
2
+ // web/agents-config-page.ts's left sidebar) — the account-wide,
3
+ // not-scoped-to-one-agent counterpart to that page's existing per-agent
4
+ // Overview/Skills/Tools/ActAuth tabs. "Global" here means: which model
5
+ // providers this deployment can actually call (an API-key-in-env-vars
6
+ // question, same everywhere regardless of which agent is running) and
7
+ // which gateway providers (Composio today) this machine's CLI session
8
+ // is authenticated against — neither is a property of any one
9
+ // AgentConfig.
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import Anthropic from '@anthropic-ai/sdk';
13
+ import OpenAI from 'openai';
14
+ import { parse as parseYaml } from 'yaml';
1
15
  import { getComposioAuthStatus } from '../core/gateway-tools.js';
2
16
  import { moveScheduledTasksToTimeZone } from '../core/scheduler.js';
3
17
  import { TIME_ZONE_ENV, isValidTimeZone, listTimeZones, serverTimeZone, workspaceTimeZone } from '../core/time-zone.js';
@@ -10,14 +24,17 @@ export class ModelKeyError extends Error {
10
24
  // union from; adding a fourth provider means one more line here and one
11
25
  // more arm in that union, not a rewrite.
12
26
  const KNOWN_PROVIDERS = [
13
- { provider: 'anthropic', envVar: 'ANTHROPIC_API_KEY' },
14
27
  { provider: 'openai', envVar: 'OPENAI_API_KEY' },
28
+ { provider: 'anthropic', envVar: 'ANTHROPIC_API_KEY' },
29
+ { provider: 'gemini', envVar: 'GEMINI_API_KEY' },
30
+ // LiteLLM's name; see core/model-calls/meta-model-call.ts.
31
+ { provider: 'meta', envVar: 'META_API_KEY' },
32
+ { provider: 'xai', envVar: 'XAI_API_KEY' },
15
33
  { provider: 'deepseek', envVar: 'DEEPSEEK_API_KEY' },
16
34
  // Moonshot AI's own env var name, not KIMI_API_KEY — see
17
35
  // core/model-calls/kimi-model-call.ts's own header comment for why.
18
36
  { provider: 'kimi', envVar: 'MOONSHOT_API_KEY' },
19
37
  { provider: 'glm', envVar: 'GLM_API_KEY' },
20
- { provider: 'gemini', envVar: 'GEMINI_API_KEY' },
21
38
  ];
22
39
  /** Every registered agent's resolved model, alongside whether each
23
40
  * supported provider actually has credentials available in this
@@ -104,6 +121,149 @@ export async function clearModelProviderKey(provider) {
104
121
  throw new ModelKeyError(`unknown provider '${provider}'`);
105
122
  await unsetEnvVar(envVar);
106
123
  }
124
+ // The OpenAI-compatible providers' base URLs, the same ones their
125
+ // model-calls/*.ts use (OpenAI's own comes from the SDK, so
126
+ // OPENAI_BASE_URL applies to both). Anthropic has its own models API.
127
+ const OPENAI_COMPATIBLE_BASE_URLS = {
128
+ deepseek: 'https://api.deepseek.com',
129
+ kimi: 'https://api.moonshot.ai/v1',
130
+ glm: 'https://open.bigmodel.cn/api/paas/v4/',
131
+ gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
132
+ xai: 'https://api.x.ai/v1',
133
+ meta: 'https://api.meta.ai/v1',
134
+ };
135
+ // OpenAI and Gemini also list models a chat agent can't use.
136
+ const NON_CHAT_MODEL = /embed|tts|whisper|dall-e|davinci|babbage|moderation|transcribe|image|audio|realtime|search|aqa|imagen|veo|sora/i;
137
+ // Offered when the provider's own list isn't available (no key saved
138
+ // yet, or the provider refused) and models.yaml doesn't list it. Only
139
+ // names checked against the provider's docs (2026-10-31); other
140
+ // providers need a key to list theirs. Meta's "-contributor" variants are
141
+ // left out, since choosing one lets Meta train on the requests; type one
142
+ // under "Other" to use it.
143
+ const KNOWN_MODELS = {
144
+ anthropic: ['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-sonnet-5'],
145
+ deepseek: ['deepseek-v4-pro', 'deepseek-flash'],
146
+ meta: ['muse-spark-1.3', 'muse-wonder-1.0', 'muse-spark-1.2', 'muse-spark-1.1'],
147
+ };
148
+ const MODEL_LIST_TTL_MS = 10 * 60 * 1000;
149
+ const modelListCache = new Map();
150
+ export const MODELS_FILE_ENV = 'LOOPENGINE_MODELS_FILE';
151
+ /** models.yaml in the project root (where .env is), or the file
152
+ * LOOPENGINE_MODELS_FILE names (absolute, or relative to the root). */
153
+ export function modelsFilePath() {
154
+ return resolve(process.cwd(), process.env[MODELS_FILE_ENV]?.trim() || 'models.yaml');
155
+ }
156
+ /**
157
+ * The hand-edited model lists in models.yaml: provider name to the models
158
+ * its dropdown offers, in order.
159
+ *
160
+ * anthropic:
161
+ * - claude-opus-5-5
162
+ * - claude-sonnet-5-5
163
+ *
164
+ * Read fresh on every call (it's a few lines), so an edit shows the next
165
+ * time a dropdown loads. A missing file is no lists; a malformed one is
166
+ * reported in `error` and ignored as a whole, so a typo can't hide one
167
+ * provider's models while the rest still load.
168
+ */
169
+ export function readModelsFile() {
170
+ const path = modelsFilePath();
171
+ if (!existsSync(path))
172
+ return { models: {} };
173
+ const name = process.env[MODELS_FILE_ENV]?.trim() || 'models.yaml';
174
+ let data;
175
+ try {
176
+ data = parseYaml(readFileSync(path, 'utf8'));
177
+ }
178
+ catch (err) {
179
+ return { models: {}, error: `${name} isn't valid YAML: ${err instanceof Error ? err.message : String(err)}` };
180
+ }
181
+ if (data == null)
182
+ return { models: {} };
183
+ if (typeof data !== 'object' || Array.isArray(data))
184
+ return { models: {}, error: `${name} should map each provider to a list of models.` };
185
+ const models = {};
186
+ for (const [provider, list] of Object.entries(data)) {
187
+ if (!modelKeyEnvVar(provider)) {
188
+ return { models: {}, error: `${name}: unknown provider '${provider}'. Use one of ${KNOWN_PROVIDERS.map((p) => p.provider).join(', ')}.` };
189
+ }
190
+ // An empty key ("openai:") means no list, same as leaving it out.
191
+ if (list == null)
192
+ continue;
193
+ if (!Array.isArray(list) || list.some((m) => typeof m !== 'string' || !m.trim())) {
194
+ return { models: {}, error: `${name}: '${provider}' should be a list of model names.` };
195
+ }
196
+ models[provider] = [...new Set(list.map((m) => m.trim()))];
197
+ }
198
+ return { models };
199
+ }
200
+ /**
201
+ * The models a provider offers, for the model dropdown in the New agent
202
+ * and Model forms. A provider listed in models.yaml (readModelsFile)
203
+ * offers exactly that list, with no key needed. Otherwise this asks the
204
+ * provider itself with the saved key (cached for ten minutes per key),
205
+ * so new models show up without a release. Never throws for a provider
206
+ * problem: the view carries an `error` instead, and the form lets the
207
+ * model name be typed. Throws ModelKeyError for an unknown provider.
208
+ */
209
+ export async function listProviderModels(provider, options = {}) {
210
+ const envVar = modelKeyEnvVar(provider);
211
+ if (!envVar)
212
+ throw new ModelKeyError(`unknown provider '${provider}'`);
213
+ const file = readModelsFile();
214
+ const fromFile = file.models[provider];
215
+ if (fromFile)
216
+ return { provider, models: fromFile };
217
+ const view = await listProviderModelsLive(provider, envVar, options);
218
+ return file.error ? { ...view, error: view.error ? `${file.error} ${view.error}` : file.error } : view;
219
+ }
220
+ async function listProviderModelsLive(provider, envVar, options) {
221
+ const key = process.env[envVar];
222
+ if (!key) {
223
+ const known = KNOWN_MODELS[provider] ?? [];
224
+ return {
225
+ provider,
226
+ models: known,
227
+ error: known.length
228
+ ? `Showing common ${provider} models. Save ${envVar} on the Models page to list all of them.`
229
+ : `No ${envVar} saved. Save it on the Models page to list ${provider}'s models, or choose Other.`,
230
+ };
231
+ }
232
+ const cached = modelListCache.get(provider);
233
+ if (cached && cached.key === key && Date.now() - cached.at < MODEL_LIST_TTL_MS)
234
+ return { provider, models: cached.models };
235
+ const clientOptions = { apiKey: key, timeout: 10_000, maxRetries: 0, ...(options.fetch ? { fetch: options.fetch } : {}) };
236
+ try {
237
+ const listed = [];
238
+ if (provider === 'anthropic') {
239
+ // Already newest first.
240
+ for await (const model of new Anthropic(clientOptions).models.list({ limit: 1000 })) {
241
+ listed.push({ id: model.id, created: 0 });
242
+ }
243
+ }
244
+ else {
245
+ const client = new OpenAI({ ...clientOptions, baseURL: OPENAI_COMPATIBLE_BASE_URLS[provider] });
246
+ for await (const model of client.models.list()) {
247
+ // Gemini's ids come back as "models/gemini-...", but its chat
248
+ // endpoint takes the bare name.
249
+ listed.push({ id: model.id.replace(/^models\//, ''), created: model.created ?? 0 });
250
+ }
251
+ }
252
+ const models = listed
253
+ .filter((model) => (provider === 'openai' || provider === 'gemini' ? !NON_CHAT_MODEL.test(model.id) : true))
254
+ .sort((a, b) => b.created - a.created)
255
+ .map((model) => model.id)
256
+ .filter((id, index, ids) => ids.indexOf(id) === index);
257
+ modelListCache.set(provider, { key, at: Date.now(), models });
258
+ return { provider, models };
259
+ }
260
+ catch (err) {
261
+ const known = KNOWN_MODELS[provider];
262
+ if (known)
263
+ return { provider, models: known };
264
+ return { provider, models: [], error: `Couldn't list ${provider}'s models: ${err instanceof Error ? err.message : String(err)}` };
265
+ }
266
+ }
107
267
  export class TimeZoneError extends Error {
108
268
  }
109
269
  export function describeTimeZone() {
@@ -126,7 +126,7 @@ export function setupChecklist(candidate, agentName) {
126
126
  case 'ability':
127
127
  return { label: `Install the ${need.name ?? need.description} ability`, href: tab('abilities') };
128
128
  case 'api':
129
- return { label: `Connect ${need.description} (an HTTP tool, or an MCP server)`, href: tab('tools') };
129
+ return { label: `Connect ${need.description} (an HTTP tool, or an MCP server)`, href: tab('advanced') };
130
130
  case 'key':
131
131
  return { label: `Add ${need.description}`, href: tab('env') };
132
132
  case 'messaging':
@@ -239,7 +239,7 @@ export function interviewTools(agents) {
239
239
  return results.slice(0, 10).map((r) => ({ name: r.name, ...(r.description ? { description: r.description } : {}) }));
240
240
  },
241
241
  async listConnections() {
242
- const providers = ['anthropic', 'openai', 'deepseek', 'kimi', 'glm', 'gemini'].filter((p) => {
242
+ const providers = ['openai', 'anthropic', 'gemini', 'meta', 'xai', 'deepseek', 'kimi', 'glm'].filter((p) => {
243
243
  const envVar = modelKeyEnvVar(p);
244
244
  return envVar ? Boolean(process.env[envVar]) : false;
245
245
  });
@@ -0,0 +1,2 @@
1
+ import type { NotifierFileChannel } from '../core/notifier-file.js';
2
+ export declare const MESSAGING_ICONS: Record<NotifierFileChannel, string>;
@@ -0,0 +1,7 @@
1
+ export const MESSAGING_ICONS = {
2
+ webhook: '<svg class="le-channel-icon" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2" /> <path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06" /> <path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8" /> </svg>',
3
+ telegram: '<svg class="le-channel-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#26A5E4"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
4
+ slack: '<svg class="le-channel-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
5
+ lark: '<svg class="le-channel-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.9238 12.8029C12.9427 12.784 12.9616 12.7682 12.9806 12.7493C13.0184 12.7146 13.0563 12.6767 13.091 12.6389L13.1667 12.5631L13.397 12.336L14.7315 11.0173L15.0659 10.686C15.129 10.6229 15.1952 10.563 15.2615 10.5031C15.3845 10.3926 15.5076 10.2854 15.6369 10.1813C15.7536 10.0866 15.8767 9.99514 15.9997 9.9068C16.1732 9.78376 16.3499 9.67019 16.5329 9.55977C16.7127 9.45251 16.8957 9.35471 17.085 9.26322C17.2616 9.17804 17.4415 9.09917 17.6276 9.02661C17.7317 8.9856 17.8326 8.94774 17.9399 8.91304C17.9935 8.89411 18.044 8.87834 18.0977 8.86256C17.6276 7.00439 16.7632 5.3008 15.5991 3.84959C15.3719 3.56566 15.0249 3.40161 14.6589 3.40161H5.0084C4.83489 3.40161 4.76233 3.6256 4.90114 3.72656C8.18528 6.13997 10.9236 9.24114 12.9017 12.825C12.908 12.8187 12.9175 12.8124 12.9238 12.8029Z" fill="#00D6B9" /><path d="M9.09696 21.2986C14.0815 21.2986 18.4225 18.5476 20.6877 14.4843C20.7666 14.3423 20.8454 14.1972 20.918 14.052C20.8044 14.2729 20.6751 14.4811 20.5394 14.6767C20.4889 14.7461 20.4385 14.8155 20.388 14.8818C20.3217 14.9669 20.2555 15.049 20.1861 15.1278C20.1324 15.1909 20.0757 15.2509 20.0189 15.3108C19.9021 15.4307 19.7823 15.5474 19.6561 15.6547C19.5867 15.7146 19.5141 15.7714 19.4415 15.8282C19.3564 15.8944 19.268 15.9575 19.1797 16.0143C19.1229 16.0522 19.0661 16.09 19.0093 16.1247C18.9494 16.1626 18.8895 16.1973 18.8264 16.232C18.7002 16.3014 18.574 16.3645 18.4446 16.4245C18.3311 16.4749 18.2175 16.5223 18.1008 16.5633C17.9746 16.6106 17.8452 16.6516 17.7159 16.6863C17.5234 16.7399 17.3247 16.7809 17.1259 16.8125C16.9808 16.8346 16.8357 16.8504 16.6874 16.863C16.5328 16.8724 16.3751 16.8787 16.2173 16.8756C16.0438 16.8724 15.8703 16.863 15.6936 16.844C15.5643 16.8314 15.435 16.8125 15.3056 16.7873C15.192 16.7683 15.0785 16.7431 14.9649 16.7178C14.9049 16.7021 14.845 16.6895 14.7851 16.6737C14.6179 16.6295 14.4538 16.5822 14.2898 16.5349C14.2077 16.5096 14.1257 16.4875 14.0437 16.4623C13.9206 16.4245 13.7976 16.3897 13.6777 16.3519C13.5768 16.3203 13.479 16.2888 13.378 16.2572C13.2834 16.2257 13.1887 16.1942 13.0941 16.1626C13.031 16.1405 12.9647 16.1184 12.9016 16.0964C12.8228 16.0711 12.7471 16.0427 12.6682 16.0143C12.6114 15.9954 12.5578 15.9765 12.501 15.9544C12.3906 15.9134 12.2802 15.8755 12.1729 15.8345C12.1098 15.8093 12.0467 15.7872 11.9836 15.7619C11.8984 15.7304 11.8132 15.6957 11.7312 15.6641C11.6429 15.6294 11.5514 15.5947 11.4631 15.5569C11.4063 15.5348 11.3463 15.5096 11.2895 15.4875C11.217 15.4591 11.1476 15.4275 11.075 15.3991C11.0214 15.3771 10.9646 15.3518 10.911 15.3297C10.8542 15.3045 10.7974 15.2793 10.7406 15.254C10.6901 15.2319 10.6428 15.2099 10.5923 15.1878C10.5482 15.1688 10.5008 15.1468 10.4567 15.1278C10.4094 15.1057 10.3652 15.0868 10.3179 15.0647C10.2705 15.0427 10.2232 15.0206 10.1759 14.9985C10.116 14.9701 10.056 14.9417 9.99608 14.9165C9.93299 14.8881 9.87304 14.8565 9.80995 14.8281C9.7437 14.7966 9.67745 14.765 9.6112 14.7303C9.55441 14.7019 9.49762 14.6735 9.44084 14.6483C6.45324 13.1592 3.80321 11.1717 1.54438 8.76145C1.43081 8.64157 1.23206 8.72044 1.23206 8.88449L1.23836 18.0933C1.23836 18.494 1.43712 18.8726 1.77153 19.0934C3.86631 20.4878 6.38699 21.2986 9.09696 21.2986Z" fill="#3370FF" /><path d="M23.7322 9.29488C22.7226 8.79642 21.5838 8.5188 20.3818 8.5188C19.6688 8.5188 18.9747 8.6166 18.3217 8.80273C18.246 8.82481 18.1703 8.8469 18.0977 8.86898C18.0441 8.88476 17.9905 8.90368 17.94 8.91946C17.8359 8.95416 17.7318 8.99202 17.6276 9.03303C17.4447 9.10559 17.2617 9.18446 17.085 9.26964C16.8957 9.36113 16.7128 9.45893 16.5329 9.56619C16.35 9.67345 16.1701 9.79018 15.9998 9.91322C15.8767 10.0016 15.7569 10.093 15.637 10.1877C15.5076 10.2918 15.3846 10.3991 15.2616 10.5095C15.1953 10.5694 15.1322 10.6325 15.066 10.6925L14.7315 11.0206L13.3939 12.3424L13.1636 12.5696L13.0879 12.6453C13.05 12.6831 13.0122 12.7178 12.9775 12.7557C12.9586 12.7746 12.9396 12.7904 12.9207 12.8093C12.8923 12.8377 12.8639 12.863 12.8355 12.8882C12.804 12.9166 12.7724 12.9481 12.7409 12.9765C11.9143 13.7368 10.9931 14.3899 9.99304 14.923C10.053 14.9514 10.1129 14.9798 10.1729 15.0051C10.2202 15.0271 10.2675 15.0492 10.3148 15.0713C10.359 15.0934 10.4063 15.1123 10.4536 15.1344C10.4978 15.1533 10.5451 15.1754 10.5893 15.1943C10.6398 15.2164 10.6871 15.2385 10.7376 15.2606C10.7944 15.2858 10.8511 15.3111 10.9079 15.3363C10.9616 15.3584 11.0184 15.3836 11.072 15.4057C11.1445 15.4373 11.2139 15.4657 11.2865 15.4941C11.3433 15.5193 11.4032 15.5414 11.46 15.5635C11.5484 15.5982 11.6367 15.636 11.7282 15.6707C11.8134 15.7023 11.8954 15.737 11.9806 15.7685C12.0437 15.7938 12.1068 15.8158 12.1699 15.8411C12.2803 15.8821 12.3875 15.9231 12.498 15.961C12.5547 15.9799 12.6084 16.002 12.6652 16.0209C12.744 16.0493 12.8197 16.0745 12.8986 16.1029C12.9617 16.125 13.028 16.1471 13.0911 16.1692C13.1857 16.2007 13.2803 16.2323 13.375 16.2638C13.4728 16.2954 13.5737 16.3269 13.6747 16.3585C13.7977 16.3963 13.9176 16.4342 14.0406 16.4689C14.1227 16.4941 14.2047 16.5162 14.2867 16.5414C14.4508 16.5888 14.618 16.6361 14.782 16.6803C14.842 16.696 14.9019 16.7118 14.9618 16.7244C15.0754 16.7528 15.189 16.7749 15.3026 16.7938C15.4319 16.8159 15.5613 16.8348 15.6906 16.8506C15.8673 16.8695 16.0408 16.8822 16.2143 16.8822C16.372 16.8853 16.5298 16.879 16.6844 16.8695C16.8326 16.8601 16.9778 16.8412 17.1229 16.8191C17.3248 16.7875 17.5204 16.7465 17.7128 16.6929C17.8422 16.6582 17.9715 16.6172 18.0977 16.5698C18.2144 16.5257 18.328 16.4815 18.4416 16.4279C18.5709 16.3679 18.7003 16.3048 18.8233 16.2354C18.8833 16.2007 18.9464 16.166 19.0063 16.1282C19.0631 16.0935 19.1199 16.0556 19.1767 16.0178C19.265 15.9578 19.3533 15.8947 19.4385 15.8316C19.5111 15.7748 19.5836 15.718 19.653 15.6581C19.7792 15.5508 19.8991 15.4341 20.0158 15.3142C20.0726 15.2543 20.1294 15.1943 20.183 15.1313C20.2524 15.0524 20.3187 14.9704 20.3849 14.8852C20.4354 14.8189 20.4859 14.7495 20.5364 14.6801C20.672 14.4845 20.7982 14.2763 20.9118 14.0586L21.0411 13.7999L22.2084 11.4748L22.2053 11.4812C22.5807 10.6578 23.1012 9.91953 23.7322 9.29488Z" fill="#133C9A" /></svg>',
6
+ email: '<svg class="le-channel-icon" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <rect width="20" height="16" x="2" y="4" rx="2" /> <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /> </svg>',
7
+ };
@@ -13,6 +13,10 @@ export interface DefaultWebhook {
13
13
  }
14
14
  export declare function describeAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): {
15
15
  source: string;
16
+ channels: NotifierFileChannel[];
17
+ primaryChannels: NotifierFileChannel[];
18
+ connections: NotifierFile[];
19
+ primary: NotifierFileChannel | null;
16
20
  defaultWebhookUrl: string | null;
17
21
  publicUrl: string | null;
18
22
  plan: string | null;
@@ -33,10 +37,16 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
33
37
  file?: NotifierFile;
34
38
  missingSecret?: string;
35
39
  error?: string;
40
+ active?: Array<{
41
+ channel: NotifierFileChannel;
42
+ missingSecret?: string;
43
+ }>;
36
44
  };
37
- /** Writes agents/<name>/messaging.yml from the console form and, when the
38
- * form includes one, the channel's secret into the agent's own env.
39
- * Throws NotifierFileError for an invalid form. */
45
+ /** Saves one app's connection in agents/<name>/messaging.yml from its
46
+ * section of the Messaging tab (the first one saved becomes primary;
47
+ * `primary: true` in the body makes this one primary) and, when the form
48
+ * includes one, its secret into the agent's own env. Throws
49
+ * NotifierFileError for an invalid form. */
40
50
  export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
41
51
  /** The bot token and signing secret behind an agent's Slack notifier —
42
52
  * from AgentConfig.httpNotifier when it's Slack, else from the agent's
@@ -60,25 +70,33 @@ export declare function slackClickAllowed(config: AgentConfig, user: string | un
60
70
  allowed: boolean;
61
71
  message?: string;
62
72
  };
63
- /** Replaces the approver list on the agent's saved Slack connection.
64
- * Throws NotifierFileError when there's no Slack connection to add them to. */
73
+ /** Replaces the approver list on the agent's saved Slack connection,
74
+ * primary or not. Throws NotifierFileError when there's no Slack
75
+ * connection to add them to. */
65
76
  export declare function setAgentApprovers(config: AgentConfig, approvers: unknown): MessagingApprover[];
66
77
  /** The people in the agent's Slack channel, for picking approvers. */
67
78
  export declare function listAgentSlackMembers(config: AgentConfig): Promise<SlackMember[]>;
68
79
  /** Channels for the console's picker, with the token being typed in or,
69
80
  * when none is given, the one already saved. */
70
81
  export declare function listAgentSlackChannels(config: AgentConfig, botToken?: string): Promise<SlackChannel[]>;
71
- /** Removes the messaging settings; the stored secret is left alone, so turning the
72
- * notifier back on doesn't need it re-entered. */
73
- export declare function removeAgentNotifier(config: AgentConfig): void;
74
- /** Posts a plain announcement through the agent's notifier — a scheduled
75
- * task's result, for one — regardless of which events it subscribed to.
76
- * A webhook gets a signed `run_finish` payload with `data` merged in;
77
- * Slack and Lark get `text`. Does nothing when no notifier applies. */
82
+ /** Removes one app's connection, or every connection when no app is
83
+ * named. Removing the primary leaves none primary. Stored secrets are
84
+ * left alone, so setting an app up again doesn't need them re-entered. */
85
+ export declare function removeAgentNotifier(config: AgentConfig, channel?: string): void;
86
+ /** Makes one saved connection the one in use. Throws NotifierFileError
87
+ * when that app isn't set up. */
88
+ export declare function setAgentPrimaryNotifier(config: AgentConfig, channel: unknown): void;
89
+ /** Posts a plain announcement through the agent's notifiers (the webhook
90
+ * and the primary messaging app) — a scheduled task's result, for one —
91
+ * regardless of which events they subscribed to. A webhook gets a signed
92
+ * `run_finish` payload with `data` merged in; the apps get `text`.
93
+ * Returns whether any notifier took it. */
78
94
  export declare function announceToNotifier(config: AgentConfig, text: string, data: Record<string, unknown>, defaultWebhook?: DefaultWebhook): boolean;
79
- /** Sends a test through whichever notifier applies to this agent —
80
- * webhook or Slack; reports what the receiver answered. */
81
- export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): Promise<DeliveryResult & {
95
+ /** Sends a test through one app's saved connection when `channel` is
96
+ * given (primary or not, so it can be tried before it's made primary),
97
+ * else through whichever notifier applies to this agent; reports what
98
+ * the receiver answered. */
99
+ export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook, channel?: string): Promise<DeliveryResult & {
82
100
  channel?: string;
83
101
  }>;
84
102
  /** Every decision still waiting for this agent, oldest first, each with
@@ -5,8 +5,9 @@ import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
5
5
  import { postLifecycleWebhook, sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
6
6
  import { SlackNotifier } from '../core/http-notify-triggers/slack.js';
7
7
  import { LarkNotifier } from '../core/http-notify-triggers/lark.js';
8
+ import { TelegramNotifier, sendTestTelegramMessage } from '../core/http-notify-triggers/telegram.js';
8
9
  import { listDeliveries } from '../core/notifier-deliveries.js';
9
- import { NOTIFIER_SECRET_VARS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, NotifierFileError, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
10
+ import { NOTIFIER_CHANNELS, NOTIFIER_SECRET_VARS, PRIMARY_CHANNELS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, NotifierFileError, notifierConfigFromFile, readActiveConnections, readMessagingConnections, readNotifierFile, removeMessagingConnection, removeNotifierFile, setPrimaryConnection, writeNotifierFile } from '../core/notifier-file.js';
10
11
  import { listSlackChannelMembers, listSlackChannels, slackAppManifest } from '../core/messaging/connectors/slack/index.js';
11
12
  import { setAgentEnvVar } from './env-admin.js';
12
13
  export function describeAgentNotifier(config, defaultWebhook) {
@@ -14,9 +15,22 @@ export function describeAgentNotifier(config, defaultWebhook) {
14
15
  const env = createAgentEnv(config.name, agentDirFor(config));
15
16
  const secretsSet = Object.fromEntries(Object.keys(NOTIFIER_SECRET_VARS).map((channel) => [channel, env.get(NOTIFIER_SECRET_VARS[channel]) !== undefined]));
16
17
  const source = described.source === 'none' && defaultWebhook ? 'default' : described.source;
18
+ // Every saved connection, one per app, for the tab's section for each;
19
+ // only `primary` is used. An unreadable file shows as none here (the
20
+ // error itself comes through `described`).
21
+ let saved = { connections: [] };
22
+ try {
23
+ saved = readMessagingConnections(agentDirFor(config));
24
+ }
25
+ catch { }
17
26
  return {
18
27
  ...described,
19
28
  source,
29
+ channels: NOTIFIER_CHANNELS,
30
+ // Only these take turns as primary; a webhook always sends.
31
+ primaryChannels: PRIMARY_CHANNELS,
32
+ connections: saved.connections,
33
+ primary: saved.primary ?? null,
20
34
  defaultWebhookUrl: defaultWebhook?.webhookUrl ?? null,
21
35
  publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
22
36
  // LOOPENGINE_PLAN ('free' on LoopEngine Cloud's free plan): the
@@ -38,22 +52,27 @@ export function describeAgentNotifier(config, defaultWebhook) {
38
52
  deliveries: listDeliveries(config.name),
39
53
  };
40
54
  }
41
- /** Writes agents/<name>/messaging.yml from the console form and, when the
42
- * form includes one, the channel's secret into the agent's own env.
43
- * Throws NotifierFileError for an invalid form. */
44
- export async function saveAgentNotifier(config, body) {
45
- // The form doesn't carry the approvers (they have their own section),
46
- // so saving it keeps them.
47
- let existing;
55
+ /** The agent's saved connection for one app, primary or not. */
56
+ function savedConnection(config, channel) {
48
57
  try {
49
- existing = readNotifierFile(agentDirFor(config));
58
+ return readMessagingConnections(agentDirFor(config)).connections.find((c) => c.channel === channel);
50
59
  }
51
- catch { }
52
- if (existing && body.channel === existing.channel) {
53
- if (body.approvers === undefined && existing.approvers)
54
- body = { ...body, approvers: existing.approvers };
60
+ catch {
61
+ return undefined;
55
62
  }
56
- const file = writeNotifierFile(agentDirFor(config), body);
63
+ }
64
+ /** Saves one app's connection in agents/<name>/messaging.yml from its
65
+ * section of the Messaging tab (the first one saved becomes primary;
66
+ * `primary: true` in the body makes this one primary) and, when the form
67
+ * includes one, its secret into the agent's own env. Throws
68
+ * NotifierFileError for an invalid form. */
69
+ export async function saveAgentNotifier(config, body) {
70
+ // The form doesn't carry the approvers (they have their own section),
71
+ // so saving it keeps them.
72
+ const existing = typeof body.channel === 'string' ? savedConnection(config, body.channel) : undefined;
73
+ if (existing?.approvers && body.approvers === undefined)
74
+ body = { ...body, approvers: existing.approvers };
75
+ const file = writeNotifierFile(agentDirFor(config), body, { makePrimary: body.primary === true });
57
76
  if (typeof body.secret === 'string' && body.secret.trim()) {
58
77
  await setAgentEnvVar(config.name, NOTIFIER_SECRET_VARS[file.channel], body.secret.trim());
59
78
  }
@@ -104,19 +123,19 @@ export function slackClickAllowed(config, user) {
104
123
  return { allowed: true };
105
124
  return { allowed: false, message: `Only approvers can decide this: ${approvers.map((a) => `<@${a.user}>`).join(', ')}.` };
106
125
  }
107
- /** Replaces the approver list on the agent's saved Slack connection.
108
- * Throws NotifierFileError when there's no Slack connection to add them to. */
126
+ /** Replaces the approver list on the agent's saved Slack connection,
127
+ * primary or not. Throws NotifierFileError when there's no Slack
128
+ * connection to add them to. */
109
129
  export function setAgentApprovers(config, approvers) {
110
- const dir = agentDirFor(config);
111
- const file = readNotifierFile(dir);
112
- if (!file || file.channel !== 'slack')
130
+ const file = savedConnection(config, 'slack');
131
+ if (!file)
113
132
  throw new NotifierFileError('Save a Slack connection first');
114
- return writeNotifierFile(dir, { ...file, approvers: Array.isArray(approvers) ? approvers : [] }).approvers ?? [];
133
+ return writeNotifierFile(agentDirFor(config), { ...file, approvers: Array.isArray(approvers) ? approvers : [] }).approvers ?? [];
115
134
  }
116
135
  /** The people in the agent's Slack channel, for picking approvers. */
117
136
  export async function listAgentSlackMembers(config) {
118
137
  const { botToken } = slackCredentialsFor(config);
119
- const channelId = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.channelId : readNotifierFile(agentDirFor(config))?.channelId;
138
+ const channelId = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.channelId : savedConnection(config, 'slack')?.channelId;
120
139
  if (!botToken || !channelId)
121
140
  throw new Error('Save the Slack bot token and channel first');
122
141
  return listSlackChannelMembers(botToken, channelId);
@@ -129,61 +148,83 @@ export async function listAgentSlackChannels(config, botToken) {
129
148
  throw new Error('Enter the bot token first');
130
149
  return listSlackChannels(token);
131
150
  }
132
- /** Removes the messaging settings; the stored secret is left alone, so turning the
133
- * notifier back on doesn't need it re-entered. */
134
- export function removeAgentNotifier(config) {
135
- removeNotifierFile(agentDirFor(config));
151
+ /** Removes one app's connection, or every connection when no app is
152
+ * named. Removing the primary leaves none primary. Stored secrets are
153
+ * left alone, so setting an app up again doesn't need them re-entered. */
154
+ export function removeAgentNotifier(config, channel) {
155
+ if (channel)
156
+ removeMessagingConnection(agentDirFor(config), channel);
157
+ else
158
+ removeNotifierFile(agentDirFor(config));
159
+ }
160
+ /** Makes one saved connection the one in use. Throws NotifierFileError
161
+ * when that app isn't set up. */
162
+ export function setAgentPrimaryNotifier(config, channel) {
163
+ if (typeof channel !== 'string' || !NOTIFIER_CHANNELS.includes(channel)) {
164
+ throw new NotifierFileError(`channel must be one of: ${NOTIFIER_CHANNELS.join(', ')}`);
165
+ }
166
+ setPrimaryConnection(agentDirFor(config), channel);
136
167
  }
137
- /** The notifier settings that apply to an agent: its code httpNotifier,
138
- * else its messaging.yml (once its secret is set), else the
139
- * deployment-wide default webhook. */
140
- function activeNotifierTarget(config, defaultWebhook) {
168
+ /** The notifiers that apply to an agent: its code httpNotifier; else its
169
+ * messaging.yml's webhook and primary messaging app (each once its
170
+ * secret is set); else the deployment-wide default webhook. */
171
+ function activeNotifierTargets(config, defaultWebhook) {
141
172
  if (config.httpNotifier)
142
- return config.httpNotifier;
173
+ return [config.httpNotifier];
143
174
  const dir = agentDirFor(config);
144
- const file = readNotifierFile(dir);
145
- if (file) {
146
- const { config: fromFile } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
147
- if (fromFile)
148
- return fromFile;
149
- }
150
- return defaultWebhook ? { channel: 'webhook', config: { webhookUrl: defaultWebhook.webhookUrl, webhookSecret: defaultWebhook.webhookSecret }, events: ['run_finish'] } : undefined;
151
- }
152
- /** Posts a plain announcement through the agent's notifier — a scheduled
153
- * task's result, for one — regardless of which events it subscribed to.
154
- * A webhook gets a signed `run_finish` payload with `data` merged in;
155
- * Slack and Lark get `text`. Does nothing when no notifier applies. */
175
+ const env = createAgentEnv(config.name, dir);
176
+ const fromFile = readActiveConnections(dir).flatMap((file) => notifierConfigFromFile(file, env, config.name).config ?? []);
177
+ if (fromFile.length)
178
+ return fromFile;
179
+ return defaultWebhook ? [{ channel: 'webhook', config: { webhookUrl: defaultWebhook.webhookUrl, webhookSecret: defaultWebhook.webhookSecret }, events: ['run_finish'] }] : [];
180
+ }
181
+ /** Posts a plain announcement through the agent's notifiers (the webhook
182
+ * and the primary messaging app) — a scheduled task's result, for one —
183
+ * regardless of which events they subscribed to. A webhook gets a signed
184
+ * `run_finish` payload with `data` merged in; the apps get `text`.
185
+ * Returns whether any notifier took it. */
156
186
  export function announceToNotifier(config, text, data, defaultWebhook) {
157
- let target;
187
+ let targets;
158
188
  try {
159
- target = activeNotifierTarget(config, defaultWebhook);
189
+ targets = activeNotifierTargets(config, defaultWebhook);
160
190
  }
161
191
  catch {
162
192
  return false;
163
193
  }
164
- if (!target)
165
- return false;
166
- switch (target.channel) {
167
- case 'webhook':
168
- postLifecycleWebhook(target.config.webhookUrl, target.config.webhookSecret, 'run_finish', { agent: config.name, text, ...data });
169
- return true;
170
- case 'slack':
171
- new SlackNotifier({ botToken: target.config.botToken, channelId: target.config.channelId }).postMessage(text);
172
- return true;
173
- case 'lark':
174
- new LarkNotifier({ appId: target.config.appId, appSecret: target.config.appSecret, chatId: target.config.chatId }).postMessage(text);
175
- return true;
176
- default:
177
- return false;
178
- }
179
- }
180
- /** Sends a test through whichever notifier applies to this agent —
181
- * webhook or Slack; reports what the receiver answered. */
182
- export async function testAgentNotifier(config, defaultWebhook) {
183
- let target = config.httpNotifier;
194
+ let sent = false;
195
+ for (const target of targets) {
196
+ switch (target.channel) {
197
+ case 'webhook':
198
+ postLifecycleWebhook(target.config.webhookUrl, target.config.webhookSecret, 'run_finish', { agent: config.name, text, ...data });
199
+ sent = true;
200
+ break;
201
+ case 'slack':
202
+ new SlackNotifier({ botToken: target.config.botToken, channelId: target.config.channelId }).postMessage(text);
203
+ sent = true;
204
+ break;
205
+ case 'lark':
206
+ new LarkNotifier({ appId: target.config.appId, appSecret: target.config.appSecret, chatId: target.config.chatId }).postMessage(text);
207
+ sent = true;
208
+ break;
209
+ case 'telegram':
210
+ new TelegramNotifier({ botToken: target.config.botToken, chatId: target.config.chatId }).postMessage(text);
211
+ sent = true;
212
+ break;
213
+ }
214
+ }
215
+ return sent;
216
+ }
217
+ /** Sends a test through one app's saved connection when `channel` is
218
+ * given (primary or not, so it can be tried before it's made primary),
219
+ * else through whichever notifier applies to this agent; reports what
220
+ * the receiver answered. */
221
+ export async function testAgentNotifier(config, defaultWebhook, channel) {
222
+ let target = channel ? undefined : config.httpNotifier;
223
+ if (channel && !savedConnection(config, channel))
224
+ return { ok: false, channel, error: `Save the ${channel} connection first` };
184
225
  if (!target) {
185
226
  const dir = agentDirFor(config);
186
- const file = readNotifierFile(dir);
227
+ const file = channel ? savedConnection(config, channel) : readNotifierFile(dir);
187
228
  if (file) {
188
229
  const { config: fromFile, missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
189
230
  if (!fromFile)
@@ -202,6 +243,9 @@ export async function testAgentNotifier(config, defaultWebhook) {
202
243
  if (target.channel === 'slack') {
203
244
  return { channel: 'slack', ...(await sendTestSlackMessage(target.config.botToken, target.config.channelId, `Test from LoopEngine: approvals for ${config.name} will be posted here.`)) };
204
245
  }
246
+ if (target.channel === 'telegram') {
247
+ return { channel: 'telegram', ...(await sendTestTelegramMessage(target.config.botToken, target.config.chatId, `Test from LoopEngine: approvals for ${config.name} will be posted here.`)) };
248
+ }
205
249
  if (target.channel === 'email') {
206
250
  try {
207
251
  await target.config.sendEmail(target.config.to, `Test from LoopEngine: ${config.name}`, `<p>This is a test. Approval requests and questions from <b>${config.name}</b> will be emailed here, each with a link to approve, deny or answer.</p>`, { kind: 'test' });
@@ -0,0 +1,2 @@
1
+ import type { ModelProviderInfo } from './global-config.js';
2
+ export declare const PROVIDER_ICONS: Record<ModelProviderInfo['provider'], string>;