loopengine 0.1.68 → 0.1.70

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.
@@ -0,0 +1,111 @@
1
+ import { cancelScheduledTask, createScheduledTask, getScheduledTask, listScheduledTasks, } from '../scheduler.js';
2
+ /** How many active (not done or cancelled) tasks one agent may create
3
+ * through these tools — each run is a model call, so a runaway loop of
4
+ * "schedule another one" stops here. The console and API aren't capped. */
5
+ function maxActivePerAgent() {
6
+ const n = Number(process.env.LOOPENGINE_SCHEDULER_MAX_PER_AGENT);
7
+ return Number.isInteger(n) && n > 0 ? n : 25;
8
+ }
9
+ function summary(t) {
10
+ return {
11
+ task_id: t.task_id,
12
+ task_name: t.task_name,
13
+ message: t.message,
14
+ schedule: t.schedule,
15
+ status: t.status,
16
+ next_run_at: t.next_run_at,
17
+ run_count: t.run_count,
18
+ last_status: t.last_status,
19
+ };
20
+ }
21
+ export function createScheduleTools(agentName) {
22
+ async function ownTask(taskId, tool) {
23
+ const task = await getScheduledTask(taskId);
24
+ // Another agent's task reads exactly like a missing one.
25
+ if (!task || task.agent !== agentName)
26
+ throw new Error(`${tool}: no scheduled task "${taskId}" for this agent`);
27
+ return task;
28
+ }
29
+ const scheduleTask = {
30
+ name: 'system_schedule_task',
31
+ description: 'Schedule a message to yourself for later — once at a set time, or repeatedly on an interval — like setting a reminder or a cron job. When it fires you receive the message as a new conversation (none of the current one carries over, so make the message self-contained) and handle it with your tools as usual, with no person live. A tool call that needs approval goes wherever your approval requests are sent and waits; if nowhere is set, it is denied. Use only when the user asks for something to happen later or regularly, never on your own initiative. Use the user\'s time zone (ask if unclear), prefer the longest interval that serves them since each run is a model call, and confirm the schedule back in plain words. Returns task_id and next_run_at.',
32
+ input_schema: {
33
+ type: 'object',
34
+ properties: {
35
+ message: {
36
+ type: 'string',
37
+ description: 'What you will receive when the task fires, written as an instruction to your future self with every detail it needs, e.g. "Check whether order 4417 has shipped and email the customer at a@example.com if it has."',
38
+ },
39
+ schedule: {
40
+ type: 'object',
41
+ description: 'Either { "run_at": "<ISO 8601 timestamp with time zone>" } for once, or { "every": "<interval>" } to repeat until cancelled — a number plus s/m/h/d, e.g. "30m", "1h", "1d"; 30s at the shortest. A repeating task keeps its first slot: started at 10:07 with "1h", it fires at 11:07, 12:07, ...',
42
+ properties: { run_at: { type: 'string' }, every: { type: 'string' } },
43
+ },
44
+ task_name: { type: 'string', description: 'Optional short label, shown when listing tasks and in the console.' },
45
+ notify: { type: 'boolean', description: 'Optional — also send each run\'s result through your notifier (where your approval requests go), so a person sees it.' },
46
+ },
47
+ required: ['message', 'schedule'],
48
+ },
49
+ execute: async (input) => {
50
+ const active = (await listScheduledTasks({ agent: agentName })).filter((t) => t.status !== 'done' && t.status !== 'cancelled');
51
+ if (active.length >= maxActivePerAgent()) {
52
+ throw new Error(`system_schedule_task: you already have ${active.length} active scheduled tasks (the limit is ${maxActivePerAgent()}); cancel one you no longer need first`);
53
+ }
54
+ const task = await createScheduledTask({
55
+ agent: agentName,
56
+ message: String(input.message ?? ''),
57
+ schedule: (input.schedule ?? {}),
58
+ task_name: typeof input.task_name === 'string' ? input.task_name : undefined,
59
+ notify: input.notify === true,
60
+ });
61
+ return JSON.stringify({ task_id: task.task_id, next_run_at: task.next_run_at, schedule: task.schedule });
62
+ },
63
+ };
64
+ const listTasks = {
65
+ name: 'system_list_scheduled_tasks',
66
+ description: 'List your own scheduled tasks (task_id, name, message, schedule, status, next_run_at, run_count, last_status). Check this before scheduling something recurring, so you don\'t create a duplicate. status "awaiting_approval" means a run is waiting on someone to approve or answer.',
67
+ input_schema: {
68
+ type: 'object',
69
+ properties: {
70
+ status: {
71
+ type: 'string',
72
+ enum: ['scheduled', 'running', 'awaiting_approval', 'paused', 'done', 'cancelled'],
73
+ description: 'Optional — only tasks with this status.',
74
+ },
75
+ },
76
+ },
77
+ execute: async (input) => {
78
+ const status = typeof input.status === 'string' ? input.status : undefined;
79
+ return JSON.stringify((await listScheduledTasks({ agent: agentName, status })).map(summary));
80
+ },
81
+ safe: true,
82
+ };
83
+ const checkTask = {
84
+ name: 'system_check_scheduled_task',
85
+ description: 'Get one of your scheduled tasks in full — schedule, status, next_run_at, and recent runs, each "ok" with your reply from that run or "error" with what went wrong (for example, a call that needed approval was denied because nowhere is set to send approval requests).',
86
+ input_schema: {
87
+ type: 'object',
88
+ properties: { task_id: { type: 'string', description: 'The task_id from system_schedule_task or system_list_scheduled_tasks.' } },
89
+ required: ['task_id'],
90
+ },
91
+ execute: async (input) => JSON.stringify(await ownTask(String(input.task_id ?? ''), 'system_check_scheduled_task')),
92
+ safe: true,
93
+ };
94
+ const cancelTask = {
95
+ name: 'system_cancel_scheduled_task',
96
+ description: 'Cancel one of your scheduled tasks so it doesn\'t fire again. A run already waiting on an approval finishes first. Its history stays (status "cancelled").',
97
+ input_schema: {
98
+ type: 'object',
99
+ properties: { task_id: { type: 'string', description: 'The task_id from system_schedule_task or system_list_scheduled_tasks.' } },
100
+ required: ['task_id'],
101
+ },
102
+ execute: async (input) => {
103
+ const task = await ownTask(String(input.task_id ?? ''), 'system_cancel_scheduled_task');
104
+ if (task.status === 'cancelled')
105
+ return JSON.stringify({ task_id: task.task_id, status: 'cancelled', already: true });
106
+ const after = await cancelScheduledTask(task.task_id);
107
+ return JSON.stringify({ task_id: task.task_id, status: after.status, ...(after.cancel_after_run ? { note: 'cancels once the run waiting on an approval finishes' } : {}) });
108
+ },
109
+ };
110
+ return [scheduleTask, listTasks, checkTask, cancelTask];
111
+ }
package/dist/index.d.ts CHANGED
@@ -10,10 +10,13 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
10
10
  export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } from '#core/notifier-file.js';
11
11
  export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
12
12
  export { describeNotifier } from '#core/http-notifier.js';
13
+ export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
14
+ export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest, type SlackInteraction, type SlackChannel } from '#core/slack-interactions.js';
15
+ export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
13
16
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
14
17
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
15
18
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
16
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, type DefaultWebhook } from './web/notifier-admin.js';
19
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
17
20
  export type * from './core/loop-events.js';
18
21
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
19
22
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
@@ -54,7 +57,7 @@ export type { ToolSource, ComposioSourceOptions } from './core/mcpplug.js';
54
57
  export { ToolLane, buildLanes, runLanes } from './core/toollane.js';
55
58
  export type { ToolLaneOptions, SafetyClassifier, ToolCall, Lane, LaneResult } from './core/toollane.js';
56
59
  export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
57
- export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
60
+ export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool, createScheduleTools } from './core/system-tools/index.js';
58
61
  export type { PendingQuestion } from './core/system-tools/index.js';
59
62
  export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
60
63
  export type { AgentEditableFields, AgentEditResult } from './web/agent-file-admin.js';
package/dist/index.js CHANGED
@@ -12,10 +12,13 @@ export { setUsageSink, recordUsage } from '#core/usage.js';
12
12
  export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
13
13
  export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
14
14
  export { describeNotifier } from '#core/http-notifier.js';
15
+ export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
16
+ export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest } from '#core/slack-interactions.js';
17
+ export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
18
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
16
19
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
17
20
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
18
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending } from './web/notifier-admin.js';
21
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
19
22
  // The framework-agnostic browser client for adapters/http.ts's own wire
20
23
  // protocol (see client.ts's own header comment) — this, not a deep
21
24
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -53,7 +56,7 @@ export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
53
56
  // answerQuestion above (core/client.ts) is the HTTP client-side helper —
54
57
  // this is the server-side function that actually resolves a pending
55
58
  // question in-process, aliased to avoid colliding with that name.
56
- export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
59
+ export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool, createScheduleTools } from './core/system-tools/index.js';
57
60
  export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
58
61
  export { createHttpTool, updateHttpTool, readHttpToolSpec, listEditableHttpToolNames, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError, HttpToolNotFoundError, HttpToolNotEditableError, } from './web/http-tool-admin.js';
59
62
  export { agentsConfigPageHtml } from './web/agents-config-page.js';
@@ -39,6 +39,13 @@ export const agentsConfigPageHtml = `<!doctype html>
39
39
  panels' own content. */
40
40
  #empty { color: var(--ink-muted); font-size: 13px; padding: 24px; }
41
41
  .env-cell { display: grid; gap: 6px; }
42
+ .schedule-result { max-width: 420px; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 12.5px; }
43
+ .schedule-history { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 8px; }
44
+ form.add-source .schedule-when { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
45
+ form.add-source .schedule-when label { display: inline; font-size: 13px; }
46
+ form.add-source .schedule-when input[type="radio"] { width: auto; margin: 0; }
47
+ form.add-source .schedule-when input[type="number"] { width: 80px; }
48
+ form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
42
49
  .approvals-nudge { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--ink); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 24px; font-size: 13px; }
43
50
  .env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
44
51
  form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
@@ -962,6 +969,7 @@ ${consoleShellScript}
962
969
  // deliveries. Reloaded every time the tab opens, so the waiting list is
963
970
  // current. ----
964
971
 
972
+ var lastNotifier = null;
965
973
  var NOTIFIER_EVENTS = [
966
974
  ['approval', 'Approvals'], ['question', 'Questions'], ['run_finish', 'Run finished'], ['run_start', 'Run started']
967
975
  ];
@@ -1017,8 +1025,27 @@ ${consoleShellScript}
1017
1025
  '<label>Send to<select name="channel" id="notifierChannel">' +
1018
1026
  ['webhook', 'slack', 'lark'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat' }[c] + '</option>'; }).join('') +
1019
1027
  '</select></label>' +
1020
- field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret')) +
1021
- field('slack', '<label>Channel ID<input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' + secretField('slack', 'Bot token')) +
1028
+ field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret') +
1029
+ '<p class="hint" style="margin:0">Your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>') +
1030
+ field('slack',
1031
+ '<details class="slack-step"' + (n.secretsSet && n.secretsSet.slack ? '' : ' open') + '><summary>Create the Slack app (once)</summary>' +
1032
+ '<ol class="hint" style="margin:6px 0;padding-left:18px">' +
1033
+ '<li>Open <a href="https://api.slack.com/apps?new_app=1" target="_blank" rel="noopener">Slack apps</a> &rarr; Create New App &rarr; From a manifest, and pick your workspace.</li>' +
1034
+ '<li>Paste the manifest below, create the app, then Install to Workspace.</li>' +
1035
+ '<li>Copy the <strong>Bot User OAuth Token</strong> (starts with xoxb-) from OAuth &amp; Permissions into the field below.</li>' +
1036
+ '</ol>' +
1037
+ '<textarea id="slackManifest" readonly rows="8" style="font-family:var(--font-mono);font-size:11.5px"></textarea>' +
1038
+ '<button type="button" id="slackManifestCopy" style="margin-top:6px">Copy manifest</button>' +
1039
+ '</details>' +
1040
+ secretField('slack', 'Bot token') +
1041
+ '<label>Channel <span class="hint">(invite the bot to a private channel first: /invite @its-name)</span>' +
1042
+ '<span style="display:flex;gap:6px"><select id="slackChannelSelect" style="flex:1;min-width:0"><option value="">' + (f.channelId ? 'Saved: ' + escapeHtml(f.channelId) : 'Load channels to pick one') + '</option></select>' +
1043
+ '<button type="button" id="slackLoadChannels">Load channels</button></span></label>' +
1044
+ '<label>Channel ID <span class="hint">(filled in when you pick a channel)</span><input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' +
1045
+ '<label>Signing secret <span class="hint">(optional, from Basic Information; ' +
1046
+ (n.slack && n.slack.signingSecretSet ? 'set &mdash; Approve/Deny buttons are on' : 'adds Approve/Deny buttons inside Slack; without it, messages carry the approval link') + ')</span>' +
1047
+ '<input type="password" name="signingSecret" autocomplete="off"></label>' +
1048
+ '<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>') +
1022
1049
  field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
1023
1050
  '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
1024
1051
  '<input type="url" name="publicUrl" id="notifierPublicUrl" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
@@ -1031,7 +1058,7 @@ ${consoleShellScript}
1031
1058
  '<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit"' + (n.canSave === false ? ' disabled' : '') + '>Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
1032
1059
  (n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
1033
1060
  '<div id="notifierResult" class="hint"></div>' +
1034
- '<p class="hint" style="margin:0">For a webhook: your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>' +
1061
+
1035
1062
  '</form>';
1036
1063
  }
1037
1064
 
@@ -1142,6 +1169,51 @@ ${consoleShellScript}
1142
1169
  result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
1143
1170
  });
1144
1171
  }
1172
+ // Slack: manifest follows the link address; Load channels fills the picker.
1173
+ var n0 = lastNotifier;
1174
+ var manifestEl = form.querySelector('#slackManifest');
1175
+ var linkInput = form.querySelector('#notifierPublicUrl');
1176
+ function refreshSlackManifest() {
1177
+ if (!manifestEl || !n0 || !n0.slack) return;
1178
+ var url = (linkInput.value || location.origin).replace(/[/]+$/, '') + n0.slack.interactivityPath;
1179
+ manifestEl.value = JSON.stringify(n0.slack.manifestTemplate, null, 2).replace('__INTERACTIVITY_URL__', url);
1180
+ var urlEl = form.querySelector('#slackInteractivityUrl');
1181
+ if (urlEl) urlEl.textContent = url;
1182
+ }
1183
+ if (linkInput) linkInput.addEventListener('input', refreshSlackManifest);
1184
+ refreshSlackManifest();
1185
+ var copyManifest = form.querySelector('#slackManifestCopy');
1186
+ if (copyManifest) {
1187
+ copyManifest.addEventListener('click', function () {
1188
+ manifestEl.select();
1189
+ var done = function () { copyManifest.textContent = 'Copied'; setTimeout(function () { copyManifest.textContent = 'Copy manifest'; }, 1500); };
1190
+ if (navigator.clipboard) navigator.clipboard.writeText(manifestEl.value).then(done, done); else done();
1191
+ });
1192
+ }
1193
+ var loadChannels = form.querySelector('#slackLoadChannels');
1194
+ var channelSelect = form.querySelector('#slackChannelSelect');
1195
+ if (loadChannels) {
1196
+ loadChannels.addEventListener('click', function () {
1197
+ loadChannels.disabled = true;
1198
+ result.textContent = 'Loading channels' + String.fromCharCode(8230);
1199
+ var token = form.querySelector('input[name="secret_slack"]').value;
1200
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/slack-channels', {
1201
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ botToken: token }),
1202
+ }).then(function (r) {
1203
+ loadChannels.disabled = false;
1204
+ if (!r.ok) { result.textContent = r.body.error || 'Could not load channels'; return; }
1205
+ var current = form.querySelector('input[name="channelId"]').value;
1206
+ channelSelect.innerHTML = '<option value="">Pick a channel</option>' + r.body.channels.map(function (c) {
1207
+ return '<option value="' + escapeHtml(c.id) + '"' + (c.id === current ? ' selected' : '') + '>' + (c.isPrivate ? '&#128274; ' : '#') + escapeHtml(c.name) + (c.isMember ? '' : ' (bot not in channel yet)') + '</option>';
1208
+ }).join('');
1209
+ result.textContent = r.body.channels.length + ' channels found.';
1210
+ });
1211
+ });
1212
+ channelSelect.addEventListener('change', function () {
1213
+ if (channelSelect.value) form.querySelector('input[name="channelId"]').value = channelSelect.value;
1214
+ });
1215
+ }
1216
+
1145
1217
  form.addEventListener('submit', function (ev) {
1146
1218
  ev.preventDefault();
1147
1219
  var data = new FormData(form);
@@ -1149,6 +1221,7 @@ ${consoleShellScript}
1149
1221
  var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
1150
1222
  var secret = data.get('secret_' + channel);
1151
1223
  if (secret) body.secret = secret;
1224
+ if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
1152
1225
  result.textContent = 'Saving…';
1153
1226
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
1154
1227
  method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
@@ -1182,6 +1255,7 @@ ${consoleShellScript}
1182
1255
  ]).then(function (results) {
1183
1256
  if (currentName !== name) return;
1184
1257
  if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
1258
+ lastNotifier = results[0].body;
1185
1259
  content.innerHTML = renderApprovalsHtml(results[0].body);
1186
1260
  wireApprovalsHandlers(name);
1187
1261
  var pendingEl = detail.querySelector('#pendingContent');
@@ -1196,6 +1270,151 @@ ${consoleShellScript}
1196
1270
  });
1197
1271
  }
1198
1272
 
1273
+ // ---- Schedules tab: messages this agent receives on a timer (core/
1274
+ // scheduler.ts behind GET/POST .../schedules and POST /schedules/:id/
1275
+ // run|pause|resume|cancel). Reloaded every time the tab opens, and
1276
+ // every few seconds while a run is in progress. ----
1277
+
1278
+ var schedulesRefreshTimer = null;
1279
+
1280
+ function schedulesContentEl() {
1281
+ return detail.querySelector('#schedulesContent');
1282
+ }
1283
+
1284
+ function renderSchedulesTabPlaceholder() {
1285
+ return '<div id="schedulesContent"><p class="hint">Loading&hellip;</p></div>';
1286
+ }
1287
+
1288
+ var SCHEDULE_PILLS = {
1289
+ scheduled: 'good', running: 'accent', awaiting_approval: 'ask', paused: 'neutral', done: 'neutral', cancelled: 'neutral'
1290
+ };
1291
+ var SCHEDULE_LABELS = {
1292
+ scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
1293
+ };
1294
+
1295
+ function scheduleText(s) {
1296
+ return s.run_at ? 'Once, ' + new Date(s.run_at).toLocaleString() : 'Every ' + escapeHtml(s.every);
1297
+ }
1298
+
1299
+ function renderScheduleRow(t) {
1300
+ var actions = [];
1301
+ if (t.status !== 'running' && t.status !== 'awaiting_approval' && t.status !== 'cancelled') actions.push(['run', 'Run now']);
1302
+ if (t.status === 'scheduled') actions.push(['pause', 'Pause']);
1303
+ if (t.status === 'paused') actions.push(['resume', 'Resume']);
1304
+ if (t.status !== 'cancelled' && t.status !== 'done') actions.push(['cancel', 'Cancel']);
1305
+ if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
1306
+ var last = t.last_run_at
1307
+ ? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
1308
+ '<span class="hint">' + escapeHtml(new Date(t.last_run_at).toLocaleString()) + '</span>' +
1309
+ '<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
1310
+ : '<span class="hint">not run yet</span>';
1311
+ var history = (t.history || []).slice().reverse().map(function (h) {
1312
+ return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(new Date(h.ran_at).toLocaleString()) + '</span>' +
1313
+ '<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
1314
+ }).join('');
1315
+ return '<tr>' +
1316
+ '<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' + (t.notify ? ' <span class="le-pill accent" title="Each result goes through this agent&#39;s notifier">notifies</span>' : '') +
1317
+ '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
1318
+ '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(new Date(t.next_run_at).toLocaleString()) + '</div>' : '') + '</td>' +
1319
+ '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
1320
+ (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Permissions</a></div>' : '') + '</td>' +
1321
+ '<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
1322
+ '<td><div style="display:flex;gap:6px;flex-wrap:wrap">' + actions.map(function (a) {
1323
+ return '<button type="button" class="schedule-action' + (a[0] === 'run' ? ' primary' : '') + (a[0] === 'cancel' || a[0] === 'delete' ? ' delete-btn' : '') + '" data-id="' + escapeHtml(t.task_id) + '" data-action="' + a[0] + '">' + a[1] + '</button>';
1324
+ }).join('') + '</div></td></tr>';
1325
+ }
1326
+
1327
+ function renderSchedulesHtml(tasks) {
1328
+ var active = tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; });
1329
+ var list = tasks.length
1330
+ ? '<div class="le-table-wrap"><table><thead><tr><th>Task</th><th>When</th><th>Status</th><th>Last run</th><th></th></tr></thead><tbody>' + tasks.map(renderScheduleRow).join('') + '</tbody></table></div>'
1331
+ : '<p class="hint">Nothing scheduled yet.</p>';
1332
+ return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the Permissions tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
1333
+ '<section><h3>Scheduled (' + active.length + ' active)</h3>' + list + '</section>' +
1334
+ '<section><h3>New schedule</h3>' +
1335
+ '<form class="add-source" id="scheduleForm" style="max-width:560px">' +
1336
+ '<label>Message <span class="hint">(what the agent receives each time, as if someone typed it)</span><textarea name="message" required placeholder="Summarize yesterday&#39;s support tickets and post the summary."></textarea></label>' +
1337
+ '<div class="body-field">When' +
1338
+ '<div class="schedule-when"><input type="radio" name="when" value="every" id="whenEvery" checked><label for="whenEvery">Every</label>' +
1339
+ '<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
1340
+ '<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option></select></div>' +
1341
+ '<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
1342
+ '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
1343
+ '</div>' +
1344
+ '<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
1345
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent&#39;s notifier (set in the Permissions tab)</label>' +
1346
+ '<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
1347
+ '<div id="scheduleResult" class="hint"></div>' +
1348
+ '</form></section>';
1349
+ }
1350
+
1351
+ function wireSchedulesHandlers(name) {
1352
+ var content = schedulesContentEl();
1353
+ if (!content) return;
1354
+ var actionBtns = content.querySelectorAll('.schedule-action');
1355
+ for (var i = 0; i < actionBtns.length; i++) {
1356
+ actionBtns[i].addEventListener('click', function (ev) {
1357
+ var btn = ev.currentTarget;
1358
+ var action = btn.dataset.action;
1359
+ if ((action === 'cancel' || action === 'delete') && btn.dataset.confirming !== '1') {
1360
+ btn.dataset.confirming = '1';
1361
+ btn.textContent = 'Click again to ' + action;
1362
+ return;
1363
+ }
1364
+ btn.disabled = true;
1365
+ var request = action === 'delete'
1366
+ ? fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id), { method: 'DELETE' })
1367
+ : fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' });
1368
+ request.then(function () { setTimeout(function () { loadSchedulesTab(name); }, action === 'run' ? 800 : 0); });
1369
+ });
1370
+ }
1371
+ var toApprovals = content.querySelectorAll('.schedule-to-approvals');
1372
+ for (var j = 0; j < toApprovals.length; j++) {
1373
+ toApprovals[j].addEventListener('click', function (ev) { ev.preventDefault(); switchTab('actauth'); });
1374
+ }
1375
+ var form = content.querySelector('#scheduleForm');
1376
+ var result = content.querySelector('#scheduleResult');
1377
+ form.addEventListener('submit', function (ev) {
1378
+ ev.preventDefault();
1379
+ var data = new FormData(form);
1380
+ var schedule;
1381
+ if (data.get('when') === 'once') {
1382
+ if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
1383
+ schedule = { run_at: new Date(data.get('runAt')).toISOString() };
1384
+ } else {
1385
+ schedule = { every: String(data.get('everyN')) + data.get('everyUnit') };
1386
+ }
1387
+ result.textContent = 'Scheduling' + String.fromCharCode(8230);
1388
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
1389
+ method: 'POST', headers: { 'content-type': 'application/json' },
1390
+ body: JSON.stringify({ message: data.get('message'), schedule: schedule, task_name: data.get('task_name'), notify: data.get('notify') === 'on' }),
1391
+ }).then(function (r) {
1392
+ if (!r.ok) { result.textContent = r.body.error || 'Could not schedule'; return; }
1393
+ loadSchedulesTab(name);
1394
+ });
1395
+ });
1396
+ }
1397
+
1398
+ function loadSchedulesTab(name) {
1399
+ var content = schedulesContentEl();
1400
+ if (!content) return;
1401
+ if (schedulesRefreshTimer) { clearTimeout(schedulesRefreshTimer); schedulesRefreshTimer = null; }
1402
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules').then(function (r) {
1403
+ if (currentName !== name) return;
1404
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
1405
+ var tasks = r.body.tasks || [];
1406
+ content.innerHTML = renderSchedulesHtml(tasks);
1407
+ wireSchedulesHandlers(name);
1408
+ leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
1409
+ if (tasks.some(function (t) { return t.status === 'running'; }) && currentTab === 'schedules') {
1410
+ schedulesRefreshTimer = setTimeout(function () { loadSchedulesTab(name); }, 3000);
1411
+ }
1412
+ }).catch(function (err) {
1413
+ if (currentName !== name) return;
1414
+ content.innerHTML = '<p class="error">Could not load schedules: ' + escapeHtml(err.message) + '</p>';
1415
+ });
1416
+ }
1417
+
1199
1418
  function actauthContentEl() {
1200
1419
  return detail.querySelector('#actauthContent');
1201
1420
  }
@@ -2670,7 +2889,7 @@ ${consoleShellScript}
2670
2889
 
2671
2890
  // ---- Tab switching + agent selection ----
2672
2891
 
2673
- var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'env'];
2892
+ var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'schedules', 'env'];
2674
2893
 
2675
2894
  function switchTab(tab) {
2676
2895
  if (CONFIG_TABS.indexOf(tab) === -1) tab = 'overview';
@@ -2698,6 +2917,7 @@ ${consoleShellScript}
2698
2917
  if (tab === 'abilities' && abilitiesLoadedFor !== currentName) {
2699
2918
  loadAbilitiesTab(currentName);
2700
2919
  }
2920
+ if (tab === 'schedules') loadSchedulesTab(currentName);
2701
2921
  }
2702
2922
 
2703
2923
  // Refreshes just Overview's own summary — used after anything that
@@ -2794,6 +3014,7 @@ ${consoleShellScript}
2794
3014
  '<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
2795
3015
  '<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
2796
3016
  '<div class="tab-panel" data-tab-panel="abilities">' + renderAbilitiesTabPlaceholder() + '</div>' +
3017
+ '<div class="tab-panel" data-tab-panel="schedules">' + renderSchedulesTabPlaceholder() + '</div>' +
2797
3018
  '</div></div>';
2798
3019
 
2799
3020
  wireSkillsHandlers(cfg.name);
@@ -42,7 +42,7 @@ export const consoleShellScript = `<script>
42
42
  var toSet = {};
43
43
  var TABS = [
44
44
  ['chat', 'Chat'], ['overview', 'Overview'], ['abilities', 'Abilities'], ['tools', 'Tools'],
45
- ['skills', 'Skills'], ['actauth', 'Permissions'], ['env', 'Environment']
45
+ ['skills', 'Skills'], ['actauth', 'Permissions'], ['schedules', 'Schedules'], ['env', 'Environment']
46
46
  ];
47
47
 
48
48
  function esc(s) {
@@ -1,7 +1,9 @@
1
1
  import type { AgentConfig } from '../core/agent-config.js';
2
2
  import type { CheckpointStore } from '../core/durable-approvals.js';
3
3
  import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
4
+ import type { HttpNotifierConfig } from '../core/agent-config.js';
4
5
  import { type NotifierFileChannel } from '../core/notifier-file.js';
6
+ import { type SlackChannel } from '../core/slack-interactions.js';
5
7
  /** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
6
8
  * _SECRET), which applies to the plain /messages route when an agent has
7
9
  * no notifier of its own. */
@@ -17,8 +19,14 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
17
19
  secretsSet: {
18
20
  [k: string]: boolean;
19
21
  };
22
+ slack: {
23
+ signingSecretVar: string;
24
+ signingSecretSet: boolean;
25
+ interactivityPath: string;
26
+ manifestTemplate: Record<string, unknown>;
27
+ };
20
28
  deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
21
- channel?: import("../core/agent-config.js").HttpNotifierConfig["channel"];
29
+ channel?: HttpNotifierConfig["channel"];
22
30
  events?: string[];
23
31
  file?: import("../core/notifier-file.js").NotifierFile;
24
32
  missingSecret?: string;
@@ -28,9 +36,25 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
28
36
  * form includes one, the channel's secret into the agent's own env.
29
37
  * Throws NotifierFileError for an invalid form. */
30
38
  export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
39
+ /** The bot token and signing secret behind an agent's Slack notifier —
40
+ * from AgentConfig.httpNotifier when it's Slack, else from the agent's
41
+ * env (the console-saved ones). The signing secret always comes from the
42
+ * agent env. */
43
+ export declare function slackCredentialsFor(config: AgentConfig): {
44
+ botToken?: string;
45
+ signingSecret?: string;
46
+ };
47
+ /** Channels for the console's picker, with the token being typed in or,
48
+ * when none is given, the one already saved. */
49
+ export declare function listAgentSlackChannels(config: AgentConfig, botToken?: string): Promise<SlackChannel[]>;
31
50
  /** Removes notifier.yml; the stored secret is left alone, so turning the
32
51
  * notifier back on doesn't need it re-entered. */
33
52
  export declare function removeAgentNotifier(config: AgentConfig): void;
53
+ /** Posts a plain announcement through the agent's notifier — a scheduled
54
+ * task's result, for one — regardless of which events it subscribed to.
55
+ * A webhook gets a signed `run_finish` payload with `data` merged in;
56
+ * Slack and Lark get `text`. Does nothing when no notifier applies. */
57
+ export declare function announceToNotifier(config: AgentConfig, text: string, data: Record<string, unknown>, defaultWebhook?: DefaultWebhook): boolean;
34
58
  /** Sends a test through whichever notifier applies to this agent —
35
59
  * webhook or Slack; reports what the receiver answered. */
36
60
  export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): Promise<DeliveryResult & {