loopengine 0.1.67 → 0.1.69

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,59 @@
1
+ export declare const SLACK_ANSWER_BLOCK_ID = "answer_block";
2
+ export declare const SLACK_ANSWER_ACTION_ID = "answer_input";
3
+ /** https://api.slack.com/authentication/verifying-requests-from-slack —
4
+ * rejects anything older than five minutes, so a captured request can't
5
+ * be replayed later. */
6
+ export declare function verifySlackRequest(rawBody: string, timestamp: string | undefined, signature: string | undefined, signingSecret: string, nowMs?: number): boolean;
7
+ export type SlackInteraction = {
8
+ type: 'decide';
9
+ pendingId: string;
10
+ decision: 'approve' | 'deny';
11
+ responseUrl?: string;
12
+ user?: string;
13
+ } | {
14
+ type: 'answer';
15
+ pendingId: string;
16
+ answer: string;
17
+ responseUrl?: string;
18
+ user?: string;
19
+ } | {
20
+ type: 'open_answer';
21
+ pendingId: string;
22
+ triggerId: string;
23
+ } | {
24
+ type: 'ignore';
25
+ };
26
+ /** Reads Slack's form-encoded `payload=<json>` body into the one thing
27
+ * it asks for. Anything unrecognized is `ignore`. */
28
+ export declare function parseSlackInteraction(rawBody: string): SlackInteraction;
29
+ /** Opens the free-text answer modal for "Other…". Slack's trigger_id
30
+ * expires three seconds after the click, so call this before anything slow. */
31
+ export declare function openSlackAnswerModal(botToken: string, triggerId: string, pendingId: string): Promise<void>;
32
+ /** Replaces the original message (its buttons included) with what
33
+ * happened, so nobody clicks a decision that's already made. */
34
+ export declare function replyInSlack(responseUrl: string, text: string): Promise<void>;
35
+ /** What to say in Slack after a decision, from the resolve route's own
36
+ * response body. */
37
+ export declare function slackOutcomeText(kind: 'approve' | 'deny' | 'answer', status: number, body: {
38
+ alreadyResolved?: boolean;
39
+ resolved?: boolean;
40
+ text?: string;
41
+ error?: string;
42
+ pending?: unknown[];
43
+ }, user?: string): string;
44
+ export interface SlackChannel {
45
+ id: string;
46
+ name: string;
47
+ isPrivate: boolean;
48
+ isMember: boolean;
49
+ }
50
+ /** Channels the bot can see — needs the channels:read (and, for private
51
+ * channels, groups:read) scope. */
52
+ export declare function listSlackChannels(botToken: string): Promise<SlackChannel[]>;
53
+ /** A Slack app manifest for one agent's notifier: a bot that can post and
54
+ * read the channel list, with interactivity pointed at this server.
55
+ * Paste it into api.slack.com/apps → Create New App → From a manifest. */
56
+ export declare function slackAppManifest(options: {
57
+ agentName: string;
58
+ interactivityUrl: string;
59
+ }): Record<string, unknown>;
@@ -0,0 +1,168 @@
1
+ // What the server needs to handle Slack button clicks itself (POST
2
+ // /hooks/slack/<agent> in adapters/http.ts), so a Slack notifier's
3
+ // Approve/Deny and answer buttons work with no handler of your own to
4
+ // deploy: request verification, reading the interaction payload, the
5
+ // "Other…" answer modal, replying in the original message, listing
6
+ // channels for the console's channel picker, and the app manifest the
7
+ // console shows for creating the Slack app.
8
+ //
9
+ // Same button contract SlackNotifier (core/http-notify-triggers/slack.ts)
10
+ // sends: action_id approve|deny with the pendingId as value,
11
+ // answer_option with {pendingId, answer}, open_answer_modal with the
12
+ // pendingId, and the modal's answer under ANSWER_BLOCK_ID/ANSWER_ACTION_ID.
13
+ import { createHmac, timingSafeEqual } from 'node:crypto';
14
+ export const SLACK_ANSWER_BLOCK_ID = 'answer_block';
15
+ export const SLACK_ANSWER_ACTION_ID = 'answer_input';
16
+ /** https://api.slack.com/authentication/verifying-requests-from-slack —
17
+ * rejects anything older than five minutes, so a captured request can't
18
+ * be replayed later. */
19
+ export function verifySlackRequest(rawBody, timestamp, signature, signingSecret, nowMs = Date.now()) {
20
+ if (!timestamp || !signature)
21
+ return false;
22
+ const age = Math.abs(nowMs / 1000 - Number(timestamp));
23
+ if (!Number.isFinite(age) || age > 60 * 5)
24
+ return false;
25
+ const expected = Buffer.from(`v0=${createHmac('sha256', signingSecret).update(`v0:${timestamp}:${rawBody}`).digest('hex')}`);
26
+ const actual = Buffer.from(signature);
27
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
28
+ }
29
+ /** Reads Slack's form-encoded `payload=<json>` body into the one thing
30
+ * it asks for. Anything unrecognized is `ignore`. */
31
+ export function parseSlackInteraction(rawBody) {
32
+ const raw = new URLSearchParams(rawBody).get('payload');
33
+ if (!raw)
34
+ return { type: 'ignore' };
35
+ let payload;
36
+ try {
37
+ payload = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ return { type: 'ignore' };
41
+ }
42
+ const user = payload.user?.id;
43
+ if (payload.type === 'view_submission' && payload.view?.private_metadata) {
44
+ const answer = payload.view.state?.values?.[SLACK_ANSWER_BLOCK_ID]?.[SLACK_ANSWER_ACTION_ID]?.value ?? '';
45
+ return { type: 'answer', pendingId: payload.view.private_metadata, answer, user };
46
+ }
47
+ if (payload.type !== 'block_actions')
48
+ return { type: 'ignore' };
49
+ const action = payload.actions?.[0];
50
+ if (!action?.value)
51
+ return { type: 'ignore' };
52
+ switch (action.action_id) {
53
+ case 'approve':
54
+ case 'deny':
55
+ return { type: 'decide', pendingId: action.value, decision: action.action_id, responseUrl: payload.response_url, user };
56
+ case 'answer_option': {
57
+ try {
58
+ const { pendingId, answer } = JSON.parse(action.value);
59
+ return pendingId && typeof answer === 'string' ? { type: 'answer', pendingId, answer, responseUrl: payload.response_url, user } : { type: 'ignore' };
60
+ }
61
+ catch {
62
+ return { type: 'ignore' };
63
+ }
64
+ }
65
+ case 'open_answer_modal':
66
+ return payload.trigger_id ? { type: 'open_answer', pendingId: action.value, triggerId: payload.trigger_id } : { type: 'ignore' };
67
+ default:
68
+ return { type: 'ignore' };
69
+ }
70
+ }
71
+ /** Opens the free-text answer modal for "Other…". Slack's trigger_id
72
+ * expires three seconds after the click, so call this before anything slow. */
73
+ export async function openSlackAnswerModal(botToken, triggerId, pendingId) {
74
+ const res = await fetch('https://slack.com/api/views.open', {
75
+ method: 'POST',
76
+ headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
77
+ body: JSON.stringify({
78
+ trigger_id: triggerId,
79
+ view: {
80
+ type: 'modal',
81
+ callback_id: 'answer_modal',
82
+ private_metadata: pendingId,
83
+ title: { type: 'plain_text', text: 'Your answer' },
84
+ submit: { type: 'plain_text', text: 'Send' },
85
+ blocks: [
86
+ {
87
+ type: 'input',
88
+ block_id: SLACK_ANSWER_BLOCK_ID,
89
+ label: { type: 'plain_text', text: 'Answer' },
90
+ element: { type: 'plain_text_input', action_id: SLACK_ANSWER_ACTION_ID, multiline: true },
91
+ },
92
+ ],
93
+ },
94
+ }),
95
+ });
96
+ const body = (await res.json());
97
+ if (!body.ok)
98
+ throw new Error(`views.open failed: ${body.error}`);
99
+ }
100
+ /** Replaces the original message (its buttons included) with what
101
+ * happened, so nobody clicks a decision that's already made. */
102
+ export async function replyInSlack(responseUrl, text) {
103
+ await fetch(responseUrl, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json' },
106
+ body: JSON.stringify({ replace_original: true, text }),
107
+ });
108
+ }
109
+ /** What to say in Slack after a decision, from the resolve route's own
110
+ * response body. */
111
+ export function slackOutcomeText(kind, status, body, user) {
112
+ const who = user ? ` by <@${user}>` : '';
113
+ if (status >= 400)
114
+ return `:warning: Couldn't record that: ${body.error ?? `HTTP ${status}`}`;
115
+ if (body.alreadyResolved)
116
+ return 'Already decided elsewhere.';
117
+ const what = kind === 'approve' ? ':white_check_mark: Approved' : kind === 'deny' ? ':no_entry: Denied' : ':speech_balloon: Answered';
118
+ if (body.resolved)
119
+ return `${what}${who}. Still waiting on another decision in the same turn.`;
120
+ if (body.pending?.length)
121
+ return `${what}${who}. The agent needs another decision; a new message is on its way.`;
122
+ return `${what}${who}.${body.text ? `\n>${body.text.replace(/\n/g, '\n>')}` : ''}`;
123
+ }
124
+ /** Channels the bot can see — needs the channels:read (and, for private
125
+ * channels, groups:read) scope. */
126
+ export async function listSlackChannels(botToken) {
127
+ const channels = [];
128
+ let cursor = '';
129
+ for (let page = 0; page < 10; page++) {
130
+ const params = new URLSearchParams({ types: 'public_channel,private_channel', exclude_archived: 'true', limit: '200' });
131
+ if (cursor)
132
+ params.set('cursor', cursor);
133
+ const res = await fetch(`https://slack.com/api/conversations.list?${params}`, { headers: { Authorization: `Bearer ${botToken}` } });
134
+ const body = (await res.json());
135
+ if (!body.ok) {
136
+ const reasons = {
137
+ missing_scope: 'The bot token is missing the channels:read scope; reinstall the app from the manifest',
138
+ invalid_auth: 'Slack rejected the bot token; copy the Bot User OAuth Token (xoxb-...) again',
139
+ not_authed: 'Enter the bot token first',
140
+ token_revoked: 'This bot token was revoked; reinstall the app and copy the new one',
141
+ };
142
+ throw new Error(reasons[body.error ?? ''] ?? `Slack said: ${body.error}`);
143
+ }
144
+ for (const c of body.channels ?? [])
145
+ channels.push({ id: c.id, name: c.name, isPrivate: Boolean(c.is_private), isMember: Boolean(c.is_member) });
146
+ cursor = body.response_metadata?.next_cursor ?? '';
147
+ if (!cursor)
148
+ break;
149
+ }
150
+ return channels.sort((a, b) => Number(b.isMember) - Number(a.isMember) || a.name.localeCompare(b.name));
151
+ }
152
+ /** A Slack app manifest for one agent's notifier: a bot that can post and
153
+ * read the channel list, with interactivity pointed at this server.
154
+ * Paste it into api.slack.com/apps → Create New App → From a manifest. */
155
+ export function slackAppManifest(options) {
156
+ const name = `LoopEngine ${options.agentName}`.slice(0, 35);
157
+ return {
158
+ display_information: { name, description: `Approval requests and questions from the ${options.agentName} agent` },
159
+ features: { bot_user: { display_name: name, always_online: true } },
160
+ oauth_config: { scopes: { bot: ['chat:write', 'channels:read', 'groups:read'] } },
161
+ settings: {
162
+ interactivity: { is_enabled: true, request_url: options.interactivityUrl },
163
+ org_deploy_enabled: false,
164
+ socket_mode_enabled: false,
165
+ token_rotation_enabled: false,
166
+ },
167
+ };
168
+ }
package/dist/index.d.ts CHANGED
@@ -10,8 +10,12 @@ 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 { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest, type SlackInteraction, type SlackChannel } from '#core/slack-interactions.js';
14
+ export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
+ export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
16
+ export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
13
17
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
14
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, type DefaultWebhook } from './web/notifier-admin.js';
18
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, type DefaultWebhook } from './web/notifier-admin.js';
15
19
  export type * from './core/loop-events.js';
16
20
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
17
21
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
package/dist/index.js CHANGED
@@ -12,8 +12,12 @@ 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 { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest } from '#core/slack-interactions.js';
16
+ export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
17
+ export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
18
+ export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
15
19
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
16
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending } from './web/notifier-admin.js';
20
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels } from './web/notifier-admin.js';
17
21
  // The framework-agnostic browser client for adapters/http.ts's own wire
18
22
  // protocol (see client.ts's own header comment) — this, not a deep
19
23
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -39,6 +39,7 @@ 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
+ .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; }
42
43
  .env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
43
44
  form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
44
45
  form.add-source.env-inline input, form.add-source.env-inline select { flex: 1 1 140px; width: auto; min-width: 0; }
@@ -955,12 +956,13 @@ ${consoleShellScript}
955
956
  '</form></section>';
956
957
  }
957
958
 
958
- // ---- Permissions → Approvals: where "ask" decisions go when nobody is
959
+ // ---- Permissions → "Where approval requests and questions are sent": where "ask" decisions go when nobody is
959
960
  // in Chat (web/notifier-admin.ts behind GET/PUT/DELETE .../notifier),
960
961
  // the decisions still waiting (GET .../pending), and recent webhook
961
962
  // deliveries. Reloaded every time the tab opens, so the waiting list is
962
963
  // current. ----
963
964
 
965
+ var lastNotifier = null;
964
966
  var NOTIFIER_EVENTS = [
965
967
  ['approval', 'Approvals'], ['question', 'Questions'], ['run_finish', 'Run finished'], ['run_start', 'Run started']
966
968
  ];
@@ -980,6 +982,9 @@ ${consoleShellScript}
980
982
  ? '<form class="pending-answer" data-id="' + escapeHtml(p.pendingId) + '" style="display:flex;gap:6px"><input name="answer" placeholder="Answer" required style="min-width:0"><button type="submit">Answer</button></form>'
981
983
  : '<div style="display:flex;gap:6px"><button type="button" class="primary pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="approve">Approve</button>' +
982
984
  '<button type="button" class="pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="deny">Deny</button></div>';
985
+ if (p.decidePath) {
986
+ actions += '<button type="button" class="pending-copy" data-path="' + escapeHtml(p.decidePath) + '" title="A link anyone can open to decide, no login needed" style="margin-top:6px">Copy link</button>';
987
+ }
983
988
  return '<tr><td><span class="le-pill ' + (p.kind === 'question' ? 'accent' : 'ask') + '">' + (p.kind === 'question' ? 'question' : 'approval') + '</span>' +
984
989
  (p.createdAt ? '<div class="hint">' + escapeHtml(new Date(p.createdAt).toLocaleString()) + '</div>' : '') + '</td>' +
985
990
  '<td>' + details + '</td><td><code>' + escapeHtml(p.sessionId) + '</code></td><td>' + actions + '</td></tr>';
@@ -991,12 +996,15 @@ ${consoleShellScript}
991
996
  if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
992
997
  if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
993
998
  if (n.source === 'default') return 'Using the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + '). Set one below to use a different target for this agent.';
994
- return 'Not set up. A call that needs approval only reaches someone in Chat; set a webhook or Slack channel so apps calling this agent over HTTP can get approvals.';
999
+ return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a webhook, Slack channel or Lark chat below.';
995
1000
  }
996
1001
 
997
1002
  function renderNotifierForm(n) {
998
1003
  var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
999
1004
  var events = f.events || [];
1005
+ function eventBox(e) {
1006
+ return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
1007
+ }
1000
1008
  function field(channel, html) {
1001
1009
  return '<div class="notifier-fields" data-channel="' + channel + '"' + (f.channel === channel ? '' : ' hidden') + '>' + html + '</div>';
1002
1010
  }
@@ -1010,18 +1018,40 @@ ${consoleShellScript}
1010
1018
  '<label>Send to<select name="channel" id="notifierChannel">' +
1011
1019
  ['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('') +
1012
1020
  '</select></label>' +
1013
- 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')) +
1014
- field('slack', '<label>Channel ID<input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' + secretField('slack', 'Bot token')) +
1021
+ 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') +
1022
+ '<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>') +
1023
+ field('slack',
1024
+ '<details class="slack-step"' + (n.secretsSet && n.secretsSet.slack ? '' : ' open') + '><summary>Create the Slack app (once)</summary>' +
1025
+ '<ol class="hint" style="margin:6px 0;padding-left:18px">' +
1026
+ '<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>' +
1027
+ '<li>Paste the manifest below, create the app, then Install to Workspace.</li>' +
1028
+ '<li>Copy the <strong>Bot User OAuth Token</strong> (starts with xoxb-) from OAuth &amp; Permissions into the field below.</li>' +
1029
+ '</ol>' +
1030
+ '<textarea id="slackManifest" readonly rows="8" style="font-family:var(--font-mono);font-size:11.5px"></textarea>' +
1031
+ '<button type="button" id="slackManifestCopy" style="margin-top:6px">Copy manifest</button>' +
1032
+ '</details>' +
1033
+ secretField('slack', 'Bot token') +
1034
+ '<label>Channel <span class="hint">(invite the bot to a private channel first: /invite @its-name)</span>' +
1035
+ '<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>' +
1036
+ '<button type="button" id="slackLoadChannels">Load channels</button></span></label>' +
1037
+ '<label>Channel ID <span class="hint">(filled in when you pick a channel)</span><input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' +
1038
+ '<label>Signing secret <span class="hint">(optional, from Basic Information; ' +
1039
+ (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>' +
1040
+ '<input type="password" name="signingSecret" autocomplete="off"></label>' +
1041
+ '<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>') +
1015
1042
  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')) +
1016
- '<div class="body-field">Send<div style="display:flex;gap:14px;flex-wrap:wrap">' +
1017
- NOTIFIER_EVENTS.map(function (e) {
1018
- return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
1019
- }).join('') + '</div></div>' +
1020
- '<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit">Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
1043
+ '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
1044
+ '<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>' +
1045
+ '<div class="body-field">Send' +
1046
+ '<div style="display:flex;gap:14px;flex-wrap:wrap">' + NOTIFIER_EVENTS.slice(0, 2).map(eventBox).join('') + '</div>' +
1047
+ '<details' + (events.indexOf('run_finish') !== -1 || events.indexOf('run_start') !== -1 ? ' open' : '') + '><summary>More events</summary>' +
1048
+ '<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">' + NOTIFIER_EVENTS.slice(2).map(eventBox).join('') + '</div>' +
1049
+ '<p class="hint" style="margin:4px 0 0">Run finished tells your app the agent&#39;s final reply once a decision lets it finish.</p></details></div>' +
1050
+ (n.canSave === false ? '<p class="error" style="margin:0">Saving needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server, because this stores a secret. Add <code>LOOPENGINE_ADMIN_AUTH=user:pass</code> to <code>.env</code> and restart.</p>' : '') +
1051
+ '<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>' +
1021
1052
  (n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
1022
1053
  '<div id="notifierResult" class="hint"></div>' +
1023
- '<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 <code>pendingId</code> and <code>resolvePath</code>; your app sends <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> there.' +
1024
- (n.publicUrl ? '' : ' Set <code>LOOPENGINE_PUBLIC_URL</code> to include a full <code>resolveUrl</code> too.') + '</p>' +
1054
+
1025
1055
  '</form>';
1026
1056
  }
1027
1057
 
@@ -1034,14 +1064,35 @@ ${consoleShellScript}
1034
1064
  }).join('') + '</tbody></table></div>';
1035
1065
  }
1036
1066
 
1037
- // Shown at the top of the tab, and only when something is waiting.
1038
- function renderPendingHtml(pending) {
1039
- if (!pending.length) return '';
1040
- return '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
1067
+ // Tools that can stop for approval: a rule that asks, or "ask" as the
1068
+ // decision when no rule matches.
1069
+ function askingTools() {
1070
+ var perms = currentCfg && currentCfg.permissions;
1071
+ if (!perms) return [];
1072
+ var tools = [];
1073
+ perms.rules.forEach(function (r) { if (r.decision === 'ask' && tools.indexOf(r.tool) === -1) tools.push(r.tool); });
1074
+ if (perms.defaultDecision === 'ask') tools.push('any tool without a rule');
1075
+ return tools;
1076
+ }
1077
+
1078
+ // The top of the tab: a nudge when approvals over HTTP would reach
1079
+ // nobody, and whatever is waiting right now.
1080
+ function renderPendingHtml(pending, n) {
1081
+ var html = '';
1082
+ var tools = askingTools();
1083
+ if (tools.length && n && n.source === 'none') {
1084
+ html += '<div class="approvals-nudge"><strong>Approval requests aren&#39;t sent anywhere yet.</strong> ' +
1085
+ escapeHtml(tools.slice(0, 3).join(', ')) + (tools.length > 3 ? ' and more' : '') + ' can stop for approval, but when an app calls this agent over HTTP nobody is notified. ' +
1086
+ '<a href="#approvalsSetup" class="approvals-nudge-link">Choose where they go</a></div>';
1087
+ }
1088
+ if (pending.length) html += '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
1089
+ return html;
1041
1090
  }
1042
1091
 
1043
1092
  function renderApprovalsHtml(n) {
1044
- return '<section><h3>Approvals when nobody is in Chat</h3><p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
1093
+ return '<section id="approvalsSetup"><h3>Where approval requests and questions are sent</h3>' +
1094
+ '<p class="muted" style="margin:0 0 8px">When an app calls this agent over HTTP and a tool call needs approval, or the agent asks a question, it&#39;s sent here. In Chat it shows up in the conversation instead.</p>' +
1095
+ '<p class="muted" style="margin:0 0 12px">Currently: ' + renderNotifierStatus(n) + '</p>' +
1045
1096
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
1046
1097
  (n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
1047
1098
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
@@ -1050,6 +1101,24 @@ ${consoleShellScript}
1050
1101
  function wirePendingHandlers(name) {
1051
1102
  var content = detail.querySelector('#pendingContent');
1052
1103
  if (!content) return;
1104
+ var nudgeLink = content.querySelector('.approvals-nudge-link');
1105
+ if (nudgeLink) {
1106
+ nudgeLink.addEventListener('click', function (ev) {
1107
+ ev.preventDefault();
1108
+ var target = detail.querySelector('#approvalsSetup');
1109
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1110
+ });
1111
+ }
1112
+ var copyBtns = content.querySelectorAll('.pending-copy');
1113
+ for (var c = 0; c < copyBtns.length; c++) {
1114
+ copyBtns[c].addEventListener('click', function (ev) {
1115
+ var btn = ev.currentTarget;
1116
+ var link = location.origin + btn.dataset.path;
1117
+ var done = function () { btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = 'Copy link'; }, 1500); };
1118
+ if (navigator.clipboard) navigator.clipboard.writeText(link).then(done, function () { window.prompt('Copy this link', link); });
1119
+ else window.prompt('Copy this link', link);
1120
+ });
1121
+ }
1053
1122
  var decideBtns = content.querySelectorAll('.pending-decide');
1054
1123
  for (var i = 0; i < decideBtns.length; i++) {
1055
1124
  decideBtns[i].addEventListener('click', function (ev) {
@@ -1093,13 +1162,59 @@ ${consoleShellScript}
1093
1162
  result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
1094
1163
  });
1095
1164
  }
1165
+ // Slack: manifest follows the link address; Load channels fills the picker.
1166
+ var n0 = lastNotifier;
1167
+ var manifestEl = form.querySelector('#slackManifest');
1168
+ var linkInput = form.querySelector('#notifierPublicUrl');
1169
+ function refreshSlackManifest() {
1170
+ if (!manifestEl || !n0 || !n0.slack) return;
1171
+ var url = (linkInput.value || location.origin).replace(/[/]+$/, '') + n0.slack.interactivityPath;
1172
+ manifestEl.value = JSON.stringify(n0.slack.manifestTemplate, null, 2).replace('__INTERACTIVITY_URL__', url);
1173
+ var urlEl = form.querySelector('#slackInteractivityUrl');
1174
+ if (urlEl) urlEl.textContent = url;
1175
+ }
1176
+ if (linkInput) linkInput.addEventListener('input', refreshSlackManifest);
1177
+ refreshSlackManifest();
1178
+ var copyManifest = form.querySelector('#slackManifestCopy');
1179
+ if (copyManifest) {
1180
+ copyManifest.addEventListener('click', function () {
1181
+ manifestEl.select();
1182
+ var done = function () { copyManifest.textContent = 'Copied'; setTimeout(function () { copyManifest.textContent = 'Copy manifest'; }, 1500); };
1183
+ if (navigator.clipboard) navigator.clipboard.writeText(manifestEl.value).then(done, done); else done();
1184
+ });
1185
+ }
1186
+ var loadChannels = form.querySelector('#slackLoadChannels');
1187
+ var channelSelect = form.querySelector('#slackChannelSelect');
1188
+ if (loadChannels) {
1189
+ loadChannels.addEventListener('click', function () {
1190
+ loadChannels.disabled = true;
1191
+ result.textContent = 'Loading channels' + String.fromCharCode(8230);
1192
+ var token = form.querySelector('input[name="secret_slack"]').value;
1193
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/slack-channels', {
1194
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ botToken: token }),
1195
+ }).then(function (r) {
1196
+ loadChannels.disabled = false;
1197
+ if (!r.ok) { result.textContent = r.body.error || 'Could not load channels'; return; }
1198
+ var current = form.querySelector('input[name="channelId"]').value;
1199
+ channelSelect.innerHTML = '<option value="">Pick a channel</option>' + r.body.channels.map(function (c) {
1200
+ return '<option value="' + escapeHtml(c.id) + '"' + (c.id === current ? ' selected' : '') + '>' + (c.isPrivate ? '&#128274; ' : '#') + escapeHtml(c.name) + (c.isMember ? '' : ' (bot not in channel yet)') + '</option>';
1201
+ }).join('');
1202
+ result.textContent = r.body.channels.length + ' channels found.';
1203
+ });
1204
+ });
1205
+ channelSelect.addEventListener('change', function () {
1206
+ if (channelSelect.value) form.querySelector('input[name="channelId"]').value = channelSelect.value;
1207
+ });
1208
+ }
1209
+
1096
1210
  form.addEventListener('submit', function (ev) {
1097
1211
  ev.preventDefault();
1098
1212
  var data = new FormData(form);
1099
1213
  var channel = data.get('channel');
1100
- var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId') };
1214
+ 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') };
1101
1215
  var secret = data.get('secret_' + channel);
1102
1216
  if (secret) body.secret = secret;
1217
+ if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
1103
1218
  result.textContent = 'Saving…';
1104
1219
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
1105
1220
  method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
@@ -1133,11 +1248,12 @@ ${consoleShellScript}
1133
1248
  ]).then(function (results) {
1134
1249
  if (currentName !== name) return;
1135
1250
  if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
1251
+ lastNotifier = results[0].body;
1136
1252
  content.innerHTML = renderApprovalsHtml(results[0].body);
1137
1253
  wireApprovalsHandlers(name);
1138
1254
  var pendingEl = detail.querySelector('#pendingContent');
1139
1255
  if (pendingEl) {
1140
- pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || []);
1256
+ pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || [], results[0].body);
1141
1257
  wirePendingHandlers(name);
1142
1258
  }
1143
1259
  if (message) { var r = content.querySelector('#notifierResult'); if (r) r.textContent = message; }
@@ -0,0 +1,16 @@
1
+ export interface DecidePageItem {
2
+ kind: 'approval' | 'question';
3
+ agent: string;
4
+ tool: string;
5
+ args: Record<string, unknown>;
6
+ reason: string;
7
+ sessionId: string;
8
+ tenant: string;
9
+ createdAt?: string;
10
+ expiresAt: number;
11
+ }
12
+ /** A link that's malformed, tampered with, or past its expiry. */
13
+ export declare function renderDecideInvalidPage(): string;
14
+ /** A valid link whose decision has already been made. */
15
+ export declare function renderDecideGonePage(): string;
16
+ export declare function renderDecidePage(item: DecidePageItem): string;