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.
- package/README.md +16 -2
- package/dist/core/agent-config.d.ts +5 -0
- package/dist/core/approval-links.d.ts +16 -0
- package/dist/core/approval-links.js +76 -0
- package/dist/core/durable-approvals.d.ts +6 -0
- package/dist/core/durable-approvals.js +20 -0
- package/dist/core/http-notifier.js +6 -6
- package/dist/core/http-notify-triggers/lark.d.ts +9 -0
- package/dist/core/http-notify-triggers/lark.js +39 -19
- package/dist/core/http-notify-triggers/slack.d.ts +12 -0
- package/dist/core/http-notify-triggers/slack.js +31 -10
- package/dist/core/http-notify-triggers/webhook.d.ts +8 -0
- package/dist/core/http-notify-triggers/webhook.js +23 -8
- package/dist/core/notifier-file.d.ts +8 -0
- package/dist/core/notifier-file.js +27 -6
- package/dist/core/slack-interactions.d.ts +59 -0
- package/dist/core/slack-interactions.js +168 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/web/agents-config-page.js +134 -18
- package/dist/web/decide-page.d.ts +16 -0
- package/dist/web/decide-page.js +138 -0
- package/dist/web/notifier-admin.d.ts +20 -0
- package/dist/web/notifier-admin.js +34 -1
- package/package.json +5 -1
|
@@ -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 →
|
|
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 '
|
|
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
|
-
|
|
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> → Create New App → 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 & 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 — 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
|
-
'<
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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'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
|
-
|
|
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
|
-
//
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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'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
|
|
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'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 ? '🔒 ' : '#') + 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;
|