loopengine 0.1.68 → 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.
package/README.md CHANGED
@@ -182,9 +182,14 @@ waiting with Approve/Deny buttons. The tab warns you when an agent has
182
182
  Every notification links to a **decision page**: a signed, single-use
183
183
  link (`/decide/<token>`, valid 7 days) where whoever got the message can
184
184
  approve, deny, edit the arguments or answer a question, with no login and
185
- no app of your own. So the quickest setup is Slack: paste a bot token,
186
- pick a channel, save; each approval arrives with an "Open to approve or
187
- deny" link.
185
+ no app of your own. So the quickest setup is Slack: create the Slack app
186
+ from the manifest the console shows, paste its bot token, pick a channel
187
+ from the list, save; each approval arrives with an "Open to approve or
188
+ deny" link. Add the app's signing secret too and the message also gets
189
+ Approve/Deny buttons, handled by LoopEngine itself at
190
+ `/hooks/slack/<agent>`, with no handler to deploy. Lark cards carry the
191
+ same link. Links and buttons need the server to be reachable from where
192
+ people click (a public HTTPS address or a tunnel).
188
193
 
189
194
  For a third-party app calling an agent over plain HTTP:
190
195
 
@@ -203,6 +203,8 @@ export type HttpNotifierConfig = {
203
203
  appId: string;
204
204
  appSecret: string;
205
205
  chatId: string;
206
+ publicUrl?: string;
207
+ buttons?: boolean;
206
208
  };
207
209
  events: HttpNotifierEvent[];
208
210
  } | {
@@ -114,8 +114,8 @@ function buildResolved(httpNotifier) {
114
114
  break;
115
115
  }
116
116
  case 'lark': {
117
- const { appId, appSecret, chatId } = httpNotifier.config;
118
- const lark = new LarkNotifier({ appId, appSecret, chatId });
117
+ const { appId, appSecret, chatId, publicUrl, buttons } = httpNotifier.config;
118
+ const lark = new LarkNotifier({ appId, appSecret, chatId, publicUrl, buttons });
119
119
  resolved = {
120
120
  approver: events.has('approval') ? lark : undefined,
121
121
  questionHandler: events.has('question') ? lark : undefined,
@@ -9,11 +9,20 @@ export declare class LarkNotifier implements DurableApprover, DurableQuestionHan
9
9
  private readonly appId;
10
10
  private readonly appSecret;
11
11
  private readonly chatId;
12
+ private readonly publicUrl?;
13
+ private readonly buttons;
14
+ /** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
15
+ * link in each card when the server's address is known, and whether to
16
+ * add card buttons, which only work with a card-callback handler
17
+ * deployed (examples/notifier-handler/lark.ts). */
12
18
  constructor(options: {
13
19
  appId: string;
14
20
  appSecret: string;
15
21
  chatId: string;
22
+ publicUrl?: string;
23
+ buttons?: boolean;
16
24
  });
25
+ private decideLine;
17
26
  requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
18
27
  pendingId: string;
19
28
  };
@@ -38,6 +38,8 @@
38
38
  // sending and receiving ever run in different processes), deliberately
39
39
  // not the approach used here.
40
40
  import { randomUUID } from 'node:crypto';
41
+ import { decidePath } from '../approval-links.js';
42
+ import { publicBaseUrl } from './webhook.js';
41
43
  /** Fixed id for the question form card's own input field — used both
42
44
  * when *building* the card (notifyPendingQuestion below) and when
43
45
  * *reading* the human's answer back out of the callback payload (the
@@ -62,10 +64,22 @@ export class LarkNotifier {
62
64
  appId;
63
65
  appSecret;
64
66
  chatId;
67
+ publicUrl;
68
+ buttons;
69
+ /** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
70
+ * link in each card when the server's address is known, and whether to
71
+ * add card buttons, which only work with a card-callback handler
72
+ * deployed (examples/notifier-handler/lark.ts). */
65
73
  constructor(options) {
66
74
  this.appId = options.appId;
67
75
  this.appSecret = options.appSecret;
68
76
  this.chatId = options.chatId;
77
+ this.publicUrl = options.publicUrl;
78
+ this.buttons = options.buttons ?? true;
79
+ }
80
+ decideLine(pendingId, kind) {
81
+ const base = publicBaseUrl(this.publicUrl);
82
+ return base ? `\n[${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}](${base}${decidePath(pendingId, kind)})` : '';
69
83
  }
70
84
  requestDurableApproval(tool, args, scope, reason) {
71
85
  const pendingId = randomUUID();
@@ -80,16 +94,18 @@ export class LarkNotifier {
80
94
  tag: 'div',
81
95
  text: {
82
96
  tag: 'lark_md',
83
- content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}`,
97
+ content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}${this.decideLine(pendingId, 'approval')}`,
84
98
  },
85
99
  },
86
- {
87
- tag: 'action',
88
- actions: [
89
- { tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
90
- { tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
91
- ],
92
- },
100
+ ...(this.buttons
101
+ ? [{
102
+ tag: 'action',
103
+ actions: [
104
+ { tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
105
+ { tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
106
+ ],
107
+ }]
108
+ : []),
93
109
  ],
94
110
  };
95
111
  await this.sendCard(token, card, pendingId);
@@ -114,19 +130,23 @@ export class LarkNotifier {
114
130
  config: { wide_screen_mode: true },
115
131
  header: { title: { tag: 'plain_text', content: `Question from ${agent}` } },
116
132
  elements: [
117
- { tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') } },
118
- ...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
119
- {
120
- tag: 'form',
121
- name: 'answer_form',
122
- elements: [
123
- { tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
133
+ { tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') + this.decideLine(pendingId, 'question') } },
134
+ ...(this.buttons
135
+ ? [
136
+ ...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
124
137
  {
125
- tag: 'action',
126
- actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
138
+ tag: 'form',
139
+ name: 'answer_form',
140
+ elements: [
141
+ { tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
142
+ {
143
+ tag: 'action',
144
+ actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
145
+ },
146
+ ],
127
147
  },
128
- ],
129
- },
148
+ ]
149
+ : []),
130
150
  ],
131
151
  };
132
152
  await this.sendCard(token, card, pendingId);
@@ -17,6 +17,10 @@ export interface NotifierFile {
17
17
  }
18
18
  /** The env var each channel's secret is read from, per agent. */
19
19
  export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
20
+ /** Optional: a Slack app's signing secret, per agent. Set, it turns on
21
+ * Approve/Deny buttons in Slack, handled by the server itself at
22
+ * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
23
+ export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
20
24
  export declare class NotifierFileError extends Error {
21
25
  }
22
26
  export declare function notifierFilePath(dir: string): string;
@@ -17,6 +17,10 @@ export const NOTIFIER_SECRET_VARS = {
17
17
  slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
18
18
  lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
19
19
  };
20
+ /** Optional: a Slack app's signing secret, per agent. Set, it turns on
21
+ * Approve/Deny buttons in Slack, handled by the server itself at
22
+ * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
23
+ export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
20
24
  const CHANNELS = ['webhook', 'slack', 'lark'];
21
25
  const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
22
26
  export class NotifierFileError extends Error {
@@ -95,10 +99,19 @@ export function notifierConfigFromFile(file, env) {
95
99
  case 'webhook':
96
100
  return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret, publicUrl: file.publicUrl }, events: file.events } };
97
101
  case 'slack':
98
- // No interactive buttons: a console-configured Slack notifier has
99
- // no interactivity endpoint behind it; the decision link replaces them.
100
- return { config: { channel: 'slack', config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
102
+ // Buttons only once the agent has a signing secret, i.e. the Slack
103
+ // app's interactivity points at /hooks/slack/<agent>; otherwise the
104
+ // decision link does the job.
105
+ return {
106
+ config: {
107
+ channel: 'slack',
108
+ config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)) },
109
+ events: file.events,
110
+ },
111
+ };
101
112
  case 'lark':
102
- return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId }, events: file.events } };
113
+ // No card buttons: nothing built in handles Lark's card callbacks yet,
114
+ // so a console-configured Lark notifier relies on the decision link.
115
+ return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
103
116
  }
104
117
  }
@@ -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,10 +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';
13
15
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
14
16
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
15
17
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
16
- 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';
17
19
  export type * from './core/loop-events.js';
18
20
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
19
21
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
package/dist/index.js CHANGED
@@ -12,10 +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';
15
17
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
16
18
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
17
19
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
18
- 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';
19
21
  // The framework-agnostic browser client for adapters/http.ts's own wire
20
22
  // protocol (see client.ts's own header comment) — this, not a deep
21
23
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -962,6 +962,7 @@ ${consoleShellScript}
962
962
  // deliveries. Reloaded every time the tab opens, so the waiting list is
963
963
  // current. ----
964
964
 
965
+ var lastNotifier = null;
965
966
  var NOTIFIER_EVENTS = [
966
967
  ['approval', 'Approvals'], ['question', 'Questions'], ['run_finish', 'Run finished'], ['run_start', 'Run started']
967
968
  ];
@@ -1017,8 +1018,27 @@ ${consoleShellScript}
1017
1018
  '<label>Send to<select name="channel" id="notifierChannel">' +
1018
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('') +
1019
1020
  '</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')) +
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>') +
1022
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')) +
1023
1043
  '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
1024
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>' +
@@ -1031,7 +1051,7 @@ ${consoleShellScript}
1031
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>' +
1032
1052
  (n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
1033
1053
  '<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>' +
1054
+
1035
1055
  '</form>';
1036
1056
  }
1037
1057
 
@@ -1142,6 +1162,51 @@ ${consoleShellScript}
1142
1162
  result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
1143
1163
  });
1144
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
+
1145
1210
  form.addEventListener('submit', function (ev) {
1146
1211
  ev.preventDefault();
1147
1212
  var data = new FormData(form);
@@ -1149,6 +1214,7 @@ ${consoleShellScript}
1149
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') };
1150
1215
  var secret = data.get('secret_' + channel);
1151
1216
  if (secret) body.secret = secret;
1217
+ if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
1152
1218
  result.textContent = 'Saving…';
1153
1219
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
1154
1220
  method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
@@ -1182,6 +1248,7 @@ ${consoleShellScript}
1182
1248
  ]).then(function (results) {
1183
1249
  if (currentName !== name) return;
1184
1250
  if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
1251
+ lastNotifier = results[0].body;
1185
1252
  content.innerHTML = renderApprovalsHtml(results[0].body);
1186
1253
  wireApprovalsHandlers(name);
1187
1254
  var pendingEl = detail.querySelector('#pendingContent');
@@ -2,6 +2,7 @@ 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
4
  import { type NotifierFileChannel } from '../core/notifier-file.js';
5
+ import { type SlackChannel } from '../core/slack-interactions.js';
5
6
  /** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
6
7
  * _SECRET), which applies to the plain /messages route when an agent has
7
8
  * no notifier of its own. */
@@ -17,6 +18,12 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
17
18
  secretsSet: {
18
19
  [k: string]: boolean;
19
20
  };
21
+ slack: {
22
+ signingSecretVar: string;
23
+ signingSecretSet: boolean;
24
+ interactivityPath: string;
25
+ manifestTemplate: Record<string, unknown>;
26
+ };
20
27
  deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
21
28
  channel?: import("../core/agent-config.js").HttpNotifierConfig["channel"];
22
29
  events?: string[];
@@ -28,6 +35,17 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
28
35
  * form includes one, the channel's secret into the agent's own env.
29
36
  * Throws NotifierFileError for an invalid form. */
30
37
  export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
38
+ /** The bot token and signing secret behind an agent's Slack notifier —
39
+ * from AgentConfig.httpNotifier when it's Slack, else from the agent's
40
+ * env (the console-saved ones). The signing secret always comes from the
41
+ * agent env. */
42
+ export declare function slackCredentialsFor(config: AgentConfig): {
43
+ botToken?: string;
44
+ signingSecret?: string;
45
+ };
46
+ /** Channels for the console's picker, with the token being typed in or,
47
+ * when none is given, the one already saved. */
48
+ export declare function listAgentSlackChannels(config: AgentConfig, botToken?: string): Promise<SlackChannel[]>;
31
49
  /** Removes notifier.yml; the stored secret is left alone, so turning the
32
50
  * notifier back on doesn't need it re-entered. */
33
51
  export declare function removeAgentNotifier(config: AgentConfig): void;
@@ -4,7 +4,8 @@ import { describeNotifier } from '../core/http-notifier.js';
4
4
  import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
5
5
  import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
6
6
  import { listDeliveries } from '../core/notifier-deliveries.js';
7
- import { NOTIFIER_SECRET_VARS, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
7
+ import { NOTIFIER_SECRET_VARS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
8
+ import { listSlackChannels, slackAppManifest } from '../core/slack-interactions.js';
8
9
  import { setAgentEnvVar } from './env-admin.js';
9
10
  export function describeAgentNotifier(config, defaultWebhook) {
10
11
  const described = describeNotifier(config);
@@ -18,6 +19,15 @@ export function describeAgentNotifier(config, defaultWebhook) {
18
19
  publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
19
20
  secretVars: NOTIFIER_SECRET_VARS,
20
21
  secretsSet,
22
+ slack: {
23
+ signingSecretVar: NOTIFIER_SLACK_SIGNING_SECRET_VAR,
24
+ signingSecretSet: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) !== undefined,
25
+ // Where the Slack app's Interactivity Request URL should point.
26
+ interactivityPath: `/hooks/slack/${encodeURIComponent(config.name)}`,
27
+ // The console swaps __INTERACTIVITY_URL__ for the link address +
28
+ // interactivityPath as you type.
29
+ manifestTemplate: slackAppManifest({ agentName: config.name, interactivityUrl: '__INTERACTIVITY_URL__' }),
30
+ },
21
31
  deliveries: listDeliveries(config.name),
22
32
  };
23
33
  }
@@ -29,6 +39,26 @@ export async function saveAgentNotifier(config, body) {
29
39
  if (typeof body.secret === 'string' && body.secret.trim()) {
30
40
  await setAgentEnvVar(config.name, NOTIFIER_SECRET_VARS[file.channel], body.secret.trim());
31
41
  }
42
+ if (file.channel === 'slack' && typeof body.signingSecret === 'string' && body.signingSecret.trim()) {
43
+ await setAgentEnvVar(config.name, NOTIFIER_SLACK_SIGNING_SECRET_VAR, body.signingSecret.trim());
44
+ }
45
+ }
46
+ /** The bot token and signing secret behind an agent's Slack notifier —
47
+ * from AgentConfig.httpNotifier when it's Slack, else from the agent's
48
+ * env (the console-saved ones). The signing secret always comes from the
49
+ * agent env. */
50
+ export function slackCredentialsFor(config) {
51
+ const env = createAgentEnv(config.name, agentDirFor(config));
52
+ const botToken = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.slack);
53
+ return { botToken, signingSecret: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) };
54
+ }
55
+ /** Channels for the console's picker, with the token being typed in or,
56
+ * when none is given, the one already saved. */
57
+ export async function listAgentSlackChannels(config, botToken) {
58
+ const token = botToken?.trim() || slackCredentialsFor(config).botToken;
59
+ if (!token)
60
+ throw new Error('Enter the bot token first');
61
+ return listSlackChannels(token);
32
62
  }
33
63
  /** Removes notifier.yml; the stored secret is left alone, so turning the
34
64
  * notifier back on doesn't need it re-entered. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -50,6 +50,8 @@
50
50
  "dist/core/notifier-deliveries.d.ts",
51
51
  "dist/core/approval-links.js",
52
52
  "dist/core/approval-links.d.ts",
53
+ "dist/core/slack-interactions.js",
54
+ "dist/core/slack-interactions.d.ts",
53
55
  "dist/core/session-store.js",
54
56
  "dist/core/session-store.d.ts",
55
57
  "dist/core/vector-index.js",